Search index
Every file and every section in VeilVoice, 391 files and 18733 sections, listed in full on this page. There is no JavaScript here and nothing to load: use your browser's own find-in-page (Ctrl+F, or Cmd+F on a Mac) to search it. Section headings and their opening text are included, so searching for a term finds the place it is discussed, not just the file name.
The live search on the main site scores and ranks the same index, and adds sorting and filtering. It needs JavaScript. This page does not, and is generated from the same walk of the repository, so the two never disagree.
Every entry is expanded rather than folded away, deliberately: text inside a collapsed section is not searchable by find-in-page on every browser, and an index that answers confidently with nothing would be worse than no index. That makes this a long page. The list below jumps to each part of it.
Contents
- Documentation
- Rust source
- Tests and fuzzing
- Website
- Tools and generators
- Build and CI
- Licence and legal
- Other
Documentation
CHANGELOG.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Changelog
The section matching a release tag is published at the top of that release's notes on GitHub, so this file is the source of truth for what changed rather than a summary written afterwards. - v0.1.22
**Twenty-seven crates became thirteen** - VeilVoice was split into twenty-seven separate libraries. Seven of them were under seven hundred lines and four were used by exactly one thing. Every one of those splits is a published surface, a - v0.1.22
manifest, a README, a page of the reference and a row in every list of what this project is made of, and somebody deciding what to depend on had to read twenty-seven descriptions to find the two they wanted. - The line is drawn at what a - v0.1.22
person would realistically take on its own. The six that watch what else the machine is doing are one library now. The two that raise an alarm and the safety catch that acts on one are another. The download verifier absorbed both of its - v0.1.22
halves, the video renderer absorbed the graphics probe that exists to answer one question for it, the decoy passphrase joined the cryptography it is part of, the update check joined the installer, and saved profiles joined the settings. - - v0.1.22
**Nothing was deleted and nothing behaves differently.** Every module kept its name, its documentation and its tests, and the whole suite passed before and after. If you were using one of these as a library, the code is in the same shape - v0.1.22
at a shorter path. - Found while doing it: the documentation generator wrote pages for what exists and never removed pages for what does not, because nothing had ever been removed before. It would have left 218 pages describing crates that - v0.1.22
are no longer there. It takes them away now, and says how many. **A link to this site now shows a picture, and search engines are told they may read it** - Every page told a link preview to use `assets/banner.png`. That tag is read by - v0.1.22
crawlers, not by browsers, and a crawler does not work out what a relative address means. So every link to this site, posted anywhere, had always shown no picture. Nothing looked wrong: the tag was there and the file exists. - The address - v0.1.22
is absolute now, and every page also says which address is its own, which stops the same page being counted twice. Two pages had no preview tags at all and now have them, and the no-JavaScript page's preview title no longer reads - v0.1.22
`VeilVoice &middot; no-JavaScript edition`. - None of it is typed any more. It is built from what each page already says, by one piece of code every generator calls, so a new page cannot arrive without it. - The site also had no - v0.1.22
`robots.txt` and no sitemap, so the only way in was a link from somewhere else. Both are there now, and the sitemap is produced by walking the site: all 398 pages, updated whenever one is added or removed. **The banner moves on the - v0.1.22
no-JavaScript page too** - That edition showed the still picture while the main site animated the same banner. It shows the animation now, which is the same file the main site already serves to anyone with scripts off. - Anyone who has - v0.1.22
asked their system for less movement still gets the still, and that choice is made by the markup rather than by a script, so it works on a page that runs none. **Links into a page land on the thing they name** - The site's header stays at - v0.1.22
the top of the screen as you scroll, so anything a link jumps to needs to be pushed down clear of it. The amount it was pushed down by was a single number, and the header is five different heights depending on the page and the width of the - v0.1.22
window. At the commonest desktop width the heading you had asked for ended up behind the bar, so the section looked as though it started halfway through a sentence. - Worse for two whole pages: the rule only covered sections and the larger - v0.1.22
headings, so every entry on the releases page and every entry on the roadmap got no push at all and landed a full header height underneath. - Driven in a browser, 425 of the 430 links into a page on this site landed in the wrong place. The - v0.1.22
edition of the site that runs no scripts had none of this, because it has no bar that follows you down, and it is the standard the rest of the site is now held to. - The header is measured instead of guessed, and re-measured when the - v0.1.22
window changes shape or a font arrives late. The landing is held for a second afterwards, because pictures and fonts below the fold can still move the page under you, and it lets go the moment you scroll. Where you landed is outlined - v0.1.22
briefly so it is obvious which heading you asked for, and if you have asked your system for less movement the outline simply sits there rather than fading. - Jumping a long way down the page no longer takes a second and a half to get - v0.1.22
there. The page glides when the distance is a screen or two, which is what tells you that you went down rather than sideways, and goes straight there when it is further, which is what a link into a page is for. - Seventeen images on the - v0.1.22
site did not say how big they were, so everything below them moved down when they arrived. That is why a section you had just landed on could be somewhere else a moment later. They all say now, and a check refuses an image that does not. - - v0.1.22
The back button, the middle button and copying a link all behave exactly as before: nothing about the navigation itself was taken over. **The list of crates on the front page was short by fifteen** - The website's front page shows the - v0.1.22
README, and the README lists what this project is made of twice. Both lists, and a third in the library documentation, had stopped being added to. They showed twelve and thirteen crates; there are twenty-seven. - The missing ones are not - v0.1.22
plumbing: the two halves of the download verifier, the failsafe, the video renderer and the workspace store are all things the rest of the documentation describes at length. - All three lists are complete, and a check now reads the - v0.1.22
workspace and the three lists on every build, so the next crate cannot be added without them. **The website no longer opens on a ticked-looking box** - The first thing the site shows is the notice a reader has to accept, and the first - v0.1.22
checkbox in it opened already wearing its focus ring, as if somebody had just tabbed onto it. Nobody had. The script moved focus to that checkbox when the dialog opened, and focus moved by a script is drawn the same way as focus moved by a - v0.1.22
keyboard, in every engine. - Focus now lands on the dialog itself, which is where a modal's focus belongs: a screen reader announces its title from there, the first Tab reaches the first control, and the trap that keeps focus inside the - v0.1.22
notice still holds. Nothing looks pressed until the reader presses it. - Checked by the website's own suite, which reads the script and the stylesheet and fails if either goes back. **The numbers this project states about itself are - v0.1.22
written by the tool that measures them** - The README, the front page and one row of the audit state how many tests this tree has, how many crates, how many website suites and how many functional lines of Rust. Ten sentences in all, and - v0.1.22
every one of them was typed by hand and compared against the tree by the website suite. - The comparison was doing its job. The typing was not: every change to any Rust file moves the line count, so a commit that changed code and not those - v0.1.22
ten places failed a check that had nothing to do with what the commit was for. It happened four times in a single round. - The generator that already takes those numbers now writes the sentences from them too, using the same patterns the - v0.1.22
suite checks with, so the tool that writes a claim and the check that reads it cannot disagree about where the claim is. The check is unchanged and still runs. - Nothing a reader sees is different, and that is the point: the numbers were - v0.1.22
right before and are right now without anybody having to remember them. **A size calculation that could wrap on 32-bit machines** - Records in the encrypted store are padded to a fixed set of sizes so that how big a file is says as little - v0.1.22
as possible about what is in it. Working out that size multiplies the record's length by eight. - On the 32-bit builds, a record over about half a gigabyte would have wrapped that sum around. Nothing was at risk: the next line caught it - v0.1.22
and reported a failure, and the store only ever holds settings and measurements a few kilobytes in size. But it was the wrong way to write it. - The arithmetic is checked now, and refuses cleanly instead of wrapping. **Two places that - v0.1.22
asked the system to find a program, rather than saying where it was** - Naming a program without its full path means the system searches for it, and whatever it finds first is what runs. VeilVoice already says full paths everywhere it - v0.1.22
starts something, and a test enforces that, but the test only reads one file. - Listing your graphics hardware on Linux, and looking up where an installed tool lives, were both still asking the system to search. Both now say exactly where - v0.1.22
they expect to find what they are running, and the graphics code has its own copy of the test that keeps it that way. - The reproducible-build check still searches for your Rust toolchain and git, deliberately: it is checking a build - v0.1.22
against the tools you have, so finding yours is the point. **A one in 65,536 chance that the voice scrambler settled into a fixed rhythm** - VeilVoice re-draws the seed behind its voice scrambling at an interval, and draws that interval's - v0.1.22
range fresh at every launch, so the rhythm is a property of your session rather than of the program. Two numbers are drawn to set the range. - About one launch in 65,536, the two came out identical, and the range had no width. That means a - v0.1.22
fixed interval, which is precisely what drawing the range is meant to avoid. Nothing looked wrong: the settings were valid and a panel showing them would have shown two matching numbers. - A drawn range is now always at least one frame - v0.1.22
wide, widened around the numbers that were drawn so a slow rhythm stays slow. - It turned up as a test failing once after passing eleven times the same day, and two hundred repeats afterwards did not bring it back. The arithmetic is a - v0.1.22
separate function now, so the case can be handed to it directly rather than waited for. **The metadata cleaner, put through the same test** - The part that reads a sound file somebody else made, and the part that replaces its metadata, - v0.1.22
were changed a line at a time the same way. Of 58 changes worth trying, four went unnoticed. - One let the reader look one byte past the end of a file whose last section has an odd length and no padding, which is what a cut-off recording - v0.1.22
looks like. - The other three were in the bland tags this writes in place of the ones it removes. Removing metadata is itself a signal, so plausible ordinary tags go in instead, and that only works if they are shaped like the ones any - v0.1.22
other program writes. Nothing had ever read back what it writes, so three ways of getting that shape wrong all passed. - All four are fixed and the whole thing was run again: nothing survives now. These two files are covered by the weekly - v0.1.22
check from here on. **Eighty-one changes to the encryption code that every test accepted** - One way to find out whether tests are worth anything is to change the code a line at a time and see whether any of them complains. Run over the - v0.1.22
app lock, the vault, the chunked store and the reversible encodings, that found 674 changes worth trying and 81 that the whole suite let through. - Forty-four were the same gap. The encodings were only ever checked by encoding something - v0.1.22
and decoding it back, which tests the pair rather than the encoder: any change the decoder still reverses passes. Each of the thirty-one encodings now has its exact output written down for one fixed input, so a change to what they emit has - v0.1.22
to be deliberate. - Three were in the app lock and none of them harmless. Two locks sharing half their identity could have compared as the same lock. A tamper warning could have been cleared without the passphrase. The limit on how long - v0.1.22
the lock makes somebody wait was checked against the setting that defines it, so changing fifteen minutes to seventy-five seconds passed every check. - Two were not missing tests but dead code: a condition that could only act where the - v0.1.22
next line already acts, and a branch that cannot be reached at all. Both are gone. - Four rounds of writing tests and re-measuring took 81 down to 15, and each of those 15 is now recorded with the reason no test could ever catch it: some - v0.1.22
compute exactly the same answer as the original, and some depend on the machine rather than on the code. - This runs every week from now on, and compares itself against that recorded list. Anything new fails the build and is named. - v0.1.22
**Fifteen gigabytes of build output that was living inside the repository** - The tool that counts how many tests this project has does it by running them. It sent that build to a folder under the user's profile on Windows, which is right, - v0.1.22
and through a fallback nobody had thought about it sent it to a folder **inside the repository** on every other system. - So every machine that is not Windows had a second complete copy of the build sitting in the source tree, rebuilt from - v0.1.22
scratch each time the numbers were regenerated. Nothing reported it: the rule that hides build output from version control hides it at any depth, which is correct and also means this never appeared in any status, any diff, or any clean. - - v0.1.22
It was found because something else broke. A long test run ran out of disk, several steps removed from the cause. - The build now goes where builds go, and a check fails if build output ever appears anywhere in the repository except the - v0.1.22
one place at the root. It names the folder and how large it has grown. **The undefined-behaviour checker can now look at the cryptography** - VeilVoice keeps every key and passphrase in a type that locks its pages out of swap, so the - v0.1.22
operating system cannot write them to disk. That lock is made with a system call the interpreter used to check for undefined behaviour cannot make, so the first key any test created stopped the check dead, and the part of the program that - v0.1.22
does the encrypting was the one part that could not be checked this way at all. - The lock is now skipped when running under that interpreter, which is the same thing that already happens on a machine with no budget for it or a platform - v0.1.22
without the call. The program reports honestly that the pages are not locked, and nothing about what it stores or wipes changes. Ordinary runs are untouched and still lock. - The checker then read the reversible encodings, the - v0.1.22
authenticated encryption, the protected-memory type itself, the chunked store, the file shredder and the private-file helper, and found nothing wrong in any of them. - Two tests keep it that way: one fails if the lock is ever taken outside - v0.1.22
the one place that knows about this, and one proves a secret holds and wipes the same bytes whether the lock happened or not. **Code that nothing reached, taken out, and a check so it cannot come back** - Five public items were compiled - v0.1.22
into every binary on every platform, documented, published into the generated reference and the wiki, and called by nothing: not by the programs, not by a test, not by anything. Each had been superseded by a path that does the same job, - v0.1.22
and none of them was wrong, which is why nobody had noticed. - One of them was costing something. An accessor for the last vault audit kept alive a private field, and that field was filled by a copy of the audit result made every time the - v0.1.22
vault was opened, for a value no line of code ever read. The accessor, the field and the copy are all gone. - The compiler cannot report this. Its dead-code warning stops at the edge of a library, because a public item might be called by a - v0.1.22
program the compiler cannot see, and an accessor that reads a private field keeps that field looking used while it does. - So a check now asks it: every public item has to be named somewhere other than the line that declares it. An item - v0.1.22
used only by its tests passes, which is a normal thing for a reader kept beside a writer. An item used by nothing fails the build, naming the file and the line. It runs in CI and before every commit, and it was proved able to fail before - v0.1.22
it was trusted. **The audit, round thirty-three: the whole tree read again** - The round after 0.1.21, run against the brief written before it began: every class of defect the earlier rounds established and every check the repository runs, - v0.1.22
asked of each file. What it found is written up in `docs/AUDIT.md`; what changed because of it is below. - **Licences, sources and duplicate versions are checked now.** `cargo audit` answers whether anything in the graph has an advisory - v0.1.22
against it and nothing asked the other three questions: whether every licence can ship under GPL-3.0-or-later, whether every crate comes from crates.io, and whether one crate is compiled at two versions. `deny.toml` answers them, `cargo - v0.1.22
deny` runs in CI beside the advisory check, and the two policy files are checked against each other so their advisory exceptions cannot drift apart. - **Five checks that ran only when somebody ran them are in CI.** The guard for a state - v0.1.22
file written one place and read from another, the per-program guides, the questions page, the app-manifest self-test and the local site host were all in `tools/verify.py` and none was in a workflow, so each caught drift only when somebody - v0.1.22
remembered to look. - **The seven coverage-guided fuzz targets run weekly**, two minutes each from the committed corpus, in a workflow of their own that can also be dispatched before a release. They had only ever run by hand. - The hybrid - v0.1.22
key exchange's documentation said both public keys went into the combiner. The recipient's X25519 key does; the recipient's ML-KEM key does not, and does not need to, because FIPS 203 binds it through the shared secret itself. The doc - v0.1.22
comment now says exactly what is bound, why the omission is not a weakness, and why the transcript is not changed: every key and container already made would change with it, for no gain. - The command line's offline claim was proved again - v0.1.22
on the built binary, four ways, and each guard was shown to fail when given something to catch. **Undefined-behaviour checking, run here for the first time** - The whole workspace forbids unsafe code, so anything an interpreter found would - v0.1.22
belong to a dependency rather than to VeilVoice. It found nothing, in the three crates it got through: the release-signature and contents readers, the WAV and metadata crate, and the speaker plan and its edits. - Said precisely, because - v0.1.22
the alternative is a claim that sounds larger than it is: four tests in the metadata crate could not run, because they write tags to a real file and the interpreter's filesystem stands in the way. No undefined behaviour and no unsupported - v0.1.22
operation was reported for them, and they pass in seconds outside it, so the tag writer is uncovered rather than broken. The engine and the cryptography were not reached at all, and that is said rather than left to be assumed. **A promise - v0.1.22
about allocation, kept** - The frame-pacing code carried a note saying it allocates nothing once a frame, and then took its median with a sort that takes a scratch buffer for a slice that long. Nothing looked wrong and nothing was slow; - v0.1.22
what was wrong is that a sentence in this repository was not true about the code fourteen lines beneath it. - It now selects the middle element in place instead, which allocates nothing and is all a median needs. **The tests now stand at - v0.1.22
the edge of every boundary in the cryptography** - Mutation testing over the four files that matter most, which changes the code a line at a time and asks whether any test objects: 127 changes, 91 objected to, 21 that do not compile, and - v0.1.22
**fifteen that the whole suite accepted**. - **Five were boundaries in the parser that reads a file somebody sends you**: the length tests and both checks on what a header claims to carry. Each is now tested from both sides, at exactly the - v0.1.22
length in question rather than near it. - **Four were the ceilings that stop a file from choosing how much memory this program allocates.** Each now accepts its own value and refuses one past it, and a header asking for zero lanes is - v0.1.22
refused on its own rather than only in company. - **Three were the randomness adapter, and that is the one worth saying plainly.** Replacing it with something that returns a constant, or that claims success without writing a byte, passed - v0.1.22
every test this project had. A key drawn from a buffer left untouched is a key somebody else already knows. It is now asked, in both forms, whether bytes actually arrive. - **Run again afterwards**, which is what makes it a result: 104 - v0.1.22
objected to, 21 that do not compile, and two left, both of them the same line. - That line cannot be killed and the reason is now written where it lives: a ceiling on parallelism that the memory ceiling always reaches first. It stays, - v0.1.22
because a guard that is only redundant today is not a guard to delete, and the next person who changes it and sees nothing happen will find the answer in the comment rather than concluding the line is dead. **The command it prints and the - v0.1.22
command it runs cannot drift apart** - `veilvoice conversation` prints an `ffmpeg` command for you to run yourself, under a line saying VeilVoice never runs it for you. The window runs a different one, because it feeds the pictures in by a - v0.1.22
list rather than by a numbered pattern. Everything after the inputs was the same decision made separately in three places, with only the frame size compared across two of them. - They agreed, and now they have to: a test compares the - v0.1.22
codec, the quality, the scale filter, the pixel format and the audio settings across all three, so an instruction this program gives cannot quietly stop being the thing this program does. **Dependencies, reviewed one by one** - **egui and - v0.1.22
eframe 0.36**, and this one is a security change as much as an upgrade: 0.36 rasterises text without `ttf-parser`, so that crate has left the dependency graph and its advisory leaves the exception list with it. Two accepted advisories - v0.1.22
remain where there were three. The port was real work: the application now draws into a root panel rather than taking the context, the OpenGL choices moved to where the OpenGL backend is configured, panels and styles are set differently, a - v0.1.22
dropped file reports its path through a trait, and every headless test had to learn that a frame's texture uploads must be accounted for. 1611 tests pass on it. - **`symphonia` 0.6**, the decoder for everything that is not a WAV. The 0.6 - v0.1.22
API changed how a stream is probed, how a decoder is made and how decoded audio is read; the port keeps one interleaved buffer across packets rather than one per packet, and every audio test passes on it. - **The GitHub Actions the - v0.1.22
workflows use**, nine of them, to their current majors. - **The cryptographic line is held, on purpose, and the reason is written where it will be read.** `sha2`, `hkdf`, `chacha20poly1305`, `argon2`, `x25519-dalek`, `ml-kem`, `rand`, - v0.1.22
`rand_core`, `rand_chacha` and `getrandom` all have a newer major. `pgp`, which verifies release signatures, pins the generation this project uses, and taking the newer one would compile two copies of every primitive into both binaries. - v0.1.22
None of the newer versions fixes a vulnerability. The line moves together the day `pgp` moves, or the day the signature check stops needing it, which is roadmap item 149 on the roadmap: a reader for exactly what a detached signature over a - v0.1.22
text file is, with nothing else in it. Dependabot is told not to reopen the same ten pull requests every week. - Every compatible update in the lock file taken. **The meters can sit above a call or a stream** - The live monitor had two - v0.1.22
places to be and both were inside the VeilVoice window. On a call or while streaming, that window is behind the thing you are talking into, so the only picture of what your microphone is doing was covered exactly when it mattered. - **A - v0.1.22
third choice: a small window of its own, kept above other windows.** Off the task bar, draggable, resizable, showing the same two levels and the same sentence about what a level is not. It is not the default, because a window that puts - v0.1.22
itself above everything is something to ask for rather than to be given. - Closing it brings the strip back rather than turning the meters off. Its close button belongs to the window manager, so pressing it means "not in my way", and - v0.1.22
reading that as "never show me my microphone again" would take the meters away from under a call without being asked. - Where a platform will not give a second window, it falls back to the floating card, which is the same thing inside the - v0.1.22
window. - **Offered where the session starts**, not only in Settings: while the voice is being veiled, a **keep the meters on top** button sits beside the live indicator. That is the moment somebody is about to put a call in front of this - v0.1.22
window, and a setting they have to go looking for afterwards is one they find after the call. **The window draws at the display's rate** - The animations ran at twenty frames a second, by design: a constant in the mark, a fifty-millisecond - v0.1.22
cadence for anything busy, and sixteen for veiling. On a display faster than sixty that is judder, and the sixteen was wrong even at sixty, because a display at sixty shows a frame every 16.67 ms and a request for one "within sixteen" - v0.1.22
misses the frame it wanted and lands on the next. Thirty a second, asked for as sixty. - **The fix is not a bigger number.** While anything is moving, the window asks for the next frame now and lets the screen space it: a window that waits - v0.1.22
for the display cannot draw faster than the display shows, so this is one frame per refresh and no more, at whatever rate the screen runs. - That is also what makes the display measurable. Nothing in the libraries this window is built on - v0.1.22
will say what the refresh rate is, so it is measured: frames paced that way are the display's own, and the middle value of the last thirty-two is a figure a single slow frame cannot move. - **Settings can lower it**, to 30, 60, 90, 120, - v0.1.22
144, 165 or 240, which is a choice to make for a battery rather than for smoothness. - **A live readout in the header** when you turn it on, and the About tab now carries what the window is aiming at, what the display measured, the rate as - v0.1.22
drawn and how many frames arrived late. - **It says when it is struggling.** A frame more than half again later than it should have been is counted late; two seconds of that shows a notice once, naming the rate, the target and what the - v0.1.22
window is drawing with, because software rendering and a slow GPU are different problems. One bad second is not enough, since every launch costs one. - Idle is unchanged: a window with nothing moving asks for no frames and draws none. - v0.1.22
**The lock button and the theme picker agree on a height** - The manual lock button in the header did not line up with the picker beside it. Measured properly, in a frame laid out the way the header lays it out: the two share a centre - v0.1.22
exactly and the picker is one pixel taller, because each control worked its own height out from padding and nothing said the two should match. - The button now takes its height from the picker's own rectangle rather than from a number - v0.1.22
written down twice, so they cannot drift apart at a font size or on a platform nobody here has tried. Two tests hold it, one of which proves the other can fail. - No screenshot can show this: the captures photograph a window with no app - v0.1.22
lock set, and the button is only drawn when there is one. **Two roadmap items on the roadmap** - Roadmap item 148, the window drawing at the display's rate and saying so: the animations ran at twenty frames a second by design, and what to - v0.1.22
build instead is specified in full. - Roadmap item 149, the signature check that needs only a signature check, which is what unblocks the cryptographic line above. **The site can be claimed in Search Console** - The ownership tag Google - v0.1.22
asks for sits in the head of every page, so the site can be verified and indexed. It is a string with no behaviour: nothing is loaded, sent or run because of it. **The Studio is in the documentation, and so is the reason for it** - The - v0.1.22
companion list recommended Audacity as being "useful for recording a file and for trimming one before veiling it". The first half stopped being true when the Recording Studio landed, and it was worse than merely out of date: it pointed a - v0.1.22
reader at exactly the thing the Studio exists to avoid. - **A recording made in another program is a plaintext file on your disk.** Open an editor, record an interview, save it, veil the result, delete the original: the original was on the - v0.1.22
disk the whole time, and on flash storage deleting it does not reliably take it back. The encryption that happens afterwards cannot reach backwards to cover it. - So the Studio never writes one. The samples go from the audio callback into - v0.1.22
memory the operating system has been asked to keep out of the page file, the WAV is assembled inside that protected memory, and what leaves is already sealed. There is deliberately no route in the code that would produce a plain copy, - v0.1.22
because a route that existed would eventually be taken. - Audacity is still recommended, for what it is actually for here: editing a file you already have. The install guide said the same thing twice and now says this instead. - The - v0.1.22
questions page gains the question a reader actually arrives with, which is whether it can record at all. It says what it captures (any input the system offers, chosen by name, so a microphone or a virtual cable carrying the computer's own - v0.1.22
audio, up to eight at once for a room), and what it keeps (uncompressed PCM at whatever rate the device is really running, nothing resampled, no lossy codec). - **It does not claim to be an editor, or to be better than one.** There is no - v0.1.22
cutting, fading or arranging, and the page says so rather than implying otherwise. No comparison with any other program has been benchmarked, so none is made. **A doc comment that had moved to the wrong function** - Factoring one loop out - v0.1.22
of three copies left a documentation block behind, and the next function down inherited it. The function that looks for GnuPG was documented as "the route to Audacity differs per platform"; the function that installs Audacity was - v0.1.22
documented as nothing at all. - **The generated wiki repeated it**, which is the part worth recording. Every check passed, because every check compares the generated pages to the source, and the source said this. - This is the second time - v0.1.22
in one release that an insertion has taken a block from the item below it, after the missing feature gate earlier. A test now reads the module and fails if a probe or an offer carries no documentation, which is the mechanical half of the - v0.1.22
mistake and the half a losing item always shows. - v0.1.21
**Every screenshot the same size, and in the face they were meant to be in** - The captures were trimmed to each tab's own content, with a floor. That gave eight pictures 1000 tall, one 1095 and one 1315, which is right for one picture and - v0.1.21
wrong for the grid the README and the website show them in: the row holding the group tab sat lower than the rows either side of it. - **One height now, and it is the tallest one's content**, measured across the set on every run rather - v0.1.21
than written down. It is the only shared height that crops nothing: trimming to the shortest would cut the group panel off, and scaling would make the text in one picture a different size from the next. The cost is real and is paid on - v0.1.21
purpose: the short tabs carry background below their content. Empty space in a picture reads as the window having room; a stepped grid reads as a mistake. - **The captures are in JetBrains Mono, and that is now proved rather than hoped.** - v0.1.21
The window prefers it and falls back to the built-in monospace when it is absent, which is right for somebody running the program and wrong for a capture: half a set in the wrong face looks subtly off and nothing about the run says so. - v0.1.21
`veilvoice-gui --typeface` answers which face the window would draw with, without opening one, and the capture script asks first and refuses to photograph anything if the answer is the fallback, naming the package for each platform. - All - v0.1.21
ten retaken, so they show what this release actually contains. - More room between them on the website: 40px across and 48px down, more vertically because each picture has a caption under it and a matching row gap put the next picture as - v0.1.21
close to a caption as the caption is to its own picture. The README's grid is a table GitHub renders and strips styles from, so there the equal heights are the whole of the fix. **ffmpeg joins the companion list, and the render points at - v0.1.21
it** - The Setup tab lists the software VeilVoice works with, says who makes each and under what licence, and installs one on an explicit yes. **`ffmpeg` was not in it**, which is the whole of this: a render told you `ffmpeg` was missing - v0.1.21
and the tab that installs things had never heard of the name, so the message pointed at nothing. - It is there now, first in the list, with what it is for, what still works without it, and the install command for this system: `winget` on - v0.1.21
Windows, Homebrew on macOS, and whichever package manager is actually on PATH elsewhere. Every message about a missing `ffmpeg` now names the tab, and a test fails if one stops doing so. - Both front ends read the same table, so `veilvoice - v0.1.21
companions` gained the entry at the same time and cannot disagree with the window about what exists. - The loop that asks each package manager in turn existed twice and would have become three copies. It is written once, which is the point - v0.1.21
at which copies start disagreeing about which managers this project recognises. - **"Look again" no longer freezes the window.** It ran a command per companion inside the frame it was drawing, which on a machine with several of them is the - v0.1.21
window going white. The search moved to a worker and the button says it is working while it runs. - Nothing here downloads anything. An install runs the package manager the machine already has, so the claim that VeilVoice ships no network - v0.1.21
client is unchanged and still checked three ways on every commit. **The video has a picture in it** (roadmap item 139, finished) - A render produced veiled audio over a black rectangle. It now produces the same picture the preview page - v0.1.21
shows: a circle per speaker in their colour, whoever is talking lit, the level under each name, the waveform and a playhead. One layout function draws both, so the two cannot drift into being pictures of different recordings. - **The - v0.1.21
frames are drawn here, as pixels.** A canvas with rectangles, antialiased circles and a PNG writer, and a five-by-seven monospace face of ninety-five glyphs written out in the file. The drawing is SVG and no build of `ffmpeg` can be - v0.1.21
assumed to read SVG; converting it would have meant an SVG rasteriser, which is exactly the large library this project spends a page explaining why it will not carry. One dependency, `miniz_oxide`, for the deflate that PNG is made of, and - v0.1.21
it was already in this tree under `flate2`. - **A picture is written when the picture changes**, not once per frame of video. The playhead moves a pixel at a time rather than a frame at a time, so ten minutes at thirty frames writes - v0.1.21
hundreds of files instead of eighteen thousand. The saving is measured and reported rather than asserted, and a short recording holds nothing, which is correct: the playhead crosses the whole waveform however long the recording is. - That - v0.1.21
is why the ffmpeg command is a **concat list with a duration per picture**. Held frames handed to the numbered-sequence reader would play an hour of conversation in the few seconds its distinct pictures cover, which is a video that is - v0.1.21
wrong rather than one that refuses to encode. - **Names the face cannot draw are named.** Printable ASCII only, so a name in another alphabet comes out as open boxes, and the render says which names before somebody watches an hour of video - v0.1.21
to find out. The preview page does not have this limit, because it is markup and uses the reader's own fonts. - Video is the one output **off by default**, because it is the one that needs a tool VeilVoice does not ship. Without `ffmpeg` - v0.1.21
the pictures and the list are still written and the exact command is handed over. **A score for what a recording gives away about who was speaking** - Beside the voice controls in the Group tab there is now a percentage. A hundred means - v0.1.21
the finished recording says nothing about which of the people in it was talking. It falls as the group grows in "a voice each" mode: two people is 70 per cent, four is 40, eight is 10. One voice for everybody is a hundred at any size. - - v0.1.21
**It is not a measure of how well a voice is disguised.** That is the engine's and it does not get weaker because somebody else joined the call: eight people are each hidden exactly as well as one. It is not cryptography either, and the - v0.1.21
module, the guide and the interface all say so rather than letting an information count be read as a claim about strength. - What it counts is the other leak, the one that does grow with the group: how much of the conversation's shape a - v0.1.21
listener gets free. Eight tellable-apart voices let anybody count the participants, follow who said what, and align two recordings of the same group by voice. `log2(classes)` bits per turn, measured against the widest the engine goes. - - v0.1.21
**Two speakers whose voices are too close to separate count as one.** So crowding the table makes a recording give *less* away while making it harder to follow. That runs backwards from the obvious reading, so it is shown on its own line - v0.1.21
rather than folded into the score, and a test pins it down. - Fixed while building it: a solo recording was reported as "crowded", because the closest-pair measure answers 1.0 when there is nothing to compare and that is below the - v0.1.21
separation floor. One voice has nothing to be confused with. **A level under every speaker's name** (roadmap item 139, the first of its two halves) - The preview page lit whoever had the turn and said nothing more. A lit circle cannot say - v0.1.21
whether that person is mid-sentence or mid-pause, and those look identical for as long as the turn lasts, so under each name there is now a bar that moves with the sound. - **Drawn from the same envelope as the waveform beneath it**, so - v0.1.21
the two cannot disagree: one array, two things drawn from it, and the page is animated from the numbers it drew rather than from a second copy. - **It is the mix, given to whoever is speaking**, and the guide says so. A render produces one - v0.1.21
mixed track, so there is no separate signal per person to measure. That is the same thing while one person talks; where two turns overlap both show the same bar, which is what a listener hears and is not a claim that each was that loud. - - v0.1.21
Somebody whose turn it is not shows **nothing**, rather than a small amount. A bar moving for a person who is not speaking would be the one thing on the picture actively saying something untrue. - The track is always drawn and only the - v0.1.21
filled part moves, so the layout does not shift under the reader every time somebody stops talking. - **What is left is drawing the frames.** The video file is still veiled audio over a black picture. That needs a rasteriser and a font, - v0.1.21
neither of which is a small addition, and the roadmap row says what they are and why a full SVG rasteriser is the wrong way to get them. **Dependabot, configured, and a check that keeps it true** - There was no `.github/dependabot.yml`. - v0.1.21
Nothing had ever raised a version update or an alert against this tree. There is one now: the Cargo workspace, `fuzz/` separately (it is outside the workspace, so an entry on the root does not reach it), and the actions the workflows run, - v0.1.21
which hold a token that can publish signed artefacts and are monitored on the same terms as the code. - **The configuration is checked rather than remembered.** `tools/audit/dependabot.py` reads it against the tree and fails on a manifest - v0.1.21
no entry covers, or an entry naming a directory that has gone. It runs in CI beside the check that every dependency says what it is for, which is the other half of the same question: saying what a dependency is for does not make anything - v0.1.21
watch it. - Two ecosystems, and the file says why there are only two. The website's JavaScript and the site tests use Node's own built-in modules and nothing else, and every script under `tools/` and `assets/` is standard-library Python, - v0.1.21
so a `package.json` or a `Gemfile` here would declare no dependencies and would be one more file to keep true. `Cargo.toml` is this project's package manifest, and there are 28 of them. **One demonstration, the command line first and the - v0.1.21
window under it** - The demonstration was in three parts in the order: the command line typed out, the window's screens, the command line one job at a time. The two halves of the same thing had the other thing wedged between them, so a - v0.1.21
reader who wanted to know what the commands are read about them, looked at pictures of a window, and then read about the commands again. - It is one section now. **Both command-line parts are together and first, and the window is below - v0.1.21
them.** The command line goes first because it is the half that can be shown rather than depicted: those are recordings of the real programs replayed at typing speed, and every byte in them is what the program wrote. Somebody who reaches - v0.1.21
the photographs has already watched it run. - Nothing else about the page moved, and nothing went behind a button. **The terminal drawings share one width** - Each drawing was exactly as wide as its own longest line, which gave eleven - v0.1.21
pictures at five widths: 651, 800, 817, 825 and 834. The README stacks all eleven vertically and the website shows the same set, so what a reader saw was a column of terminal windows whose edges did not line up. - They share one width now, - v0.1.21
measured across the set on every run, so a command whose help grows moves all of them together instead of becoming the one exception. Height still varies, because height is the content: a longer help screen is a taller picture, which is - v0.1.21
the axis a page can afford. - These draw a **terminal window**, and a terminal window does not shrink to fit whichever command printed the least. The shared width is the widest one's content, which is the only shared width that re-wraps - v0.1.21
nothing. - Checked rather than asserted: the ten window captures are all 1400 by 1537 and the eleven drawings are all 834 wide, and both are produced by tools that measure the set rather than by a number written down somewhere. **The tag a - v0.1.21
release publishes now names the commit it built** - The release workflow ends by creating a GitHub release for a tag. Creating a release for a tag that does not exist yet makes GitHub create the tag, and with no target it creates it **at - v0.1.21
the default branch** rather than at the commit the run compiled. - Both of the last two releases were tagged that way. v0.1.21's tag landed on a tree whose `Cargo.toml` still said 0.1.20; v0.1.20's landed thirteen minutes ahead of what it - v0.1.21
published, on a commit that does not compile on nine of the twelve targets. - **This goes to the whole of what a release claims.** Every binary is built twice in different directories and compared byte for byte, the hashes are signed, and - v0.1.21
the reproducible-builds guide tells a reader to check out the tag and rebuild. That is all machinery for one sentence, and the sentence is false when the tag names a different tree: the reader who does the work gets different bytes and - v0.1.21
correctly concludes the release does not reproduce. - Nothing was tampered with, and the binaries are what the run built. What was wrong was the pointer, and it is one line: the release step names the commit now, and - v0.1.21
`tools/audit/publishing.py` fails a build if it stops doing so or starts naming something else. **A release published saying it had no release notes** - v0.1.21's notes on GitHub read, in full, that no changelog section could be found for - v0.1.21
it. The workflow matches `## v<version>` as a whole line, the entry had been written with a date after it and no `v` in front, and the match failed, so seven hundred lines of notes were dropped. - It then published anyway. That is the - v0.1.21
defect rather than the heading: a note saying the notes are missing is not a smaller version of the notes, it is a release whose contents nobody can tell. - The heading is the shape every other entry uses. The workflow refuses to publish - v0.1.21
without a section now, and because that refusal only fires after twelve jobs have built and compared every binary, the same question is asked first where it costs nothing: `tools/release/version.py --check` requires the changelog to carry - v0.1.21
a heading the workflow's reader can find, and says which heading is actually there when it is the wrong shape. - Two readers of one file disagreeing is why this survived. The website's releases page is built from the same headings and - v0.1.21
tolerates both forms, so every local check passed while the one that mattered found nothing. **The feature nine release jobs turn off, that nothing built** - `veilvoice-audio`'s live capture is an optional feature, off on nine of the - v0.1.21
twelve targets a release builds: `cpal` has no backend for the BSDs, cannot be linked into a static musl binary, and has no cross-architecture ALSA to build against. Those nine build the command line with default features off. - **Nothing - v0.1.21
in CI built that.** Every `cargo` line took the default features, so a module whose `#[cfg(feature = "live")]` had gone missing compiled on all three platforms, passed every check twice over two days, and then failed nine release jobs at - v0.1.21
once. The attribute had been taken by a `playback` declaration inserted directly above it, because an attribute attaches to the item that follows it. - The gate is back, and the gap it fell through is closed: `tools/audit/features.py` - v0.1.21
reads the build arguments out of the release workflow and compiles each selection, in CI beside the release build. Reading the workflow rather than copying its arguments is what makes a target added to that matrix built on every push - v0.1.21
without anybody remembering, and the tool refuses to pass if it can read fewer than two selections, so a workflow rewritten into a shape it cannot parse fails rather than checking nothing. - The existing platform guard could not have - v0.1.21
caught this. It varies the operating system; this varies the features, and a guard covering one axis reads as covering the other. **Several microphones at once, veiled and metered each** (roadmap item 147) - `veilvoice_audio::room` opens - v0.1.21
one input per guest, veils each with its own engine, seed and destination voice, and mixes the results into the one output a call or a recorder hears. One microphone carrying four people is one signal, and whatever it is turned into, - v0.1.21
everybody in it is turned into the same thing. - **The cost is a number rather than a promise.** The output callback runs every guest's engine before it returns, so they share one deadline of a few milliseconds and the cost is the sum. - v0.1.21
`RoomStats::load` is that sum measured against that deadline. At 1.0 the engines have used the whole block and what follows is dropouts. The guest limit is a bound on the arithmetic, not a claim about any machine. - **The mix is summed and - v0.1.21
clipped, and never limited.** Two people talking at once is two signals added, which can pass full scale; a render fixes that afterwards with one factor and a live path cannot see the rest of the conversation. So the peak *before* clipping - v0.1.21
is reported, the blocks that clipped are counted, and there is deliberately no limiter: a limiter is a dynamics processor, it changes the voice, and this program's whole claim is about what changes a voice. - Each guest has their own ring, - v0.1.21
so a microphone whose clock runs fast drops that guest's samples rather than everybody's, and a slow one starves and is padded. A rate *mismatch* is not absorbed: it is refused before anything opens, which is the fix in this same release. - v0.1.21
- A recorder per guest, veiled or unveiled, and one for the mix. Roadmap item 131's warning about the unveiled side applies once per guest. - **The Studio drives it.** Ticking "several microphones, a guest each" in the Studio turns the - v0.1.21
input picker into a guest list: a name and a microphone per person, up to eight. Starting it opens all of them, and each guest is veiled into a voice of their own, from the same table a group render hands out and in the same order. - **Two - v0.1.21
bars per guest**, what went into their microphone and what their engine produced from it, with the mix under them and the load beside them. A room drawn as one pair of bars cannot say which microphone is dead, which is the reading somebody - v0.1.21
actually needs. The load is said as a percentage of the block every engine shares, and past 80 per cent it says what to do about it. - **A take stores every guest separately, beside the mix.** One name, and the recordings under it are the - v0.1.21
mix, called "(everybody)", and one or two per guest called after them. The choice of which side to keep is the one the single microphone already has and applies to every guest at once, so an unveiled room is eight recordings of eight real - v0.1.21
voices and says so before it starts. - **The Studio holds one session or the other and never both**, as one field rather than two: two of them would be two streams on one output with every guest's voice arriving twice, and a field that has - v0.1.21
to be remembered is a field that gets forgotten. - **Two guests on one microphone is refused by name before anything opens.** One microphone carrying two people is one signal, so veiling it would give both of them the same voice, which is - v0.1.21
what a microphone each was for. Two guests on the *default* device are the same refusal: `None` is a device, not an absence. - Every recording of a take is named in one function, checked by a test that reads this module. A take now - v0.1.21
produces up to seventeen recordings from three loops, and a suffix added in two of them would leave an entry that is somebody's real voice looking exactly like the veiled one beside it. **A microphone and an output that never compared - v0.1.21
their rates** - The live path built the engine and the ring between its callbacks from the output device's sample rate, and the input stream from the microphone's, and never checked that the two were the same number. - Where they are not, - v0.1.21
and a laptop with a 44.1 kHz microphone and 48 kHz speakers is an ordinary machine, the ring starves continuously and the veiled voice comes out about a semitone and a half sharp and stuttering. - Invisible twice over: the starvation reads - v0.1.21
as "this machine is too slow", and the pitch is *meant* to change, because this is a voice de-identifier. - Both devices are now put on one rate: the one they already share, the output's if the microphone will take it, or the microphone's - v0.1.21
if the output will. If neither will move, it refuses and names both rates and what to do, rather than running them together and quietly shifting the voice. - The output's rate is preferred, because it is what the person hears through and - v0.1.21
what anything on a virtual cable expects. - Nothing here resamples, and adding a resampler to paper over a mismatch would be a quality and latency decision taken to avoid saying something. **The Studio has a failsafe of its own, and a - v0.1.21
running take says what it keeps** - Both are clauses of roadmap item 145 that nothing had built, found by reading that row rather than treating it as a sum of the roadmap items under it. - **A device that goes stops the Studio, not the - v0.1.21
recording.** Since the last change the Studio knows when the device a take is being recorded from has stopped existing; knowing was as far as it went, and the take carried on recording silence until somebody looked at the screen. It now - v0.1.21
stores what was captured and stops. - It does not discard, retry or switch device. Not discard, because everything up to the fault is a real recording of something somebody said. Not retry or switch, because the person chose that - v0.1.21
microphone and moving a recording onto another one is this program deciding that for them, which on most machines means a laptop's built-in microphone. - Only a device that has gone. Anything else the platform reports is shown and left - v0.1.21
alone: an underrun is not a reason to end somebody's recording. - Separate from the application's safety catch, which is about *other programs* taking the microphone. This one is about this tab. - **A running take now says which voice it - v0.1.21
is keeping.** The choice is made on a form that disappears when recording starts, so a take of somebody's real voice looked exactly like one that is not for the whole of the recording. It is beside the clock, in yellow when the microphone - v0.1.21
is being kept. - Roadmap item 145 is still planned, and its row now says what it is waiting for rather than reading as a sum of the roadmap items under it: group mode recording every guest with the same guarantees, which is roadmap item - v0.1.21
147. **A BSD reader was told to run a command their system does not have** - `veilvoice verify --script` writes a shell script that checks the signature and the hashes with the reader's own GnuPG. It knew two systems, Linux and macOS, and - v0.1.21
every mapping onto it ended in a catch-all meaning Linux, so a reader on FreeBSD, OpenBSD or NetBSD was handed `sha256sum -c`. - The same release's *other* script, the one that reproduces the build, has always given the BSDs `sha256 -c` - v0.1.21
and has a test forbidding `sha256sum` there. Two scripts in one release, disagreeing about the reader's machine. - The cause was a copy: both modules answered the same question and one of them was not kept current. The copy is gone. There - v0.1.21
is one public `System::hash_check_command`, the verification script asks it, and the two catch-all matches are now one exhaustive function that will not compile if a system is added to one enumeration and not the other. - The guard is that - v0.1.21
no system's script may contain another system's hash command, which fails on exactly the fall-through this was. **Checking a download, written up per system** - `veilvoice verify` on its own is the same command everywhere: it hashes and - v0.1.21
checks the signature itself, and needs no GnuPG, no network and none of the system's own tools. A BSD reader has nothing to translate, which is why the guide now says so first. - The second opinion is where systems differ, and the guide - v0.1.21
carries a table of which script each system gets and what it runs. The table is checked against the program in the test suite, so a command in the documentation that the program no longer prints fails a build. - Installing GnuPG on OpenBSD - v0.1.21
and NetBSD is the one thing left unsaid. This project has not run those package managers and does not print commands it has not run; FreeBSD's spelling is named because `install/install.sh` already uses it. **Two bars per speaker, while a - v0.1.21
group render runs** - As the render walks the file, each person gets what went into the turn just finished and what the engine produced from it, with how far through their turns the render is. One bar answers "is something being written" - v0.1.21
and not "is this person being veiled", which is the question somebody rendering an interview is asking. - Drawn with the shared meter the live path uses, so it is the same bar rather than a second one that would drift from it, and the - v0.1.21
limit is printed under them in the same words. - The render reports through a small structure of atomics: nothing allocates, nothing locks, and the render threads write to it while the window reads it every frame. A watched render and an - v0.1.21
unwatched one produce byte-identical audio, which is a test rather than a claim. - A progress made for fewer speakers than the plan holds drops what it cannot keep rather than failing the render. A bar with nowhere to go is not worth a - v0.1.21
refused render. - This is the half of roadmap item 133 that could be built from what was here. The other half, several guests on several microphones at once, is now roadmap item 147: the live path opens one input and everything downstream - v0.1.21
assumes one. **The audio path says when something interfered with it** - A device unplugged or swapped mid-session, and anything else the platform reports about either stream, is now shown where the person is looking. Both error callbacks - v0.1.21
used to be `eprintln!` and nothing else: on Windows the desktop application is built with no console at all, so a microphone taken away in the middle of a call was completely silent and a recording carried on being made of nothing. - The - v0.1.21
report is the platform's own words, with the device-is-gone case named separately because it does not come back on its own. - Detected by the platform telling us rather than by polling a device list. A list read once a second is a guess - v0.1.21
between reads, and enumerating devices from another thread on Windows is what F-163 and F-165 were. - While a take is running, a program other than VeilVoice taking the microphone is named on the tab and again with the stored take. That - v0.1.21
program heard the real voice whatever was going to the cable. Independent of the safety catch's posture: that setting is about closing other programs, and somebody who turned it off did not ask to be told less about their own recording. - - v0.1.21
The command line shows the same thing as `INTERRUPTED` on the meter line, and `veilvoice record` repeats it when the take is sealed, because the meter line is gone by the time somebody decides whether to keep what was recorded. - The limit - v0.1.21
is stated beside the warning rather than after it: this is what VeilVoice's own path noticed, and it cannot vouch for a microphone that was already being intercepted before this opened it. - The roadmap item asked for the samples reaching - v0.1.21
the recorder to be *checked* against the engine's output. They cannot differ, because the recorder is fed from inside the output callback from the same slice the engine has just written into, so the check would be a buffer compared with - v0.1.21
itself. That is a property worth keeping rather than measuring, and a test now reads the source for it: two sinks, two writes, each from the one place its samples exist. **A check that failed once now says what it disagreed about** - The - v0.1.21
recorded-session check failed once on a loaded machine and passed on the twenty-two runs after it. It has not been reproduced and is not claimed to be fixed. - What it said was that the transcript "is not what the program prints now", and - v0.1.21
nothing else. It now prints the differing lines, diffed on the same normalised text it compares, so the next occurrence explains itself instead of being re-run until it passes. **Two roadmap rows corrected rather than built around** - - v0.1.21
Roadmap item 132's opening sentence described a check that would compare a buffer with itself, and the row now says what was actually missing. - Roadmap item 133 asked for two **live** bars per speaker **in group mode**, and neither word - v0.1.21
survives reading the code: group mode is a panel for a recording that already exists and never opens a device, and the live path opens one input, so there is no per-guest live signal in this tree to draw. The row now says that this is two - v0.1.21
things: bars drawn during a render, which the plan already has the information for, and a multi-input capture path, which is the actual work. It stays planned. **Optimisation stops being a pass and becomes how this is written** - The - v0.1.21
practices roadmap item 125's reading established are now in `CLAUDE.md` as the standing way this project is written, and three of the four are enforced by a build rather than by somebody remembering. - **No audio callback allocates, blocks - v0.1.21
or prints.** A callback runs on the operating system's audio thread with a deadline of a few milliseconds; allocating takes a lock in the allocator, blocking on a mutex hands the thread away, and printing takes the lock on standard output. - v0.1.21
Each of the three callbacks already carried a comment saying its buffers are sized once. A test now reads the callbacks themselves and fails naming the line and what it would cost. - **Every dependency says what it is for, on the line that - v0.1.21
declares it**, checked in CI. A dependency is code this project ships and does not review, build time on every machine that compiles this, and one more thing that has to work on the BSDs and the 32-bit targets. If there is no sentence to - v0.1.21
write, that is the answer. - Writing those sentences found three dependencies no line of code referred to: `sha2` in `veilvoice-verify`, `hex` in its tests and `hex-literal` in the crypto crate's. All three had been compiled by every build - v0.1.21
on every platform for as long as they had been there. They are gone. **Live scramble is the Studio now, not a tab beside it** - Veiling as it runs has stopped being a separate tab. The Studio is where it happens, and the tab is the voice - v0.1.21
above and the take below: devices, engine settings, meters, performance figures and the preview button on top, the vault and the take under them. - The voice half works with the vault shut. Veiling a call has never needed a recording - v0.1.21
vault, and requiring one would be a worse program. - Both screens always ran the same engine through the same session, which is what made two of them wrong rather than merely redundant. The Studio recorded with whichever devices the other - v0.1.21
tab happened to be set to, and nothing on its screen said so. Worse, each tab started a session of its own: veiling on one and recording on the other opened the same microphone twice. - There is one starter now, and a test that reads the - v0.1.21
desktop crate's source and fails if a second one appears. - Ending a take leaves the veiling running. Somebody who has just stopped recording has not asked to be heard in their own voice again. **stop**, in the voice half, ends the - v0.1.21
veiling, and it stores a take still running rather than discarding it. - Starting and ending a take each restart the audio, because a recorder cannot be attached to a stream that has already started. That costs a short gap in the outgoing - v0.1.21
voice, and it is said on screen and in the guide rather than hidden. - `veilvoice-gui --tab live` still opens a window. It opens the Studio, because the name is in shortcuts and scripts written before the tab moved and the honest - v0.1.21
destination is the tab that does the job today. - The monitor strip works during a take, which it did not before: it read the live tab's session, and a Studio recording was a different one, so the strip went blank over a recording that was - v0.1.21
running. **A recording was written with the rate it asked for, not the rate it got** - `record::start` takes the sample rate for the WAV header, and its own documentation says that has to be the rate the device agreed to. Both callers - v0.1.21
passed `config.sample_rate`, which is the rate the engine was configured for and is 48 kHz by default, and the session then overwrites that field with what the hardware actually gave. On a machine whose output runs at 44.1 kHz the take was - v0.1.21
written 48 kHz over 44.1 kHz samples: about nine per cent fast and a semitone and a half sharp, on top of the veiling. - Fixed by construction rather than by care. `LiveSession::start_recording` builds the recorders itself, after the - v0.1.21
device has answered, and hands them back: it takes a `Keeping` saying which sides to keep and returns a `Kept` holding them. The caller has no rate to get wrong. - The property roadmap item 131 asked for survives: `Keeping`'s fields are - v0.1.21
named at the call site, so no caller reaches a recording of somebody's real voice without writing the word `plain` next to it. - A test reads the workspace and fails if a recorder is built anywhere outside the crate that knows the rate. - v0.1.21
**The Studio can keep the real voice, and asks before it does** - "What to keep": the veiled voice, both, or the microphone unveiled. Before the start button rather than after it, because a recording of somebody's real voice is not a thing - v0.1.21
to discover having made. - The veiled voice is what is selected. The choice is not remembered between runs and locking the window puts it back, for the reason group mode is not remembered: a mode somebody forgets is on eventually records - v0.1.21
what they did not mean to record. - Anything that keeps the microphone says what that costs in the same words the plaintext path uses. It is sealed in the vault exactly as strongly as a veiled take, and it is still a recording anybody who - v0.1.21
opens the vault can hear who was speaking in. - Keeping both gives two takes, and the unveiled one's name ends in "(unveiled)". The name is the only thing telling them apart, which the panel says where the choice is made. - The microphone - v0.1.21
is copied into its recorder from inside the input callback, after the downmix and before anything else sees it, through a buffer sized once at startup so nothing allocates in a realtime path. It is a separate argument to `start_recording` - v0.1.21
rather than a flag on the existing one, so no caller can reach it without naming it, and every path that was not asked for it passes nothing. - `veilvoice record` on the command line keeps the veiled voice only and is unchanged. This is a - v0.1.21
Studio decision, made where the vault that receives it is. - The panel that runs during a take drains **both** recorders every frame and takes the clock from whichever is running. Draining one of two would have made the second take quietly - v0.1.21
short, which is the failure the dropped-sample count exists to report; and reading the clock from the veiled recorder alone would have shown 0:00 for the whole of a microphone-only take. **A guard for it, rather than a third one at a - v0.1.21
time** - No test in the desktop crate may open a device, a dialog or a window. The guard walks that crate's test code and fails naming any line that reaches one, with a single exception by test name for the one place that enumerates - v0.1.21
devices on purpose. - Proved both ways: it passes on the tree as it stands, and planting one line that enumerates a device makes it fail naming the file, the line and the test. - Its own first false positive is recorded too. `dialog.rs`'s - v0.1.21
guard searches the source for `rfd::FileDialog`, and the string it searches for is not an opened dialog, so a match inside a string literal is skipped. **Windows, a second time, and the same mistake in a new place** - A test written for - v0.1.21
the new setup card asked the machine how many audio devices it has, twice, to check the answer was stable. The desktop crate's test binary already enumerates real devices once, deliberately, in one place; a second enumerator beside it - v0.1.21
killed the process on Windows with an access violation, exactly as the last one did. - Gone rather than made conditional. It was checking that a call the machine answers does not fail, which is a fact about the machine. What is worth - v0.1.21
checking is that the card asks the machine rather than carrying a number, and a test reads the card's source for that. "One enumeration, in one place" is now written where the counting function is. **Every platform is green again, and the - v0.1.21
crash is understood** - `test / windows-latest` was dying with an access violation after every test it printed had passed. A step that reran the desktop crate's tests on one thread named the culprit on its first run: a test that played a - v0.1.21
recording and then locked the window, on the stated assumption that a build machine has no audio device. The Windows runner has one. A stream started and tearing it down took the whole test binary with it. - A test whose correctness - v0.1.21
depends on the machine not having a sound card is not testing the thing it names. It reads `close`'s own source now, the way its sibling reads `play`'s, and no test in the crate opens a device. - That step was there to identify the defect - v0.1.21
and is gone, as it said it would be. All thirteen jobs pass: the offline proof runs all four of its steps for the first time, and macOS and Windows are green. **A fifth setup card, and every number on it read from the machine** - First run - v0.1.21
ends on "What this machine says": where recordings will go and how much room is free there, how many devices there are to record from and play to, and what the window will ask the graphics driver for. - Every figure is read at the moment - v0.1.21
the card is drawn. None of it is a default written into the program: a setup screen that asserts how much room there is, or that the graphics will be fine, is guessing on somebody else's hardware and sounding certain about it. - The free - v0.1.21
space is said as an hour of veiled audio rather than as a number of bytes, because that is the question somebody about to record actually has. - Where the machine will not answer, the card says so instead of printing a figure nobody - v0.1.21
measured. A system that does not say where an application keeps its files is told plainly what that costs, which is that nothing is kept between runs. - Nothing on the card has to be answered, and like the four before it there is a way - v0.1.21
past it. - The guard that proves no card is a gate read a fixed four thousand characters after each function's name, which is a length rather than a body. A card longer than that reported no way past it, and a shorter one was checked - v0.1.21
against the card after it as well. It ends where the function does now. **Acceleration is a switch now, and the About tab shows both halves** - The window has always asked the platform for a hardware context and accepted a software one, - v0.1.21
which is why it opens in a virtual machine, over a remote desktop and on a server with no card. That was not settable. - One tick in Settings turns the asking off. It is for the case the request cannot cover: a driver that accepts and then - v0.1.21
draws badly, which is a hybrid-graphics laptop handing over the wrong adapter or a black window on a broken OpenGL path. Nothing can detect that, because from inside the process it looks like success, so it is a switch rather than a - v0.1.21
measurement and the panel says so. - It never becomes a demand. `Required` refuses to open where no hardware context exists, and a privacy tool that will not run is not more private. - The About tab shows what was asked for beside what the - v0.1.21
driver actually gave. Either line alone answers half of "why is this slow". **Portable first, and the About tab says exactly where things are** - A folder called `veilvoice-data` beside the program makes the settings, the vaults, the - v0.1.21
policies, the palettes and the app lock live in it. A copy on a memory stick now stays a copy on a stick. Remove the folder and it goes back to the platform's own configuration directory; neither switch moves anything that is already - v0.1.21
there. - Opted into rather than detected. "Beside the program if that is writable" would have moved an ordinary installation's state the day somebody unpacked it somewhere writable, and the symptom would have been an empty vault. - The - v0.1.21
About tab lists the exact folders in use, worked out on the machine rather than written down, with a line saying which of the two arrangements is in force. The app lock's file names are not among them: they are derived rather than fixed, - v0.1.21
on purpose, and printing them in a window would hand that back to anybody standing behind the reader. - A test reads the crate's own source for every place it keeps something and fails naming any the tab does not show, so a location added - v0.1.21
tomorrow cannot quietly stop being reported. - Installing a portable copy now asks what should happen to that folder, and the button waits for the answer. There is no default because the two right answers point in opposite directions: - v0.1.21
carrying over is right when moving onto your own machine, and leaving is right when installing on somebody else's. Carried means copied, never moved, and nothing already at the destination is replaced. **The build was red, and the offline - v0.1.21
proof had never run** - The `offline-runtime` job proves the front page's claim four ways. Its third step ran the command line inside an empty network namespace with `unshare -rn`, which asks for an unprivileged user namespace, and Ubuntu - v0.1.21
24.04 refuses those by default. It failed on its first run and on every run since, and because a failed step ends a job, **the two steps after it never ran**: the syscall trace and the window's socket families. The job existed, looked like - v0.1.21
it proved four things, and proved one. - The namespace is now taken whichever way the kernel allows, with the program dropped back to the ordinary account inside it, and the step fails saying the claim is unproved rather than passing - v0.1.21
quietly if neither way works. - The fourth step, never reached, had one quotation mark too many on its last line and would have failed the job the first time it ran. - The desktop tests were opening a real file panel. On macOS that panics - v0.1.21
outright, and on Windows the dialog thread outlived the harness and took the process down with an access violation after every test had passed. Both platforms were red. A file panel is not opened where there is no window to open it on, and - v0.1.21
on macOS an ask from any thread but the main one now reads as a cancel rather than crashing the application. - Three counts said on the front page and in the README had drifted from the tree: the tests, the functional lines and the - v0.1.21
defects. They are checked against it, and now agree with it. **Decoy vaults, and a real vault that is not found by its name** - The Browser can now fill the vault folder with decoys: vaults whose contents never existed, sealed under a key - v0.1.21
made and dropped inside the call that writes them. Nobody holds that key, so there is nothing to find, to leak, or to be compelled to hand over, and cracking one yields bytes that parse as nothing. - `make_decoy` and `Shape::of` had been - v0.1.21
written, documented and tested since 0.1.20 and were reached by nothing. This is the half that was missing. - How many is worked out from the room actually free where the vaults live, read from the operating system rather than guessed: one - v0.1.21
twentieth of it, up to a stated ceiling of thirty-two. Where the system will not say how much is free, the panel says that instead of showing an invented figure as though it had been measured. - The real vault used to sit at a fixed name, - v0.1.21
`studio`, which would have made every decoy beside it pointless: the one directory called `studio` is the one worth attacking. Vaults now live in directories with opaque names and the real one is found by trying each in turn until one - v0.1.21
opens, which only the pair of passphrases does. A vault written the old way is moved down into a directory of its own on the next unlock, index last, so an interrupted move finishes on the following one rather than splitting the vault in - v0.1.21
two. - A wrong pair of passphrases finds nothing and **makes** nothing. Creating a fresh vault there would show somebody who mistyped an empty vault, which reads exactly like their recordings having been lost. - A decoy's index is padded - v0.1.21
to the length the real one measured. Without it, every decoy in the folder would be the one with the smallest index file, and the sizes were the thing this was meant to make identical. - The limit is stated where the button is: this raises - v0.1.21
the cost of a search. It does not hide the real vault from somebody watching you open it, from something already running inside the computer, or from a backup taken before the decoys were made. **The pictures of the window showed nine - v0.1.21
tabs, and there are eleven** - Both the README's table and the website's "what it looks like" grid were hand-written lists. The Studio and the Browser shipped in 0.1.20 and appeared in neither, so the whole section quietly described a - v0.1.21
different application from the one released. Both now show all eleven, and a test reads the tab keys out of the window's own source and fails if either page is missing one. - The count was already checked and the *list* was not, which is - v0.1.21
why this got through: knowing there are eleven tabs does not notice that nine of them have pictures. - The README also said the captures were taken on Windows 11 with `gui.ps1` and `PrintWindow`. That stopped being true when they were - v0.1.21
recaptured headlessly on Linux for this release. It now says which script took the committed ones and what the other is for, and keeps the Windows 11 note as what it actually is: the platform a person has run the application on, rather - v0.1.21
than the one a script photographed it on. **The two lists of command line screens became one** - `tools/shots/terminal.py` takes the pictures and `tools/site/demo.py` names them for the walkthrough, and each held its own copy of the same - v0.1.21
eleven entries. The comment in the second justified this by saying the first holds the command "in a form no page can read", which is a list of arguments, and joining a list of arguments with spaces is not difficult. - They had already - v0.1.21
drifted: the note under `render` said "a plan, a recording and a page" in one and "a plan, a recording, and a page" in the other. Adding a screen to one of them left the other unable to draw it, which is how this was found. - `veilvoice - v0.1.21
conversation fix --help` is now in the walkthrough, drawn from what the built program prints. **The verifier transcript, recorded against the published 0.1.20** - It is the one recording that cannot be made before a release exists, because - v0.1.21
it verifies a real download: it fetches the published archive, the hash list and the signature, checks the embedded key's fingerprint, checks the signature over the list, checks the archive against the list, and then does the whole thing - v0.1.21
again through the reader's own GnuPG. - The hash in the transcript is the hash GitHub reports for that asset. Nothing in it is staged. **The Studio meters what it is doing, and the Browser can play** - Two bars while a take records, input - v0.1.21
and output. One output meter answers "is something being recorded" and not "is it being veiled", which is the question somebody at that tab is actually asking; seeing the two move differently is the only thing on screen that shows the - v0.1.21
engine is between them. The bar itself moved out of the window's private module into `monitor`, so there is one of it rather than a second that would slowly stop looking like the first. - A take in the Browser plays, straight out of locked - v0.1.21
memory. **Nothing is written to the disk**, so there is no copy to remember to shred: the obvious way to hear a WAV is to put it somewhere and hand over the path, and that would leave an unencrypted recording lying about, which is what the - v0.1.21
vault exists to prevent. - Said as built rather than as first imagined: the take is decrypted **whole**, not in blocks. The container is sealed and authenticated as one piece, and an encryption that let you open the first second without - v0.1.21
the rest would not be authenticating anything. What that buys is no plaintext file at any point. What it does not buy is a footprint smaller than the recording, and the roadmap entry now says that instead of the other thing. - One take at - v0.1.21
a time, and starting a second releases the first: two decrypted recordings in memory at once is twice as much of somebody's voice as the reason for it. Locking the window releases whatever is playing, along with the vault. - Playing at the - v0.1.21
wrong sample rate is refused rather than done. It would make a voice sound higher or lower, and in a program whose whole point is that a voice cannot be traced back, that is a wrong answer rather than a small one. - v0.1.20
**The offline claim is now proved by a machine, four ways** - The front page says VeilVoice never touches the network. CI checked the dependency graph for HTTP clients, and then a *comment* in that job said the source names no network API - v0.1.20
anywhere. True, and a sentence a reader was asked to believe, inside a job whose purpose is to replace belief with a check. - Four independent layers now, each re-runnable by a stranger from the workflow file: the dependency graph as - v0.1.20
before; the **source**, which names no network API; the **built binary**, which imports no network function at all; and the **running program**, which de-identifies a recording inside an empty network namespace with no interfaces and not - v0.1.20
even loopback, and makes zero network syscalls under `strace`. - The window is claimed separately, because it is a different program and the honest statement differs. It talks to the display server and the desktop portal. Traced, it opens - v0.1.20
exactly two sockets, both local, and asks for no internet socket at any point. - Each guard was tested against a case that should fail it. A guard that has never failed is a guard nobody has tested. **Ten drift checks that existed and did - v0.1.20
not fail a build** - Every one has a `--check`, every one was run by hand before a release, and none was in CI. The rule they enforce is written down: drift fails a build rather than being noticed later by a reader. They were the half of - v0.1.20
that sentence nobody wired up. - Three caught real staleness in this round, which is how the gap was noticed: the walkthrough had two new tabs with no picture and no caption, generated source pages were behind their source, and terminal - v0.1.20
drawings disagreed with the text they came from. **A cryptographic review, end to end** - Every mechanism and every place it is used. Argon2id at 256 MiB is above current guidance rather than at it; the whole container header is the AEAD's - v0.1.20
associated data, so KDF parameters cannot be downgraded by editing a file; the hybrid KEM binds the full transcript, so an attacker who replaces one half of the exchange cannot influence the derived key; every secret comparison is constant - v0.1.20
time; no key material anywhere lives outside locked, self-wiping memory. - No finding. The one change is a comment: the RNG bridge the KEM crates require panics when the OS random source fails, and now says why, because the alternative is - v0.1.20
handing predictable bytes to a key derivation. **A Studio to record into, and a Browser for what is in it** - Two new tabs, and the vault they share. `veilvoice-crypto::studio` had the vault already, tested and unused: a key derived from - v0.1.20
the app lock **and** the at-rest passphrase, and from neither alone. Nothing reached it. These two tabs are what reaches it. - The Studio records straight into the vault, veiled on the way in. What gets to the recorder is what the engine - v0.1.20
produced, never the microphone, and there is no path here that captures the original: a path that existed would eventually be taken, and the file it left would be somebody's real voice in a vault they believed was safe. The recording is - v0.1.20
assembled in page-locked memory and handed straight to the vault. It is never a plain file, not even briefly. - Both passphrases are asked for here, every time, rather than borrowed from whatever the rest of the application is holding. The - v0.1.20
app-lock secret is only kept for the session when app-lock sealing is chosen, so reading it when it happens to be there and prompting when it is not would make the vault's strength depend on an unrelated setting, and nobody would know - v0.1.20
which they had. Both typing buffers are wiped the moment the key is derived. - Locking the window closes the vault. A take still recording at that moment is stopped and **stored** first, not discarded: the vault is still open, and throwing - v0.1.20
away a recording because an idle timer fired would be the worst thing the tab could do. - The Browser lists what is in the vault without opening any of it, and can rename and remove. Renaming rewrites the sealed index only, because the - v0.1.20
audio is sealed under an identifier rather than a name, so it never re-encrypts anything and cannot lose a recording if it is interrupted. - Said plainly rather than implied: a vault on a disk gives away how many recordings there are and - v0.1.20
roughly how large each is. Not their names, not their dates. Hiding the count and the sizes means padding and decoys, which is a different trade and is what the program folder's own storage does. **Taking a recording back out: a page, a - v0.1.20
video, or both** - Any take in the Browser can be written out as a self-contained player page, as an MP4 with a black picture, or as both. The page is the one from `conversation render`, so it plays the audio, draws the waveform, lights - v0.1.20
the speaker and carries its own captions, and it needs nothing installed. - The plan the renderer wants is built rather than stored. A take is one person at a microphone, so it is one speaker and one turn spanning the recording; a plan - v0.1.20
kept beside each take would be a second description of something the audio already says, and the two would disagree the first time a take was trimmed. The length comes from the recording's own WAV header, because the recorder writes the - v0.1.20
rate the **device agreed to**, which is not always the rate that was asked for. - Without `ffmpeg` the exact command is printed, along with the audio it needs, which is what the command line already does. VeilVoice does not ship it and - v0.1.20
will not install it. - **Anything taken out is unsealed, and the tab says so before the button is pressed** rather than in a note afterwards. That is not a defect: a video nobody can open is not a video. The voice in it is still veiled, - v0.1.20
because it was veiled before it was ever stored. - A take is called whatever somebody typed, and here that name reaches a path. Everything that is not a letter, a digit, a dash or an underscore becomes a dash, so a recording called - v0.1.20
`../../etc/passwd` writes `etc-passwd` inside the folder that was chosen and nowhere else. - The folder is asked for every time rather than remembered, because a remembered one is how the second export lands somewhere the first was - v0.1.20
deliberately kept out of. **The screenshot scripts no longer keep their own list of tabs** - `veilvoice-gui --tabs` prints the tab names from the window's own list, and both capture scripts read it. They each carried a copy, and a copy of - v0.1.20
a list goes stale the first time a tab is added: the run succeeds, every picture it takes is correct, and the new tab simply has none. - The same list marks the first-run tour as seen, so the tour does not open over the panel being - v0.1.20
photographed. Written by hand, that would have left the tour covering exactly the new tab whose picture was the reason for the run. **The preview page can be driven, and corrected from inside it** - The self-contained player gains a - v0.1.20
transport: back ten seconds, forward ten seconds, and playback from 1x to 5x. Checking a plan means going back because the wrong circle lit up, and crossing a long recording faster than it was spoken. Five is the ceiling: past it the audio - v0.1.20
is unintelligible and playing it at all stops being the point. - The page names who it thinks is speaking, live, beside the transport. - **A correction panel for when that is the wrong person.** Press the speaker it should have been and - v0.1.20
the page writes the `veilvoice conversation fix` command that moves it, with the timestamp filled in. It does not edit the plan: doing that in the browser would mean a second implementation of the plan format, in JavaScript, able to drift - v0.1.20
from the one every other reader uses, and a plan produced by the drifted copy would look right and render somebody in the wrong voice. The page suggests, the program applies, and the change goes through the same checking as every other - v0.1.20
route into a plan. - **The captions now work when the page is opened from a folder**, which is how somebody who has just rendered one opens it. A browser treats every `file:` URL as its own origin, so the caption track fetched from the - v0.1.20
file beside the page was refused and no captions appeared, in a page whose own text promised they would. They are carried in the page now. The separate `.vtt` is still written, for every other player. - A speaker's name reaches the page - v0.1.20
inside a script as well as inside the markup, and those need different escaping: `</script>` inside a JavaScript string ends the element whatever quotes surround it. Both are escaped, and a name holding a line separator, which is a newline - v0.1.20
to a JavaScript parser and not to an HTML one, no longer breaks the script silently. **Correcting a plan before you render it** - A stretch of audio given to the wrong person is the one mistake in this program that **cannot be heard in the - v0.1.20
result**. Every voice in a veiled recording is unfamiliar, so a listener has nothing to compare against: thirty seconds of one person rendered in another's voice sounds exactly like that other person talking, and nobody notices. `veilvoice - v0.1.20
conversation fix` is how it gets corrected before the render, which is the only time it can be. - `reassign --at 20 --to 2` gives the stretch at a moment to a different speaker, leaving the timing exactly as it was. `split --at 18` cuts a - v0.1.20
stretch in two where a hand-over was missed, so half of it can be reassigned. `merge` joins two neighbouring stretches of one speaker back together. `move` nudges a stretch's edges for a hand-over caught a second late. - Every one of them - v0.1.20
refuses rather than half-applying, so a mistyped command leaves the plan byte for byte as it was. A half-edited plan looks fine and renders somebody in the wrong voice, which is the failure being prevented. - Refusals name what is actually - v0.1.20
there: asking about a moment inside no stretch reports the nearest one and when it runs, and joining two different speakers names both of them and says to reassign one first. - Speakers can be given a colour, as `#rrggbb`, which the plan - v0.1.20
file carries and every drawing uses. `None` is still the ordinary case and means the colour their slot gets from the palette. A colour is stated to be a label like a name, and no more part of the de-identification than a name is: the - v0.1.20
colour somebody always uses identifies them. **Render settings that are actually settings** - The frame size is a choice for the first time. `monitor` is the default and matches the display VeilVoice is running on, resolved when the render - v0.1.20
starts rather than stored, so it stays true when the monitor changes. Where nothing can say what the display is running at, which on a machine with no display server is the ordinary case, it uses 1080p and **says that it is a fallback - v0.1.20
rather than a measurement**. 720p, 1080p, 1440p and 4K are offered by name, and any size can be typed. - Frames per second is adjustable from 5 to 60. Thirty stays the default: the picture is a waveform, some circles and words, and sixty - v0.1.20
doubles the render time and the file for motion that is not there. - A size that no video can be made from is refused with the size that can. `1921x1080` reports that H.264 cannot store an odd side in yuv420p and offers 1920x1080; a typo - v0.1.20
that would fill a disk is refused against an 8K ceiling. - `veilvoice video` and `veilvoice conversation preview` now print how many frames the render will draw and roughly how much scratch space they want, before it starts. A minute of - v0.1.20
conversation at 4K and 60 frames a second is 3,660 frames and about 7 GiB of temporary files, which is worth knowing in advance rather than when the disk fills. - `veilvoice video` accepted no size at all and `black_command` had `1280x720` - v0.1.20
written into it, so asking for a larger frame was not possible and would have been ignored if it were. `--width` and `--height` are replaced by `--size`, which is one way to say one thing and cannot be set to a pair that describes no - v0.1.20
picture. - v0.1.19
**The audited release.** No new features. The whole repository was read for security, memory safety, correctness, reproducibility, optimisation and accuracy, and everything it found was fixed. Eight defects, F-149 to F-156, are written up - v0.1.19
in `docs/AUDIT.md`. **A recording no longer passes through unprotected memory (F-155)** - The Studio vault decrypted a recording into an ordinary heap buffer and only then copied it into locked memory. Between those two moments the whole - v0.1.19
recording sat in memory the kernel is free to write to swap, in the one place whose entire purpose is that this does not happen. It is now decrypted in place inside protected memory, so there is nowhere else the plaintext has been. A - v0.1.19
failed authentication check wipes the buffer rather than returning partial plaintext. **It is `veilvoice verify`, and nine places still said otherwise (F-150, F-151)** - The verifier stopped being an executable of its own at 0.1.18. Three - v0.1.19
lists inside the programs still named it, including the one the reproducible-build report is built from, so every such report since 0.1.18 has named a file no release publishes. The lists are now read from the job that publishes the - v0.1.19
binaries rather than restated. - The front page said the verifier "ships in every archive and is a single small program with no installer", the download-failure message printed a command that does not exist, and the recorded terminal - v0.1.19
session on the front page opened with a prompt its own recorder had already stopped producing. **The demonstration is on the page, and the drawing of the app is gone** - The website carried a hand-drawn model of the desktop application, in - v0.1.19
CSS, opened from a button, sitting beside photographs of the same interface. The drawing is removed rather than relabelled: it asked a reader to work out which of the two to believe. What is left is the real thing, on the page and not - v0.1.19
behind a button: five recorded terminal sessions that type themselves out, then the window screen by screen from captures the build re-takes, then every worked command checked against this build's own `--help`. - That removed 688 lines of - v0.1.19
JavaScript and 4,840 characters of CSS, and the demonstration's data now downloads only on the page that uses it rather than on all nine. **A roadmap page that is not fighting its own compositor (F-152)** - Every roadmap item square in the - v0.1.19
roadmap picture held a GPU layer for as long as the page was open, because a reveal animation was set to fill forwards and a filling animation never finishes. 162 composited layers became 8, and the scrolling film beside them stopped
README.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- The animated banner, as a GIF, for the reason the still one was here instead: a README is read in a hundred clients that handle animation differently. The version this replaces needed - line 1
a `<picture>` element to offer an APNG with a still fallback, and GitHub's renderer escapes `<picture>` -- which put a paragraph of raw markup above the project's name in the website's repository panel. One plain Markdown image needs no - line 1
element to escape, and GIF is the one animated format every client draws. A client that will not animate it shows the first frame, which is exactly `assets/banner.png`. Nothing here is a committed blob: `assets/generate.py` draws the - line 1
frames, writes the GIF with its own LZW encoder, and CI fails the build if the file and the generator disagree. The website does not serve this picture at all any more -- its banner is drawn in CSS, so it follows the reader's palette and - line 1
its claims are text. -->  - VeilVoice
**Irreversible voice de-identification, fully offline.** - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
Website, wiki, and an in-browser hash verifier that never uploads your file. There is a [JavaScript-free edition](https://tilas01.github.io/veilvoice/nojs/) for readers who would rather not run scripts. The whole site is static files in - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
[`website/`](website), so you can read it offline if you cloned the repository, or if GitHub Pages is ever down: run `python3 tools/site/serve.py` and open <http://localhost:8000>. For a real server there is - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
[`deploy/nginx.conf`](deploy/nginx.conf). The one number on the site that must never be stale, the signing-key fingerprint, lives in the HTML, so a local copy shows exactly what the source says. Releases also carry a **self-signed code - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
certificate** as a second, optional identity beside the OpenPGP key. It is trust-on-first-use, not a certificate authority, and it does not replace the OpenPGP check; what it adds is a publisher an organisation can import once to reduce - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
low-reputation false positives, and an independent second signature. See [docs/SELF_SIGNING.md](docs/SELF_SIGNING.md). VeilVoice destroys the *biometric voiceprint* of a speaker, meaning pitch, formants, timbre, micro-timing and the melody - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
of an accent, so that neither software nor a human listener can re-identify the speaker or reconstruct the original voice, **while the words themselves stay clean and transcribable**. There is no telemetry, no account, and no network code - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
in the dependency graph, and CI fails the build if an HTTP client appears in it. **One thing reaches the network, and only when you press it.** The desktop app has a *check for updates* button. It runs then and at no other time: no timer, - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
no check at startup, nothing in the background. It sends nothing about you or your machine, it reads a public page anybody can open, and it downloads and installs nothing: it reports a version number and every decision after that is yours. - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
There is still no HTTP client in the dependency graph: like the release verifier, it borrows the transfer tool your operating system already ships. Anonymising, scrambling, encrypting and every other thing VeilVoice does still talk to no - → [tilas01.github.io/veilvoice](https://tilas01.github.io/veilvoice/)
servers at all. --- - What it does
1. **Anonymise a recording.** Wav, mp3, flac, ogg, m4a and friends in; a clean WAV out, with metadata stripped. 2. **Scramble your microphone live** and route the result to a virtual audio cable, so any application, whether a call, a - What it does
stream or a recorder, receives the veiled voice instead of yours. 3. **Encrypt recordings at rest, by default.** Every file VeilVoice writes is sealed with post-quantum-hybrid cryptography unless you explicitly turn that off, and turning - What it does
it off makes you read why first. 4. **Lock the app** behind a separate password, so someone who picks up your unlocked computer cannot open it. See the honest limits below. 5. **Detect tampering** with VeilVoice's own files, and say - What it does
plainly when it cannot tell you which program did it. 6. **Strip identifying metadata** from audio and images (EXIF, GPS, tags). 7. **Watch what is listening.** See every application currently holding your microphone or camera, with alerts - What it does
the moment one starts. 8. **Securely erase** a recording, with an honest account of what that is worth on flash storage. 9. **Work as a Rust library** in your own project. See below. > ### Honest scope > > "Fill the whole spectrogram with - What it does
white noise" and "stay understandable and > transcribable" are mutually exclusive, because noise that covers the voice covers the > words. VeilVoice therefore targets the achievable goal: **irreversible speaker > de-identification with - What it does
intelligibility preserved on purpose.** If the *message* > also needs to be secret, encrypt it. That is a separate problem with a > separate answer. > > The same honesty applies to **accent**. VeilVoice maps every speaker onto one > - What it does
canonical pitch register, vocal-tract scale and spectral tilt, so an accent's > melody and colour do not survive. What no signal-level transform can change is > *which phonemes you actually produced*, and at that level the accent and the - What it does
words > are the same thing. > > And to the **app lock**. It is an Argon2id password verifier with a rate > limit, and it protects against *casual access*, meaning the person who picks up your > unlocked laptop. It is not tamper-proof and - What it does
it is not disk encryption: anyone > who can write to your files can delete the lock, and anyone holding the drive > can attack the stored hash offline. VeilVoice says this on the unlock screen > itself rather than in a footnote. If the - What it does
disk is the threat, encrypt the > volume. > > The full argument, and everything an attacker can still learn, is in > [`docs/WHITEPAPER.md`](docs/WHITEPAPER.md). --- - What it looks like
Every picture below is of this build. The window captures are taken by `tools/shots/gui.sh` on Linux or `tools/shots/gui.ps1` on Windows, either of which drives the release build and photographs each tab; the terminal drawings are - What it looks like
generated from the command output committed beside them, and CI fails if a drawing and its output disagree. See [`assets/screenshots/README.md`](assets/screenshots/README.md) for why those two are different kinds of thing. - The desktop application
| | | |---|---| | **Anonymise a file.** One recording, veiled, encrypted at rest by default. | **Group mode.** Several people, a name and a colour each. | |  |  | | **Recording Studio.** A microphone in, a voice that is not yours out, and a locked vault to keep it in. | **Recording Browser.** What is in the vault, played out of locked memory, and the decoys - The desktop application
that hide which vault is yours. | |  |  | | **Monitor.** Who is using the microphone and camera. | **Lock.** The app lock, and - The desktop application
what it is and is not worth. | |  |  | | **Verify.** Drop a download on the window and be told what it is. | **Settings.** Nine palettes, motion, - The desktop application
Failsafe, and which tabs are shown. | |  |  | | **Install.** Offered only to a portable copy. | **About.** Versions, scope, and the - The desktop application
update check you press. | |  |  | Both scripts start the application once per tab with `--tab <name>` and photograph it. There is no clicking and there - The desktop application
are no coordinates, so a picture cannot quietly end up showing the wrong tab. Neither script keeps a list of the tabs either. `veilvoice-gui --tabs` prints them from the window's own, so one added tomorrow is photographed without anybody - The desktop application
remembering to add it. **The pictures committed here were taken on Linux**, by `tools/shots/gui.sh`, which runs the application under Xvfb with no window manager. Without one the window is mapped at the origin at exactly the size it asks - The desktop application
for, so the X root window is the application window pixel for pixel and no cropping can include a strip of desktop. `tools/shots/gui.ps1` is the Windows counterpart and takes the same pictures with `PrintWindow`. It is what produced the - The desktop application
captures up to v0.1.19. **Confirmed on Windows 11.** That is the one this application has been run on by a person, rather than photographed by a script. **Windows 10 is supported and not yet confirmed**, which is a different sentence and - The desktop application
is meant to be. Nothing in the desktop application needs anything newer than Windows 10: the oldest interfaces it uses are `DwmGetWindowAttribute` (Windows Vista), `SetProcessDpiAwareness` and `PrintWindow` with `PW_RENDERFULLCONTENT` - The desktop application
(Windows 8.1), and the two are only used by the screenshot tool in any case. The application itself asks for nothing beyond `whoami`, `tasklist`, `taskkill` and `reg`, all of which predate Windows 10 by years. So it should run, and saying - The desktop application
"it does" is not something this page will claim until somebody has sat in front of one. macOS and Linux build and their tests pass in CI, which is weaker still: a green test run is not a person using the window. - The command line
Everything the window does, and some things it does not.    <details> <summary>The rest of the commands</summary>   - The command line
      </details> - Install
Pick your system. Each one is a single command to get running, and a second path if you would rather build it and check the result against what was published. **Check the download before you run it.** Every route below ends with that, - Install
because a privacy tool you have not verified is a privacy tool you are taking on faith. <details> <summary><b>Linux</b> (any distribution)</summary> # 1. Download the archive, the hash list and the signature. V=v0.1.22 - Install
B=https://github.com/tilas01/veilvoice/releases/download/$V curl -fsSLO $B/veilvoice-$V-linux-x86_64.tar.gz curl -fsSLO $B/SHA256SUMS curl -fsSLO $B/SHA256SUMS.asc curl -fsSLO $B/veilvoice-signing-key.asc # 2. Unpack and check it, from - Install
inside the folder. tar xzf veilvoice-$V-linux-x86_64.tar.gz cd veilvoice-$V-linux-x86_64 ./veilvoice verify # 3. Run it, or install it so `veilvoice` works in any terminal. ./veilvoice-gui ./veilvoice install `install` copies the programs - Install
into your own program directory and adds it to `PATH`. No administrator rights, no service, nothing outside your account. **Restart your terminal afterwards**, or the new `PATH` will not be in the one you are using. If the desktop - Install
application exits immediately saying a library could not be loaded, install it and try again. The window toolkit opens it by name at startup, so a minimal or server install often does not have it: sudo apt install libxkbcommon-x11-0 # - Install
Debian, Ubuntu sudo dnf install libxkbcommon-x11 # Fedora, RHEL sudo pacman -S libxkbcommon-x11 # Arch The command line needs none of this. On a distribution nothing else fits, take the `musl-static` archive: it needs no system libraries - Install
at all. </details> <details> <summary><b>macOS</b> (Intel and Apple Silicon)</summary> V=v0.1.22 B=https://github.com/tilas01/veilvoice/releases/download/$V # arm64 for Apple Silicon, x86_64 for Intel. curl -fsSLO - Install
$B/veilvoice-$V-macos-arm64.tar.gz curl -fsSLO $B/SHA256SUMS curl -fsSLO $B/SHA256SUMS.asc curl -fsSLO $B/veilvoice-signing-key.asc tar xzf veilvoice-$V-macos-arm64.tar.gz cd veilvoice-$V-macos-arm64 ./veilvoice verify ./veilvoice-gui - Install
macOS will refuse an unsigned download the first time. Right-click the program and choose Open, rather than turning Gatekeeper off. </details> <details> <summary><b>Windows</b> (10 and 11)</summary> $V = "v0.1.22" $B = - Install
"https://github.com/tilas01/veilvoice/releases/download/$V" curl.exe -fsSLO "$B/veilvoice-$V-windows-x86_64.zip" curl.exe -fsSLO "$B/SHA256SUMS" curl.exe -fsSLO "$B/SHA256SUMS.asc" curl.exe -fsSLO "$B/veilvoice-signing-key.asc" - Install
Expand-Archive "veilvoice-$V-windows-x86_64.zip" -DestinationPath . cd "veilvoice-$V-windows-x86_64" .\veilvoice.exe verify .\veilvoice-gui.exe `.\veilvoice.exe install` puts it on your `PATH`. **Open a new terminal afterwards**: an - Install
existing one keeps the `PATH` it started with. </details> <details> <summary><b>WSL</b></summary> WSL is Linux, so the Linux instructions apply unchanged and `veilvoice` works exactly as it does there. Two differences worth knowing: - The - Install
window needs **WSLg**, which recent Windows has by default. - A microphone belongs to Windows, not to the distribution, so **live mode is the Windows build's job**. Use the Windows download for that. </details> <details> - Install
<summary><b>FreeBSD, OpenBSD, NetBSD</b></summary> The command line only. The audio library has no BSD backend, so live capture cannot work and the window is not shipped. Everything that operates on a file runs exactly as it does - Install
elsewhere. V=v0.1.22 fetch https://github.com/tilas01/veilvoice/releases/download/$V/veilvoice-$V-freebsd-x86_64.tar.gz tar xzf veilvoice-$V-freebsd-x86_64.tar.gz cd veilvoice-$V-freebsd-x86_64 ./veilvoice verify </details> <details> - Install
<summary><b>Build it yourself, and prove it matches</b></summary> A fresh clone needs **no secrets**: git clone https://github.com/tilas01/veilvoice && cd veilvoice cargo build --release To prove your build is the published one, byte for - Install
byte: veilvoice verify --build-script > reproduce-veilvoice.sh sh reproduce-veilvoice.sh v0.1.22 That clones the tag, builds it with the committed lockfile and the commit's own date, and compares the result with the release. **If it does - Install
not match**, the script says which file differed. Before reporting it, check the three things that cause a mismatch on an otherwise honest machine: 1. **A different compiler.** The version is pinned in `rust-toolchain.toml` and `rustup` - Install
honours it automatically. Without `rustup`, you may be building with something else. 2. **`RUSTFLAGS` set in your environment**, which changes codegen. 3. **A dirty checkout.** The script clones fresh for exactly this reason; if you built - Install
by hand, `git status` should be clean. If all three are ruled out, that is worth reporting, and [`docs/REPRODUCIBLE_BUILDS.md`](docs/REPRODUCIBLE_BUILDS.md) explains what is pinned and why. </details> - Guides
| If you have | Read | |---|---| | an archive you just downloaded | [Checking a download](docs/GUIDE_VERIFY.md) | | a terminal | [The command line](docs/GUIDE_CLI.md) | | a window | [The desktop application](docs/GUIDE_GUI.md) | | all of - Guides
it | [The full user guide](docs/USER_GUIDE.md) | Also: [installing in detail](docs/INSTALL.md), [packaging it yourself](docs/PACKAGING.md), [reproducible builds](docs/REPRODUCIBLE_BUILDS.md). --- - Use it
- Desktop app
veilvoice-gui Ten tabs: anonymise a file, group conversations, the Studio, which scrambles a microphone live and records into the vault, browse what is in that vault, who is using the microphone and camera, the app lock, verify a download, - Desktop app
settings, portable or installed, and an about panel that states the scope. Nine palettes, or your own, and every screen is captured under [What it looks like](#what-it-looks-like). - Command line
veilvoice anonymise recording.mp3 -o clean.wav # writes clean.wav.veil, sealed veilvoice anonymise recording.mp3 --encrypt-to friend.pub veilvoice anonymise recording.mp3 --encrypt false # warns first veilvoice live --output "CABLE Input - Command line
(VB-Audio Virtual Cable)" veilvoice devices veilvoice clean photo.jpg veilvoice encrypt secret.wav veilvoice decrypt clean.wav.veil -o clean.wav veilvoice keygen veilvoice lock set # password-gate the desktop app veilvoice lock status - Command line
veilvoice guard init --sealed # record what the files should be veilvoice guard check # and see whether they still are veilvoice watch # who is using the mic and camera veilvoice shred secret.wav # irreversible Every command takes `--help`. - Encrypted by default
`anonymise` seals its result into a `.veil` container rather than writing a bare WAV, because de-identification and confidentiality are different problems and only the first one is solved by the engine: **the words survive on purpose**, so - Encrypted by default
an unencrypted result is still a recording of everything that was said. The WAV is encoded in memory and sealed there, so a recording that is going to be encrypted never touches the disk in the clear, not even for a moment, because a - Encrypted by default
plaintext file that is written and then deleted is exactly what [`veilvoice shred`](crates/veilvoice-crypto/src/shred.rs) explains cannot be reliably taken back on flash storage. Passing `--encrypt false` still works. It prints what you - Encrypted by default
are giving up and, on a terminal, waits for you to type `UNENCRYPTED`. - Who is listening?
De-identifying your voice on a call achieves little if a second program is recording the raw microphone at the same time. `veilvoice watch` names what is holding your microphone and camera, and alerts the moment something starts: ● - Who is listening?
veilvoice is now using your microphone Windows reads the same records that drive the OS privacy indicator; Linux reads open handles under `/proc`. **macOS exposes no public interface for this**, so nothing is reported there rather than - Who is listening?
something guessed: the tool tells you it cannot see, because an empty list from a blind monitor is a false reassurance. --- - Route it into a call
Live mode veils your microphone and writes the result to an output device. To put that into a call, the call has to be able to *read* that output, and an operating system will not normally let one program's output be another's input. A - Route it into a call
**virtual audio cable** is a device that exists only in software: VeilVoice writes to one end, and Zoom, Discord, OBS or anything else picks its microphone as the other. None of these is written by this project, none is bundled, and each - Route it into a call
is under its own licence. Install whichever your system uses, then in VeilVoice choose your real microphone as the input and the cable as the output, and in the call choose the cable as the microphone. **Where this does not apply.** The - Route it into a call
FreeBSD, OpenBSD and NetBSD archives have no live microphone mode at all, because `cpal`, the audio device library, has no backend for them. The BSD sections below are there so that somebody on one of those systems knows that rather than - Route it into a call
hunting for a cable that would not help. Run `veilvoice info` on any platform and it says what that build supports. <details> <summary><b>Windows 10 and 11</b></summary> **[VB-CABLE](https://vb-audio.com/Cable/)** by VB-Audio Software. - Route it into a call
Proprietary donationware, free to use. One cable, and the usual choice. 1. Download from [vb-audio.com/Cable](https://vb-audio.com/Cable/) and unzip it. 2. Right-click `VBCABLE_Setup_x64.exe` and choose **Run as administrator**. It needs - Route it into a call
that to install a driver. 3. Reboot. Windows will not show the device until you do. 4. In VeilVoice: input **your microphone**, output **CABLE Input (VB-Audio Virtual Cable)**. 5. In the call: microphone **CABLE Output (VB-Audio Virtual - Route it into a call
Cable)**. To hear yourself while you talk, turn on *Listen to this device* for CABLE Output in Windows sound settings and point it at your headphones. **[Voicemeeter](https://vb-audio.com/Voicemeeter/)**, by the same author, is the larger - Route it into a call
version: several cables plus a mixer, for feeding more than one program at once. Same licence. **[Virtual Audio Cable](https://vac.muzychenko.net/en/)** by Eugene Muzychenko is the long-standing commercial alternative, with a trial that - Route it into a call
adds a spoken reminder to the audio. `veilvoice install` on Windows takes a `-WithVBCable` switch, which opens the VB-CABLE download page in your browser. It downloads nothing itself and installs nothing: VeilVoice does not install other - Route it into a call
people's drivers. </details> <details> <summary><b>macOS</b> (Intel and Apple Silicon)</summary> **[BlackHole](https://existential.audio/blackhole/)** by Existential Audio. MIT licensed, open source, and a universal binary, so the same - Route it into a call
installer covers both Intel and Apple Silicon. 1. Install it with Homebrew: brew install blackhole-2ch or download the signed installer from [existential.audio/blackhole](https://existential.audio/blackhole/). The source is at - Route it into a call
[github.com/ExistentialAudio/BlackHole](https://github.com/ExistentialAudio/BlackHole). 2. In VeilVoice: input **your microphone**, output **BlackHole 2ch**. 3. In the call: microphone **BlackHole 2ch**. To hear yourself as well, open - Route it into a call
**Audio MIDI Setup**, create a **Multi-Output Device** containing BlackHole and your headphones, and send VeilVoice there instead. The 16-channel and 64-channel builds (`blackhole-16ch`, `blackhole-64ch`) exist for larger routing setups - Route it into a call
and are installed the same way. Two channels is what a call needs. **[Loopback](https://rogueamoeba.com/loopback/)** by Rogue Amoeba is the commercial option, with a graphical patchbay and a free trial that degrades the audio after twenty - Route it into a call
minutes. Soundflower, which older guides still recommend, is unmaintained and is not a good choice on a current macOS. macOS will ask for microphone permission the first time. That is the real microphone, not the cable. </details> - Route it into a call
<details> <summary><b>Linux</b> (any distribution)</summary> **[PipeWire](https://pipewire.org/)** is almost certainly already running: it is the default on Fedora, Ubuntu since 22.10, Debian 12, Arch and most others. Nothing to install, - Route it into a call
and the cable is one command. 1. Create the cable: pw-loopback --capture-props='media.class=Audio/Sink node.name=veilvoice_cable' \ --playback-props='media.class=Audio/Source node.name=veilvoice_cable_out' Leave that running. It disappears - Route it into a call
when you stop it, which is the tidy way round: nothing is installed and nothing survives a reboot. 2. In VeilVoice: input **your microphone**, output **veilvoice_cable**. 3. In the call: microphone **veilvoice_cable_out**. A graphical - Route it into a call
patchbay makes the wiring visible and is worth having: [**qpwgraph**](https://gitlab.freedesktop.org/rncbc/qpwgraph) or [**Helvum**](https://gitlab.freedesktop.org/pipewire/helvum), both packaged nearly everywhere. **On PulseAudio**, if - Route it into a call
your distribution still uses it: pactl load-module module-null-sink sink_name=veilvoice_cable \ sink_properties=device.description=VeilVoice_Cable VeilVoice outputs to `VeilVoice_Cable`; the call takes `Monitor of VeilVoice_Cable` as its - Route it into a call
microphone. `pactl unload-module` with the number that command printed removes it again. **On [JACK](https://jackaudio.org/)**, connect VeilVoice's output port to the call's input port in `qjackctl` or Carla. PipeWire provides a JACK - Route it into a call
interface, so this works without running JACK itself. </details> <details> <summary><b>FreeBSD, OpenBSD and NetBSD</b></summary> **The BSD builds have no live mode**, so no cable will make one appear. `cpal` has no BSD backend, which the - Route it into a call
archive's own notes and `veilvoice info` both say. Everything else works: anonymise, clean, encrypt, decrypt, keygen, conversation rendering and verification. Recorded on the roadmap as a real gap rather than a decision. If you want to - Route it into a call
route audio on these systems for other reasons, this is what people use: - **FreeBSD**: [`virtual_oss`](https://github.com/freebsd/virtual_oss), in ports as `audio/virtual_oss`, creates virtual devices in front of a real one. PulseAudio - Route it into a call
and PipeWire are both in ports as well. - **OpenBSD**: [**sndio**](https://sndio.org/), which is part of the base system. `sndiod` sub-devices route audio between programs with no third-party driver at all. - **NetBSD**: the - Route it into a call
[`pad(4)`](https://man.netbsd.org/pad.4) pseudo-device, again in the base system, presents an audio device whose output another program can read. </details> --- - Use it as a library
**Worked examples, with the licence implications spelled out, are in [`docs/USING_THE_CRATES.md`](docs/USING_THE_CRATES.md).** Every example there is a real file under `crates/*/examples/`, compiled on every commit, so none of it can - Use it as a library
quietly stop being true: cargo run -p veilvoice-core --example veil_a_buffer cargo run -p veilvoice-crypto --example seal_and_open Every crate is a normal Rust library. Point Cargo at the repository: [dependencies] veilvoice-core = { git = - Use it as a library
"https://github.com/tilas01/veilvoice" } veilvoice-audio = { git = "https://github.com/tilas01/veilvoice" } | Crate | What it gives you | |---|---| | `veilvoice-core` | The de-identification engine. No I/O, no threads, allocation-free - Use it as a library
`process()`. | | `veilvoice-audio` | Device enumeration, file decode/encode, live capture→process→playback. | | `veilvoice-crypto` | Argon2id, X25519+ML-KEM-768 hybrid, XChaCha20-Poly1305, page-locked secrets, the app-lock verifier, and - Use it as a library
the decoy passphrase. | | `veilvoice-meta` | Metadata stripping for audio and images. | | `veilvoice-conversation` | Several speakers in one recording: a voice each, names, and subtitles. | | `veilvoice-video` | The conversation made - Use it as a library
watchable: a waveform, a circle per speaker, subtitles, and what the graphics hardware here can encode. | | `veilvoice-policy` | The settings VeilVoice keeps: policies that can only be tightened, and the named profiles and projects a - Use it as a library
recording was made with. | | `veilvoice-setup` | Per-user install and its exact reversal, the optional companion software, and whether a newer release exists. | | `veilvoice-verify` | A release checked end to end: the signed hash lists, - Use it as a library
the contents manifest, and the same three checks through your own GnuPG. | | `veilvoice-guard` | Has anything touched what was meant to be left alone: the integrity manifest, ransomware canaries, and the failsafe that acts when another - Use it as a library
program takes a real microphone. | | `veilvoice-watch` | What else this machine is doing: the microphone and camera, screen recorders, keyboard and mouse, kernel drivers, the process list and the privilege this is running at. | | - Use it as a library
`veilvoice-gui` | The desktop application. A library so its own tests can reach it, rather than something to build on. | The engine itself is small enough to drop into an audio callback: use veilvoice_core::{DeidConfig, Deidentifier}; let - Use it as a library
mut deid = Deidentifier::new(DeidConfig::default())?; let mut out = vec![0.0; block.len()]; deid.process(&block, &mut out); // no allocation, callback safe - Example: speech-to-text without handing over your voice
Cloud transcription is genuinely useful and genuinely invasive: the provider receives a biometric identifier that is as durable as a fingerprint, and it usually keeps it. But transcription only needs the *words*, which is exactly the half - Example: speech-to-text without handing over your voice
VeilVoice preserves. So run the audio through VeilVoice first. The service gets speech it can transcribe and a voiceprint that belongs to nobody: use veilvoice_audio::{deidentify, io}; use veilvoice_core::DeidConfig; // Your real voice - Example: speech-to-text without handing over your voice
never leaves this function. let original = io::load(std::path::Path::new("dictation.wav"))?; let veiled = deidentify(&original, DeidConfig::default())?; io::save_wav(std::path::Path::new("safe-to-upload.wav"), &veiled)?; Or from the shell: - Example: speech-to-text without handing over your voice
veilvoice anonymise dictation.wav -o safe-to-upload.wav **Two caveats, stated plainly.** Accuracy drops somewhat, because the output is synthetic-sounding, and recognisers are trained on natural speech. And *the words still go to the - Example: speech-to-text without handing over your voice
provider*: this protects your identity, not the content of what you said. If the content is sensitive too, do not upload it at all: transcribe locally. Local transcription is the stronger answer and is a planned integration (see - Example: speech-to-text without handing over your voice
[`ROADMAP.md`](ROADMAP.md)); until then, `whisper.cpp` reads the WAV VeilVoice writes with no extra work. --- - Design pillars
- **Offline by construction.** Zero servers, enforced in CI. - **No `unsafe` anywhere.** Every crate carries `#![forbid(unsafe_code)]`, including the page-locking path. - **62334 functional lines of Rust**, across 28 crates. A *functional - Design pillars
line* is a line holding code: blank lines and lines holding only a comment are not counted, and a line with code and a trailing comment counts once. Each crate's own README states its share of that total under **The files**. It is a - Design pillars
smaller number than the length of the tree, and deliberately so. This project is written with a high comment-to-code ratio, and the per-file line counts printed on the generated pages, in the artwork and in the reference links are the - Design pillars
other measure, the length of the file. Both are stated with their definitions rather than one being quietly redefined to match the other, and both come from `tools/loc/count.py`. The 28 includes `fuzz/`, the harnesses, which is a Cargo - Design pillars
project of its own rather than a workspace member. - **Irreversible.** Each frame's measured phase is discarded and resynthesised, permanently destroying the speaker's waveform and micro-timing. - **Normalising, not just scrambling.** - Design pillars
Pitch register, vocal-tract length and spectral tilt are each collapsed onto one canonical target, so a whole population of speakers maps to the same output. That destroys information rather than moving it. - **Cryptographically modulated, - Design pillars
with a rolling seed.** The residual transform is driven every frame by a ChaCha20 CSPRNG whose seed never leaves the process, and that seed is ratcheted forward every couple of seconds, so each stretch of audio is sealed off behind a - Design pillars
one-way step rather than sharing one stream with the whole recording. Configurable, and inaudible by construction. - **Post-quantum ready, and on by default.** At-rest encryption is X25519 + ML-KEM-768 hybrid, because a recording stored - Design pillars
today may be attacked decades from now, and it is what `anonymise` does unless you say otherwise. - **Amnesic.** Secrets are page-locked out of swap, zeroized on drop, compared in constant time, and opaque to `Debug`. - **Reproducible & - Design pillars
verifiable.** Pinned toolchain, committed lockfile, path-remapped builds, and a double-build check in CI. - **Libre.** GPL-3.0-or-later. --- - Layout
| Crate | Purpose | |-------|---------| | `veilvoice-core` | De-identification DSP engine and accent neutralisation, the security-critical heart. | | `veilvoice-crypto` | Argon2id, X25519+ML-KEM-768 hybrid, XChaCha20-Poly1305, amnesic - Layout
secrets, and the decoy passphrase. | | `veilvoice-audio` | Capture/playback (cpal), virtual-cable routing, file import/export. | | `veilvoice-meta` | Metadata strip/spoof for audio and image EXIF/GPS. | | `veilvoice-conversation` | Who - Layout
spoke when, one destination voice each, WebVTT and SubRip subtitles. | | `veilvoice-video` | The conversation drawn and played back, and what this machine's graphics hardware can encode. ffmpeg muxes it. | | `veilvoice-policy` | Settings - Layout
fixed so the interface cannot turn them off, and the named profiles a recording can be repeated from. | | `veilvoice-setup` | Per-user install, PATH, removal, companion detection and the update check, shared by both front ends. | | - Layout
`veilvoice-verify` | Hash, signed list and detached signature, with no GnuPG on the machine or through the one you have. | | `veilvoice-guard` | The integrity manifest, the canaries, and the safety catch. Detects and reacts; prevents - Layout
nothing, and says so. | | `veilvoice-watch` | Everything else running here: microphone and camera, screen recorders, input, drivers, processes, privilege. | | `veilvoice-cli` | The `veilvoice` command-line tool. | | `veilvoice-gui` | The - Layout
desktop app (egui, Tokyo Night). | Artwork is **generated, not committed as opaque blobs**: `python assets/generate.py` reproduces every icon and the banner from source. --- - Status
**v0.1.22: early but real.** The engine, cryptography, audio path, metadata cleaning, at-rest encryption, app lock, tamper detection, encrypted-volume destinations, CLI and GUI are implemented and tested (1659 tests across 13 crates plus - Status
doctests, and 20 website suites, clippy clean, no `unsafe`), with randomised campaigns against every parser that reads untrusted input and against the website's Markdown renderer. Release binaries are built for eleven targets, each one - Status
built twice from a copy of the source at a different path and compared byte for byte. **At v0.1.19 all eleven reproduced**, the three BSDs included: those were the last three to be built only once, and the release notes carry the verdict - Status
each platform actually reached rather than a claim about all of them. **Audited by tilas01**, who wrote and reviewed it. Be clear about what that is worth: a maintainer audit catches what the author can see, and **no external firm or - Status
independent researcher has reviewed this code**. Read the source before relying on it for anything that matters. It is written to be read. Thirty-three audit rounds have found and fixed **196 defects**. Among them: a four-kilobyte file - Status
that killed the process, a configuration value that made every output sample silent, a secure erase that destroyed a file other than the one named, a locked encrypted volume that went on accepting recordings onto the ordinary disk, and two - Status
ways to freeze a reader's browser tab. **None in any round was a confidentiality failure** in the strict sense that nothing let an attacker recover a voiceprint, read a sealed recording, bypass a password or weaken the cryptography. The - Status
two encrypted-volume defects came closest, and `docs/AUDIT.md` is exact about which side of that line they fall on rather than leaving the claim to do the work. Every one is written up individually, including the ones earlier rounds had - Status
declared clean, in [`docs/AUDIT.md`](docs/AUDIT.md). **Found something?** Report it privately through [GitHub's security advisories](https://github.com/tilas01/veilvoice/security/advisories/new) rather than as a public issue. - Status
[`docs/SECURITY.md`](docs/SECURITY.md) says what counts as a vulnerability here, what is a documented limitation rather than one, and why there is no PGP address to send it to. Using it: [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md), or [the - Status
wiki](https://tilas01.github.io/veilvoice/wiki.html). Roadmap and open work: [`ROADMAP.md`](ROADMAP.md). - Credits
**Written and maintained by tilas01**, who holds the copyright and is the sole author for licensing purposes. The architecture, every decision about what this program does and refuses to do, and a great deal of the code are theirs - Credits
directly. Some of the code, the documentation and this website were drafted with the help of **Claude**, Anthropic's assistant, working to that direction. Nothing reaches a release unread: every change is reviewed, built and tested before - Credits
it is committed, and the audit rounds in [`docs/AUDIT.md`](docs/AUDIT.md) are the record of that review finding its own mistakes. The credit is stated here, and once more in the "Who wrote it" answer on the [questions - Credits
page](https://tilas01.github.io/veilvoice/faq.html). Those are the two places somebody looking for it will look. It is deliberately not repeated in the footer of every page or scattered through the commit log, where an acknowledgement - Credits
turns into a badge. - Licence
GPL-3.0-or-later. See [`LICENSE`](LICENSE). None of the virtual audio cables listed under [Route it into a call](#route-it-into-a-call) is bundled here, and none of them is ours. Each is somebody else's software under its own licence, - Licence
named there with what that licence is. The same goes for `ffmpeg`, which the video render asks for its last step and which is not shipped, not vendored and not linked against: `veilvoice companions` names it, its vendor and its licence - Licence
beside the rest, and installs it through the package manager your machine already has.
ROADMAP.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - VeilVoice: roadmap
**What is built, what is coming, and roughly when.** One roadmap item is one feature: written, tested, documented and merged. A roadmap item is not ticked because the code compiles. It is ticked when the thing works, has tests, has - VeilVoice: roadmap
documentation, and survives the checks in CI. Estimates are in working days and they are estimates. Where a roadmap item depends on something outside this project, such as a platform's rules or a decision that has not been taken, that is - VeilVoice: roadmap
written down rather than absorbed into a number. **Where we are now:** **v0.1.22 is released**, signed and published for eleven platforms -- OpenBSD included since v0.1.11. Everything below the line marked *shipped* is work in progress. - VeilVoice: roadmap
This line used to name v0.1.14 while three releases went out past it, which is why the version in it is now checked against `Cargo.toml` by `tools/release/version.py` along with every other copy of it. Since v0.1.20: the build was found - VeilVoice: roadmap
red and made green, which is where this round started rather than where it ended. The offline proof had never run a single one of its last three steps, because the first of them asked for a privilege the runner does not grant; two - VeilVoice: roadmap
platforms were failing on tests that opened a file panel and an audio device on machines that have neither. Then the Studio's decoys, which had been written and reached by nothing; a vault found by opening it rather than by its name, so a - VeilVoice: roadmap
decoy is not told apart by reading a folder; state that lives beside the program when you ask it to; an About tab that prints the exact paths; a setup card that reads this machine instead of asserting things about it; acceleration as a - VeilVoice: roadmap
switch; and a choice, before the button, of which side of the engine a take keeps. Four defects, F-160 to F-163. Since v0.1.18: the whole repository was read for security, memory safety, correctness, reproducibility, optimisation and - VeilVoice: roadmap
accuracy, and everything it found was fixed. The Studio vault stopped decrypting a recording through unprotected memory on its way into locked memory; the demonstration on the website is the real programs on the page rather than a - VeilVoice: roadmap
hand-drawn model behind a button; the roadmap page holds eight GPU layers instead of 162; and the counts, the versions and the binary names that had drifted are now derived or checked rather than repeated. Eight defects, F-149 to F-156. - VeilVoice: roadmap
Since v0.1.16: the window is genuinely idle when nobody is touching it, and the cause of the frames it was drawing was found by asking the toolkit rather than by reasoning about the code. The toolkit itself moved forward two versions, the - VeilVoice: roadmap
About tab reports the graphics driver that actually drew the window, and a crash caused by a missing system library names the library and the package that carries it. Since v0.1.15: a guide for each program a release then shipped, three of - VeilVoice: roadmap
them at the time and two since 0.1.18, in the documentation, in the wiki and in every release archive; installing as a dropdown per system; verification and reproducible-build scripts for Linux, macOS, WSL, the BSDs and Windows; and the - VeilVoice: roadmap
website republished automatically after a release. Since v0.1.14: the verifier checks every file you extracted and then asks your own GnuPG the same question, offering to install it if you have none; the release page lists every archive of - VeilVoice: roadmap
every release; and the desktop application opens at a size you can read. **Every item on this page is linkable on its own.** The [roadmap page](https://tilas01.github.io/veilvoice/roadmap.html) gives each one an anchor: item 98 is at - VeilVoice: roadmap
`roadmap.html#m98`, and each heading below has one too, so a single roadmap item can be sent to somebody without sending the whole list. --- - Legend
| | | |---|---| | **done** | Built, tested, documented, in `main` | | **next** | Started or specified in detail, being worked on now | | **planned** | Specified, not started | | **blocked** | Cannot proceed until something outside the code - Legend
changes | --- - Shipped
| # | Item | Status | |---:|---|---| | 1 | DSP engine: phase discard, many-to-one normalisation, CSPRNG modulation | **done** | | 2 | Accent neutralisation, on by default | **done** | | 3 | Cryptography: Argon2id, X25519+ML-KEM-768, - Shipped
XChaCha20-Poly1305 | **done** | | 4 | Encryption at rest, by default, plaintext never touching disk | **done** | | 5 | App lock: Argon2id verifier, persisted rate limit | **done** | | 6 | Audio: device enumeration, live path, decode, WAV - Shipped
write | **done** | | 7 | Metadata stripping: tags, EXIF/GPS, chunk-level RIFF cleaner | **done** | | 8 | Microphone and camera monitor (Windows, Linux) | **done** | | 9 | Tamper detection, unprivileged half (`veilvoice-guard`) | **done** | - Shipped
| 10 | Secure erase, with an honest account of flash storage | **done** | | 11 | CLI and desktop app | **done** | | 12 | Website, wiki, no-JavaScript edition, legal gate | **done** | | 13 | Search over the whole repository and website, - Shipped
with a static fallback | **done** | | 14 | Portable release verifier needing no GnuPG (`veilvoice-verify`) | **done** | | 15 | Install scripts for Windows, Linux and macOS | **done** | | 16 | Reproducible signed releases on ten platforms | - Shipped
**done** | | 17 | Four audit rounds: 47 defects found and fixed | **done** | - In progress
| # | Item | Status | Estimate | |---:|---|---|---| | 18 | Documentation generator: a page, flowchart and banner for every crate and **every** `.rs` file, mirrored to the website and the GitHub wiki | **done** | - | | 20 | Repository panel - In progress
no longer shows a README's own markup as text | **done** | - | | 21 | Write the missing module documentation for the 14 files that had almost none | **done** | - | | 22 | Website split into a page per section, every published link still - In progress
working | **done** | - | | 23 | Motion and polish: smooth loading and scrolling, hover, CSS-first tooltips | **done** | - | | 24 | Demonstration animation: a voice going in, the mark lighting up, an unidentifiable wave coming out | - In progress
**done** | - | | 25 | Cycling line of project facts, slow enough to read: CSS rather than an image, so it follows the reader's theme and needs no script | **done** | - | | 26 | Every website theme in the app, plus user-defined palettes - In progress
with contrast computed rather than assumed | **done** | - | | 27 | Interactive workflow diagrams that open the relevant source, highlighted, in the site's palette | **done** | - | | 28 | Randomised, user-configurable ratchet interval, with - In progress
invalid input refused rather than clamped | **done** | - | | 30 | Installer with a window: Tokyo Night, animated, and **portable** described as the normal case rather than as something missing | **done** | - | | 31 | Optional companion - In progress
setup: VB-CABLE on Windows, PipeWire on Linux, BlackHole on macOS, and Audacity everywhere, detected if present and installed only if confirmed | **done** | - | | 32 | The site's search presented as an **index**, and animated | **done** | - In progress
- | - Security and monitoring features
Each of these is a crate of its own, so that another project can depend on one without taking all of them. Every one is **opt-in**, and every one states what it cannot do as plainly as what it can. | # | Item | Status | Estimate | - Security and monitoring features
|---:|---|---|---| | 33 | Screen-capture detection: which recorders are running, muted per program by an allowlist | **done** | - | | 35 | Keyboard and mouse activity monitoring, reported as the heuristic it is | **done** | - | | 36 | - Security and monitoring features
Ransomware canaries and mass-change rate detection, now `veilvoice_guard::sentry` | **done** | - | | 37 | Learn what runs, then allowlist it, with time-limited grants and a log, now `veilvoice_watch::appctl` | **done** | - | | 38 | - Security and monitoring features
`veilvoice-policy`: settings sealed with the existing post-quantum cryptography, and shaped so they can only be tightened | **done** | - | | 39 | Privileged mode: an opt-in service, and an elevated no-service mode, with the difference - Security and monitoring features
visible to the user | **done** | - | | 40 | Alert on driver and kernel-module installation; cross-view checks | **done** | - | | 65 | **Failsafe**: on by default: notice the moment another program picks up a *real* microphone while you are - Security and monitoring features
being veiled, warn, and close it | **done** | - | | 41 | Notification overlay: rounded, translucent, contrast computed, or an alert, or off | **done** | - | | 42 | Duress and decoy passwords | **done** | - | - Conversations, subtitles and video
Asked for after v0.1.12. One recording, several speakers, each given a different voice and each voiceprint destroyed just as thoroughly; names and subtitles; and an optional video of the result. | # | Item | Status | Estimate | - Conversations, subtitles and video
|---:|---|---|---| | 46 | **Conversation mode**: tell the engine a recording holds more than one speaker, and give each a distinct voice while destroying every voiceprint | **done** | - | | 47 | Up to ten speakers, each with a name, - Conversations, subtitles and video
carried into the audio and into subtitles | **done** | - | | 48 | A rolling seed **per speaker**, at a randomised interval inside a range the user sets, with no interval hardcoded and a fresh one at every launch | **done** | - | | 49 | - Conversations, subtitles and video
**Video output**: the waveform, a circle per speaker in their palette colour or their own picture inside a coloured ring, a title, and a black or image background with padding | **done** | - | | 50 | A **preview** of the video and of the - Conversations, subtitles and video
voices before anything is generated | **done** | - | | 51 | An **asynchronous pipeline**, every speaker rendering at once rather than in sequence | **done** | - | | 52 | Every crate and every `.rs` file explained: the technical workflow in - Conversations, subtitles and video
a paragraph, then the same thing in plain words | **done** | - | | 53 | The website on mobile, and on every engine: not only the one it was written in | **done** | - | | 54 | **Seventh audit round** across the whole tree, then the - Conversations, subtitles and video
production deploy | **done** | - | - Building it yourself, and proving the download matches
Asked for after the conversation work. Today `veilvoice-verify` answers one question, *is this download the one that was published*, and answers it without GnuPG, without a network client of its own, and without ever holding a private key. - Building it yourself, and proving the download matches
The request is to make the same program answer the harder question: **is the published build the one this source produces**, and to have it set your machine up so you can find out. The program is renamed `veilvoice-setup-tools`, because - Building it yourself, and proving the download matches
checking a signature is then the smallest thing it does. | # | Item | Status | Estimate | |---:|---|---|---| | 55 | **Build the whole repository from source**, from the tool itself: find or install a toolchain, pin it to - Building it yourself, and proving the download matches
`rust-toolchain.toml`, and run the same build the release does | **done** | - | | 56 | **Reproducibility check**: build here, hash what came out, and compare it against the published `SHA256SUMS` entry for this platform, saying which files - Building it yourself, and proving the download matches
matched and which did not | **done** | - | | 57 | **The hashes are trusted only after the signature is**: verify the detached signature over `SHA256SUMS` against the project key *before* any hash from it is compared, and refuse rather than - Building it yourself, and proving the download matches
warn if it does not verify | **done** | - | | 58 | **Set the machine up per platform**: the build dependencies each operating system actually needs, detected, named with who ships them, and installed only on an explicit yes | **done** | - - Building it yourself, and proving the download matches
| | 59 | **Custom install**: CLI, desktop app, or both, from a build you just made or from a download you just verified | **done** | - | | 60 | **Four verbosity levels**: nothing, minimal, normal (the default) and everything, applied to - Building it yourself, and proving the download matches
every one of the above, with the exit status carrying the answer when the output carries nothing | **done** | - | - Group mode, where you can see it
The engine has handled several speakers since roadmap item 46. The desktop app has never shown it. These are about making the thing visible and usable rather than about the signal, which is already done. | # | Item | Status | Estimate | - Group mode, where you can see it
|---:|---|---|---| | 61 | **Group mode in the desktop app**, shown as a mode rather than hidden in a flag: off by default, a toggle that does not persist, and a separate tick for "always start in group mode" | **done** | - | | 62 | **A - Group mode, where you can see it
name and a colour per speaker in the app**: the colour chosen automatically to be as distinct as the number of speakers allows, overridable per speaker, and drawn from every palette the website offers | **done** | - | | 63 | **Live levels - Group mode, where you can see it
while a recording is running**, in the app and in the terminal | **done** | - | - Seeing it before you install it
Asked for after v0.1.14. Everything here is about the same problem from two sides: somebody deciding whether to trust this, and somebody using it and not being sure it is working. Neither is answered by more features. | # | Item | Status | - Seeing it before you install it
Estimate | |---:|---|---|---| | 66 | **The live monitor**: what is going in and what is coming out, on every tab, on by default, and a preview that lets you hear yourself veiled before anybody else does | **done** | - | | 67 | **An - Seeing it before you install it
interactive demonstration on the website**: the inside of the application and of the command line, laid out in the site's own colours, that a reader can click through before downloading anything | **done** | - | | 68 | **A frequently asked - Seeing it before you install it
questions page**, answering what gets asked rather than what is convenient to answer | **done** | - | | 69 | **A drawn graphic for every workflow chart**: coloured arrows, an explanation inside the picture, and every word wrapped rather - Seeing it before you install it
than running off the edge | **done** | - | | 70 | **This roadmap, published as a page**, with a picture of what is done and what is not, generated from this file so the two cannot disagree | **done** | - | | 71 | **A video of the - Seeing it before you install it
roadmap**, scrolling what is finished, with a short pause and a countdown before it repeats | **done** | - | | 72 | **The front page animation, in more depth**: the same picture, saying what the engine actually does to the signal rather - Seeing it before you install it
than one word | **done** | - | | 73 | **A full security and functionality audit, and an optimisation pass, before the next deploy**: the whole tree, both halves, and the last thing that happens | **done** | - | - The lock, the guard, and a window that does not stutter
Asked for after the tenth audit round. Four of these are one subject seen from different sides: the app lock is the weakest control this project ships, it is described as such in several places, and the request is to make it as strong as - The lock, the guard, and a window that does not stutter
it can honestly be made rather than to keep apologising for it. | # | Item | Status | Estimate | |---:|---|---|---| | 74 | **The lock screen tells an attacker nothing**: no explanation of what the lock is or is not worth while it is - The lock, the guard, and a window that does not stutter
locked, the account of that moved to the documentation and to the unlocked application, and a small animation in its place | **done** | - | | 75 | **`veilvoice-guard` inside the desktop application**: the integrity record taken at the - The lock, the guard, and a window that does not stutter
first launch and checked at every one after, sealed under the app-lock passphrase where there is one | **done** | - | | 76 | **The app lock, hardened as far as it honestly goes**: an authentication tag under the passphrase, two copies with - The lock, the guard, and a window that does not stutter
the spare administrator-owned where the platform allows it, restoration when one goes, randomised names and masked contents, and a report that only the passphrase can clear | **done** | - | | 77 | **What the lock is worth, written down - The lock, the guard, and a window that does not stutter
properly**: one account, in the documentation, separating the parts that are real from the parts that are only obscurity | **done** | - | | 78 | **Every website palette in the application, chosen from the interface** | **done** | - | | 79 - The lock, the guard, and a window that does not stutter
| **A window that does not stutter**: the interface measured rather than described, every task off the drawing thread, and the smallest amount of code that does it | **done** | - | - Finally
| # | Item | Status | Estimate | |---:|---|---|---| | 44 | Fifth audit round: every vulnerability class across the tree, twelve findings written up individually (F-48 to F-59) | **done** | - | | 45 | **v0.1.10 released**: ten platforms, - Finally
signed, and verified by hand after publication | **done** | - | | 80 | **Ready for the release audit**: the RPM built, `lintian` run and its findings fixed, manual pages generated from the binaries, 32-bit re-run over the new code, and the - Finally
parser campaign run over all six targets with a seed corpus kept | **done** | - | --- - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath
| # | Item | Status | Estimate | |---:|---|---|---| | 81 | **Find the encrypted volumes this machine already has**: detect an installed Cryptomator or VeraCrypt, and the vaults and mounted volumes each is offering, without asking either to - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath
do anything | **done** | - | | 82 | **Write veiled output into a chosen volume**: a destination that is a Cryptomator vault or a mounted VeraCrypt volume, remembered, and used for every export | **done** | - | | 83 | **The hidden-volume - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath
question, asked properly**: asked before the first write, three answers, and a job that will not start until one is given | **done** | - | | 84 | **The guided path, for when detection fails**: plain instructions, a folder chosen by hand, - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath
and the same confirmation a detected one gets | **done** | - | | 85 | **What full-disk encryption is for, said once and said properly**: BitLocker, FileVault, LUKS and LUKS2, and the OpenBSD and FreeBSD equivalents, single-sourced and - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath
shown in both | **done** | - | | 86 | **The app lock as a key, not only a verifier**: the app-lock passphrase seals everything VeilVoice veils, automatically, as an option that says what it costs | **done** | - | --- - Asked for after the encrypted volumes
| # | Item | Status | Estimate | |---:|---|---|---| | 87 | **A video of a veiled recording**: a black frame and the audio, so a recording can be posted where only video is accepted | **done** | - | | 88 | **Import from every format OBS - Asked for after the encrypted volumes
writes**: bring in a recording made elsewhere, video or audio, and take the sound out of it | **done** | - | | 89 | **Veil the other person afterwards**: the interviewee given their own voice in post, through the group plan that already - Asked for after the encrypted volumes
exists | **done** | - | | 90 | **GnuPG verification inside the window**: in the verify tab, beside the hash check, using the GnuPG somebody already has | **done** | - | | 91 | **`veilvoice-verify` finds the release itself**: GnuPG - Asked for after the encrypted volumes
arguments where wanted, and an `auto` that looks in Downloads, checks the archive, and checks what came out of it | **done** | - | | 92 | **An autolock timeout**: on at half an hour, from five minutes to forty eight hours, chosen from a - Asked for after the encrypted volumes
list or typed, with the range itself adjustable, and offered during first-run setup | **done** | - | | 93 | **Group mode explained where it is used**: how to build a plan, what each field does, and what happens without one | **done** | - | - Asked for after the encrypted volumes
| 94 | **Release notes people can actually read**: every release listed newest first, its notes opening in place, and every file one click away | **done** | - | | 95 | **One version per release, in order, enforced**: the tag, the workspace - Asked for after the encrypted volumes
and every package definition checked against each other before a release can go out | **done** | - | | 96 | **v0.1.15 released**: the audit run over everything since v0.1.14, CI green, and the release published | **done** | - | | 97 | **A - Asked for after the encrypted volumes
verifier anybody can use, checking everything**: one press or one command checks the signature, the archive, every file you extracted, and then asks your own GnuPG the same question | **done** | - | --- - The window, and the things that were marked done and were not
Two rows above were marked **done** before they were, and this section exists partly to say so. Roadmap item 79 declared a window that does not stutter, twice: once when the drawing thread was cleared, and again when a repaint timer was - The window, and the things that were marked done and were not
removed and the improvement measured. Both changes were real and neither reached the cause, which was the animated logo asking for another frame thirty times a second whether or not anybody could see it. Roadmap item 95 declared one - The window, and the things that were marked done and were not
version per release enforced, and the enforcement covered the package definitions and not the twelve other places, the README's install block among them, that repeat the version by hand. Neither is being un-marked. What was done was done. - The window, and the things that were marked done and were not
The correction is that a roadmap item means the work described happened, not that the symptom is gone, and this list is more useful if the difference is visible. | # | Item | Status | Estimate | |---:|---|---|---| | 98 | **A window that is - The window, and the things that were marked done and were not
genuinely idle**: the cause of the frames found by asking the toolkit rather than by reasoning, the animation stopped when the window is unfocused or being dragged, and the result measured to nothing | **done** | - | | 99 | **The window - The window, and the things that were marked done and were not
toolkit brought forward**: `eframe` and `egui` from 0.29 to 0.32, which is what made the frames answerable, and the runtime dependency it added declared everywhere a package can declare it. Brought forward again to 0.36 with roadmap item - The window, and the things that were marked done and were not
148, which took `ttf-parser` out of the dependency graph and one advisory out of the exception list with it | **done** | - | | 100 | **What drew the window, reported**: the graphics choices named in the source with their reasoning, and the - The window, and the things that were marked done and were not
driver actually obtained shown on the About tab, read from the driver | **done** | - | | 101 | **Crash reports about the failure they are about**: the closing note chosen from the panic rather than the same guess every time, and a missing - The window, and the things that were marked done and were not
system library named along with the package that carries it | **done** | - | | 102 | **One version, in one file**: every other copy derived from `Cargo.toml` or checked against it, with the files that keep a history added to rather than - The window, and the things that were marked done and were not
rewritten | **done** | - | | 103 | **v0.1.17 released**: the idle window fixed at the cause, the toolkit upgraded, and the release published | **done** | - | --- - Asked for while v0.1.17 was being built
Everything here came from one message and they belong together: somebody arriving at this project for the first time, deciding whether to trust it, installing it, and finding their way around it without reading a manual. | # | Item | - Asked for while v0.1.17 was being built
Status | Estimate | |---:|---|---|---| | 104 | **A demonstration that is the program**: five recorded sessions of the real binaries replayed on the website, re-run and compared on every build, sitting with the screenshots | **done** | - | - Asked for while v0.1.17 was being built
| 105 | **A crash report offered rather than buried**: the report already written to disk surfaced above whatever tab you land on, with what it contains listed and readable in full before anything leaves your machine, and the filing left - Asked for while v0.1.17 was being built
to the person | **done** | - | | 106 | **A first run that explains itself**: one card per tab saying what it is for, skippable, and after an upgrade only the tabs that are new, with portable and installed said plainly on the last card | - Asked for while v0.1.17 was being built
**done** | - | | 109 | **An obfuscated program folder**: VeilVoice's own files encrypted under names derived from the app-lock passphrase, with decoys among them, so the lock protects data rather than only a window | **done** | - | | 110 | - Asked for while v0.1.17 was being built
**A first-run setup and tour**: the app lock, the recording passphrase and the autolock explained and offered once, skipping whichever is already set | **done** | - | | 111 | **Host the website locally**: a script and an nginx config that - Asked for while v0.1.17 was being built
serve `website/` exactly as GitHub Pages does, so the site survives the repo or Pages going down, and is the audit surface during development | **done** | - | | 112 | **A self-signed code certificate** beside the OpenPGP key: a detached, - Asked for while v0.1.17 was being built
signed `APPMANIFEST.json` describing each binary, verify scripts for Unix and Windows, and an import tutorial, for organisations that want a known publisher without breaking reproducible builds | **done** | - | **What was checked before - Asked for while v0.1.17 was being built
estimating that, rather than after.** The same rule that turned the speaker-detection question from planned into a measurement was applied here: the Android target was installed and the workspace was compiled against it. * **The engine, - Asked for while v0.1.17 was being built
the crypto and the metadata cleaner build for `aarch64-linux-android` as they are.** No conditional compilation, no substitutions, nothing to port. That is the part of VeilVoice that does the actual work, and it is already portable. * - Asked for while v0.1.17 was being built
**Live capture has a real path.** `cpal` reaches Android through `oboe`, and the dependency resolves. It failed here only for want of the NDK, which is a missing tool on this machine rather than a missing backend, and that is a materially - Asked for while v0.1.17 was being built
different answer from the BSDs, where the backend does not exist. * **The window is the work.** `eframe` on Android needs `android-activity` with one of `game-activity` or `native-activity` chosen, an activity entry point in place of - Asked for while v0.1.17 was being built
`main`, and a `cargo-apk` or `cargo-ndk` packaging step. None of that exists in this tree today, and none of it is a decision anybody is waiting on. * **The interface is laid out for a mouse and a wide window.** Nine tabs in a strip, hover - Asked for while v0.1.17 was being built
text carrying real explanation, and file dialogues. A phone build is not a recompilation of the desktop one. So the estimate stands as work rather than as a question, which is why this is **planned** and not blocked. iOS is the half that - Asked for while v0.1.17 was being built
may not be possible: it needs macOS to build and an Apple Developer ID to sign, and this project is published under a pseudonym on purpose, which is the same wall already documented for kernel drivers. That will be answered before anything - Asked for while v0.1.17 was being built
is promised. - Asked for after v0.1.18
The app lock already turns the app-lock passphrase into a key that seals everything VeilVoice writes (roadmap item 86). This asks for the same protection one layer in: the secrets and the veiling state while they are in memory, so that a - Asked for after v0.1.18
program which can read another process's RAM, a rootkit or a debugger, reads ciphertext rather than a passphrase or a voiceprint. The honest limit is stated with the feature rather than after it. Data the CPU is actively working on has to - Asked for after v0.1.18
be plaintext for the instant it is used, and an attacker already running as the kernel can wait for that instant. This raises the cost of an external read and narrows the window to nearly nothing; it does not claim to beat an adversary who - Asked for after v0.1.18
already owns the machine. | # | Item | Status | Estimate | |---:|---|---|---| | 113 | **Memory that reads as ciphertext from outside the process**: the app-lock key holds the secrets and the veiling state encrypted in RAM with the same - Asked for after v0.1.18
post-quantum sealing used at rest, each value decrypted only for the moment it is used and re-sealed straight after, so a scan of the process's memory finds no passphrase and no voiceprint | **planned** | 20 | | 114 | **A layout no two - Asked for after v0.1.18
copies share**: the in-memory shape of that protected state varied per build and per run, so a scanner tuned to one copy of VeilVoice does not recognise the next, and no fixed offset survives from one binary to another. No junk and no - Asked for after v0.1.18
decoy RAM: the footprint is unchanged, the protection is in the arrangement rather than in bulk | **planned** | 10 | | 115 | **Tamper noticed, and the source named**: an attempt to read or write VeilVoice's memory from another process - Asked for after v0.1.18
detected where each operating system allows it, the app locked and the person told, and the reaching process identified as far as the platform permits, on Windows, macOS, Linux and the BSDs. Where a platform cannot say, it says that rather - Asked for after v0.1.18
than guessing | **planned** | 20 | | 116 | **One author in the history, and every commit verified**: the commit history rewritten so the whole of it matches the attribution rule this project already states, with the assistance credit - Asked for after v0.1.18
staying exactly where a reader looks for it (the README, and the footer of every page) rather than in a trailer that makes a second contributor of it, and every commit in the history signed rather than only the recent ones. Trees are - Asked for after v0.1.18
unchanged by this, so reproducible builds still verify | **done** | - | | 117 | **A demonstration you navigate rather than watch**: the website's demo driven by the reader, a header per part of the program, and the screenshots for - Asked for after v0.1.18
whichever they pick shown under it. Not an imitation of the interface: the real captures, the ones the build already regenerates and compares, so the demo cannot drift from the program. The command line gets the same treatment, a worked - Asked for after v0.1.18
case per thing somebody actually wants to do, explained rather than listed, and the site's own Demo link lands on the section and opens it | **done** | - | | 118 | **Told when anything touches VeilVoice at all**: every attempt to open, - Asked for after v0.1.18
read, write or attach to one of VeilVoice's processes surfaced to the person, not only the ones that succeed, with what reached in named as far as the platform will say. Built on roadmap items 113 to 115 rather than beside them: the memory - Asked for after v0.1.18
is already sealed, this is the part that says somebody tried | **planned** | 15 | | 119 | **A verdict, and then a choice**: something reaching into VeilVoice is reported with what it did and what VeilVoice can prove about it, and the - Asked for after v0.1.18
person decides: remove it, quarantine it, or mark it a false positive that is remembered. The decision is the person's, always, because a program that silently uninstalls another program on a heuristic is a worse problem than the one it - Asked for after v0.1.18
set out to solve | **planned** | 12 | | 120 | **The allowlist that cannot be worn as a disguise**: a signed antivirus reading VeilVoice's memory is what antivirus does, and flagging Defender as a rootkit would train somebody to ignore the - Asked for after v0.1.18
warnings. So known-good is recognised by a verified code signature checked at the moment of the access, never by a process name, a path or a hash somebody can copy. An allowlist entry permits the read and still records it, still shows it - Asked for after v0.1.18
in the log, and never widens to the file storage or the app lock: nothing on it can turn into a way through the protection roadmap items 113 to 115 provide. Stated limit, as everywhere: an attacker who already holds the kernel can forge - Asked for after v0.1.18
what the kernel is asked, and this says so rather than promising otherwise | **planned** | 18 | | 121 | **A studio vault that needs both keys**: every recording the studio makes, and every preview it holds, sealed into one vault whose key - Asked for after v0.1.18
exists only when the app lock *and* the at-rest passphrase have both been given. Neither alone derives it, so a stolen laptop with the app unlocked opens nothing and a known recording passphrase without the app opens nothing. The same - Asked for after v0.1.18
post-quantum sealing used everywhere else, held in the same page-locked, zeroizing memory, with the same honest report of how much the operating system actually agreed to lock | **done** | - | | 122 | **Recording Studio**: the place - Asked for after v0.1.18
recording happens, with the vault already open because both locks were answered on the way in. Capture, monitor, preview and re-take without a plaintext file existing at any point, and a session that locks itself back up when the app does - Asked for after v0.1.18
rather than staying open behind a screensaver | **done** | - | | 123 | **Recording Browser**: everything in the vault, listed with what it is and when it was made, played back by decrypting into page-locked memory rather than writing a - Asked for after v0.1.18
temporary file somebody would have to remember to shred, whole rather than in blocks because the container is sealed and authenticated as one piece. Export is a deliberate act with its own warning, because leaving the vault is the moment - Asked for after v0.1.18
the protection ends | **done** | - | | 124 | **Neither lock alone, proven rather than asserted**: tests that open the vault with each secret in turn and get nothing, and a stated account of what the pair does and does not buy. It raises - Asked for after v0.1.18
the cost of a stolen machine and of a guessed passphrase; it does not defeat somebody watching the process while both are entered, and that is written beside the feature rather than left implied | **done** | - | | 125 | **A pass for size - Asked for after v0.1.18
and speed, changing no behaviour**: the tree read for the things that accumulate rather than the things that break. Work repeated that could be done once, allocations in paths that run per frame or per sample, generic code instantiated - Asked for after v0.1.18
more times than it needs to be, dependencies pulled in for one function, and anything the compiler is doing twice. Measured before and after, with the binary size and the timings recorded, and **not one behavioural change**: every test - Asked for after v0.1.18
that passed before passes after, or the change is reverted rather than argued for | **done** | - | | 126 | **Written so the next pass finds nothing**: the practices that pass established are now the standing way this project is written, in - Asked for after v0.1.18
`CLAUDE.md`, and three of the four are enforced by a build rather than by somebody remembering. No audio callback may allocate, block or print, checked by a test that reads the callbacks themselves rather than by the comments that said so. - Asked for after v0.1.18
Every dependency says what it is for on the line that declares it, checked in CI, which found three on its first run that no line of code referred to. Work that can be done once is done once, and the comment says what made it constant. The - Asked for after v0.1.18
fourth is a habit and is written as one: the reading happens before the push rather than to the tree once a year | **done** | - | | 128 | **The BSDs built twice, like everywhere else**: FreeBSD, OpenBSD and NetBSD were the three platforms - Asked for after v0.1.18
whose archives said `not-verified (built once, in a VM)`, which was honest and was the only gap in the reproducibility claim. The second build now happens in the same VM, with the same path remapping and the same `SOURCE_DATE_EPOCH` the - Asked for after v0.1.18
other ten platforms use, and the verdict is published in the release notes beside theirs. **At v0.1.19 all three reported `reproducible`**, so every one of the eleven targets is now verified rather than eight of them. A BSD that stops - Asked for after v0.1.18
reproducing says so in those words rather than quietly dropping the line | **done** | - | | 129 | **A BSD reader can check their own copy either way**: all three routes written up per system rather than left as a Linux instruction somebody - Asked for after v0.1.18
has to translate. `veilvoice verify` on its own is the same command everywhere and needs no GnuPG, no network and none of the system's own tools, which is why it is offered first and why a BSD reader has nothing to translate. The second - Asked for after v0.1.18
opinion is where systems differ, and the script now has a spelling for the BSDs: it had two, Linux and macOS, and a BSD reader fell through to the Linux one and was told to run a command this project's *other* script says they do not have. - Asked for after v0.1.18
That is F-167. The guide's table of which system runs what is checked against the program rather than typed beside it, and the one command this project has not run on a BSD, installing GnuPG on OpenBSD and NetBSD, is left unsaid rather - Asked for after v0.1.18
than guessed | **done** | - | | 130 | **The live scramble moved into the Studio**: veiling as it runs has stopped being a separate tab and is what the Studio does. It was already the same engine, the same ratchet and the same virtual-cable - Asked for after v0.1.18
routing on both screens, which is what made two of them wrong rather than merely redundant: the Studio recorded with whichever devices the other tab happened to be set to, and each tab started a session of its own, so veiling on one and - Asked for after v0.1.18
recording on the other opened the same microphone twice. There is one starter now. The tab is the voice above and the take below, the voice half works with the vault shut because veiling a call has never needed a vault, and ending a take - Asked for after v0.1.18
leaves the veiling running | **done** | - | | 131 | **Both sides of the glass, kept or discarded**: the Studio asks which of the two to keep before the button rather than after it, because a recording of somebody's real voice is not a - Asked for after v0.1.18
thing to discover having made. The veiled voice is selected, both is offered, and the microphone on its own is offered last. Anything that keeps the microphone says so in the same words the plaintext path uses: it is sealed in the vault as - Asked for after v0.1.18
strongly as anything else, and it is still a recording anybody who opens the vault can hear who was speaking in. Never remembered between runs and reset when the window locks, for the reason group mode is not remembered. Both kept means - Asked for after v0.1.18
two takes, the unveiled one named for it. The command line's `record` keeps the veiled voice only and is unchanged: this is a Studio decision, made where the vault that receives it is | **done** | - | | 132 | **Audio that says when it was - Asked for after v0.1.18
interfered with**: a device swapped or unplugged mid-session, and another program taking the microphone while a take is running, are noticed and shown rather than recorded silently. This row used to open by asking for the samples reaching - Asked for after v0.1.18
the recorder to be *checked* against what the engine produced. They cannot differ: the recorder is fed from inside the output callback, from the same slice the engine has just written into, so that check is a buffer compared with itself. - Asked for after v0.1.18
It is a property to keep rather than one to measure, and a test reads the source for it. What was actually missing was the report: the platform announces a stream error on a callback of its own, and both of them were `eprintln!` and - Asked for after v0.1.18
nothing else, so on Windows, where the window has no console, a microphone unplugged mid-call was silent. Stated limit up front, beside the report rather than after it: this notices interference with VeilVoice's own path and cannot vouch - Asked for after v0.1.18
for a microphone that was already lying | **done** | - | | 133 | **Every guest, veiled and plain, side by side**: two bars per speaker in group mode, what went into each of their turns and what the engine produced from it, drawn as the - Asked for after v0.1.18
render walks the file. One bar answers "is something being written" and not "is this person being veiled", which is the question somebody rendering an interview is asking; two that move differently are the only thing on screen showing the - Asked for after v0.1.18
engine is between them. Under them, in the same words the live meters use, what they cannot show. The row asked for this **live**, and that half is roadmap item 147: group mode works on a recording that already exists and never opens a - Asked for after v0.1.18
device, the live path opens one input, and there is no per-guest live signal in this tree to draw. The one-microphone live case already has its two bars, in the Studio | **done** | - | | 134 | **Decoy vaults, as many as you choose**: the - Asked for after v0.1.18
Browser asks whether to make dummy vaults and how many, sizes them from the real one so they are indistinguishable by size, and fills them with encrypted nonsense under keys that are thrown away. Cracked, one yields bytes that will not - Asked for after v0.1.18
parse as anything: a decoy is not a vault with weak contents, it is a vault whose contents never existed. Asked there rather than at first run, because a decoy is sized from the real vault and at first run there is not one yet. The real - Asked for after v0.1.18
vault is found by trying each directory rather than by name, so a decoy is not told apart by reading the folder either. The count defaults from the free space actually detected rather than from a number picked here, and the honest limit is - Asked for after v0.1.18
stated: this raises the cost of a search, it does not make the real vault unfindable to somebody who watches you open it | **done** | - | | 135 | **Setup inside the app, with defaults it worked out**: first run ends on a card that reads - Asked for after v0.1.18
this machine rather than asserting anything about it. Where recordings will go and how much room is free there, read from the operating system and said as an hour of veiled audio rather than as a number of bytes. How many devices there are - Asked for after v0.1.18
to record from and play to. What the window will ask the graphics driver for, with the tick that turns it off. Every one says what it costs, and where the machine will not answer the card says that instead of printing a figure nobody - Asked for after v0.1.18
measured. Nothing on it has to be answered and nothing is a gate | **done** | - | | 136 | **Portable first, and it tells you where it is**: a folder called `veilvoice-data` beside the program makes the settings, the vaults and the lock - Asked for after v0.1.18
live in it, so a copy on a stick stays a copy on a stick. Opted into rather than detected, because "beside the program if that is writable" would move an ordinary installation's state the day somebody unpacked it somewhere writable, and - Asked for after v0.1.18
the symptom would be an empty vault. The About tab prints the exact paths it is using and says which of the two arrangements is in force, rather than leaving somebody to guess. Installing later can carry those settings over or leave them, - Asked for after v0.1.18
and that is a question the install button waits for rather than a default, because the answer differs for a shared machine and a private one. Carried means copied, never moved, and nothing already at the destination is replaced | **done** - Asked for after v0.1.18
| - | | 137 | **Acceleration on unless you turned it off**: the window asks the platform for a hardware context and accepts a software one, so a virtual machine, a remote desktop or a server with no card still opens. One tick in Settings - Asked for after v0.1.18
turns the asking off, for the machines where a driver accepts and then draws badly: a hybrid-graphics laptop handing over the wrong adapter, or a black window on a broken OpenGL path. That case cannot be detected, because from inside the - Asked for after v0.1.18
process it looks like success, so it is a switch rather than a measurement and the honest limit is said where the tick is. The About tab shows what was asked for beside what the driver actually gave, which is the pair that answers "why is - Asked for after v0.1.18
this slow" | **done** | - | | 139 | **A video of who said what, from the Studio**: **done.** The preview page and the video file are now two drawings of one recording rather than a picture and a black rectangle. A circle per speaker in - Asked for after v0.1.18
their own colour, whoever is talking lit, a level under each name that moves with the sound, the waveform and a playhead. **The frames are drawn here**, as pixels: `raster` is a canvas with rectangles, antialiased circles and a PNG writer, - Asked for after v0.1.18
and `font` is a five-by-seven monospace face of ninety-five glyphs written out in the file. Neither borrows anything, because the drawing is SVG and no ffmpeg can be assumed to read SVG, and pulling in a rasteriser to convert it would have - Asked for after v0.1.18
undone the argument the ffmpeg module already makes about large C libraries. The one dependency is `miniz_oxide` for the deflate PNG needs, which was already in this tree under `flate2`. **A picture is written when the picture changes**, - Asked for after v0.1.18
not thirty times a second: the playhead moves a pixel at a time rather than a frame at a time, so ten minutes at thirty writes hundreds of files rather than eighteen thousand, and the saving is reported rather than claimed. A short - Asked for after v0.1.18
recording holds nothing and should not, because the playhead crosses the whole waveform however long the recording is. That is why the ffmpeg command is a concat list with a duration per picture rather than a numbered sequence, which would - Asked for after v0.1.18
have played an hour of conversation in a few seconds. Names outside printable ASCII cannot be drawn by a face this size and come out as boxes; the render **names them** rather than letting somebody find out by watching. Single-person - Asked for after v0.1.18
recordings take the same path as a group of eight | **done** | 6 | | 140 | **Resolutions somebody would actually pick**: 1080p as the default, with 1440p, 2160p and a custom size offered, rather than the 1280x720 the renderer starts at - Asked for after v0.1.18
today. The size the display is actually running at is offered as a preset where the platform will say, and where it will not the list is simply the fixed ones: a guess about somebody's monitor is worse than a menu | **done** | - | | 141 | - Asked for after v0.1.18
**How much code this actually is, counted one way and said once**: a functional line count in the README, and the same count per crate in each crate's own documentation, with the definition stated where it is used: a line holding code, not - Asked for after v0.1.18
a blank line and not a comment. Deliberately a **new** number rather than a redefinition of an existing one. The per-file line counts already printed on the generated pages, in the artwork and in the reference links are a different - Asked for after v0.1.18
measure, counted differently, and are left exactly as they are: changing what an existing number means, everywhere it appears, to match a new definition would silently alter every page and link that carries one | **done** | - | | 138 | - Asked for after v0.1.18
**v0.1.19, the audited release**: the full audit of roadmap item 127 run over the whole repository, everything it finds fixed, and the tag cut on what came out. Not the code alone: the documentation, the website, the packaging, the scripts - Asked for after v0.1.18
and the reproducibility, and the optimisation pass, because a release audited in part is a release described inaccurately. The Studio is deliberately **not** in it. A release named after a feature that is still being written is the thing - Asked for after v0.1.18
this roadmap exists to prevent, so 0.1.19 is the release that says the tree is sound and 0.1.20 is the one that says what was built on it | **done** | - | | 127 | **The full final audit, and what counts as complete**: no part of this - Asked for after v0.1.18
repository is signed off until every part of it has been. Security, memory safety and the post-quantum surface; correctness and QA over every crate; reproducibility of the build and of every generated artefact; the documentation, the - Asked for after v0.1.18
website, the packaging and the scripts; **and the optimisation pass above**, because bloat that nobody measured is a claim nobody checked. A round that covers the code and skips the documentation, or covers both and skips whether the thing - Asked for after v0.1.18
still builds byte for byte, is not a final audit and is not to be described as one | **done** | - | | 142 | **The demonstration on the page, and the drawing of it gone**: the recorded terminal sessions and the photographs of the window - Asked for after v0.1.18
both on the front page in the order somebody meets them, neither behind a button. The hand-drawn CSS model of the application is removed rather than relabelled: it sat beside photographs of the same interface and asked a reader to work out - Asked for after v0.1.18
which to believe, on a site whose argument is that they should not have to take anybody's word for anything | **done** | - | | 143 | **`veilvoice verify`, said that way everywhere**: nine places still described a `veilvoice-verify` - Asked for after v0.1.18
executable that stopped being published at 0.1.18, three of them lists the program itself reads. The lists are now taken from the job that publishes the binaries, and a test reads the repository the way a reader does and fails on anything - Asked for after v0.1.18
telling somebody to run a program that is not there | **done** | - | | 144 | **Staleness as an invariant rather than a habit**: `CLAUDE.md` now states that a change is not finished until everything describing it has changed with it, that a - Asked for after v0.1.18
fact appearing twice is derived or checked rather than repeated, and that the only exception is a record of the past. Written down because the two roadmap items above were the same failure found twice, in different files, months apart | - Asked for after v0.1.18
**done** | - | | 145 | **The Recording Studio, in full**: everything roadmap items 121 to 137 specify, built and working together rather than as parts. Recording in an environment that never lets a plaintext sample reach the disk, sealed - Asked for after v0.1.18
post-quantum into a vault that needs both the app lock and the at-rest passphrase, held in page-locked zeroizing memory. It shows, at all times and both live and on replay, whether the voice being recorded is veiled or not: **done**, and - Asked for after v0.1.18
the part that was missing was the running take, which said the clock and not which voice it was keeping, so a take of somebody's real voice looked exactly like one that was not for the whole of the recording. It has its own failsafe, - Asked for after v0.1.18
separate from the application's, so a fault in the Studio stops the Studio rather than the recording: **done**. The device a take is being recorded from going away is the fault it catches, and it stores what was captured and stops, rather - Asked for after v0.1.18
than discarding it, retrying, or moving the recording onto a microphone the person did not choose. Group mode records every guest with the same guarantees: **done**, and it is roadmap item 147, because group mode never opens a device and - Asked for after v0.1.18
the live path opened one input. A room in the Studio opens one per guest, veils each into a voice of their own, and stores a take per guest beside the mix, in the same vault, under the same lock and with the same choice of which side to - Asked for after v0.1.18
keep. The output is rendered inside the vault, viewed and listened to inside the vault, and leaving it is an export: a deliberate act, warned about, because that is the moment the protection ends. Something like a recording studio somebody - Asked for after v0.1.18
already knows how to use, that happens to be secure, rather than a security tool somebody has to learn to record with. No longer carries a version number: it was aimed at v0.1.20, that release shipped with the parts named in their own - Asked for after v0.1.18
entries, and a target date a release has already passed is worse than none | **done** | - | | 146 | **The Studio release**: 145 with the Browser, the decoys, the setup and the BSD reproducibility, tagged after its own audit round rather - Asked for after v0.1.18
than on the strength of an earlier one. An audit covers the tree it was run on and no other. v0.1.20 carried the vault, both tabs, the render settings, the corrections, the player and the BSD double build, and was audited in its own round; - Asked for after v0.1.18
the decoys, the machine card, the portable arrangement, the acceleration switch and the choice of which side to keep landed after it. So this describes the release after 0.1.20, and says so rather than keeping a number that has gone | - Asked for after v0.1.18
**planned** | 8 | | 147 | **Several microphones at once, veiled and metered each**: one input per guest, each veiled by its own engine with its own seed and destination voice, mixed into the one output a call or a recorder hears. The - Asked for after v0.1.18
capture path is `veilvoice_audio::room`: a recorder per guest and one for the mix, one sample rate agreed across every device before anything opens, a ring per guest so one microphone's clock drifting is that guest's dropped samples rather - Asked for after v0.1.18
than everybody's, and the honest account the row asked for as a number rather than a promise, since the output callback runs every engine before it returns and the load is the sum of their realtime factors against the deadline they share. - Asked for after v0.1.18
The mix is summed and clipped and **never limited**, because a limiter is a dynamics processor and this program's whole claim is about what changes a voice. The Studio drives it: a guest list with a name and a microphone each, two bars per - Asked for after v0.1.18
guest with the load and the clipping count beside them, and a take that stores the mix and every guest separately, veiled or unveiled on the same choice the single microphone has. The Studio holds one session or the other and never both, - Asked for after v0.1.18
which is one field rather than two so it cannot be got wrong. Two guests on one microphone is refused by name before anything opens, the default device included, because one microphone carrying two people is one signal and veiling it gives - Asked for after v0.1.18
both of them the same voice | **done** | - | | 148 | **The window draws at the display's rate, and says so**: the animations ran at twenty frames a second by design, from a constant in the mark and a fifty-millisecond cadence in the busy - Asked for after v0.1.18
path, and a reader with a 144 Hz display saw that as judder. The sixteen-millisecond veiling path was worse than it looks: a display at sixty shows a frame every 16.67 ms, so asking for one "within sixteen" misses the frame it wanted and - Asked for after v0.1.18
lands on the next, which is thirty asked for as sixty. **Done**, and not by choosing a bigger number: while anything is moving the window asks for the next frame now and vsync spaces it, so it draws once per refresh at whatever the display - Asked for after v0.1.18
runs at. That is also what makes the display measurable, since neither `egui` nor `eframe` exposes a refresh rate: frames paced that way are the display's own, and the median of the last thirty-two is a figure one slow frame cannot move, - Asked for after v0.1.18
clamped between 30 and 240. Settings offers 30, 60, 90, 120, 144, 165 and 240 for somebody who wants fewer frames on a battery, and a live readout in the header. The About tab carries what it is aiming at, what the display measured, the - Asked for after v0.1.18
rate as drawn and how many frames arrived late; a frame more than half again late is late, and two seconds of that says so once, naming what the window is drawing with. Idle still draws nothing. Nothing in the measurement allocates, locks - Asked for after v0.1.18
or prints per frame | **done** | - | | 149 | **A signature check that needs only a signature check**: `veilvoice verify` and the Verify tab check an RSA-4096 OpenPGP signature over `SHA256SUMS`, and do it through `pgp`, which brings an - Asked for after v0.1.18
entire OpenPGP implementation, `rsa`, `aes`, `aes-gcm`, `aes-kw` and the whole RustCrypto generation it was written against. That last part is why every cryptographic crate in this tree is held a generation back: moving them while `pgp` - Asked for after v0.1.18
stays would compile two copies of each primitive into both binaries. The job is a reader for exactly what a detached signature over a text file is, the packet framing, the hashed subpackets, the RSA-PKCS1-v1.5 verify over SHA-256 and - Asked for after v0.1.18
SHA-512, with nothing else in it, tested against the published signatures of every release and against the fixtures the fuzz targets already hold. When it lands, `pgp` and `rsa` leave the graph, RUSTSEC-2023-0071 leaves the exceptions with - Asked for after v0.1.18
them, and the ten held majors move in one commit | **planned** | 20 | | 150 | **The meters above a call**: the live monitor had two places to sit and both were inside the VeilVoice window, which on a call or while streaming is behind the - Asked for after v0.1.18
thing being talked into, so the one picture of what a microphone is doing was covered exactly when it mattered. A third choice now: a small window of its own, kept above other windows, off the task bar, draggable and resizable, drawing the - Asked for after v0.1.18
same two levels and the same sentence about what a level cannot tell you. Not the default, because a window that puts itself above everything is a thing to ask for. Closing it brings the strip back rather than turning the meters off, since - Asked for after v0.1.18
the close button belongs to the window manager and pressing it means "not in my way". Where a platform will not give a second window it falls back to the floating card, which is the same thing inside the window | **done** | - | | 151 | - Asked for after v0.1.18
**Mutation testing, as a check rather than as an afternoon**: changing the code a line at a time and asking whether any test objects is the only thing this project runs that asks whether an assertion exists for what came back, as opposed - Asked for after v0.1.18
to whether an input was explored. Its first run over four cryptographic files found fifteen changes the whole suite accepted, including one that replaced the randomness adapter with a constant. All three parts are in. `mutants.yml` runs - Asked for after v0.1.18
the campaign weekly over the eight cryptography files and dispatches before a release, and its verdict is not a number: `tools/mutants/check.py` compares the run against `tools/mutants/survivors.txt`, a committed list where every entry - Asked for after v0.1.18
carries the argument for why that mutant cannot be killed, so a new survivor is a diff somebody reads and a survivor that has been killed fails too, because the list would then claim something untrue. The campaign was extended to the app - Asked for after v0.1.18
lock, the studio vault, the obfuscated store and the reversible encodings: 674 mutants, 81 survivors, and four rounds of writing tests and re-measuring brought that to fifteen, every one of them argued. And `tools/audit/build_output.py` - Asked for after v0.1.18
fails the build on any build output outside the root `target/`, which is F-185, because the fifteen gigabytes the first attempt died on turned out to be written by this project's own tooling on every machine that is not Windows | **done** - Asked for after v0.1.18
| - | | 152 | **The numbers the README states, written by the tool that measures them**: the functional line count and the test count appear in `README.md`, on the front page and in one row of `docs/AUDIT.md`, and all four are typed by - Asked for after v0.1.18
hand and compared against the tree by the website suite. The comparison is right and the typing is the problem: every change to any Rust file moves the line count, so a commit that touches code and does not touch those four places fails a - Asked for after v0.1.18
check that has nothing to do with what the commit was for. Four times in one round was the measurement. `tools/measured/generate.py` now writes all ten of them from the numbers it already takes, using the same anchors the suite checks, so - Asked for after v0.1.18
the tool that writes a claim and the suite that checks it cannot disagree about where the claim is. The comparison stays exactly as it was: it is the guard, and it no longer doubles as the thing that catches a person on every push | - Asked for after v0.1.18
**done** | - | | 153 | **A link into a page lands on what it names**: the site's header is sticky, so a fragment jump puts the heading underneath it unless the target is pushed down clear of the bar. The amount it was pushed by was one - Asked for after v0.1.18
number, `90px`, and the header is 133px at desktop widths, 171px where the navigation wraps to three rows, 115px on a phone, 143px at 320px and 81px on the reference pages. The rule also covered sections and the two larger headings only, - Asked for after v0.1.18
so every entry on the releases page and every roadmap entry got nothing at all. Driven in a browser, 425 of 430 fragment links on this site landed wrong. The offset is measured from the header now and re-measured when the window changes - Asked for after v0.1.18
shape or a font arrives late, the landing is held for a second afterwards because pictures below the fold can still move the page and it lets go the moment the reader scrolls, and where somebody landed is outlined briefly, which under - Asked for after v0.1.18
reduced motion is an outline that sits there rather than fading. Navigation itself is untouched: no click is prevented and no history entry written, because the full-size screenshot viewers open through `:target`. F-191 | **done** | - | | - Asked for after v0.1.18
154 | **What this project says it is made of, checked against what it is**: the front page renders the README, the README lists the crates twice and the library guide lists them a third time, and the three had drifted to thirteen, twelve - Asked for after v0.1.18
and thirteen rows against a workspace of twenty-seven. The missing fifteen included both halves of the download verifier, the failsafe, the video renderer and the workspace store. `tools/audit/crate_tables.py` reads the workspace and all - Asked for after v0.1.18
three tables on every build and fails naming each crate a table has missed, so the next crate cannot be added without them. The sentences stay hand-written, because one assembled from a crate name is padding. F-192 | **done** | - | | 155 | - Asked for after v0.1.18
**Companion software installed for you, wherever that can honestly be done**: today VeilVoice detects ffmpeg, Audacity, GnuPG and the audio routing drivers, and offers a package-manager line where the platform has one and its own page - Asked for after v0.1.18
where it does not. That leaves the Windows reader, who has no package manager by default, doing the most work on the platform where the routing driver matters most. This finishes the job per operating system and per architecture: the - Asked for after v0.1.18
release that matches the machine, fetched and checked against its published hash before anything is run, then installed. Where the software has an installer of its own, that installer is run and the install path is its question to ask, - Asked for after v0.1.18
because a program that answers it on the installer's behalf is a program guessing at somebody else's layout. Where there is no installer, the default location for the platform is used unless the person names another, and where a package - Asked for after v0.1.18
manager already governs the machine it stays in charge rather than being worked around. Nothing proprietary is ever installed silently: its licence is still accepted by the person it binds. The same routes from `veilvoice companions` and - Asked for after v0.1.18
from the About tab, one list, one implementation, with progress, cancel and resume as the existing installs already have | **planned** | 25 | | 156 | **Every page says where it lives, and a crawler is told it may read the site**: - Asked for after v0.1.18
`og:image` was `assets/banner.png` on every page, a relative address, and the crawlers that read that tag do not resolve one. Every link to this site posted anywhere had always shown no picture, and nothing about the page looked wrong. - Asked for after v0.1.18
There was no canonical address either, so one page reachable two ways was two pages to an index, and two pages carried no preview tags at all. `tools/site/seo.py` builds all of it from what each page already says, every generator that - Asked for after v0.1.18
writes a page calls it, and `robots.txt` and a sitemap covering all 398 pages exist where there were none. Checked twice on purpose: once that each page is what the generator would write, once that what the generator writes is correct. - Asked for after v0.1.18
F-194 | **done** | - | | 157 | **The banner moves on the no-JavaScript page too**: that edition showed the still while the main site animated the same artwork, both drawn by `assets/generate.py` from the same source. It shows the animation - Asked for after v0.1.18
now, with the still still served to anybody who has asked their system for less movement, chosen by markup rather than by a script because that page runs none | **done** | - | | 158 | **Fewer crates, chosen by what somebody would actually - Asked for after v0.1.18
take**: twenty-seven crates for one application, seven of them under seven hundred lines and four used by exactly one caller. Full modularity is not free: every split is a published surface, a `Cargo.toml`, a README, a banner, a page of - Asked for after v0.1.18
the reference and a row in every table that lists them, and a reader deciding what to depend on had to read twenty-seven descriptions to find the two they wanted. Thirteen now, drawn at what somebody would realistically take on its own. - Asked for after v0.1.18
The six observers became `veilvoice-watch`, the two alarms and the safety catch became `veilvoice-guard`, the verifier absorbed both of its backends, the renderer absorbed the graphics probe that answers one question for it, the decoy - Asked for after v0.1.18
passphrase joined the cryptography it is part of, the update check joined the installer, and the saved profiles joined the settings. Nothing was deleted and no behaviour changed: every module kept its name, its tests and its page, and the - Asked for after v0.1.18
whole suite passed before and after | **done** | - | | 159 | **The wiki becomes the documentation, and publishes itself**: the wiki held the reference, a page for each of the thirteen crates and each source file in them, and three guides, - Asked for after v0.1.18
and nothing else. No `Home`, so a reader arrived at whichever page GitHub picked; no `_Sidebar`, so there was no list of the rest; and none of the prose that answers what somebody actually arrives asking, how to build it, how to check a - Asked for after v0.1.18
download reproduces, how to contribute, what the website is made of. Those are pages now, converted from the documents rather than written again, so `--check` fails on a difference and there is no second copy to go stale. Two documents - Asked for after v0.1.18
that did not exist were written to be converted: `docs/CONTRIBUTING.md` and `docs/WEBSITE.md`, the second covering both editions of the site and all twelve scripts. A guard walks every `[[link]]` in all 202 pages, because a wiki link to a - Asked for after v0.1.18
missing page renders as an invitation to create it rather than as an error, so nothing else would ever notice. And a workflow pushes the lot to the wiki repository, which is where a reader looks and where none of it was | **done** | - | | - Asked for after v0.1.18
160 | **The lock and the unlock are one control**: the button that locks the window and the button that unlocks it were written where each was needed and sized by whatever its own text happened to measure, so one was 46 points wide beside - Asked for after v0.1.18
a 132-point dropdown and the other was the word `unlock` padded with literal spaces, eight points taller than the password field beside it and four points off its middle. Roadmap item 123 fixed the height of the first and said the width - Asked for after v0.1.18
was a different complaint; it was the same complaint. One width for the picker and both buttons, each button's height taken from whatever it stands beside, and the field grown to the button rather than the button squashed to the field. - Asked for after v0.1.18
Measured in a headless frame rather than read off a photograph, on the rows that ship rather than on replicas of them, with a test that fails if controls left to themselves happen to agree anyway. F-196 | **done** | - | | 107 | **VeilVoice - Asked for after v0.1.18
on a phone**: an Android package a person installs without developer tools, signed, published beside the desktop archives and verifiable the same way. Roadmap item 52 established that the code compiles for Android; what is missing is the - Asked for after v0.1.18
NDK in the release build, a capture path that uses the platform's own audio rather than ALSA, a window that works at a phone's size, and a signing key that does not require a legal identity, all four set out in *VeilVoice on a phone* above - Asked for after v0.1.18
along with why iOS is a separate question. It sits last because it is worth more once there is a Studio to put on the phone, and because none of the desktop work is waiting on it | **planned** | 10 | --- - The things that are not just work
Some of the roadmap items above depend on something other than effort, and pretending otherwise would make this roadmap a wish list. They are named rather than numbered, because a number changes whenever a row above it does -- which is - The things that are not just work
exactly what happened when the USB work was dropped from this list. **Transcription: the decision has now been taken, and it is a narrow one.** Roadmap item 43 was blocked because VeilVoice talks to no servers at all and CI fails the build - The things that are not just work
if a network client appears anywhere in the dependency graph, one of the few claims a reader can check in ten seconds, and a large part of why this project is worth trusting. The decision is: **transcription may happen, and anything that - The things that are not just work
leaves this machine is the veiled audio, never the recording.** That is a smaller trade than it first looks, because the veiled audio is the thing this project exists to produce: the words are intact and the voiceprint is gone, so a - The things that are not just work
provider given it receives a transcribable recording of a voice that is not anybody's. Sending the original would hand a biometric to a third party, and that is the thing being refused rather than the transcription. Three rules go with it, - The things that are not just work
and they are what keep the front page true: * **Off by default, and never a default.** No transcription happens unless it is switched on for that run. * **The guarantee is kept in the dependency graph.** Nothing here adds an HTTP client to - The things that are not just work
VeilVoice: a local model is reached by running the program the user already installed, and a provider is reached by shelling out to the system's own transfer tool, the same arrangement the release verifier has used for downloads since it - The things that are not just work
existed. The CI job that fails on a network client stays exactly as it is. * **The claim is reworded where it appears, not quietly kept.** "It talks to no servers. Ever." becomes true-with-a-named-exception the moment this ships, and the - The things that are not just work
wording changes in the same commit as the code, not after it. Not every provider accepts audio input, and that is not a small caveat: an API that takes text and images does not take a WAV, whatever else it can do. Which providers actually - The things that are not just work
accept audio is a fact about somebody else's service that this machine cannot check offline, so it is checked *from a machine that can* before a line of it is written, the same rule that turned roadmap item 64 from planned into blocked, - The things that are not just work
one paragraph down, and saved a feature that would have shipped unable to work. **Detecting who is speaking: decided in principle, and then measured, and the measurement moved it.** The decision stands: VeilVoice ships no model, and uses - The things that are not just work
software you already have or nothing. What changed is *which* software, and it changed because the promise a few paragraphs up, "that gets checked before anything is built rather than discovered by a user", was kept. `ollama` was the named - The things that are not just work
candidate. It was checked on a machine that has it: * **It is there and it is detectable.** Found at an absolute path, version 0.32.5, with seven models installed. The companion-detection pattern works on it exactly as it does on Audacity. - The things that are not just work
* **None of it transcribes speech.** Every model on that machine is a text model. ollama's registry is language and vision models; speech-to-text is not what it hosts. Detecting ollama and offering "transcription" through it would have - The things that are not just work
produced a feature that cannot work, on a machine where every check passed. * **Running it is not free.** A single `ollama list` started a background server, opened a local UI port, started an update checker on an hourly timer, and made a - The things that are not just work
network request to GitHub, all of it in the first two seconds, none of it asked for. For most programs that is unremarkable. For this one it means "VeilVoice can use ollama" would have to be read as "VeilVoice can start a background - The things that are not just work
service that phones home on a timer", and that has to be said in those words or not offered. So roadmap item 64 is **blocked**, on a question rather than on effort: local speech-to-text means a Whisper-family program such as `whisper.cpp` - The things that are not just work
or `faster-whisper`, and speaker diarisation means a third thing again. Which of those to detect, and whether starting any of them is acceptable given what was measured above, is the maintainer's call. Roadmap item 43 is blocked behind the - The things that are not just work
same question for its local half. The two honest paths that exist today, one microphone per person or a turn list, remain, and remain the default. A machine with none of this installed behaves exactly as it does now. **Privileged mode and - The things that are not just work
driver alerting cannot reach kernel level on Windows or macOS.** Loading a kernel driver on 64-bit Windows requires an EV code-signing certificate issued to a verified legal entity and then Microsoft's attestation signing. macOS requires - The things that are not just work
an Apple Developer ID and an entitlement Apple grants case by case. Both are identity checks, and this project is published under a pseudonym on purpose. **The decision taken is to ship the administrator version**, which is most of the - The things that are not just work
protection and none of the pretence, and to say plainly that kernel-level enforcement is unavailable on those two platforms and why. Linux and OpenBSD have no such gate. **"Builds for every operating system" will mean "builds for the one - The things that are not just work
it is running on".** Roadmap items 55 to 58. A build needs that platform's headers and linker: `veilvoice-cli` cannot be compiled for Linux from this machine today because `alsa-sys` needs ALSA's headers, and a macOS build needs Apple's - The things that are not just work
SDK, which Apple's licence does not allow to be redistributed or run elsewhere. Every other crate cross-checks cleanly with `--target`, and that is a *type check*, not a binary anyone should install. So the honest shape is: the tool builds - The things that are not just work
VeilVoice for the machine it is on, and compares that against the published build **for that platform**. Three machines give you three platforms verified, which is exactly how a reproducible-build claim is normally checked, and it is a - The things that are not just work
real answer rather than a pretended one. Where a cross-target check *is* possible the tool will offer it and will label it as what it is. **Reproducibility is a property of the release, not of the checker.** Roadmap item 56 can only report - The things that are not just work
what it finds. If a build here and the published build differ, that is a finding to publish, not a bug in the tool to paper over -- and the first version will print both hashes and the differing file names rather than a verdict, because - The things that are not just work
"not reproducible" has several causes and most of them are boring. **The one thing roadmap item 55 does not do is install the compiler.** It reports the Rust toolchain like any other dependency, found or missing and with the version, and - The things that are not just work
points at rustup, which is how the Rust project ships it. It does not run that installer. rustup downloads a compiler, writes to the home directory and edits the shell profile, and all three belong to the person whose machine it is rather - The things that are not just work
than to a program acting for them. Every other dependency on Linux is offered through the system package manager under the rule below. **A dependency probe can be wrong in the direction that matters, and one was.** The Windows linker check - The things that are not just work
looked for `link` on `PATH` and reported whatever it found. On the first machine it ran on that was Git for Windows' `usr/bin/link.exe`, GNU coreutils' hardlink utility, which shares a name with Microsoft's linker and has nothing to do - The things that are not just work
with building Rust. It said the linker was present; the build would then have failed. There is no honest probe for it, because cargo finds MSVC through the registry rather than `PATH`, so it now says it cannot tell and lets the build be - The things that are not just work
the judge. Recorded as F-68. **Roadmap items 48 and 63 are each half-built, and stay open until both halves are.** Roadmap item 48's per-speaker seeding is done: every speaker gets its own seed and its own destination, so there is nothing - The things that are not just work
shared between them. What is missing is the *randomised interval inside a range the user sets*, which is roadmap item 28 and is still open. Roadmap item 63's levels are done and run in the terminal during `veilvoice live`; the wave **per - The things that are not just work
speaker** is not, because it needs per-speaker capture, which live mode does not have. Rounding either of these up to done would be the overstatement this project's second rule exists to prevent. **A reproducibility checker that always - The things that are not just work
says no is worse than none.** Roadmap item 56's first version ran `cargo build --release` and nothing else, so it would have reported every user's build as differing from the published one, for the dull reason this repository has - The things that are not just work
documented since before the checker existed: absolute paths are baked into panic messages and debug info, and removing them is the build environment's job. Two builds of this tree in two directories on this machine produced three differing - The things that are not just work
binaries out of three, measured. It now reproduces the release environment instead of approximating it: the same `--remap-path-prefix` for source and `CARGO_HOME`, the same `SOURCE_DATE_EPOCH` from the commit, the same per-linker flag, the - The things that are not just work
same explicit `--target`, and prints every one of them before building, because a comparison whose settings are invisible cannot be checked by whoever reads the result. A test compares the flags against `release.yml` itself, so changing - The things that are not just work
one and not the other fails the build. Recorded as F-70. **Installing build dependencies means running somebody else's package manager.** Roadmap item 58. That is the same trade the companion setup already makes and it gets the same rule, - The things that are not just work
which predates this roadmap: detect what is there, say what each thing is and who ships it, and install only on an explicit yes -- never silently, never ticked by default. What it will not do is add a network client to VeilVoice: it shells - The things that are not just work
out to the tool the platform already has, exactly as the verifier does for downloads today, so the guarantee that this project's own dependency graph contains no HTTP client is unchanged. **"Nothing" is a real verbosity level and needs the - The things that are not just work
exit status to carry the answer.** Roadmap item 60. A tool that prints nothing and returns zero on failure is worse than a noisy one. Every operation gets a distinct non-zero status, and they are documented, before the quiet mode exists. - The things that are not just work
**"Every engine" is a claim only one engine has been asked about.** Roadmap item 53. The mobile half is done and was done by measurement: twelve pages at five viewport widths, with and without scripts, and eight separate causes of - The things that are not just work
horizontal scrolling found and fixed -- a grid item's default minimum width, an unshrinkable table of code names, a tooltip that pushed the front page sideways while closed, two sections missing their gutters, and a note in the header that - The things that are not just work
only appears when scripts are off. None of them was visible from the source. All of it was measured in Chromium, because that is the engine on this machine. Firefox and WebKit have rendered none of it. The stylesheet has long carried - The things that are not just work
fallbacks written *for* those engines -- `-webkit-backdrop-filter` for Safari 17 and earlier, a solid colour before every `color-mix`, `:focus-visible` split into its own rule -- and `tools/site-tests/css.test.js` checks that each is still - The things that are not just work
there. That is reading the specification carefully; it is not the same as having looked. The roadmap item stays open until something other than Chromium has drawn the page. **A seed cannot roll faster than a frame, and a frame is 5.3 ms.** - The things that are not just work
The request was for a rolling interval between 0.7 ms and 2.7 ms. The engine analyses audio in frames of 1024 samples with 75 % overlap, so it produces one set of modulation parameters every 256 samples -- 5.33 ms at 48 kHz. Rolling the - The things that are not just work
seed more often than that changes nothing, because there is nothing in between to change. Making the frame short enough would mean a 128-point transform, which is 375 Hz per bin: too coarse to find a formant, which is the thing being - The things that are not just work
moved. So the interval will be settable in milliseconds and randomised inside the range asked for, it will be **quantised to whole frames**, and the interface will report the interval that is actually in force rather than the one that was - The things that are not just work
typed. Rolling every single frame is what the whole requested range comes to, and that is already the fastest this can honestly go. **Video needs an encoder, and this project ships no codec.** A window of waveform and circles is - The things that are not just work
straightforward to draw; turning a few thousand frames into a file somebody can play is not, and writing an H.264 encoder is not a sensible thing for this project to do. The plan is to render the frames here and produce the video through - The things that are not just work
`ffmpeg` when it is present -- detected and offered exactly as the other companions are, never bundled, never installed without an explicit yes -- and to always write a self-contained animation that needs nothing else installed, so the - The things that are not just work
feature still produces something on a machine without it. What will not happen is a silent dependency on a program the user did not know they were running. **Hiding VeilVoice's own window from a screen recorder needs `unsafe`.** Roadmap - The things that are not just work
item 34. Excluding a window from capture is `SetWindowDisplayAffinity` on Windows, and the equivalents on macOS and under Wayland; all of them are foreign-function calls, and every crate in this workspace carries `#![forbid(unsafe_code)]`, - The things that are not just work
which is on the front page and is one of the things a reader can check in ten seconds. The trade is the maintainer's: a documented `unsafe` shim in one file, or a window that can be recorded. **Until it is made, the honest state is written - The things that are not just work
where a user will read it**: VeilVoice does not hide itself, `veilvoice capture` says so, and roadmap item 33 shipped without pretending otherwise. Nothing about it is hard except the decision. Worth noting that the same decision would not - The things that are not just work
buy very much. A window excluded from capture is still visible to a camera pointed at the screen, and the thing VeilVoice protects, the recording, is a file rather than a picture of a window. **Roadmap item 39 ships the administrator - The things that are not just work
version and reports the difference; it does not acquire anything.** `veilvoice privilege` says what VeilVoice is running with, what that level can and cannot see, and prints the command to run it the other way. It never re-launches itself - The things that are not just work
elevated, installs a service, or asks for a password: those are changes to somebody's machine and they belong to the person whose machine it is. A test names every subprocess the crate starts, so "it only reports" stays true rather than - The things that are not just work
staying a comment. **The opt-in service is deliberately not shipped**, and the reason is written where a reader will meet it: a service outlives the window it was started from, starts itself at boot, and runs whether or not anybody is - The things that are not just work
using the program, somebody who tried VeilVoice once should not find it still running next month. Leaving the window open is the honest form of continuous monitoring, because then what it can see is exactly what it says it can see. Two - The things that are not just work
details found by measuring rather than reasoning. The Windows probe keys on the well-known SID `S-1-5-32-544` rather than the group's **name**, which is translated and would report every non-English machine as unprivileged. And the "Group - The things that are not just work
used for deny only" attribute, which is what an administrator account looks like when it is *not* running elevated, is on the same 236-character line as the SID, not the next one; a console wraps it so it looks like two rows, and reading - The things that are not just work
it that way would report every administrator account as elevated whether or not it was. Verified on a machine in exactly that state. **Who is talking is clear in everything VeilVoice writes, and the engine's settings are not.** The page - The things that are not just work
and the video carry each speaker's name and a circle that lights on their turn, and the subtitles carry the names as typed. What none of them carry is the destination voice's register, vocal tract or frequencies. Those are shown in the - The things that are not just work
application, where somebody choosing between voices needs them, and a test renders a page and fails the build if any of that vocabulary appears in it. It describes the *destination* rather than the speaker, so it leaks nothing either way; - The things that are not just work
it is simply noise to a viewer, and it invites a reader to think the numbers say something about the people. **In a live session, who is talking is a different question and an honest one to refuse.** One microphone carries one signal, and - The things that are not just work
telling two voices apart inside it is diarisation, which is roadmap items 43 and 64 and is blocked for the reason recorded there. The path that does work is one microphone per person, which is roadmap item 63's other half. **Roadmap item - The things that are not just work
63 is split in two, because one half shipped and the other cannot start.** The maintainer confirmed the live output is what was wanted and that showing it is right, so the levels are marked done under their own number and the diarisation - The things that are not just work
half is roadmap item 63b, which stays blocked for the reason below. Carrying both under one number meant a finished feature reading as blocked. The *levels* are done and have been for some time: `veilvoice live` draws them in the terminal - The things that are not just work
and the desktop application draws them beside the devices, both with peak-hold. The **wave per speaker** is a different thing entirely: it needs the live input separated by who is talking, which is diarisation, which is roadmap items 43 - The things that are not just work
and 64 and is blocked for the reason recorded there: real speaker separation means shipping a trained model, and locked decision 5 says this project does not. Leaving it marked *planned* would imply an estimate exists for work that cannot - The things that are not just work
start, which is the same overstatement in the other direction. What could be built without diarisation, a wave per speaker in a *rendered* conversation where the plan already says who speaks when, exists, and is what the video output and - The things that are not just work
the HTML player draw. **Roadmap item 29 moves to blocked, because it is a decision rather than a task.** One executable that opens a window when double-clicked and takes subcommands when given them needs `AttachConsole` and `FreeConsole` - The things that are not just work
on Windows: a PE declares exactly one subsystem, so a console binary that opened a window would flash a console every time, and a windowed one would send its output nowhere when run from a terminal. Switching at run time is FFI, and every - The things that are not just work
crate here carries `#![forbid(unsafe_code)]`. Relaxing that for one convenience is the maintainer's call and not something to slip in, so it waits for one. **Roadmap items 81 to 85 are two encryption tools and one honest sentence about - The things that are not just work
what stacking them buys.** The request is that VeilVoice notice an installed Cryptomator or VeraCrypt, offer to put every exported file inside one, support VeraCrypt's hidden volumes, guide the user by hand when it cannot manage the - The things that are not just work
integration itself, and say plainly that the disk underneath should be encrypted too. Each of those is a separate roadmap item because each can be finished, shipped and judged on its own, and because the first one being impossible on some - The things that are not just work
platform must not stop the last one being written. *Detection is reading, never driving.* Roadmap item 81 finds what is installed and what is currently mounted, and does nothing else. It does not launch either program, does not ask either - The things that are not just work
to mount or unlock anything, and never handles a volume passphrase. VeilVoice already refuses to acquire privilege (roadmap item 39) and this is the same rule in a new place: mounting somebody's encrypted volume is their act, taken in the - The things that are not just work
tool they chose, not something a voice de-identifier does on their behalf. A mounted Cryptomator vault and a mounted VeraCrypt volume are both just directories by the time VeilVoice sees them, which is exactly why this can be honest and - The things that are not just work
small. *Writing into one is a destination, not a mode.* Roadmap item 82 is a remembered output directory with a label saying what kind of volume it is. The encryption is entirely the other tool's, and calling it "VeilVoice encryption" - The things that are not just work
would be the overclaim this project refuses. What VeilVoice adds is that the export lands there by default rather than in a Downloads folder somebody meant to clear out. *The hidden-volume question is the one that must not be guessed.* - The things that are not just work
VeraCrypt's hidden volumes exist so that a person under compulsion can hand over one passphrase and reveal an outer volume. Writing to the outer volume of a container that has a hidden one can destroy the hidden data, because the outer - The things that are not just work
filesystem does not know the hidden one is there. VeilVoice cannot tell the two apart by looking, and no amount of cleverness will change that: it is the design of the feature that they are indistinguishable. So roadmap item 83 asks, once, - The things that are not just work
before the first write, and stores the answer with the destination. It never infers, never defaults to "probably fine", and refuses to write until it has an answer. A tool that quietly guessed wrong here would destroy exactly the data its - The things that are not just work
user was most careful about. *Roadmap item 84 exists because detection will fail.* Portable installs, custom paths, a distribution that packages either tool somewhere unexpected, a platform neither supports. The answer is not a silent - The things that are not just work
fallback to writing somewhere unencrypted: it is instructions, a directory the user picks by hand, and a confirmation step that will not continue until they have said which volume they mean and what kind it is. The failure mode to avoid is - The things that are not just work
a user who believes their exports are in a vault and finds them beside it. *Roadmap item 85 is a sentence, and it is the most important one in the group.* Cryptomator and VeraCrypt protect files at rest inside a container. They do not - The things that are not just work
protect the temporary files an operating system writes, the swap or hibernation image the kernel writes, the thumbnails a file manager writes, or the recently- opened list a desktop keeps. A veiled recording that lives inside a vault can - The things that are not just work
still have left traces outside it, and full-volume encryption is what covers those: BitLocker on Windows, FileVault on macOS, LUKS or LUKS2 on Linux, `softraid -C` on OpenBSD, GELI on FreeBSD. So the honest framing, and the one the - The things that are not just work
documentation and the application will both use, is that this is **defence in depth and not a second lock on the same door**: the volume protects the file, the disk protects everything the system wrote about the file without being asked. - The things that are not just work
Describing the pair as "dual layer encryption" without that sentence would leave somebody thinking a vault alone is enough, and it is not. *What none of it changes*: VeilVoice's own `.veil` containers are already encrypted with its own - The things that are not just work
cryptography, and nothing here replaces or weakens that. A veiled recording written into a Cryptomator vault is encrypted twice, by two independent tools, and the useful property of that is not extra strength but independence: a defect in - The things that are not just work
one is not a defect in both. **Roadmap items 87, 88, 89 and 93 turned out to be one thing: an interview, start to finish, and most of it already existed.** Roadmap item 89 asked for the interviewee to be veiled in post through the group - The things that are not just work
plan. `veilvoice conversation render` has done exactly that for several releases: a voice per speaker, every voiceprint destroyed, subtitles and a player beside it. Nothing needed building. What was missing was that nobody could find it, - The things that are not just work
which is roadmap item 93, and the two are answered by the same page rather than by two. So `USER_GUIDE.md` section 5.9 is the whole sequence in the order it happens: take the sound out of the OBS recording, write a plan, check the plan, - The things that are not just work
render it, make a video of it. Each step names the one before it. A person who has just finished recording an interview now has a path to follow instead of five commands to discover separately. *Roadmap items 87 and 88 are two ffmpeg - The things that are not just work
commands, and the shape is the one this project already settled on for video.* VeilVoice ships no codec and no demuxer, for the reason `veilvoice-video` has always given, so both prepare the exact command and run it when ffmpeg is there, - The things that are not just work
print it when it is not, and never offer to install anything. The video is a synthesised black source rather than a rendered frame sequence, so there is no temporary directory holding thousands of images and no wait beyond the encode. That - The things that are not just work
has one trap, held by a test: a synthesised colour source never ends, so without `-shortest` ffmpeg encodes black until the disk fills. *Not verified on this machine.* Both commands are correct by construction and tested against what they - The things that are not just work
emit, and no video has been produced here, because `ffmpeg` will not install in this environment. The no-ffmpeg path was exercised and reports honestly. Somebody with ffmpeg should run both once before the next release. **Roadmap item 90 - The things that are not just work
puts the GnuPG commands where the question is already being asked.** The verify tab is where somebody is working out whether a download is genuine, and the honest answer to that question includes "and here is how to ask something other - The things that are not just work
than me". The commands are copyable and they are not run: this project checks signatures with a key compiled into itself, which is a convenience with an obvious circularity, and a window that shelled out to `gpg` and reported what it said - The things that are not just work
would not have escaped it. The body of the recipe moved into `veilvoice_verify::check`, which the portable verifier and the window already share for the checking itself, so the two cannot drift into printing different commands. A test - The things that are not just work
holds that. **Roadmap item 91 reports the archive and the extracted folder separately, and that separation is the whole of the thinking.** `auto` already found the release and checked the archive. What was asked for on top is that it check - The things that are not just work
what came out of the archive, and that GnuPG be available for anybody who wants it. The second is easy. The first has a limit that must not be papered over: `SHA256SUMS` is signed and it covers **archives**. Nothing signs the contents of a - The things that are not just work
directory somebody unzipped last week, and nothing on disk records which archive a folder was extracted from. So verifying `veilvoice-0.1.14-linux-x86_64.zip` proves that archive is the signed one, and proves nothing whatever about the - The things that are not just work
folder beside it, which may predate the download or have been edited since. Rolling both into one green result would tell somebody their installed copy is verified when it is not, which is the most expensive kind of wrong this project can - The things that are not just work
be. The two are reported separately, the limit is stated in the output in those words, and the one thing that resolves it is given: extract the archive that was just checked, now, and use that. What can honestly be said about the extracted - The things that are not just work
folder is whether the programs are there and whether the system will run them, and that is a real thing to get wrong: an unpacking tool that drops the execute bit leaves somebody with files that look right and will not start. A failed - The things that are not just work
archive stops before any of this, held by a test, because "the archive is bad, and here are the programs beside it" reads as reassurance and there is none to give. *GnuPG commands are printed, never run.* A verifier that shells out to - The things that are not just work
`gpg` and reports what it said has not escaped the circularity it exists to escape: the thing running `gpg` is the binary under suspicion. `veilvoice-verify gnupg` is its own subcommand rather than a footnote under `auto`, because somebody - The things that are not just work
who wants the independent answer should not have to be told the answer by this binary first. **Roadmap items 81 to 85 are built, and two things were learned in the building.** The first is that the hidden-volume question is worth more than - The things that are not just work
the feature around it. Everything else here is a remembered output directory; that question is the only part where getting it wrong destroys data that cannot be recovered. So it is not a checkbox: `Hidden` starts `Unanswered`, an - The things that are not just work
unanswered destination refuses to place a file, the outer volume of a declared pair is refused outright, and a settings file edited into nonsense reads as unanswered rather than as "fine". A job with an unanswered destination is - The things that are not just work
**blocked** rather than redirected back beside the source, because the silent fallback puts a recording outside a vault while its owner believes it is inside one. The second was found by writing it. The panel called `still_there`, a stat - The things that are not just work
syscall, once per frame, for an answer that changes when somebody unlocks a volume. That is precisely what roadmap item 79 taught the draw path to refuse, and it went straight into a new module where that guard test does not look. The
deploy/README.md
- Hosting the VeilVoice website yourself
The site is static files in [`website/`](../website). It has no build step and nothing server-side, so any web server that serves a directory can host it, the same way GitHub Pages does at <https://tilas01.github.io/veilvoice/>. Two - Hosting the VeilVoice website yourself
ready-made ways: | For | Use | | --- | --- | | A quick local look, or the repository going down | `python3 tools/site/serve.py` | | A real server, a mirror, an internal copy | [`deploy/nginx.conf`](nginx.conf) | - The development script
python3 tools/site/serve.py # http://localhost:8000 python3 tools/site/serve.py --check # start, fetch every page, report, exit `--check` is what proves the site is servable and every page returns 200; it is wired into `tools/verify.py` so - The development script
a broken page is caught before it is pushed. - Under nginx
Edit the `root` in [`nginx.conf`](nginx.conf) to point at your checkout's `website/` directory, include the file, `nginx -t`, reload. Caddy, Apache and anything else that serves a directory work identically; the config just writes the few - Under nginx
content-type and header details down for the server people most often reach for. - Why static matters here
A site that can be served by copying a folder is a site that can be mirrored, audited and trusted. The one number on it that must never be stale -- the signing-key fingerprint you check a release against -- is in the HTML, so a mirror - Why static matters here
shows exactly what the source says, with nothing in between to get it wrong.
docs/AUDIT.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - VeilVoice: internal audit
**Auditor:** tilas01 (maintainer). **Date:** 2026-08-19. **Version:** 0.1.10 (unreleased), covering the whole tree. - The standard this is held to
Earlier revisions of this document named "an independent audit" as the one outstanding item, and treated everything else as done. That framing is dropped. It let a very large amount of unexamined code sit behind a single caveat, and it - The standard this is held to
made the honest answer to "has this been checked?" depend on someone else doing the work. The standard now is stated positively, and it is a standard about **this code** rather than about who looked at it: > Every line is written to - The standard this is held to
current Rust security practice, and the whole tree > has been audited against the full range of Rust-specific vulnerability > classes: integer overflow and truncating casts under **both** profiles; > panics reachable from untrusted input; - The standard this is held to
allocation sized by untrusted input; > every parser that reads bytes somebody else produced; resource exhaustion and > loops whose termination depends on a length field; TOCTOU, symlinks and file > creation modes; secret zeroization, page - The standard this is held to
locking, constant-time comparison and > `Debug` leakage; cryptographic misuse; concurrency; dependency risk; and error > handling that degrades quietly to a weaker posture. The website is held to the > same standard as a security boundary - The standard this is held to
in its own right. Each of those classes has a section below saying what was examined and what was found, including the ones that found nothing, because "we looked and there was nothing" is a result, and it is not the same as not looking. - The standard this is held to
**What has not changed:** no external firm or independent researcher has reviewed this code, and the documentation still says so wherever it matters. That is a fact about the world, not an outstanding task item, and it is recorded as such - The standard this is held to
rather than as a promise to be redeemed later. An outside reviewer would still be worth having. The difference is that their absence is no longer offered as the explanation for anything. - The thirty-third round: the whole tree again, read for what accumulates
The round after 0.1.21. The thirty-first and thirty-second rounds were about guards: which drift checks did not fail a build, which one had never run. This one starts from the other end, with the brief written before it began, which lists - The thirty-third round: the whole tree again, read for what accumulates
every class of defect the earlier rounds established and every check the repository now runs, and asks of each file whether it is still held to them. The findings are written up as they are made, so the ordering here is the order of the - The thirty-third round: the whole tree again, read for what accumulates
reading rather than of importance. - F-173: the site opened with a checkbox that looked ticked on
The first page anybody sees is the legal gate, and its first checkbox opened already wearing a focus ring. Nothing had been pressed. `legal.js` called `waiver.focus()` when the dialog was built, and a browser treats focus moved by script - F-173: the site opened with a checkbox that looked ticked on
the same as focus moved by a keyboard: `:focus-visible` matches, and the ring that exists to tell a keyboard user where they are was shown to somebody who had done nothing yet. On a page that asks a reader to tick a box saying they have - F-173: the site opened with a checkbox that looked ticked on
read something, a box that already looks selected is the wrong first impression to make. The call was there for a good reason. A modal that does not take focus leaves a keyboard reader behind it, with the page inert and no way to find out - F-173: the site opened with a checkbox that looked ticked on
why. But a modal's focus is meant to land on the dialog itself, not on its first control: the container carries `tabindex="-1"`, so a script can focus it and the Tab key never stops on it, a screen reader announces the title from there, - F-173: the site opened with a checkbox that looked ticked on
and the first Tab reaches the first checkbox in the ordinary way. That is what it does now, and `.legal-box:focus` draws no outline, because a ring around the whole dialog would say the same wrong thing at a larger size. Proved in a - F-173: the site opened with a checkbox that looked ticked on
headless browser before and after: before, `document.activeElement` was the checkbox and it matched `:focus-visible`; after, it is the box, no input matches, and the box's computed outline is `none`. The site suite reads the script and the - F-173: the site opened with a checkbox that looked ticked on
stylesheet and fails if either goes back. - F-174: two changes written under the heading of a release that did not carry them
`CHANGELOG.md` is the one file this repository allows to describe the past, and the rule that goes with that is the strict one: it is not edited to agree with the present. Two entries, the Studio's place in the documentation and the doc - F-174: two changes written under the heading of a release that did not carry them
comment that had moved to the wrong function, were written after v0.1.21 was tagged at `addea46f` and were written under `## v0.1.21`. The release notes GitHub published for that tag were derived from the file before those entries existed, - F-174: two changes written under the heading of a release that did not carry them
so a reader comparing the page with the file would find the file claiming two changes the release does not contain. The shape is the ordinary one, and the reason it is worth an entry is that the file has a section for exactly this and it - F-174: two changes written under the heading of a release that did not carry them
was not used. `## Unreleased` is what `tools/site/releases.py` reads for the next version's notes, and it renders as its own dropdown on the releases page when there is something in it. Both entries are under it now, with everything else - F-174: two changes written under the heading of a release that did not carry them
this round adds. Nothing checks this and nothing easily can: the tag is the only thing that says which commit a section's entries were written after, and a commit that edits a released section is sometimes right (a typo, a link). The rule - F-174: two changes written under the heading of a release that did not carry them
stays a rule, and this entry is the reminder that it was broken once. - F-175: the hybrid combiner's documentation claimed more than the code binds
`hybrid.rs` said: "Both secrets, both ciphertexts and both public keys go into the input". The transcript the combiner salts HKDF with is the ephemeral X25519 public key, the ML-KEM ciphertext and the recipient's X25519 public key. The - F-175: the hybrid combiner's documentation claimed more than the code binds
recipient's ML-KEM encapsulation key is not in it. This is a documentation defect and not a cryptographic one, and the write-up has to be careful to say which. FIPS 203 derives the ML-KEM shared secret from the message and a hash of the - F-175: the hybrid combiner's documentation claimed more than the code binds
encapsulation key, so that key is bound through the secret itself, and the X-Wing combiner, which is the reference shape for this construction, omits it for that reason. X25519 makes no such promise, which is why its public key is bound by - F-175: the hybrid combiner's documentation claimed more than the code binds
hand. The construction is sound as it stands and it is not changed: changing the transcript would change every key and every container already made, for no difference in what an attacker can do. The doc comment now says what is in the - F-175: the hybrid combiner's documentation claimed more than the code binds
salt, what is not, why the omission is not a weakness, and why the transcript is not going to change. A sentence in a module note that claims a binding the code does not perform is exactly the kind of thing a reader auditing this crate - F-175: the hybrid combiner's documentation claimed more than the code binds
would find first and trust the rest of the crate less for, and it was there through five rounds that read the file. - F-176: five of the repository's own drift checks ran only when somebody ran them
`tools/verify.py` runs thirty-odd checks before a commit. Comparing its list with `ci.yml` found five that were in it and in no workflow: the guard for a state file written in one place and read from another (F-141 and F-142, the defect - F-176: five of the repository's own drift checks ran only when somebody ran them
this project has shipped twice), the per-program guides against the user guide, the questions page against `docs/FAQ.md`, the app-manifest generator's self-test, and the local site host fetching every page. The thirty-first round found - F-176: five of the repository's own drift checks ran only when somebody ran them
eleven of these and wired ten into CI, and wrote that "a check that exists and is not in CI catches things eventually". Five more were missed then, so the same sentence applies to the round that wrote it. Each of the five needs nothing but - F-176: five of the repository's own drift checks ran only when somebody ran them
the tree and an interpreter and each is in the `assets` or `site` job now. Two remain out on purpose, and the reason is written here rather than left for the next round to rediscover. `tools/shots/sessions.py --check` runs the release - F-176: five of the repository's own drift checks ran only when somebody ran them
binaries and one of its sessions checks a published release, which needs the archive, the sums and the signature downloaded into a folder; `tools/measured/generate.py --check` runs the whole test suite a second time to count it. Both are - F-176: five of the repository's own drift checks ran only when somebody ran them
in `tools/verify.py`, and neither belongs in a job that should finish in minutes. - F-177: the seven coverage-guided fuzz targets had never run in CI
`fuzz/fuzz_targets/` holds seven libFuzzer targets and a committed corpus for each. The thirty-second round's inventory listed them as an existing check. They were: they ran before releases, by hand, and `docs/AUDIT.md` records the - F-177: the seven coverage-guided fuzz targets had never run in CI
durations. No workflow ran them, so between releases a regression the corpus already reached would wait for the next person to remember. `fuzz.yml` runs every target for two minutes from its corpus, weekly and on dispatch, with nightly and - F-177: the seven coverage-guided fuzz targets had never run in CI
`cargo-fuzz` installed in the job and the memory limit the recorded campaigns used. Two minutes is not a campaign and the workflow's own comment says so; it is enough to fail on what the corpus reaches, which is the thing a scheduled run - F-177: the seven coverage-guided fuzz targets had never run in CI
is for. The memory limit is worth one sentence. Run with libFuzzer's default of two gibibytes, `container_header` reports an out-of-memory at once, on an input whose header declares an Argon2 memory cost of two gibibytes. That is not a - F-177: the seven coverage-guided fuzz targets had never run in CI
defect: the attended ceiling is four gibibytes by design (F-82, F-83), the harness deliberately reaches the KDF with attacker-chosen parameters, and the campaigns in the record were run at six. The workflow uses six, and this note is here - F-177: the seven coverage-guided fuzz targets had never run in CI
so that the next person who runs it at two does not write F-178 about it. - F-178: the lock button and the theme picker never agreed on a height
Reported by the person who uses the window, in v0.1.21, after it had been asked for once before: the manual lock button in the header does not line up with the theme picker beside it. **The first measurement was wrong, and that is the more - F-178: the lock button and the theme picker never agreed on a height
useful half of this entry.** The screenshots were read for the two controls and gave a picker twenty-seven pixels tall centred at y=30 against a lock button eight pixels tall centred at y=25, which is a five-pixel misalignment and a clear - F-178: the lock button and the theme picker never agreed on a height
defect. It is also impossible: the header lays those controls out in one row centred on a common line. What the second run of the tool found is that `gui-file.png` has no lock button in it at all. **The captures photograph a window with no - F-178: the lock button and the theme picker never agreed on a height
app lock set**, and the button is only drawn when there is one, so the run that "measured the lock button" had measured the word "offline" three controls further right. A photograph of a window is not a measurement of a widget, and a - F-178: the lock button and the theme picker never agreed on a height
reading that produces an arithmetically impossible answer is a reading to distrust rather than to write up. It was written up. This is what that correction looks like. Measured properly, by laying the two controls out in the same row in a - F-178: the lock button and the theme picker never agreed on a height
headless frame with this application's own theme installed: they share a centre exactly, and the picker is 26 pixels tall against the button's 25. One pixel, which is small and is not nothing when two boxes sit side by side, and which - F-178: the lock button and the theme picker never agreed on a height
comes from each control working its height out from padding with nothing anywhere saying the two should match. So the button is given the picker's own rectangle to take its height from, rather than a number written down twice. Whatever the - F-178: the lock button and the theme picker never agreed on a height
picker turns out to be on a platform or at a font size nobody here has tried, the button is that. Two tests hold it: one that the heights and the centres agree, and one that a button left to size itself does *not* agree, so the first - F-178: the lock button and the theme picker never agreed on a height
cannot pass by accident. Nothing about this can be proved from a screenshot, and the test says so where somebody will read it. A capture with an app lock in it would mean a capture run against a configured lock file, which is a larger - F-178: the lock button and the theme picker never agreed on a height
change to the capture scripts than this finding justifies. - F-181: a promise about allocation, broken in the same file that made it
`pace.rs` was written this round with a note at the top saying that `frame` runs once per drawn frame and "allocates nothing, locks nothing and prints nothing", and that the median "is taken over a copy of it on the stack". Both sentences - F-181: a promise about allocation, broken in the same file that made it
were written by somebody who had just read the rule in `CLAUDE.md` about realtime paths and meant them. The median was taken with `slice::sort_by` over a thirty-two element array. Rust's stable sort takes a scratch buffer for a slice that - F-181: a promise about allocation, broken in the same file that made it
long. So the function allocated, once a frame, in the one module whose documentation had just promised it did not. Nothing was slow and nothing was wrong on screen: an allocation a frame is not a defect anybody would notice in a window. It - F-181: a promise about allocation, broken in the same file that made it
is a defect in the sense this document cares about, which is that a sentence in the repository was not true about the code beneath it, and the sentence was fourteen lines above the code. The fix is `select_nth_unstable_by`, which sorts - F-181: a promise about allocation, broken in the same file that made it
nothing, allocates nothing and only has to get the middle element right, which is all a median wants. The comment beside it now says which of the two functions allocates and why the choice is not a micro-optimisation. **Worth naming - F-181: a promise about allocation, broken in the same file that made it
because of where it came from.** This is not old code that drifted; it is code written in this round, by the reading that the same round was doing. A rule you have just read is easiest to break while writing the thing you read it for, and - F-181: a promise about allocation, broken in the same file that made it
the guard that catches this class today reads audio callbacks rather than the window's draw path. Extending it is roadmap item 151's neighbour and is not done here. - F-182: five public items that nothing reached, and the reason no compiler said so
A sweep of every `pub` declaration in the twenty-seven crates against every mention of its name anywhere in the tree. Of 1631 public items, twenty-six are named only by their own tests, which is normal and was left alone: a reader kept - F-182: five public items that nothing reached, and the reason no compiler said so
beside a writer, a constructor whose front end is not written yet. Five were named by nothing at all. | Item | What it did | Why nothing called it | |---|---|---| | `AppLock::needs_upgrade` | true for a record older than the authentication - F-182: five public items that nothing reached, and the reason no compiler said so
tag | `LockStore::unlock` re-tags on every success, so the upgrade happens without anybody asking the question | | `edit::speaker_with_colour` | a validating constructor for a speaker with a colour | `Speaker::named` plus `set_colour` is - F-182: five public items that nothing reached, and the reason no compiler said so
the path both front ends take, and it validates through the same function | | `Studio::is_a_room` | whether the running session is a room | the reading already arrives as `Reading::Room`, which is the same answer carrying the data with it - F-182: five public items that nothing reached, and the reason no compiler said so
| | `WatchFeed::has_unseen` | whether an alert is queued | `unseen` drains the queue and the caller tests what it got | | `VaultStore::last_audit` | what the last vault audit found | `unlocked` returns the audit, and the one caller uses - F-182: five public items that nothing reached, and the reason no compiler said so
the returned value | None of the five was wrong. That is the point of the finding and the reason it is one: each was compiled into every binary on every platform, documented, published into the generated reference and into the wiki, and - F-182: five public items that nothing reached, and the reason no compiler said so
answerable to nobody, because there was no caller whose behaviour would change if it were. **The last one cost something measurable.** `last_audit` read a private `audit` field, and that field was filled by `self.audit = - F-182: five public items that nothing reached, and the reason no compiler said so
Some(audit.clone())` on every vault open. So the accessor nobody called was keeping alive a field nobody read and a clone of the audit result performed every time the vault was opened. The field and the clone are gone with it. **Why - F-182: five public items that nothing reached, and the reason no compiler said so
`dead_code` is structurally unable to report any of this.** The lint stops at the crate boundary, because a public item in a library might be called by a consumer the compiler cannot see. In a workspace whose libraries have exactly two - F-182: five public items that nothing reached, and the reason no compiler said so
consumers, both inside the workspace, that leaves the whole class unwatched. It is worse than silence: a public accessor *reads* its private field, so the field is live as far as the lint is concerned, and the pair survives together. - F-182: five public items that nothing reached, and the reason no compiler said so
Nothing in a build was looking at this, which is why the sweep was done by hand and why it is now a check. **The guard.** `tools/audit/reachable.py`, in `tools/verify.py` and in CI beside `dependencies.py`, which asks the same question of - F-182: five public items that nothing reached, and the reason no compiler said so
the code this project imports. It asks the narrowest version deliberately: is this name written down anywhere but on the line declaring it. An item reached only by a test passes, because that is a real and often correct state; an item - F-182: five public items that nothing reached, and the reason no compiler said so
reached by nothing does not. The exemption list is empty and is meant to stay that way, since an entry in it is an argument to be written out rather than a line that makes a build go green. It was proved able to fail before it was trusted, - F-182: five public items that nothing reached, and the reason no compiler said so
by planting a public function nothing called and watching it name the file and line. **What the plan expected, and what was actually there.** The brief named `ffmpeg::command` as the first suspect. It is called, from - F-182: five public items that nothing reached, and the reason no compiler said so
`veilvoice-cli/src/conversation.rs`. A suspicion about which item is dead is worth exactly nothing next to the sweep that answers it for all 1631 at once, and the sweep is the part that is now repeatable. - F-184: the site proved its own ownership on nine pages out of fourteen
Search Console verifies that somebody owns a site by fetching a page and looking for one meta tag. The tag was added this round and recorded as being in the head of every page. It was on nine of the fourteen hand-written pages and on none - F-184: the site proved its own ownership on nine pages out of fourteen
of the 383 generated ones: `404.html`, `search.html`, `wiki.html` and both editions of the JavaScript-free site did not carry it, and neither did the whole generated reference. **Why that is a defect rather than an untidiness.** Which URL - F-184: the site proved its own ownership on nine pages out of fourteen
a crawler fetches is not this project's decision. An ownership proof that holds on the home page and not on the page somebody actually asked about fails in the one way that is hardest to diagnose from outside: nothing is broken, nothing is - F-184: the site proved its own ownership on nine pages out of fourteen
logged, the tag simply is not there. The claim in the notes that it was on every page was also not true, and this document's standing rule is that no part of this repository may say something that is not. The two generators were changed to - F-184: the site proved its own ownership on nine pages out of fourteen
emit it, the four remaining hand-written pages given it, and a check added that all 397 pages carried the same token. **Withdrawn shortly afterwards, and this entry is kept as the record of it.** The verification was never completed at - F-184: the site proved its own ownership on nine pages out of fourteen
Search Console's end, and indexing the site is not something this project is pursuing for now, so the tag, the generators' half of it and the check were all taken back out. What the entry is still worth is the shape of the mistake: a claim - F-184: the site proved its own ownership on nine pages out of fourteen
that something was on every page, made without counting the pages. That part did not depend on the tag. - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
Miri interprets a program and reports undefined behaviour. It was run here for the first time this round, and `veilvoice-crypto` was on the list of crates still to do. Running it produced this, on the first test that built anything: error: - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
unsupported operation: can't call foreign function `mlock` on OS `linux` region::os::unix::lock region::lock::<u8> amnesia::Secret::zeroed amnesia::Secret::new aead::tests::key `Secret` is the type every key, every passphrase-derived value - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
and every plaintext buffer in this project lives in. `Secret::zeroed` locks its pages out of swap through `region::lock`, which is `mlock`, and Miri has no shim for it. So the first `Secret` any test constructed ended the entire run, and - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
the crate holding the AEAD, the container format, the key derivation, the hybrid encapsulation, the reversible encodings and the file shredder was the one crate in this workspace that the undefined-behaviour checker could not examine at - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
all. **Nothing had said so, because nothing had asked.** Miri had never been run in this repository until this round; before that there was no observation to be missing. The defect is the state, not the omission: a large amount of parsing, - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
framing and constant-time comparison was outside the reach of the one tool this project has for that class of question, and it would have stayed outside it for as long as nobody tried. **The fix, and why it weakens nothing.** `lock_pages` - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
and `unlock_pages` now wrap the two calls, and under `cfg(miri)` they do nothing and answer false. That is not a new path. Locking has been best effort here since the type was written, and the module says so at length: a machine with no - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
memlock budget, a platform without the call, and an allocation that will not align all take this same path already, `is_locked` answers false, and no correctness property has ever depended on the answer. The interpreter is one more - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
environment in which the lock does not happen, and the only one that is a build rather than a machine. The zeroing, which is the guarantee the type actually makes, is untouched, so Miri still watches every write and every drop. **Two - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
tests, because the fix is only worth what keeps it true.** One reads the file and fails, naming the function and the line, if `region::lock` or `region::unlock` is called from anywhere but those two wrappers: a second direct call written - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
into a constructor would be invisible until somebody ran Miri again and found the crate unexaminable for a second time. It needed its needles assembled from halves at run time, because written out whole they occur in the test's own body - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
and it reported itself on the first run. The other asserts that a secret stores, returns and wipes the same bytes whether or not locking happened, and that under Miri the type says it is not locked rather than claiming a guarantee it did - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
not obtain. **Also seen, and not a finding.** Miri warns that `region` casts an integer to a pointer inside `round_to_page_boundaries`, which weakens its provenance tracking around that call. It is moot under the fix, since the call no - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
longer happens there. **What the interpreter then saw, and what it still cannot reach.** With the lock out of its way it ran 72 of this crate's 240 tests and reported no undefined behaviour in any of them: the reversible encodings (18), - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
the AEAD (12), the protected-memory type itself (11), the chunked tape (10), the file shredder (10), the private-file helper (9) and two of the container's. The container's remaining tests and the key derivation are the same wall the - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
engine's transform was, in a harsher form: Argon2id at 256 MiB is memory-hard on purpose, and a memory-hard function under an interpreter is not slow but unfinishable. That half of the crate stays outside Miri's reach and the reason is - F-183: the crate the whole program's security rests on could not be checked for undefined behaviour
arithmetic rather than arrangement, so it is recorded as a limit rather than carried as work. - F-186: eighty-one changes to the cryptography that the whole suite accepted
Mutation testing changes the code a line at a time and asks whether any test objects. F-180 ran it over four files of the cryptography crate and found fifteen survivors. This is the same tool over the four it had not reached: the app lock, - F-186: eighty-one changes to the cryptography that the whole suite accepted
the studio vault, the chunked tape and the reversible encodings. **674 mutants, 544 caught, 42 that do not compile, 5 timeouts, and 81 the suite accepted.** Five rounds of writing tests and re-measuring brought that to eighteen, and every - F-186: eighty-one changes to the cryptography that the whole suite accepted
one of the eighteen carries a written argument for why it cannot be killed. **What that is measured over, stated exactly.** All four files were campaigned end to end: the app lock 137 mutants, the vault 28, the tape 39, the encodings 463. - F-186: eighty-one changes to the cryptography that the whole suite accepted
A single run of the encodings does not finish here: `cargo mutants` dies on `pthread_create` partway through even at `--jobs 1`, with fourteen gigabytes free and the thread limit at 64,318. The cause is not established, and is written down - F-186: eighty-one changes to the cryptography that the whole suite accepted
that way rather than guessed at. `--shard k/6`, six separate processes of 78 mutants each, gets past whatever the limit is. **Running it whole was worth insisting on.** The partial runs reached 332 of the encodings' 463 and reported four - F-186: eighty-one changes to the cryptography that the whole suite accepted
survivors there. The six shards found twelve. The eight the partial runs never reached were all in the base conversions, which sit near the end of the file, and five of the eight were real gaps rather than equivalences. A campaign that - F-186: eighty-one changes to the cryptography that the whole suite accepted
stops early does not report that it stopped early: it reports a number, and the number looks like an answer. **Forty-four of the eighty-one were one gap.** The encodings had exactly one structural test: encode, decode, compare against the - F-186: eighty-one changes to the cryptography that the whole suite accepted
input. That tests the pair and not the encoder. Any change to the encoder that the decoder still reverses passes it, and forty-four such changes exist: run lengths counted to a different limit, literal runs broken at a different place, - F-186: eighty-one changes to the cryptography that the whole suite accepted
comparisons moved by one, several `|` turned into `^` inside the base conversions. Every one produced different encoded bytes and every one still decoded correctly. There are now golden vectors naming exactly what each of the thirty-one - F-186: eighty-one changes to the cryptography that the whole suite accepted
encodings emits for one fixed input, each paired with a decode of that same output so a vector cannot be quietly corrected to match a broken encoder. **Three were the app lock, and none was cosmetic.** | What survived | What it would mean - F-186: eighty-one changes to the cryptography that the whole suite accepted
| |---|---| | `same_secret_as` accepting `\|` in place of `&` | two locks sharing a salt but not a verifier would compare as the same lock | | `acknowledge` replaced with `Ok(())` | a tamper report anybody could dismiss without the - F-186: eighty-one changes to the cryptography that the whole suite accepted
passphrase, which is the thing a sticky flag exists to prevent | | the rate-limit cap asserted against the constant defining it | `15 * 60` became `15 + 60`, and every assertion passed because every assertion read the mutated value from - F-186: eighty-one changes to the cryptography that the whole suite accepted
both sides | That last one is F-71's shape in a third place: a check that compares a claim against a copy of itself. The cap is now asserted as 900, which is the number the sentence beside it states. **Two were not missing tests at all.** - F-186: eighty-one changes to the cryptography that the whole suite accepted
In the run-length encoder, one condition could only break where the very next line already breaks, and one branch handling a zero-length literal cannot be reached: entering that code path means the run test found fewer than two equal - F-186: eighty-one changes to the cryptography that the whole suite accepted
bytes, so the first pass always takes one. Three mutants lived in the first and two in the second. Both are gone. A condition nothing depends on is invisible from the outside until something changes it and no test complains, which is the - F-186: eighty-one changes to the cryptography that the whole suite accepted
only way either could have surfaced. **Three of the tests written to kill these did not test what they said, and the campaign is what found each.** The never-identity rotation test fixed the first seed byte at a value selecting an encoding - F-186: eighty-one changes to the cryptography that the whole suite accepted
that is not a rotation, so the assertion inside never executed and the two mutants walked through it. A later version drew a hundred random encodings, which reaches a rotation about ninety-six times in a hundred runs: the campaign duly - F-186: eighty-one changes to the cryptography that the whole suite accepted
reported one of that pair surviving while killing the other, which is what a test that only usually tests something looks like from outside. It now enumerates all 65,536 seed pairs, draws four thousand times, and fails if it never saw a - F-186: eighty-one changes to the cryptography that the whole suite accepted
rotation at all. The truncated-escape test derived its escape roadmap item by encoding a space, which works for the two encodings that escape a space and hands yEnc an ordinary encoded byte, because yEnc escapes four particular values and - F-186: eighty-one changes to the cryptography that the whole suite accepted
leaves a space alone. It was feeding yEnc something that is not an escape and then reporting yEnc for accepting it. Corrected again after that, because yEnc takes one byte after its roadmap item where the other two take two hexadecimal - F-186: eighty-one changes to the cryptography that the whole suite accepted
digits, so an input truncated for them is complete for it. A vault test meant to prove that storing into a missing directory creates it never reached the code, because the vault creates that directory a step earlier. The case that reaches - F-186: eighty-one changes to the cryptography that the whole suite accepted
it is the folder being deleted between opening the vault and writing to it. **The lesson, and it is the same one each time.** A test that passes proves nothing about whether it is testing anything. Only changing the code underneath and - F-186: eighty-one changes to the cryptography that the whole suite accepted
watching the test fail does. Every one of these three looked correct on the page, was reviewed while being written, and was wrong. **The five the shards found, and what it took to kill them.** Base-91 decides between packing thirteen bits - F-186: eighty-one changes to the cryptography that the whole suite accepted
and fourteen by comparing a thirteen-bit window against 88, in the encoder and again in the decoder. Moving either comparison by one changes the encoded form only when that window is exactly 88, and no sample, no length from nothing to - F-186: eighty-one changes to the cryptography that the whole suite accepted
sixty-four bytes, and neither a counter nor an all-ones run ever lands on it. Two searches found inputs that do: one of twenty-two bytes that reaches the boundary on the way in, and one of five that reaches it on the way out, because the - F-186: eighty-one changes to the cryptography that the whole suite accepted
decoder rebuilds a fourteen-bit value and tests the low thirteen of it, which is not the number the encoder tested. Both are named in the test with the reason each was needed. The decoder also needed pinning on its own. The digest test - F-186: eighty-one changes to the cryptography that the whole suite accepted
calls `apply` and never `undo`, and the round trip is blind to any change the pair still agrees on. That is not enough to call a decoder mutant equivalent, because `undo` is not only fed `apply`'s output: it is fed bytes read back from - F-186: eighty-one changes to the cryptography that the whole suite accepted
disk, which whoever can write that file chooses. So it is pinned on input built from its own alphabet. **What cannot be killed, and why that is written down rather than rounded off.** Eighteen survivors remain, in - F-186: eighty-one changes to the cryptography that the whole suite accepted
`tools/mutants/survivors.txt`, each with its argument. Seven are equivalent by arithmetic: six where the operands occupy disjoint bits so `|` and `^` compute the same value, and one where a cast to a byte already reduces modulo 256. Ten - F-186: eighty-one changes to the cryptography that the whole suite accepted
are properties of the machine rather than of the code: whether the operating system granted a page lock, whether a portable roadmap item sits beside the executable, whether a directory under `/etc` can be made, what the account's own - F-186: eighty-one changes to the cryptography that the whole suite accepted
configuration directory holds. One is reachable only through a self-contradiction: telling the vault's index guard apart from one that accepts any failure needs a read of a path that fails and a write to that same path that then succeeds. - F-196: two halves of one idea, at three different sizes
Reported by the person who uses the window, after F-178: the unlock button on the lock screen does not line up with the password field, and the lock button in the header still does not line up with the theme picker. **Half of that was - F-196: two halves of one idea, at three different sizes
already fixed and half of it was never looked at, and the useful part of this entry is which.** Measured in a headless frame with this application's own theme installed, laying out each row exactly as it ships: | control | width | height | - F-196: two halves of one idea, at three different sizes
middle | | --- | --- | --- | --- | | theme picker | 132.0 | 26.00 | 21.00 | | lock button | 46.5 | 26.00 | 21.00 | | password field | 260.0 | 19.12 | 17.56 | | unlock button | 98.3 | 27.00 | 21.50 | So F-178 did what it said: the lock - F-196: two halves of one idea, at three different sizes
button is the picker's height, on the picker's middle, exactly. What it explicitly decided not to do was the width, on the reasoning that "a button as wide as the picker would be a different complaint". It was the same complaint. A - F-196: two halves of one idea, at three different sizes
46-point button against a 132-point dropdown, sharing a middle and agreeing on nothing else, is what somebody looking at the header is describing when they say the two do not line up. The lock screen had never been measured at all. Its - F-196: two halves of one idea, at three different sizes
field and its button differ by nearly eight points of height and their middles by four, and the button was sized by writing `" unlock "`: the word padded with two literal spaces at each end. **That is the mistake `crate::layout::column` - F-196: two halves of one idea, at three different sizes
already exists to stop somebody making**, in a proportional font, for the second time in this repository. A space is not a fixed fraction of a letter, egui gives trailing whitespace no reliable width, and the result is a control whose size - F-196: two halves of one idea, at three different sizes
depends on the typeface the reader has installed. The repair is one number and one rule. `crate::layout::LOCK_WIDTH` is the width of the theme picker, the lock button and the unlock button, so the control that locks the window and the - F-196: two halves of one idea, at three different sizes
control that unlocks it are one control to a reader rather than two sizes. The rule is that each button takes its *height* from whatever it stands beside, because that differs by where it is drawn: the picker in the header, the password - F-196: two halves of one idea, at three different sizes
field on the lock screen. The field is grown to the button rather than the button squashed to the field, since a button at a text field's height reads as a link. | control | width | height | middle | | --- | --- | --- | --- | | theme - F-196: two halves of one idea, at three different sizes
picker | 132.0 | 26.00 | 21.00 | | lock button | 132.0 | 26.00 | 21.00 | | password field | 260.0 | 27.12 | 55.56 | | unlock button | 132.0 | 27.12 | 55.56 | Four tests hold it. Two measure the rows as they ship, one for each screen, and - F-196: two halves of one idea, at three different sizes
check height, middle and width. One draws the same controls with nothing made to match and fails if they agree anyway, so the first two cannot pass by accident. The fourth checks that `layout::button_height`, which is arithmetic over the - F-196: two halves of one idea, at three different sizes
style, is still what egui actually makes a button: it is the one piece of this that could go quietly wrong when the toolkit is upgraded. The lock screen's row was lifted out of `unlock_screen` into a function of its own for the first two - F-196: two halves of one idea, at three different sizes
of those. A test that measures a replica of a row proves something about the replica, which is the same class of mistake as F-178's first measurement reading a photograph instead of a widget. - F-195: a generator that wrote what was current and never took away what was not
Found by doing something that had not been done before: deleting a crate. `tools/docs/generate.py` writes the reference, the wiki, the per-file pages and the banners. Its `--check` had noticed for a long time that a file it no longer - F-195: a generator that wrote what was current and never took away what was not
produces should not be in the tree, and said so. Its `write` did not act on that. So the page for a crate that stopped existing would be written once, never rewritten, and never removed: it would sit in `website/reference/` describing - F-195: a generator that wrote what was current and never took away what was not
something that is not there, be linked from the index, be walked into the search index, and be found by a reader. Nothing had ever removed a crate, so nothing had ever exercised it. Folding fourteen of them into modules produced **218** - F-195: a generator that wrote what was current and never took away what was not
such files in one run. The sweep `--check` already had is now a function both halves call, and `write` removes what it finds before reporting. The report says how many, so a run that takes something away says so rather than doing it - F-195: a generator that wrote what was current and never took away what was not
quietly: wrote 1142 files for 14 crate(s), and removed 218 that are no longer produced Empty directories go too, in the same pass, because a directory left behind is the same defect one level up. This is the shape F-185 had, and it is - F-195: a generator that wrote what was current and never took away what was not
worth naming the pattern rather than just the instance: a tool that only ever adds is a tool whose output is correct about the present and permanent about the past. - F-194: the picture every link to this site was supposed to show, as a relative URL
Every page carried `<meta property="og:image" content="assets/banner.png">`. That tag is read by crawlers, not by browsers, and a crawler does not resolve a relative URL against the page it came from. So every link to this site posted - F-194: the picture every link to this site was supposed to show, as a relative URL
anywhere, in any chat application and on any social network, showed no picture at all, and had never shown one. Nothing about it looked wrong. The tag was present, it was spelled correctly, and it named a file that exists. It could only be - F-194: the picture every link to this site was supposed to show, as a relative URL
found by asking what reads it rather than by reading the page. Three more things in the same place. There was no `og:url` and no canonical link, so a page reachable at more than one address is more than one page as far as an index is - F-194: the picture every link to this site was supposed to show, as a relative URL
concerned; `404.html` and `wiki.html` carried no tags of any kind; and the no-JavaScript page's preview title read `VeilVoice &middot; no-JavaScript edition`, because the title it was copied from was already HTML and had been escaped a - F-194: the picture every link to this site was supposed to show, as a relative URL
second time. The cause is the same one this project has a rule about: the tags were typed into each page. They are built now, by `tools/site/seo.py`, from what the page already says. The title comes from its `<title>`, the description from - F-194: the picture every link to this site was supposed to show, as a relative URL
its `<meta name="description">`, the address from where the file is, and the site's own address from the `repository` field of `Cargo.toml`, which is where this project already says where it lives. Every generator that writes a page calls - F-194: the picture every link to this site was supposed to show, as a relative URL
the same function, so a page cannot be produced without them. The site also said nothing at all to a crawler. There was no `robots.txt` and no sitemap, so the only way in was a link from somewhere else. Both are generated now: `robots.txt` - F-194: the picture every link to this site was supposed to show, as a relative URL
allows everything and names the sitemap, and the sitemap is produced by walking the site, so a page that is added is listed and a page that is deleted stops being listed. All 398 pages are in it. That is the whole of what a site can do - F-194: the picture every link to this site was supposed to show, as a relative URL
from its own side, and it is worth being plain that it is not the same as being found. A sitemap asks to be read. Nothing in a repository can ask to be ranked. Checked twice, on purpose. `tools/site/seo.py --check` asks whether each page - F-194: the picture every link to this site was supposed to show, as a relative URL
is exactly what the generator would write. `tools/site-tests/addresses.test.js` asks whether what the generator writes is correct: one canonical address per page, every address absolute, every page in the sitemap. A generator that started - F-194: the picture every link to this site was supposed to show, as a relative URL
emitting relative URLs again would pass the first and fail the second. One test had to be narrowed to let the canonical link through, and the narrowing is an improvement rather than a concession. `html.test.js` refused any `<script>`, - F-194: the picture every link to this site was supposed to show, as a relative URL
`<link>` or `<img>` pointing at an absolute URL, on the ground that a privacy tool's site loading a third-party asset would undercut its own argument. That rule is right, and a `<link rel="canonical">` fetches nothing: it is a statement - F-194: the picture every link to this site was supposed to show, as a relative URL
about this page's address, which has to be absolute to be a statement at all. The rule now applies to the values of `rel` that make a request, and still fails on a stylesheet from a CDN. - F-193: seventeen images with no size, and everything under them moving
Following on from F-191, and found by the same measurement: a landing that was correct the moment it was made was wrong a second later, and the offset was not what had changed. Seventeen `<img>` elements on this site declared no `width` - F-193: seventeen images with no size, and everything under them moving
and `height`. Six of them are the drawings of the command-line help screens, high on the front page, and one is the screen photograph in the demonstration. An image with no declared size occupies nothing until it arrives, so everything - F-193: seventeen images with no size, and everything under them moving
below it moves down when it does, and the reader who followed a link to one of those sections arrives, reads a line, and finds the line somewhere else. The brand icon in the header was another, on every page of the site including the - F-193: seventeen images with no size, and everything under them moving
twelve hundred generated ones. All seventeen now carry their size, taken from the file rather than typed: the help drawings and the banners from their own `viewBox`, the screen photograph from the size `tools/site/demo.py` already enforces - F-193: seventeen images with no size, and everything under them moving
for every capture, the icons and the banner image from the files themselves. The generators that write the reference and source pages read the banner they have just written rather than carrying a number, because a banner whose subtitle - F-193: seventeen images with no size, and everything under them moving
runs to a third line is taller than the rest. `anchors.test.js` fails on an `<img>` without both, so the next one to be added without them fails a build rather than moving a paragraph under somebody. - F-192: three tables listing the crates, none of them listing the crates
The website's front page renders the README. The README lists the crates twice, in a Layout table and a table for somebody using them as libraries, and `docs/USING_THE_CRATES.md` lists them a third time. The three carried thirteen, twelve - F-192: three tables listing the crates, none of them listing the crates
and thirteen rows. The workspace has twenty-seven crates. So the public description of what this project is made of was short by fifteen crates, and had been for as long as those fifteen had existed. Among the missing were the verifier's - F-192: three tables listing the crates, none of them listing the crates
two backends, the failsafe, the video renderer and the workspace store: not internal plumbing, but things the rest of the documentation talks about at length. Nothing careless produced this. A crate is added in `crates/`, and the tables - F-192: three tables listing the crates, none of them listing the crates
are in two other files. That is the shape this project already has a rule about, and the rule is that a fact in more than one place is derived or checked. Descriptions cannot be derived, because a sentence assembled from a crate name is - F-192: three tables listing the crates, none of them listing the crates
padding. So the membership is checked: `tools/audit/crate_tables.py` reads the workspace and every table, and fails naming each crate a table has missed and each row that names a crate that is not there. The library tables are held to the - F-192: three tables listing the crates, none of them listing the crates
crates with a `src/lib.rs`, the Layout table to every workspace member. The check was written first, run against the tree to produce the list of fifteen, and the rows were then written by hand. - F-191: the offset that clears the sticky header was a number, and the number was wrong
The website's header is `position: sticky`, so whatever a fragment jump puts at the top of the viewport is underneath it. `scroll-margin-top` exists for that, and the stylesheet set it to a flat `90px`. The header is not 90px tall at any - F-191: the offset that clears the sticky header was a number, and the number was wrong
width. Driven in a browser it measures 133px at desktop widths, 171px where the navigation wraps to three rows, 115px on a phone, 143px at 320px and 81px on the reference pages. At the commonest width that is 43px in the direction that - F-191: the offset that clears the sticky header was a number, and the number was wrong
hides the heading, so following `download` from the navigation arrived at a page that appeared to begin mid-sentence. The rule also named `section`, `h2` and `h3` and nothing else, so an `h4` or a list item with an id got no offset at all - F-191: the offset that clears the sticky header was a number, and the number was wrong
and landed a full header height under the bar. That is every entry on the releases page and every entry on the roadmap. Measured across both, 425 of 430 fragment links on the site landed wrong. The edition of the site that runs no scripts - F-191: the offset that clears the sticky header was a number, and the number was wrong
had none of this, because it has no sticky header, and it is the standard the rest of the site is now held to. The offset is measured rather than written down. `website/js/teleport.js` reads the header's height, publishes it as - F-191: the offset that clears the sticky header was a number, and the number was wrong
`--anchor-offset` and keeps it current with a `ResizeObserver`, so a header that grows a row, a font that loads late and a phone turned sideways all correct themselves. The stylesheet applies it to `[id]` rather than to a list of tag - F-191: the offset that clears the sticky header was a number, and the number was wrong
names. The landing is then held for a second afterwards, because a fragment jump happens once and the page is not finished at that moment, and the holding stops the instant the reader scrolls. Where the reader landed is outlined briefly, - F-191: the offset that clears the sticky header was a number, and the number was wrong
which under `prefers-reduced-motion` is an outline that simply sits there rather than fading. Navigation itself is left alone: no click is prevented and no history entry is written, because the full-size screenshot viewers open through - F-191: the offset that clears the sticky header was a number, and the number was wrong
`:target`, which follows a real fragment navigation and not a `pushState`. One more thing was in the way, and it was the site's own stylesheet. `scroll-behavior: smooth` is set on `html`, so a fragment jump is an animation rather than an - F-191: the offset that clears the sticky header was a number, and the number was wrong
event. Over the height of a screen or two that animation is the thing that tells a reader they have gone down the page rather than sideways. Over twenty thousand pixels, which is what the front page's later sections are from the top, it is - F-191: the offset that clears the sticky header was a number, and the number was wrong
a second and a half of the whole page rushing past, and measured it was a second and a half during which the reader had not arrived anywhere. A hop longer than two screens now goes straight there, and the outline says where "there" is; a - F-191: the offset that clears the sticky header was a number, and the number was wrong
shorter one still glides. `tools/site-tests/anchors.test.js` checks what can be read without a browser: that the offset comes from the variable rather than a constant, that it applies to every id, that the script measures the header, that - F-191: the offset that clears the sticky header was a number, and the number was wrong
every image declares its size, and that every page carrying the sticky header loads the script. The last of those found four more pages on its first run. Measured again afterwards, at 1280px and at 390px: of 436 fragment links, 411 land - F-191: the offset that clears the sticky header was a number, and the number was wrong
with the heading exactly 14px below the header, within 50ms. The other 25 cannot land anywhere else. Twenty open the full-size screenshot viewers, which are fixed overlays that cover the page rather than places in it, and five name - F-191: the offset that clears the sticky header was a number, and the number was wrong
something at the very bottom of a page, where the document has no more room to scroll. The edition that runs no scripts has the same five. - F-190: a record length multiplied by eight, on a target where that wraps
Part 2.5 of the brief, which is integer width, and the reason this project builds i686 and armv7 in CI: everything in that section bites only on 32-bit. The obfuscated store pads every record to a bucket so that its size says as little as - F-190: a record length multiplied by eight, on a target where that wraps
possible about its contents. The bucket is chosen from the record's length multiplied by the worst-case expansion any of the reversible encodings can produce, which is eight. A record over about 512 MiB wraps that multiplication on a - F-190: a record length multiplied by eight, on a target where that wraps
32-bit target, and `bucket_for` would then size the buffer from a number smaller than the data it has to hold. **What actually happened next is worth stating, because it is not a breach.** The line after it compares the encoded body - F-190: a record length multiplied by eight, on a target where that wraps
against the padded length and refuses when the body is larger, so a wrapped size was caught and reported as an encryption failure rather than producing a short buffer. In a debug build the multiplication would have panicked first. So the - F-190: a record length multiplied by eight, on a target where that wraps
outcome was a confusing error, not a corrupt record, and the check that saved it was written for a different reason: to catch a new encoding that expands further than the allowance. That is still the wrong shape. This project's stated - F-190: a record length multiplied by eight, on a target where that wraps
pattern for a length that came from a caller is checked or saturating arithmetic, and F-79 was this same class on the recorder. The multiplication and the addition are checked now, and `bucket_for`'s own rounding saturates rather than - F-190: a record length multiplied by eight, on a target where that wraps
wrapping to zero. The store holds settings and measurements, a few kilobytes each, so nothing reachable today goes near the boundary. The test does the arithmetic directly rather than allocating half a gigabyte to prove it. - F-189: a bare program name is a search, and two of them were left
Part 2.1 of the round's brief: every place this workspace starts a process, checked against the rule the project already states, which is that a program is named by absolute path and never left to `PATH` to find. The rule is enforced by a - F-189: a bare program name is a search, and two of them were left
test in `veilvoice-gui`'s reduced-motion probe, which reads its own source and fails on any spawn that does not go through the helper beside it. That test only reads one file in one crate, which is the gap: two other crates spawn processes - F-189: a bare program name is a search, and two of them were left
and neither had anything watching them. `veilvoice-accel` lists the machine's graphics adapters. Windows names PowerShell under `%SystemRoot%`, macOS names `/usr/sbin/system_profiler`, and Linux said `lspci`. Anything earlier on `PATH` - F-189: a bare program name is a search, and two of them were left
under that name is what would have run, in a process that is already the desktop application. `veilvoice-verify`'s `which` asks where a program lives, and asked by spawning `where` on Windows and `sh` elsewhere, both bare. Asking `PATH` - F-189: a bare program name is a search, and two of them were left
where something on `PATH` lives, by way of a program found on `PATH`, is a circle with an obvious way into it. The command-line half of this project already names `where.exe` absolutely; the verifier did not. Both are named absolutely now, - F-189: a bare program name is a search, and two of them were left
and `veilvoice-accel` has the same read-your-own-source test the interface crate has, proved by putting the bare name back and watching it fail. **Deliberately left alone.** The reproducible-build path spawns `rustc`, `cargo` and `git` by - F-189: a bare program name is a search, and two of them were left
bare name, and that is correct: it is checking a build against the toolchain the person running it has, so resolving through their `PATH` is the whole point. Naming those absolutely would break the thing they are for. - F-188: one launch in 65,536 drew a reseed range with no width in it
The de-identifier re-draws its scrambling seed on an interval, and that interval is itself drawn at launch from a range the program also draws. The reason is written in the function: a fixed ratchet period is a fixed thing to observe, and - F-188: one launch in 65,536 drew a reseed range with no width in it
every copy of VeilVoice having the same one makes the period a property of the *program* rather than of the session. The range came from two sixteen-bit draws, sorted. Two equal draws give a range of no width, which is a **fixed** - F-188: one launch in 65,536 drew a reseed range with no width in it
interval: exactly the thing the drawing exists to prevent. `checked` accepts it, because it only refuses a range that is backwards. A front end showing it would show two identical numbers and nothing would look wrong. **How it surfaced, - F-188: one launch in 65,536 drew a reseed range with no width in it
and why that is the interesting part.** A verification run failed on `a_drawn_range_is_always_valid`, a test that had passed eleven times that day. Sixty-four draws a run against a one in 65,536 event is about one run in a thousand. Two - F-188: one launch in 65,536 drew a reseed range with no width in it
hundred repeats of the test afterwards did not reproduce it, which is what a one in a thousand event looks like when you go looking for it. The test was right and the code was wrong. That is worth saying plainly: the assertion encoded an - F-188: one launch in 65,536 drew a reseed range with no width in it
invariant the function did not actually provide, and it had been sitting there being true by luck. **The fix, and why it is a separate function now.** Waiting for a one in 65,536 event is not a test. The arithmetic that turns two draws - F-188: one launch in 65,536 drew a reseed range with no width in it
into a range is now `reseed_range_from`, which takes the draws as arguments, so the collision can be handed to it directly at every value it can take rather than waited for. A collision is widened to one frame, centred on the draw so that - F-188: one launch in 65,536 drew a reseed range with no width in it
a collision near the slow end stays near the slow end, and clamped to the available room. One frame because that is the resolution the engine has: `reseed_range_is_finer_than_a_frame` already exists to report a range that collapses onto a - F-188: one launch in 65,536 drew a reseed range with no width in it
single interval, and a drawn range should never be one. Removing the widening again makes the new test fail with `draws 0 and 0 gave 21.3 to 21.3`, which is the check that it checks anything. - F-187: a walker that reads past the end, and bland metadata that is not quite bland
The same tool over the two readers that parse somebody else's file. `veilvoice-meta`'s WAV chunk walker and image stripper: 58 mutants, 53 caught, one that does not compile, and four the suite accepted. All four were in the walker, and - F-187: a walker that reads past the end, and bland metadata that is not quite bland
they divide in a way worth naming. One was a guard against reading past the end. An odd-sized chunk is followed by a pad byte, and the walker copies it after checking there is one; moving that `<` to a `<=` reaches one byte beyond the - F-187: a walker that reads past the end, and bland metadata that is not quite bland
region it is allowed to read. Every existing test built its chunks with a helper that always pads, so no odd-sized chunk ever ended a file and the guard was never the thing that stopped anything. A truncated recording is exactly that - F-187: a walker that reads past the end, and bland metadata that is not quite bland
shape. The other three were in the bland metadata this crate writes **in place of** what it strips, and that is the part worth explaining. Stripping metadata is itself a signal: a file with no tags at all says something about how it was - F-187: a walker that reads past the end, and bland metadata that is not quite bland
made. So a plausible, bland `LIST`/`INFO` chunk goes in instead, which only works if what goes in looks like what any other tool would write. One line word-aligns those entries, and three mutations of it turned the alignment off, inverted - F-187: a walker that reads past the end, and bland metadata that is not quite bland
it, or applied it to two lengths in every however many. Nothing objected, because nothing had ever read back what that function writes. A generator whose output is never parsed is the encodings' gap in a second place. All four are fixed - F-187: a walker that reads past the end, and bland metadata that is not quite bland
and the re-run is **58 mutants, 57 caught, one unviable, nothing surviving**. Each of the four was planted by hand afterwards to confirm the new tests fail on it. The weekly workflow covers this crate alongside the cryptography now. **The - F-187: a walker that reads past the end, and bland metadata that is not quite bland
guard, which is roadmap item 151.** `.github/workflows/mutants.yml` runs the campaign weekly over all eight cryptography files and can be dispatched before a release. Its verdict is not a number. `tools/mutants/check.py` compares the run - F-187: a walker that reads past the end, and bland metadata that is not quite bland
against the committed list: a survivor that is not argued for fails the build and is named, as a diff somebody reads, and a survivor that has been killed fails it too, because the argument above that line would then be claiming something - F-187: a walker that reads past the end, and bland metadata that is not quite bland
untrue about the code. A list that over-claims is how a real survivor hides in it. Half an hour over eight files does not fit a per-push job, so what runs on every push is the half that does not need a campaign: `--lint` checks that every - F-187: a walker that reads past the end, and bland metadata that is not quite bland
entry still points at a real file and a real line, since those move whenever the code above them moves. Both halves were proved able to fail before they were trusted. - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
`tools/measured/generate.py` runs the test suite to count the tests, because a count of `#[test]` is a different number from the one a reader gets. It ran it with the build redirected: environment.setdefault( "CARGO_TARGET_DIR", - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
str(Path(os.environ.get("LOCALAPPDATA", ROOT)) / "veilvoice" / "target"), ) On Windows that is deliberate and correct: the build goes under `%LOCALAPPDATA%`, away from a repository that may sit in a synced folder or deep enough to meet the - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
path limit. On every other machine `LOCALAPPDATA` does not exist, the fallback is the repository root, and the build goes to **`<repo>/veilvoice/target`**: a second complete copy of the workspace build, inside the repository, rebuilt from - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
nothing every time the measured numbers were regenerated. **Nothing could report it, and the reason is a rule that is correct.** `.gitignore` carries a bare `target/`, which git matches at any depth. That is the right rule, since a build - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
directory is certainly not source. It also means this one never appeared in `git status`, never appeared in a diff, was never cleaned, and could only be found by walking the tree with `du`. **What found it was several steps removed from - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
it.** A mutation-testing campaign died copying the tree, reporting no space on the device. The disk was not the finding; the campaign was not the finding; the fifteen gigabytes were. A defect whose only symptom is an unrelated tool failing - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
for an unrelated reason is the kind this document exists to write down, because the next person to meet the symptom will debug the symptom. **And it came back.** The directory was deleted mid-round, and the next regeneration recreated it, - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
at 7.2 GB, because deleting output without fixing the line that writes it is not a fix. That is the whole argument for the guard rather than the sweep. **The fix and the guard.** The redirect is now conditional on actually being on - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
Windows; everywhere else the tool says nothing and cargo uses the `target/` at the root, which is where every ignore rule and every other tool already expects it. `tools/audit/build_output.py` walks the tree for Cargo's own `CACHEDIR.TAG` - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
signature, rather than for directories named `target`, and fails naming the path and its size if one turns up anywhere but the root and `fuzz/`. It is in `tools/verify.py` and in CI, and it was proved able to fail by planting a tagged - F-185: fifteen gigabytes of build output inside the repository, that no tool could see
directory under a crate and watching it report the path and the size. - The checks this round ran, and where each one lives now
The inventory is not a finding. It is here because the next round's reader needs to know what ran, and a check that found nothing is still worth listing: "we looked and there was nothing" is a result, and it is not the same as not having - The checks this round ran, and where each one lives now
looked. | Check | Ran before | In a workflow | What it found | |---|---|---|---| | `cargo audit` | yes | yes | nothing new; one exception retired because the crate left the graph | | `cargo deny`, licences and sources | no | **now** | - The checks this round ran, and where each one lives now
every licence compatible, every crate from crates.io | | the two advisory policies agree | no | **now** | they did; they cannot drift now | | `cargo clippy`, workspace, all targets | yes | yes | clean | | `cargo fmt` | yes | yes | clean | - The checks this round ran, and where each one lives now
| `forbid(unsafe_code)` in every crate | yes | yes | present in all twenty-seven | | `warn(missing_docs)` in every crate | yes | yes | present in every library; the two `main.rs` files do not carry it, which is right | | the seven - The checks this round ran, and where each one lives now
coverage-guided fuzz targets | by hand | **now, weekly** | ten minutes each, no crash, no hang, no out-of-memory | | the deterministic parser campaigns | yes | yes | clean | | **mutation testing** | no | **now, weekly** | **fifteen - The checks this round ran, and where each one lives now
survivors over four files (F-180), then eighty-one over four more (F-186)** | | **a public item reached by nothing** | no | **now** | **five; F-182** | | **build output outside the root `target/`** | no | **now** | **one, at fifteen - The checks this round ran, and where each one lives now
gigabytes, rebuilt on every run; F-185** | | a state file written one place and read another | by hand | **now** | nothing | | the per-program guides against the user guide | by hand | **now** | nothing | | the questions page against - The checks this round ran, and where each one lives now
`docs/FAQ.md` | by hand | **now** | nothing | | the app-manifest generator's self-test | by hand | **now** | nothing | | the local site serves every page | by hand | **now** | nothing | | the offline claim, on the built command line | yes - The checks this round ran, and where each one lives now
| yes | no import, no syscall, works in an empty network namespace; each guard proved able to fail | | Miri | no | no | **run, first time**: five crates, nothing found; `veilvoice-crypto` could not be run at all until F-183, and Argon2id - The checks this round ran, and where each one lives now
and the transform stay out of reach | | a reproducible-build rebuild | yes, per release | yes, per release | not re-run here | Two checks stay out of a workflow on purpose and the reason is worth writing down rather than rediscovering. - The checks this round ran, and where each one lives now
`tools/shots/sessions.py --check` runs the release binaries, and one of its sessions checks a *published* release, which needs the archive, the sums and the signature downloaded into one folder. `tools/measured/generate.py --check` runs - The checks this round ran, and where each one lives now
the whole test suite a second time in order to count it. Both are in `tools/verify.py`, which is what somebody runs before a release; neither belongs in a job that should finish in minutes. - Miri, and what it could and could not say
**V5 in the brief, and the first time this project has run it.** `cargo +nightly miri test` interprets the program and reports undefined behaviour. In a workspace where every crate forbids `unsafe`, anything it found would belong to a - Miri, and what it could and could not say
dependency, which makes a hit here a supply-chain finding rather than a code one. `veilvoice-check`, which holds the release-signature and contents-manifest readers: **29 tests, no failures, no undefined behaviour**, in 273 seconds. - Miri, and what it could and could not say
`veilvoice-meta`, which holds the WAV chunk walker and the metadata stripper: **20 of 24 passed with no undefined behaviour reported anywhere**. The four that did not pass all fail the same way and it is worth being precise about what that - Miri, and what it could and could not say
does and does not mean. Each is a test that writes tags into a real file through `lofty`, and each fails with `Malformed("failed to write to file")`. Miri reported no undefined behaviour and no unsupported operation: what failed is a write - Miri, and what it could and could not say
through Miri's filesystem shim, and the same four tests pass in seconds outside it. So this is the interpreter's environment rather than a defect, and it is recorded as a limit on the coverage rather than as a result: **the tag writer is - Miri, and what it could and could not say
the one part of this crate Miri did not get to examine.** `veilvoice-conversation`, which holds the speaker plan and the edit operations: **78 tests, no failures, no undefined behaviour**, in 31 seconds, with its 24 render tests skipped. - Miri, and what it could and could not say
Those render tests are why the first attempt produced nothing for this crate. They process audio, which under an interpreter is slow enough that one of them held a combined run for a quarter of an hour while the two crates behind it - Miri, and what it could and could not say
waited. **Passing several crates to one Miri run lets the slowest test in the first of them decide what the rest get**, which is an arrangement mistake rather than a result: run per crate, and a crate that takes thirty seconds takes thirty - Miri, and what it could and could not say
seconds. Written down here because the next person to reach for this tool will otherwise arrange it the same way. `veilvoice-core`, the engine: **33 tests, no failures, no undefined behaviour**, in 743 seconds, with 52 of its 85 skipped. - Miri, and what it could and could not say
The 52 are every test that drives the short-time Fourier transform over more than a frame or two, and skipping them is not a preference. The first attempt ran the whole crate and reached **two tests in twenty-nine minutes**, with the - Miri, and what it could and could not say
second still running; at that rate the crate is days rather than hours, and the run was stopped rather than left to expire against its own timeout. What did run is the window function, the voice table, the reseed range arithmetic and the - Miri, and what it could and could not say
transform's own three tests, which exercise `realfft` and `rustfft` at a 512-point transform and are the part of this crate where a dependency's `unsafe` actually lives. So the interpreter did reach the numerical library; what it did not - Miri, and what it could and could not say
reach is the engine driven over seconds of audio, and there is no reason to expect those paths to differ in kind from the ones that passed. **The general shape, after four crates.** Miri costs roughly a thousand times the wall clock, so - Miri, and what it could and could not say
what it can cover is decided by which tests process how much data rather than by which code is worth checking. Every crate here has been approached the same way: run it alone, and where a subset has to be skipped, say which subset and why, - Miri, and what it could and could not say
rather than reporting a clean run over an unnamed fraction of it. `veilvoice-crypto`, once it could be run at all: **72 of 240 tests, no failures, no undefined behaviour**, over the reversible encodings, the AEAD, the protected-memory - Miri, and what it could and could not say
type, the chunked tape, the shredder and the private-file helper. It could not be run at all before this round, which is F-183 above. The honest summary is that Miri has been run here for the first time, it has found nothing anywhere, and - Miri, and what it could and could not say
it has now covered five crates in part or in whole. What it cannot reach is now a short and specific list rather than an open question: the conversation crate's render path, the engine driven over more than a frame or two, and everything - Miri, and what it could and could not say
in the cryptography that runs Argon2id. All three are the same cause, which is that an interpreter costs about a thousand times the wall clock and these are the parts written to be expensive. - What was read and found correct
Not findings, and listed because "we looked and there was nothing" is a result. **The three ways into a video now have to agree.** `ffmpeg::command` builds the invocation that `veilvoice conversation` **prints for a person to run by - What was read and found correct
hand**, under a line saying VeilVoice never runs it for them; `concat_command` builds the one the window actually runs; `black_command` builds the one that puts a recording behind a black frame. All three were correct and all three worked - What was read and found correct
their encoder settings out separately, with only the frame size compared across two of them. If the printed one and the run one ever drifted, the instruction this program gives would stop being the thing this program does, and no build - What was read and found correct
would say so. They agree today and now have to: a test compares the codec, the quality, the scale filter, the pixel format and the audio settings across them, and asserts that the black-frame command has no scale filter, which is right - What was read and found correct
because there is nothing to scale. **`ffmpeg::command` is not the dead public item the brief expected.** It was the candidate on the list, on the ground that its only caller prints it. Printing it *is* the feature: the command line's whole - What was read and found correct
posture there is to hand somebody a command and not run it. Reached, argued, and staying. **Ten hand-typed numbers, checked but not written, now written.** The functional line count, the test count, the crate count and the suite count are - What was read and found correct
stated in the README, on the front page and in one row of this document: ten claims in all, and the website suite already compared every one of them against the tree. The comparison was right and it is untouched. The typing was the - What was read and found correct
problem. Every change to any Rust file moves the line count, so a commit that touched code and not those ten places failed a check that had nothing to do with what the commit was about, four times in this round alone. That is a measurement - What was read and found correct
rather than an impression, which is why it was worth acting on rather than noting. `tools/measured/generate.py` already took all four numbers to write `docs/MEASURED.md`. It now writes the ten sentences from them as well, using the same - What was read and found correct
regular expressions the suite checks with, so the tool that writes a claim and the suite that checks it cannot disagree about where the claim is. A pattern that stops matching fails the tool rather than letting it write nine of ten and - What was read and found correct
report success. The one row it touches in this document is the "Test suite" row of the state-of-the-tree table, which describes now rather than recording the past, and the pattern is anchored to that row so no older number further up the - What was read and found correct
page is within its reach. Roadmap item 152, done. **The reproducible-build machinery, read end to end.** `Cargo.lock` is committed, every `cargo build` in the release workflow passes `--locked`, the toolchain is pinned in - What was read and found correct
`rust-toolchain.toml`, `SOURCE_DATE_EPOCH` comes from the commit date, and `--remap-path-prefix` maps both the source tree and the Cargo home. The rebuild is not a thing this project intends to do: it already happens on every release, for - What was read and found correct
every published binary, on every target including the three BSDs, building twice in different directories and comparing byte for byte. The verdict per target goes into the release notes. It warns rather than failing the release, and that - What was read and found correct
is a decision written into the top of the workflow rather than an oversight: a target that is not reproducible still ships and says which one it was, on the ground that quietly claiming reproducibility would be worse than publishing the - What was read and found correct
gap. Read again this round and it still reads as the right call. What is not established, and is the one thing that workflow cannot establish about itself, is a rebuild of a **published** release from its tag on a different machine, - What was read and found correct
compared against the artefacts actually on the release page. That needs disk this round did not have and stays open. **The fuzz project's lock file.** Ignored rather than committed, which is the opposite of the workspace's choice and had - What was read and found correct
no sentence beside it in a file where every other entry has one. The reason is real and is now written where the rule is: nothing in `fuzz/` is released, so nothing there needs rebuilding years later, and a campaign pinned to last year's - What was read and found correct
dependencies is a campaign that cannot find a defect introduced since. The weekly workflow passes no `--locked` to match, and Dependabot watches the manifest so a version range that stops making sense is still raised. **The private-file - What was read and found correct
helper.** Creates owner-only with `create_new`, so a symlink planted at the path is refused rather than followed, and replaces by renaming inside the same directory, so the replacement is atomic and cannot cross a filesystem. Both already - What was read and found correct
tested, including the symlink case. **The panic sites in the parsers.** The WAV chunk walker's two `expect("4 bytes")` calls are on slices whose length the line above them fixed at four; the FFT's are on buffers the constructor sized; the - What was read and found correct
conversation editor's are on a turn inserted two lines earlier. Each is an invariant established locally rather than assumed, which is the standard this document sets for them. - F-179: the window drew at twenty frames a second, by construction
Reported as "8 to 40 fps instead of a consistent 60". The cause was not a slow frame anywhere; it was three numbers. `soundbar.rs` carried `FRAMES_PER_SECOND = 20` and asked for its next frame fifty milliseconds out. The busy path in - F-179: the window drew at twenty frames a second, by construction
`app.rs` asked for one every fifty milliseconds. The veiling path asked for one every sixteen. Each was argued where it was written, and the arguments were about cost rather than about the display: twenty a second is indistinguishable from - F-179: the window drew at twenty frames a second, by construction
thirty *for that animation in isolation*, and it is plainly distinguishable from a hundred and forty-four when it is the only thing moving on a display running at that. The sixteen is the one worth naming, because it looks correct. A - F-179: the window drew at twenty frames a second, by construction
display at sixty shows a frame every 16.67 ms, so a request for a repaint "no later than sixteen milliseconds from now" wakes the loop just before the frame it wanted and then misses it, and the drawing lands on the next one. That is - F-179: the window drew at twenty frames a second, by construction
thirty a second, asked for as sixty, which is the shape of every number in the report. **What replaces them is not a bigger constant.** `crate::pace` asks for the next frame *now* while anything is animating, and vsync spaces it: a window - F-179: the window drew at twenty frames a second, by construction
that waits for the display cannot draw faster than the display shows, so this costs one frame per refresh and no more, at whatever rate the screen runs. The same fact is what makes the display measurable, which matters because neither - F-179: the window drew at twenty frames a second, by construction
`egui` 0.32 nor `eframe` exposes a refresh rate: the interval between frames paced this way *is* the display's, and the median of the last thirty-two is a figure one slow frame cannot move. Settings offers the fixed rates for somebody who - F-179: the window drew at twenty frames a second, by construction
wants fewer frames on a battery, and that is the only thing a timer is used for now. Idle is unchanged and deliberately so: a window with nothing moving requests no frame and draws none, which is the finding from the twenty-third round and - F-179: the window drew at twenty frames a second, by construction
is the reason this file could say what an idle window costs. Measured rather than assumed, both ways: the module's tests drive the measurement at 60, 120, 144, 165 and 240 and get each back; the soundbar's test asserts that on the - F-179: the window drew at twenty frames a second, by construction
display's rate it puts no timer in the way and that on a chosen rate it asks for that interval; a late frame is counted as late and an idle gap is not; and two seconds of late frames raises the notice while one, which is what every launch - F-179: the window drew at twenty frames a second, by construction
costs, does not. - F-180: the tests never stood at the edge of any boundary in the crypto
Mutation testing, run over `aead.rs`, `kdf.rs`, `container.rs` and `hybrid.rs`: 127 mutants, 91 caught, 21 that do not compile, and **15 that survived the whole suite**. A surviving mutant is a change to the code that no test objects to, - F-180: the tests never stood at the edge of any boundary in the crypto
which is a claim about the tests rather than about the code, and the fifteen fell into three groups. **Five were boundaries in the container header's parser.** `bytes.len() < HEADER_LEN` became `<=`, `bytes.len() < end` became `<=` and - F-180: the tests never stood at the edge of any boundary in the crypto
became `==`, and both mode guards, the one that refuses a password header claiming an encapsulation and the one that refuses a hybrid header of the wrong length, became `false`. Every one of those is reachable from a file somebody sends - F-180: the tests never stood at the edge of any boundary in the crypto
you. The suite tested what the parser accepts and threw a great deal of rubbish at it, including a fuzzing campaign; what it never did was hand it a buffer of exactly the length in question. The test added here does each of the five from - F-180: the tests never stood at the edge of any boundary in the crypto
both sides: a header of exactly `HEADER_LEN` parses, one byte short is truncated, a hybrid header whose encapsulation is exactly as long as it says parses and one byte short does not, a password header claiming an encapsulation is refused, - F-180: the tests never stood at the edge of any boundary in the crypto
and a hybrid header is refused at one less, one more, and zero. **Four were the ceilings on what a header may ask the key derivation for.** `>` became `>=` and `==` on the memory ceiling and on the unattended ceiling, and `||` became `&&` - F-180: the tests never stood at the edge of any boundary in the crypto
in the parallelism test, which would have let a header declaring zero lanes through on its own. These are the numbers that stop a `.veil` file from choosing how much memory this program allocates, and they had been tested at values that - F-180: the tests never stood at the edge of any boundary in the crypto
are obviously fine and obviously absurd and never at the number itself. Each ceiling now accepts its own value and refuses one past it, and zero is refused on its own. **Three were the randomness adapter**, and this is the one worth being - F-180: the tests never stood at the edge of any boundary in the crypto
blunt about. `next_u32` and `next_u64` were replaced by functions returning a constant, and `try_fill_bytes` by one returning `Ok(())` without touching the buffer, and all three passed. Nothing in the crate checked that its only source of - F-180: the tests never stood at the edge of any boundary in the crypto
randomness emits anything. A key drawn from a buffer those mutants left untouched is a key an attacker already knows, and the suite would have been green. It is not a statistical test that was missing, which `getrandom` is not this crate's - F-180: the tests never stood at the edge of any boundary in the crypto
to assess; it is the question of whether bytes arrive at all. They are asked for now, in both forms, and two draws are required to differ. **Two of the fifteen are one line, and that line cannot be killed.** Both are mutations of `p_cost > - F-180: the tests never stood at the edge of any boundary in the crypto
MAX_P_COST`, which can never be the only test that fires: Argon2 wants eight KiB per lane, `checked` enforces that in widened arithmetic, and the memory ceiling is four gibibytes, so nothing above `MAX_M_COST / 8` reaches that line - F-180: the tests never stood at the edge of any boundary in the crypto
whatever it says. The guard stays, because it is the bound Argon2 documents and because the argument it belongs to is about the order these checks run in: a later change that moved the lane relation would leave this as the only thing - F-180: the tests never stood at the edge of any boundary in the crypto
between a header and an overflow. What changes is the doc comment, which now says all of that, so the next reader who mutates it and sees nothing happen finds the answer where they are standing rather than concluding the line is dead. - F-180: the tests never stood at the edge of any boundary in the crypto
**The campaign was run again against the new tests**, which is the part that makes this a result rather than an intention: 127 mutants, 104 caught, 21 unviable, **2 missed**, and both of those are the two mutations of that one unkillable - F-180: the tests never stood at the edge of any boundary in the crypto
line. Thirteen of the fifteen are dead; the other two are documented where they live. The campaign is not yet part of any build. It takes twenty-three minutes over four files and there are twenty-seven crates; making it a check is the - F-180: the tests never stood at the edge of any boundary in the crypto
work, and roadmap item 151 carries it. - The thirty-second round: the guard that failed and the two behind it
The round after 0.1.20, covering the decoy vaults and everything they touched, and beginning where it should have begun the round before: with the build. **The build was red, and had been for five commits.** The thirty-first round's - The thirty-second round: the guard that failed and the two behind it
largest finding was that eleven drift checks did not fail a build, and ten were wired into CI to fix it. What that round did not do was look at whether the build those checks were joining was passing. It was not, and one of the checks it - The thirty-second round: the guard that failed and the two behind it
was not passing is the one that runs before them. - F-160: the offline proof that never ran once
The `offline-runtime` job added in the thirty-first round proves the claim on the front page four ways: the dependency graph, the source, the built binary's imports, and the running program with no network at all. The third of those runs - F-160: the offline proof that never ran once
the command line inside an empty network namespace: unshare -rn target/release/veilvoice anonymise ... `unshare -r` asks for a **user** namespace, so that an ordinary account may then make a network one. Ubuntu 24.04, which is what - F-160: the offline proof that never ran once
`ubuntu-latest` is, refuses unprivileged user namespaces by default through `kernel.apparmor_restrict_unprivileged_userns`. The step failed on its first run and on every run since: unshare: write failed /proc/self/uid_map: Operation not - F-160: the offline proof that never ran once
permitted **The two steps after it were skipped**, because a failed step ends the job. So the syscall trace never ran and the window's socket families were never read. For the whole of 0.1.20 the job existed, looked like it proved four - F-160: the offline proof that never ran once
things, and proved one: the import check, which is the step before the failure. That is worse than the state it replaced. A missing guard is a known gap. A guard that fails for a reason unrelated to what it guards, in a job whose name says - F-160: the offline proof that never ran once
the program opens no network socket, is a gap that reads as coverage. The namespace is now obtained whichever way the kernel allows: unprivileged through a user namespace where that is permitted, and otherwise made as root with the program - F-160: the offline proof that never ran once
dropped straight back to the ordinary account inside it, so what is measured is still VeilVoice as somebody runs it. If neither works the step fails and says the claim is unproved, rather than passing quietly. - F-161: a shell quote that would have failed the step behind it
The last line of the fourth step is echo "... the window's sockets are local"" with one quotation mark too many, which is a syntax error `bash` reports before running any of it. It has never been executed, because F-160 skipped the step, - F-161: a shell quote that would have failed the step behind it
and it would have failed the job the first time it was. Two defects in one job, the second hidden by the first, in the job that carries the repository's strongest claim. - F-162: the desktop tests opened a real file dialog
`test / macos-latest` and `test / windows-latest` were both red, and for one cause. Two tests in the Studio drive the export path, which asks `dialog` for a folder, and `dialog::Pending::start` opened a **platform panel**. On macOS `rfd` - F-162: the desktop tests opened a real file dialog
does not decline politely: it panics. > You are running RFD in NonWindowed environment, it is impossible to spawn > dialog from thread different than main in this env. On Windows every test passed and then the process died at exit with - F-162: the desktop tests opened a real file dialog
`STATUS_ACCESS_VIOLATION`, which is the dialog thread outliving the harness. Both are the same mistake: opening a window's file panel where there is no window. `start` now asks whether a panel can be shown at all, and where it cannot the - F-162: the desktop tests opened a real file dialog
ask reads as open and then as cancelled, which is the truth about what happened. That covers a real case as well as the test one: on macOS the panel must come from the main thread, and asking from any other thread used to panic, which - F-162: the desktop tests opened a real file dialog
would take down the application in the middle of somebody's recording rather than declining to open a dialog. - F-163: a test that assumed the machine had no sound card
Windows stayed red after F-162, with the same signature and a different cause, and the step added to name it did its job on the first run. test studio::tests::locking_the_window_stops_a_take_that_is_playing ... and then nothing: the - F-163: a test that assumed the machine had no sound card
process died inside it. Serially the crash follows the test that causes it, which is what that step is for. The test asserted that locking the window releases whatever is playing. It did so by playing a recording and then locking, under a - F-163: a test that assumed the machine had no sound card
comment that said in as many words: > No audio device in a test runner, so `play` will not start a stream. On the Windows runner there is one. A stream started, and tearing it down took the whole test binary with it: every test in the - F-163: a test that assumed the machine had no sound card
crate passed and the process exited with an access violation anyway, which reads as a defect in VeilVoice and is a defect in the test. **A test whose correctness depends on the machine not having a sound card is not testing the thing it - F-163: a test that assumed the machine had no sound card
names.** What it actually names is one line in `close`, and that is what it asserts now, by reading `close`'s own source, the same way its sibling asserts the first line of `play`. No device is opened by any test in this crate. - The decoys that a folder listing would have identified
Not a defect in shipped behaviour, and recorded because the design decision is easy to get wrong and this nearly did. `veilvoice-crypto::studio` shipped `make_decoy` and `Shape::of` in 0.1.20: documented, tested, and reached by nothing. - The decoys that a folder listing would have identified
Wiring them to a button is what roadmap item 134 asks for. Wiring only that would have produced decoys worth nothing. The vault lived at a fixed name, `studio`, in the folder the decoys would have been made in. A decoy is indistinguishable - The decoys that a folder listing would have identified
from the real vault by size, by file count and by contents; it is told from it instantly by reading the name of the directory it is in. Every vault is now a directory with an opaque name and the real one is found by trying each until one - The decoys that a folder listing would have identified
opens, which only both passphrases do. A vault written the old way moves down into a directory of its own on the next unlock, index last, so an interrupted move finishes on the following one rather than splitting the vault in two. One - The decoys that a folder listing would have identified
measurable tell survived the first implementation and is worth recording because a test caught it rather than a reading: a decoy's index was eighteen bytes smaller than the real one, because a real vault's recordings are called something - The decoys that a folder listing would have identified
and a decoy's were called nothing. The sizes were the one thing this was meant to make identical. The names are now padded to the length the real index measured, and the test builds decoys and adds up the real files. - The guard for F-163 and F-165, rather than a third one at a time
Both were fixed individually and neither fix stops the next one. So there is now a guard: **no test in the desktop crate may open a device, a dialog or a window.** It walks that crate's source, takes the test code, and fails naming any - The guard for F-163 and F-165, rather than a third one at a time
line that reaches `devices::list`, `devices::open`, `playback::start`, `LiveSession::start`, `LiveSession::start_recording` or `rfd::FileDialog`. One deliberate exception, by test name rather than by call site: - The guard for F-163 and F-165, rather than a third one at a time
`app::tests::building_the_app_with_real_device_enumeration_does_not_panic` enumerates once, on purpose, because that is how the window's device pickers are known to survive a machine with no sound card. F-165 was a second test borrowing - The guard for F-163 and F-165, rather than a third one at a time
that, so the exception cannot be borrowed. A needle is not a call. `dialog.rs`'s own guard searches its crate's source for `rfd::FileDialog`, and the string it searches for is not an opened dialog, so a match inside a string literal is - The guard for F-163 and F-165, rather than a third one at a time
skipped. That was the guard's own first false positive and it is worth recording, because the check that caught it was running the guard rather than reading it. Proved both ways, as this repository requires of a guard: it passes on the - The guard for F-163 and F-165, rather than a third one at a time
tree as it stands, and planting one line that enumerates a device in a Studio test makes it fail naming the file, the line and the test. It is scoped to the desktop crate rather than the workspace. That binary is the one linking cpal, - The guard for F-163 and F-165, rather than a third one at a time
`rfd`, egui and winit together and it is where both crashes happened; a narrower guard that is exactly right is worth more than a wide one that has to be argued with. Widen it the day another binary does the same thing. - F-165: a second enumerator, and Windows red again
The same shape as F-163, a day later, in a test written to check that the new setup card could not fail. Roadmap item 135's card counts the recording and playback devices this machine has. The test asked for that count twice and asserted - F-165: a second enumerator, and Windows red again
the two agreed, on the ground that a build machine with no sound card, a sandbox that refuses to enumerate and an ordinary desktop all have to reach the card without an error. The desktop crate's test binary **already** enumerates real - F-165: a second enumerator, and Windows red again
devices, once, on purpose, in `app::tests::building_the_app_with_real_device_enumeration_does_not_panic`. A second enumerator running beside it on another thread killed the process on Windows with an access violation, exactly as F-163 did, - F-165: a second enumerator, and Windows red again
and the two runs either side of the change say so: green without the test, red with it, crashing at the point in the listing where it sits. The test is gone rather than made conditional. It was checking that a call the machine answers does - F-165: a second enumerator, and Windows red again
not fail, which is a fact about the machine; what is worth checking is that the card asks the machine rather than carrying a number, and a test reads the card's own source for that. **One enumeration, in one place**, is now written where - F-165: a second enumerator, and Windows red again
the counting function is, so the next person to reach for a second one reads why there is not one. - F-164: a second recorder nobody drained
Found by re-reading the change that introduced it, before it was pushed, which is the only reason it is a paragraph rather than a bug report. Roadmap item 131 gives the Studio two recorders: one for the veiled voice and one for the - F-164: a second recorder nobody drained
microphone. The panel that runs while a take is recording drained the first, every frame, because a recorder nobody drains fills its ring and starts dropping samples. It did not drain the second. So keeping the microphone would have - F-164: a second recorder nobody drained
produced a take that was quietly short, which is the exact failure `dropped` exists to report, arrived at by not asking the question anywhere. The same code read the clock from the veiled recorder alone. Keeping *only* the microphone - F-164: a second recorder nobody drained
leaves no veiled recorder at all, so the counter would have sat at 0:00 for the whole of a recording that was running, which reads as nothing being recorded. Both come from the same missing idea, which is that there are now two of these - F-164: a second recorder nobody drained
and everything done to one has to be done to both. The panel drains every recorder that exists and takes the clock from whichever is running, and a test reads the panel's source for that. - F-166: the WAV header said 48 kHz because that is what was asked for
Found while reading the recording path for roadmap item 130, in code that has been in both front ends since the recorder existed. `veilvoice_audio::record::start` takes the rate to write into the WAV header, and its own documentation says - F-166: the WAV header said 48 kHz because that is what was asked for
what that rate has to be: > `sample_rate` is the rate the device actually agreed to, not the one that was > asked for: it is written into the WAV header, and a header that disagrees > with the samples plays back at the wrong speed and the - F-166: the WAV header said 48 kHz because that is what was asked for
wrong pitch, which on > a de-identified recording would be a second voice change nobody chose. Both callers passed the other one. The command line's `record` and the Studio each built their recorder from `config.sample_rate`, which is the - F-166: the WAV header said 48 kHz because that is what was asked for
rate the engine was **configured** for and is 48 000 by default, and then handed the sinks to `LiveSession::start_recording`, whose first act is to overwrite that field with the rate the output device agreed to. On any machine whose output - F-166: the WAV header said 48 kHz because that is what was asked for
runs at 44 100, which is a great many of them, the take was written with a 48 000 header over 44 100 samples: about nine per cent fast and a semitone and a half sharp, on top of the veiling. Nothing could have caught it by reading a call - F-166: the WAV header said 48 kHz because that is what was asked for
site, because each call site was self-consistent. The rate came from a field that was true when it was read and false a function call later. **So the caller no longer has a rate to get wrong.** `start_recording` takes a `Keeping`, which is - F-166: the WAV header said 48 kHz because that is what was asked for
two named booleans saying which sides of the engine to keep, and returns a `Kept`, which is the recorders for them, built after the device has answered. Roadmap item 131's property survives the change and is the reason `Keeping` has named - F-166: the WAV header said 48 kHz because that is what was asked for
fields rather than being a pair of positional flags: no caller reaches a recording of somebody's real voice without writing the word `plain` next to it. This is the shape roadmap item 126 asks for. A guard reading the two call sites would - F-166: the WAV header said 48 kHz because that is what was asked for
have worked and would have had to keep working; a signature with no rate in it cannot be got wrong by a third caller written next year. - Three dependencies nothing referred to
Roadmap item 126 asks for a dependency to be justified where it is declared. Writing that sentence for each of the 122 entries in this tree's manifests is what found the three that had no sentence to write. `veilvoice-verify` declares - Three dependencies nothing referred to
`sha2` and never mentions it: it hashes through `veilvoice_check::sha256_file`, which is the right way round, and the direct dependency is left over from when it did not. Its tests declare `hex` and never call it. The crypto crate's tests - Three dependencies nothing referred to
declare `hex-literal` and never call it. Not a defect in what any of them does, which is why this has no finding number. What it is, is three crates compiled by every build on every platform, in a project whose whole argument about - Three dependencies nothing referred to
dependencies is that each one is code it ships and does not review. Nothing noticed, because a manifest full of bare names reads as a list rather than as a set of decisions. They are gone, and the check that made them visible is now in CI: - Three dependencies nothing referred to
`tools/audit/dependencies.py` fails on a dependency with no reason beside it. It cannot tell whether a dependency is *used*, which is `cargo udeps` and needs nightly. What it can do is force somebody to answer the question that made these - Three dependencies nothing referred to
three obvious. A second question was not being asked at all, and is now. Saying what a dependency is for does not make anything *watch* it, and until this pass nothing did: there was no `.github/dependabot.yml`, so no version update and no - Three dependencies nothing referred to
alert had ever been raised against this tree. There is one now, covering the workspace, `fuzz/` (which is outside the workspace and would otherwise have been missed by an entry on the root) and the actions the workflows run. The - Three dependencies nothing referred to
configuration is checked rather than remembered. `tools/audit/dependabot.py` reads it against the tree and fails on either direction: a manifest no entry covers, or an entry naming a directory that has gone. The first is the one that - Three dependencies nothing referred to
matters, because a manifest nothing watches is worse than a dependency with a known problem: there is no one looking at it. - The comments said no callback allocates, and nothing checked
The three audio callbacks in this tree each carry a comment saying its buffers are sized once so that the callback never allocates. They are true. They were also the only thing standing between this project and a per-frame allocation on - The comments said no callback allocates, and nothing checked
the operating system's audio thread, and a comment is not a guard: F-159 and the two before it were all comments that had stopped being true. `no_audio_callback_allocates_or_blocks` finds every closure handed to `build_input_stream` or - The comments said no callback allocates, and nothing checked
`build_output_stream`, takes its body by matching braces, and fails naming any line that allocates, blocks or prints, with what that costs. `try_lock` and `try_push`, the non-blocking forms this code already uses, are matched on the call - The comments said no callback allocates, and nothing checked
rather than on the name, so the guard does not object to the thing it is asking for. Proved both ways, as a guard here has to be: it passes on the tree as it stands, and one planted `data.to_vec()` in the live input callback makes it fail - The comments said no callback allocates, and nothing checked
naming the file, the line and the reason. Read rather than measured, deliberately. A test that counted real allocations would need a global allocator hook and a running stream, which means a machine with a sound card, which is what F-163 - The comments said no callback allocates, and nothing checked
and F-165 were about. This one finds the same mistake on a build machine with no audio at all. - A stream error printed to a console the window does not have
Found while building roadmap item 132, in code as old as the live path. `cpal` reports trouble on a stream through an error callback, and the three in this tree were each `move |e| eprintln!(...)` and nothing else. On the command line that - A stream error printed to a console the window does not have
is right: there is a console and somebody watching it. In the desktop application it is not. A release build on Windows declares `windows_subsystem = "windows"` and has **no console attached**, which is the same fact F-119 turned on: - A stream error printed to a console the window does not have
`println!` there writes to nothing. So the one case this feature exists for, a microphone unplugged or swapped in the middle of a call, produced no visible effect of any kind. The meters fell to zero, which is what a person who has stopped - A stream error printed to a console the window does not have
talking also looks like, and a take carried on being recorded. Not given a finding number, because nothing computed a wrong answer: the program did exactly what it was written to do, and what it was written to do was insufficient on a - A stream error printed to a console the window does not have
platform it ships for. It is recorded because the shape is worth recognising. A diagnostic that goes to a stream the front end does not have is a diagnostic that does not exist, and this project has now found that twice. The reports are - A stream error printed to a console the window does not have
now kept and shown: `LiveStats::interfered` counts them, which is `Copy` and is read every frame beside the meters, and `LiveSession::interference` returns the last one, which holds a string and is asked for only when the count moves. The - A stream error printed to a console the window does not have
device-is-gone case is named separately because it is the one that does not come back on its own. They are still printed, for the command line, where the console exists. - The check that would have compared a buffer with itself
Roadmap item 132 opened by asking for the samples reaching the recorder to be checked against what the engine produced. Reading the path to build that showed there is nothing to check: the veiled sink is written from inside the output - The check that would have compared a buffer with itself
callback, from the same `scratch_out` slice `Deidentifier::process` has just written into, and the microphone sink from the `mono_scratch` the downmix has just filled. A comparison would be a slice compared with itself. The row is - The check that would have compared a buffer with itself
corrected rather than the check written. What the row wanted is a property, and the property is now read out of the source: two sinks, two writes, each from the one place in this process where its samples exist, and a third write would be - The check that would have compared a buffer with itself
a third copy of somebody's voice. That is the third roadmap row this cycle whose opening sentence described something the code does not do, after roadmap item 139's video and roadmap item 133's live bars. All three were written before the - The check that would have compared a buffer with itself
code they describe. - A check that failed once and would not say why
The recorded-session check failed once, on a machine that had just finished a release build, and passed on the next run and on the twenty-one after it. It has not been reproduced and is **not** written up here as fixed. What is written up - A check that failed once and would not say why
is why that is unsatisfactory. The failure said: assets/screenshots/session-anonymise.txt is not what the program prints now. Run: tools/shots/sessions.py --record That is not enough to act on. The transcript holds two values that vary by
docs/CONTRIBUTING.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Contributing to VeilVoice
- Before anything else
**A security problem does not go here.** Report it privately, the way [`docs/SECURITY.md`](SECURITY.md) describes. The ordinary issue tracker is right for everything else, including a bug that is merely embarrassing. - Getting it building
VeilVoice is a Rust workspace of thirteen crates producing two programs. It needs a recent stable toolchain, and on Linux the audio and windowing headers. git clone https://github.com/tilas01/veilvoice cd veilvoice # Linux only: the - Getting it building
headers cpal and the window need. sudo apt-get install -y libasound2-dev libgtk-3-dev libxdo-dev cargo build --workspace cargo test --workspace The desktop application is `veilvoice-gui`, the command line is `veilvoice`, and both are in - Getting it building
`target/debug` after that build. There is no third binary: the verifier lives inside both. - The one thing that decides whether a change is finished
**Run `python tools/verify.py` before you push.** It regenerates everything derived from the source, then checks that what is committed matches, and it is the same set of checks CI runs. It takes a while and it is not optional: most of - The one thing that decides whether a change is finished
what it catches is not a broken test but a document that has quietly stopped being true. python tools/verify.py If it fails, the message says which check and why. Nothing in it is advisory. - Why so much of this is generated
Anything stated in more than one place is derived from one source or checked against it, rather than written twice. The version comes from `Cargo.toml`. The line counts come from the tool that counts lines. The per-crate pages, the website - Why so much of this is generated
reference and the wiki are all one generator reading the doc comments in the code. The release notes are `CHANGELOG.md` and everything else quotes it. So the way to change what a document says is usually to change the code or the one - Why so much of this is generated
source file, and re-run the generator. Editing a generated file directly is wasted work: the next `--check` will fail on it, and the header at the top of each one says so. - The four standing rules
Three of these are enforced by a build rather than by anybody remembering. **Nothing in a realtime path allocates, locks or prints.** An audio callback runs on the operating system's audio thread with a deadline of a few milliseconds, and - The four standing rules
each of those three hands the thread to somebody else's schedule. Every buffer is sized once before the stream starts, and only the non-blocking forms (`try_lock`, `try_push`) are used. A test reads the callbacks themselves and fails - The four standing rules
naming the line. **A dependency says what it is for, on the line that declares it.** One sentence, at the moment the decision is made. `tools/audit/dependencies.py` fails the build on a bare name. If there is no sentence to write, that is - The four standing rules
the answer, and it does not go in. **Upgrades arrive continuously and never land on their own.** Dependabot watches every manifest weekly, and `.github/workflows/ci.yml` is the gate: nothing auto-merges, so every bump waits for a person - The four standing rules
looking at green CI. Minor and patch versions arrive batched, because twenty a week is noise. A major arrives alone, because it is a decision rather than an update: `cpal` 0.15 to 0.18 was thirty-seven compile errors across four files in - The four standing rules
the realtime path. A handful of majors are held back in `.github/dependabot.yml`, each scoped to `version-update:semver-major` so patches and security fixes still come through, and each with its reason written where the dependency is - The four standing rules
declared. `tools/audit/dependabot.py` fails the build if one of those holds names a dependency the tree no longer uses, or if one is missing its `update-types` and would therefore silence an advisory. **Work that can be done once is done - The four standing rules
once, and a comment says what made it constant.** A value computed per frame or per sample that does not change per frame or per sample is a defect. **The reading happens before the push.** Every change is read for the three above before - The four standing rules
it goes. That is the one rule a test cannot check, which is why it is written as a habit and the others are guards. - House style
- **British spelling.** - **No em dashes** anywhere a person reads: not in the interface, the documentation or the website. Use a comma, a full stop, or a pair of hyphens. - **Every behavioural change carries a regression test.** A fix - House style
without one is a fix that comes back. - **`docs/AUDIT.md` gets the write-up when the change is a fix**, including what was wrong, how it was found and what now stops it returning. - **Say why, not what.** The code already says what it - House style
does. A comment earns its place by recording the decision somebody would otherwise have to re-derive, and the reason a plausible alternative was not taken. - Commits
Write the message for somebody reading it in two years with no memory of the conversation that produced it: what changed, and why that rather than the obvious alternative. Long is fine. Vague is not. Commits here have **one author**. Do - Commits
not add `Co-Authored-By:` trailers, and do not put an assistant or model name in a commit message, a tag, a release note or a pull request body. That is a rule about where credit lives rather than a denial that it is owed: AI assistance is - Commits
credited in the README and in the footer of every page of the website, which is where a reader looking for it will look. - Pull requests
Say what the change does and why, and what you did to convince yourself it works. If `tools/verify.py` passes, say so. If it does not and you think it is wrong, say that too, and why: a guard that fires on something legitimate is itself a - Pull requests
finding, and the repair is usually to make the guard read the program more precisely rather than to loosen it. Small and complete beats large and nearly. A change that needs a paragraph of caveats usually wants to be two changes. - Licence
GPL-3.0-or-later. By contributing you agree your work is licensed under it. Copyright stays with tilas01, who is the sole author for licensing purposes.
docs/FAQ.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Questions people ask
Every claim here is also made somewhere it can be checked: the whitepaper, the audit, the user guide or the source. Where the answer is that VeilVoice does not do something, that is the answer rather than a gap. Rendered to a page at - Questions people ask
`website/faq.html` by `tools/site/faq.py`. This file is the source; edit it here. - What does VeilVoice actually do?
It destroys the biometric voiceprint of a speaker and keeps the words. Pitch, formants, timbre, micro timing and the melody of an accent go; what was said stays intelligible and transcribable. That is the whole claim, and the second half - What does VeilVoice actually do?
is deliberate. A scrambler you cannot understand protects nobody, because nobody uses it. - Can the original voice be recovered from the output?
No, and there are two separate reasons rather than one. The measured phase of every frame is **discarded and never written anywhere**. It is not encrypted or hidden, so there is no key that brings it back and nothing stored from which it - Can the original voice be recovered from the output?
could be reconstructed. And every speaker is mapped onto **one** canonical register and vocal tract. Many voices go in and one set of characteristics comes out, so several different people arrive at the same place. Even in principle there - Can the original voice be recovered from the output?
is nothing to invert, because the mapping is not one to one. - Does it hide what I said?
No, and it is important that it does not. The words survive on purpose. If the message itself is sensitive, that is a different problem with a different answer, which is encryption. - Does it send anything anywhere?
No. There is no networking code in the project, and the build fails if an HTTP client appears anywhere in the dependency graph. That is a CI job rather than a promise, and it is one of the claims you can check in about ten seconds. The one - Does it send anything anywhere?
exception is the desktop application's check-for-updates button, which you press or do not press. - Will other people still understand me?
Yes. Intelligibility is the design constraint the rest of the engine works around. What changes is who you sound like, not what you said. - Can I use it on a call?
Yes, through a virtual audio cable: VeilVoice takes your microphone and writes the veiled voice to the cable, and the calling program listens to the cable instead of the microphone. VB-CABLE on Windows, BlackHole on macOS, PipeWire on - Can I use it on a call?
Linux. VeilVoice detects them, never bundles them, and the Setup tab will install the ones that can be installed without accepting somebody else's licence on your behalf. Press **preview to my headphones** first. It runs the same engine - Can I use it on a call?
and sends the result to your own output and nowhere else, so you can hear what you sound like before an interview rather than during one. - Can it record, or do I need another program for that?
It records. The **Recording Studio** is a tab in the desktop application, and it is there for one reason: a recording made anywhere else is a plaintext file on your disk. That is not a small point, and it is the whole argument for building - Can it record, or do I need another program for that?
a recorder into a tool like this. Open Audacity, record an interview, save it, veil the result with VeilVoice, delete the original: the original was on the disk the whole time, and on flash storage deleting it does not reliably take it - Can it record, or do I need another program for that?
back. Every minute between pressing record and remembering to shred is a minute the unveiled voice exists in the clear, and the encryption VeilVoice does afterwards cannot reach backwards to cover it. So the Studio never writes one. The - Can it record, or do I need another program for that?
samples go from the audio callback into memory the operating system has been asked to keep out of the page file, the WAV is assembled inside that protected memory, and what leaves is already sealed. There is no point in the process at - Can it record, or do I need another program for that?
which an unencrypted recording exists as a file, and there is deliberately no route in the code that would produce one, because a route that existed would eventually be taken. **What it records.** Any input your system offers, chosen by - Can it record, or do I need another program for that?
name: a microphone, an interface, or a virtual cable carrying your computer's own audio, which is how you capture what is playing rather than what is spoken. Up to eight microphones at once, a person each, if you are recording a room. It - Can it record, or do I need another program for that?
keeps the veiled voice, the original, or both, and it says which before you start. **What it keeps.** Uncompressed PCM at whatever rate the device is actually running, so nothing is resampled and no lossy codec is involved. Nothing is - Can it record, or do I need another program for that?
resampled to a rate you did not ask for either: a mismatch between devices is refused with both numbers rather than quietly converted, because in a program about how a voice sounds, silently changing the pitch is a wrong answer rather than - Can it record, or do I need another program for that?
a small one. **What it does not do.** It is not an editor. There is no cutting, no fading, no multitrack arranging, and Audacity is recommended in the Setup tab for exactly that. This is not a comparison anybody has benchmarked, and this - Can it record, or do I need another program for that?
page is not going to claim it is faster or better than an editor that has had twenty years of work: it does one job, which is getting sound onto a disk without it ever being readable on the way. - Can it make a video, and why does that need ffmpeg?
Yes. A rendered conversation can be written as an MP4 whose picture is the same one the player page draws: a circle per speaker in their own colour, whoever is talking lit, a level under each name that moves with the sound, the waveform - Can it make a video, and why does that need ffmpeg?
and a playhead. **VeilVoice draws every one of those pictures itself**, frame by frame, with its own drawing code and its own typeface. What it does not do is encode the video, and it asks `ffmpeg` for that one step. Every usable video - Can it make a video, and why does that need ffmpeg?
encoder is a large piece of C, and carrying one would end the thing this project keeps saying about itself: that you can read the whole of it, and that `cargo tree` shows nothing large. So the last step belongs to a tool many people - Can it make a video, and why does that need ffmpeg?
already have. Without `ffmpeg` nothing fails. A render still writes the audio, the subtitles, the player page and every picture, then hands you the exact command that turns them into the file. The Setup tab lists `ffmpeg` under companion - Can it make a video, and why does that need ffmpeg?
software with the install command for your system, and `veilvoice companions --install ffmpeg` does the same from the command line. Nothing is downloaded by VeilVoice, there either. An install runs the package manager your machine already - Can it make a video, and why does that need ffmpeg?
has, which is why the promise that this program ships no network client survives having an install button at all. - What operating systems does it run on?
Eleven platforms have signed builds, including Windows, macOS on both architectures, Linux, FreeBSD and OpenBSD. The microphone and camera monitor works on Windows and Linux; macOS exposes no public interface for it, and the tab says so - What operating systems does it run on?
rather than showing an empty list as good news. - Is it free, and what licence?
Free software, GPL-3.0-or-later. The source is the whole of it: no separate paid version, no telemetry, nothing held back. - Who wrote it, and why a pseudonym?
It is published under the name tilas01. The pseudonym is deliberate, and it costs something concrete rather than nothing: kernel-level enforcement on Windows and macOS needs a certificate issued to a verified legal identity, and so does - Who wrote it, and why a pseudonym?
macOS notarisation. Those are unavailable here, and the roadmap says so rather than describing them as future work. tilas01 is the developer: the architecture, the decisions about what this does and refuses to do, and a great deal of the - Who wrote it, and why a pseudonym?
code are theirs directly. Some of the code, the documentation and this website were drafted with the help of Claude, Anthropic's assistant, working to that direction. Nothing reaches a release unread. Every change is reviewed, built and - Who wrote it, and why a pseudonym?
tested first, and the audit rounds in `docs/AUDIT.md` are the record of that review finding its own mistakes. - How do I know the download is the one that was published?
Run `veilvoice verify` in the folder you downloaded to, or drop the archive on the desktop application's verify tab. That is the whole instruction, and it does every check below in the right order. The checks, and the order is the point: - How do I know the download is the one that was published?
1. The signing key's **fingerprint**, compared against the one published in the README, on the website and in every release's notes. 2. The **signature** over `SHA256SUMS`, verified with that key. 3. The **archive's hash**, compared - How do I know the download is the one that was published?
against the now trusted list. 4. **Every file you extracted**, compared against `CONTENTS.sha256`, which the release publishes and `SHA256SUMS` covers. This is the one that tells you the program you are about to run is the published one, - How do I know the download is the one that was published?
rather than only that the zip was. Checking the hash first and the signature afterwards proves only that the file matches a list that might itself have been replaced. `veilvoice verify` does it in the right order, needs no GnuPG installed, - How do I know the download is the one that was published?
and has no flag that skips a step, because a verification with a skip switch is decorative. If you do have GnuPG it uses that too: it adds the key to your keyring, tells you it did and how to remove it, runs `gpg --verify`, and fails if - How do I know the download is the one that was published?
the two implementations disagree. It also prints the commands so you can run them yourself, which is the part no program can do for you. Releases before v0.1.15 carry no `CONTENTS.sha256` and stop at check 3, which the tool says at the - How do I know the download is the one that was published?
time. You can also build the repository yourself and compare what comes out against the published hashes for your platform. - Does the app lock protect my recordings?
No. The app lock guards the application: it stops somebody who walks up to your unlocked computer from opening VeilVoice and reading what is in it. A recording is protected by its own encryption, which is on by default and is a separate - Does the app lock protect my recordings?
thing. Somebody who can read your disk can read the lock file. The lock is worth having and it is not a substitute for encrypting the recording, and the application says so where you set it. - What is the decoy passphrase? Is it deniability?
A second passphrase that opens VeilVoice with nothing in it, so you can comply with somebody standing over you without handing over your recordings. **It does not give you deniability.** VeilVoice is open source and this feature is - What is the decoy passphrase? Is it deniability?
documented, so anybody who recognises the program knows the decoy exists and can ask for the other passphrase. It buys you a way to hand something over. It does not buy you an argument that there is nothing more, and that sentence is the - What is the decoy passphrase? Is it deniability?
first thing the feature prints. There is no destructive duress passphrase and there will not be one. On flash storage a write does not overwrite: the controller puts new data in a fresh page and leaves the old one until it is collected, - What is the decoy passphrase? Is it deniability?
which may be never. A passphrase that claimed to destroy your recordings would be believed at exactly the moment being wrong costs the most. - Can it work out who is speaking in a recording?
No. Group mode gives each speaker a different voice, and it works from a plan you write saying who speaks when. VeilVoice does not guess, because a program that guessed would sometimes put one person's words in another person's voice and - Can it work out who is speaking in a recording?
you would not find out by listening: the result would sound perfectly fine. Telling voices apart automatically is diarisation, it means shipping a trained model, and this project ships none. - Does it detect keyloggers?
No, and nothing can. The mechanisms a logger uses are the mechanisms accessibility software, password managers and remote support tools use, and software written to hide is written to hide from a process list. What `veilvoice input` does - Does it detect keyloggers?
is name the programs currently running that are **able** to see your keyboard and mouse, and say what each is for. It prints, with every result, that a clean answer proves nothing. Somebody who reads "nothing found" as "nothing there" has - Does it detect keyloggers?
been made less safe by running it. - Does it hide its own window from screen recording?
No. Excluding a window from capture needs a foreign function call, and every crate here carries `#![forbid(unsafe_code)]`, which is on the front page and is one of the things you can check quickly. `veilvoice capture` says so rather than - Does it hide its own window from screen recording?
implying otherwise. Worth noting that it would not buy much: a window excluded from capture is still visible to a camera pointed at the screen, and the thing VeilVoice protects is a file rather than a picture of a window. - What is Failsafe, and does it prevent anything?
It notices the moment another program picks up a **real** microphone while you are being veiled, and by default it closes that program. The accident it exists for: you are talking through VeilVoice, you plug in a headset, the operating - What is Failsafe, and does it prevent anything?
system offers the new microphone, the calling program takes it, and from that moment your real voice is going out with the veiled window still open in front of you and the meters still moving. Nobody notices, because there is nothing to - What is Failsafe, and does it prevent anything?
notice. **It notices; it does not prevent, and the difference is printed every time.** Stopping the operating system handing over a microphone needs exclusive capture of every input device or a driver, and this project ships neither. - What is Failsafe, and does it prevent anything?
Failsafe sees it within about a second and acts. That moment is short and it is not zero. - Has it been audited?
Nine rounds, eighty-three defects found and fixed, all written up individually in `docs/AUDIT.md` with what each one was and how it was found. **By the author.** There has been no outside review, and that is recorded at the top of the - Has it been audited?
audit rather than left for you to wonder about. Three rounds of wider tools and wider scope have each found real defects in code a previous round called clean, which is a measurement of what author-only review is worth. - What does it not protect against?
The honest list, which is also in the whitepaper: - **Anyone who has the original recording.** VeilVoice changes a copy. - **What you say.** Names, places and details identify you regardless of voice. - **Everything around the audio.** - What does it not protect against?
Metadata is stripped from files VeilVoice writes; it cannot strip the metadata of a platform you upload to. - **A compromised machine.** Software that is already running as you can read the microphone before VeilVoice does. - **Being the - What does it not protect against?
only person it could be.** If three people were in the room, changing the voice does not change that. - Do I need a GPU, or the internet?
Neither. It runs on the processor, offline, and the hardware report says what it found and why the engine does not use it: the work per frame is small and sending it to a graphics card and back costs more than doing it. - What happens if I forget a passphrase?
The recording stays sealed. There is no recovery, no backdoor and no reset, because any of those would be a way in for somebody else too. The app lock can be removed by deleting its file, which does not affect encrypted recordings.
docs/GUIDE_CLI.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/guides.py from docs/USER_GUIDE.md. Do not edit: edit the section in the user guide and run the tool. Verified in CI with `python tools/docs/guides.py --check`. - line 1
--> - VeilVoice: the command line
**`veilvoice`.** Everything the application does, over SSH, in a container, in a script, or on a machine with no graphics toolkit at all. If you have a terminal and nothing else, this is your half of VeilVoice. Every command takes - VeilVoice: the command line
`--help`, and the help is the reference; this is the part that says what to reach for and why. The whole story, including the parts about the other two programs, is in [`USER_GUIDE.md`](USER_GUIDE.md). This is the same text, narrowed to - VeilVoice: the command line
one program. --- - 1. Installing
Download an archive from [Releases](https://github.com/tilas01/veilvoice/releases), or build it, since a fresh clone needs no secrets: git clone https://github.com/tilas01/veilvoice && cd veilvoice cargo build --release **Verify the - 1. Installing
download before running it.** Instructions are in [`REPRODUCIBLE_BUILDS.md`](REPRODUCIBLE_BUILDS.md) and on the site; there is also an in-browser hash verifier that uploads nothing. Nothing installs a service, writes to a registry, or - 1. Installing
phones home. Delete the folder and it is gone. --- --- - 2. The two programs, and which one you want
A release contains two executables. They overlap on purpose, and which one to reach for depends only on what you have in front of you. | Program | What it is for | |---|---| | `veilvoice` | The command line. Everything the application - 2. The two programs, and which one you want
does, over SSH, in a container, in a script, or on a machine with no graphics toolkit at all. Checking a download is `veilvoice verify`, which was a third binary until 0.1.18. | | `veilvoice-gui` | The window. The same engine with - 2. The two programs, and which one you want
somewhere to click, plus the things that only make sense with a screen: live level meters, the app lock, the microphone monitor, and a Verify tab that runs the same check as `veilvoice verify`. | - Which parts are built in, and which are not
Everything VeilVoice does is in these binaries. There is no runtime to install, no service, no plugin directory, and nothing is downloaded on first run. That includes the parts people expect to be separate: - **The signature check.** The - Which parts are built in, and which are not
signing key is compiled into the programs, and the OpenPGP verification is Rust code in this repository. `veilvoice verify` needs no GnuPG to do its job. - **The audio decoders**, the resampler, the encryption, the key exchange, the - Which parts are built in, and which are not
hashing. All of it is in the binary. - **The at-rest encryption**, including the post-quantum half. Three things are genuinely outside, and each is optional: - **GnuPG**, for a second opinion on a release signature. Worth having, and - Which parts are built in, and which are not
explained under §7. - **A virtual audio cable**, if you want live mode to feed a call. On Linux this is usually PipeWire, which is already there. - **`ffmpeg`**, only if you ask for a video file. VeilVoice draws every picture in the video - Which parts are built in, and which are not
itself and asks `ffmpeg` for the last step only, because a video encoder is a large piece of C and carrying one would end the claim that you can read the whole of this program. Without it, a render still writes the audio, the subtitles, - Which parts are built in, and which are not
the player page and every picture, then prints exactly the command that turns them into the file and exits successfully, because nothing failed. `veilvoice companions` lists all of them, says whether this machine has each, and prints the - Which parts are built in, and which are not
one command that would install it. It never runs somebody else's installer. - What runs where
Eleven platforms get a signed archive with every release, and the table says what is in each one. It is not the same everywhere, and where it is not, that is a limit of what the platform offers rather than something waiting to be written. - What runs where
| Platform | Command line | Desktop app | Live microphone | |---|---|---|---| | Windows 10 and 11, x86-64 | yes | yes | yes, with a virtual cable | | macOS on Intel | yes | yes | yes, with a virtual cable | | macOS on Apple Silicon | yes | - What runs where
yes | yes, with a virtual cable | | Linux, x86-64 and arm64 | yes | yes | yes, through PipeWire | | Linux, statically linked (musl) | yes | yes | yes | | Raspberry Pi and other armv7 | yes | yes | yes | | WSL on Windows | yes | yes, with - What runs where
WSLg | through the Windows side | | FreeBSD, OpenBSD, NetBSD | yes | not shipped | no | **Any Linux distribution.** The `.deb` and `.rpm` are conveniences, not requirements: the plain archive is a folder of binaries that needs no package - What runs where
manager, and the statically linked build needs no system libraries at all, which is the one to reach for on a distribution nothing else fits. **One library the desktop app needs, and why it is worth a paragraph.** The window toolkit opens - What runs where
`libxkbcommon-x11` by name when it starts, rather than linking against it. Nothing that works out dependencies by reading a binary can see that, so a minimal or server install can be missing it and the application will exit at once instead - What runs where
of drawing a window. If that happens, the crash report VeilVoice writes names the library and the package that carries it; the short version is `libxkbcommon-x11-0` on Debian and Ubuntu and `libxkbcommon-x11` elsewhere. The command line - What runs where
needs none of it. **The first time you open it.** After the two settings questions, VeilVoice shows one card per tab saying what that tab is for, which takes about twenty seconds and can be skipped at any point. Two of the nine are worth - What runs where
the card on their own: Monitor is not a level meter, it watches for another program picking up a real microphone while you are being veiled; and Lock is a passphrase on the application rather than on a recording. The last card says whether - What runs where
this copy is **portable** or **installed**, in those words. Portable means it runs from wherever you put it and installs nothing: move the folder and VeilVoice moves with it, delete the folder and it is gone. Installed means it is on this - What runs where
machine for good, on your menu or path, with its settings in your account. Both are fine, and the Install tab is where the decision is made rather than in the tour. After an upgrade the tour comes back only for tabs that did not exist last - What runs where
time, and a release that adds no tab shows nothing. What is stored is the list of tabs you have been shown, which is what "which of these is new to you" is actually asking. **When something goes wrong.** VeilVoice writes a report of a - What runs where
crash to a file beside its settings, and on the next launch it offers it to you above whatever tab you land on: what happened, where the file is, and a button to read the whole of it before you decide anything. Nothing is sent. Nothing - What runs where
here *can* send it, and that is not a policy but a property of the build: this project contains no network client and the build fails if one enters the dependency graph. The ordinary shape of this feature is a reporter that uploads, and - What runs where
that is the wrong shape for a program people use to protect themselves, because a report from a privacy tool is a report about somebody who was being careful. So the panel offers two things instead: copy the report, and open the issue - What runs where
tracker. What happens next is your decision and your clipboard. If you would rather it went away, "dismiss and delete it" removes the file. The report holds the version, your operating system and processor, and the error with its source - What runs where
location. It holds no file names, no settings, no passphrase and nothing about any audio. That list is in the panel too, because "would you like to send this" is only a real question if you can see what "this" is. **The BSDs get the - What runs where
command line only, and the reason is specific.** The audio library VeilVoice uses has no backend for them, so live capture cannot work there and the desktop application is built around a window that would have nothing to listen to. - What runs where
Everything that operates on a file, meaning de-identification, encryption, metadata cleaning and verification, is pure Rust and runs exactly as it does anywhere else. **WSL is Linux**, so the command line runs unchanged. The window needs - What runs where
WSLg, which recent Windows has by default. A microphone belongs to Windows rather than to the distribution, so live mode is the Windows build's job. **Nothing is emulated and nothing is a wrapper.** Every archive is a native build for that - What runs where
processor, compiled from the same source with the same pinned compiler, and built twice in separate directories and compared byte for byte before it ships. - How anything reaches the network, given that nothing here is a network client
VeilVoice bundles no HTTP client, and this is checked rather than claimed: nothing in the workspace links one. Two features nonetheless involve the network, and the way they do it is the point. **Check for updates**, in the desktop - How anything reaches the network, given that nothing here is a network client
application only, asks the operating system's own transfer tool to fetch one small file, and reads a version number out of what it printed. It is a button, it is never automatic, and the command line has no such feature at all. The tool is - How anything reaches the network, given that nothing here is a network client
found by **absolute path**, never through `PATH`. On Windows that is `%SystemRoot%\System32\curl.exe`, which has shipped with Windows since 2018. Elsewhere it is `curl` at `/usr/bin`, `/bin` or `/usr/local/bin`, and `wget` at the same - How anything reaches the network, given that nothing here is a network client
three places if there is no `curl`. That distinction is not fussiness. Windows searches the current directory before `PATH`, so a file called `curl.exe` sitting beside VeilVoice would otherwise be the program that ran, and a privacy tool - How anything reaches the network, given that nothing here is a network client
reaching for the network is the last place to accept a stranger's binary. If none of those paths holds a tool, the button says so and nothing is run. **Installing a companion** does not fetch anything either. It runs the package manager - How anything reaches the network, given that nothing here is a network client
already on the machine, which is the thing your system already trusts to install software, and for anything needing root it prints the command instead of running it. The consequence worth stating: there is no code path in VeilVoice that - How anything reaches the network, given that nothing here is a network client
opens a socket. A firewall rule that blocks it entirely costs you the update button and nothing else. --- --- - 3. The command line
`veilvoice`. Everything the app does, over SSH, in a container, or on a machine with no GUI toolkit at all. veilvoice anonymise interview.mp3 -o clean.wav # writes clean.wav.veil veilvoice anonymise interview.mp3 --encrypt-to friend.pub - 3. The command line
veilvoice anonymise interview.mp3 --encrypt false # warns, then asks veilvoice decrypt clean.wav.veil -o clean.wav veilvoice live --output "CABLE Input (VB-Audio Virtual Cable)" veilvoice devices veilvoice clean photo.jpg # EXIF, GPS, tags - 3. The command line
veilvoice encrypt notes.wav veilvoice keygen veilvoice lock set veilvoice watch # who is using the mic/camera veilvoice shred secret.wav # irreversible veilvoice info Every command takes `--help`. - Flags worth knowing
| Flag | Effect | |---|---| | `--intensity 0.0–1.0` | How far pitch and formants move. Default 1.0. | | `--keep-accent` | Leaves intonation, accent and vocal tract intact. Weaker; use only if you know why. | | `--reseed-secs N` | Seed roll - Flags worth knowing
interval. 0 keeps one stream for the session. | | `--preview` | On `live`: sends the veiled voice to this machine's own output rather than to a virtual cable, so you hear it and nothing else does. Use headphones. | | `--no-monitor` | On - Flags worth knowing
`live`: does not draw the level meters. For a terminal that is being logged or read by something other than a person. | | `--clean-metadata false` | Keeps tags on the written file. On by default. | | `--encrypt false` | Writes the - Flags worth knowing
recording in the clear. On by default; prints what you are giving up and waits for you to type `UNENCRYPTED`. | | `--encrypt-to key.pub` | Seals to a recipient's hybrid public key instead of a passphrase. | | `--yes` | Skips that - Flags worth knowing
confirmation, for scripts that already mean it. | - Where did my WAV go?
`anonymise` seals its output, so `-o clean.wav` produces `clean.wav.veil`. Open it with: veilvoice decrypt clean.wav.veil -o clean.wav If you genuinely want a bare WAV, `--encrypt false` still does that. It will tell you what that costs - Where did my WAV go?
first. --- --- - 4. An interview, start to finish
The commonest thing people ask VeilVoice to do, in the order it happens. This is also how to veil the person you were interviewing rather than only yourself. - Step 1: get the sound out of what you recorded
If you recorded in OBS, or anything like it, you have a `.mkv` or `.mp4` holding a video track and an audio track. VeilVoice reads audio: veilvoice import interview.mkv # writes interview.wav That needs `ffmpeg`. VeilVoice does not ship - Step 1: get the sound out of what you recorded
one and will not install one; when it is missing you get the exact command printed, to run yourself or after installing it. `--dry-run` prints it without running anything. Already have a `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a`, `.aac` or - Step 1: get the sound out of what you recorded
`.opus`? Skip this step; those go straight into `anonymise`. - Step 2: write a plan, so each person gets their own voice
Running an interview through `anonymise` gives **both people the same voice**. That is private and useless: nobody can tell a question from its answer. A plan gives each speaker their own destination voice. **VeilVoice will not work out - Step 2: write a plan, so each person gets their own voice
who is talking.** That is speaker diarisation, it needs a trained model, this project ships none and asks no server, and a wrong guess would either merge two people or invent a third with nothing in the output showing it. So you tell it. A - Step 2: write a plan, so each person gets their own voice
plan is a text file: VEILCONV1 title Interview with Sam speaker 0 Me speaker 1 Sam turn 0.000 4.200 0 So, how did it go? turn 4.100 19.050 1 turn 19.000 22.400 0 And after that? Line by line: | Line | What it is | |---|---| | `VEILCONV1` | - Step 2: write a plan, so each person gets their own voice
The first line of every plan, so the file says what it is | | `title` | What the recording is called, shown in the player | | `speaker <n> <name>` | One per person. The number is how turns refer to them | | `turn <from> <to> <speaker> - Step 2: write a plan, so each person gets their own voice
[words]` | One per stretch of speech, in seconds | The words on a turn are optional. With them the subtitles carry what was said; without them they carry the speaker's name, which is still enough to follow a conversation whose voices have - Step 2: write a plan, so each person gets their own voice
all been replaced. Overlapping turns are fine. People talk over each other, and VeilVoice mixes them rather than picking a winner. **Anything no turn claims is silenced, not passed through.** A gap in a plan must never put a real voice - Step 2: write a plan, so each person gets their own voice
into the result, and how much was silenced is printed so you can tell a deliberate pause from a plan that missed a minute. Check a plan before spending time on a render: veilvoice conversation inspect interview.plan It prints who is in it, - Step 2: write a plan, so each person gets their own voice
which voice each gets, and any overlaps. - Step 3: render it
veilvoice conversation render interview.plan interview.wav -o veiled.wav Every speaker comes out with their own voice and every voiceprint is destroyed, including the interviewee's. Subtitles are written beside the audio in both formats, - Step 3: render it
and a self-contained player page comes with it that needs nothing installed. **None of it is encrypted, unlike `anonymise`.** Seal the audio afterwards with `veilvoice encrypt` if it matters. Everything a render writes is created readable - Step 3: render it
only by your account, which is a file permission and nothing more: it does not survive a copy, a backup, or anyone who has the disk. And read the subtitles before sending them anywhere. They carry the names you typed and the words you - Step 3: render it
typed, in plain text, and nothing veils a name. - Step 4: a video, if you need one
Somewhere that will not accept an audio file: veilvoice video veiled.wav # writes veiled.mp4 A black picture for the length of the recording. The picture is not the point and does not pretend to be. Needs `ffmpeg`, same as step 1. - Recording each person on their own microphone instead
If your recording already has one channel per person, the split is exact and there is no plan to write. That is the better arrangement whenever you can manage it: no times to type, and no chance of typing them wrong. --- - 5. Things VeilVoice will not do
Read this twice. Misunderstanding it is the only way this software gets someone hurt. - **It does not hide what you said.** The words are preserved on purpose and can be transcribed. Encrypt the file, which is now the default, if the - 5. Things VeilVoice will not do
content must stay secret. - **It does not fully remove a strong accent.** Its melody and colour go; which phonemes you actually produced cannot be changed by any filter. - **It does not sanitise the background.** Room acoustics, other - 5. Things VeilVoice will not do
voices, a passing siren. - **It does not hide your speaking rate** or rhythm. A weak biometric, but a real one. - **It does not help against an attacker already running code on your machine.** - **It does not clean the filename**, the - 5. Things VeilVoice will not do
filesystem timestamps, or the channel you send the file over. - **The app lock is not tamper-proof**, as above. - **Secure erase is not reliable on flash storage.** `shred` overwrites in place, which works on a spinning disk; wear - 5. Things VeilVoice will not do
levelling on an SSD, SD card or USB stick leaves the original blocks where no software can reach them. Full volume encryption is the answer that works. ---
docs/GUIDE_GUI.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/guides.py from docs/USER_GUIDE.md. Do not edit: edit the section in the user guide and run the tool. Verified in CI with `python tools/docs/guides.py --check`. - line 1
--> - VeilVoice: the desktop application
**`veilvoice-gui`.** The window. The same engine as the command line with somewhere to click, plus the things that only make sense with a screen: live level meters, the app lock, and the microphone monitor. One tab for each thing it does. - VeilVoice: the desktop application
Every one of them is below, and `veilvoice-gui --tab <name>` opens the window on one directly. The whole story, including the parts about the other two programs, is in [`USER_GUIDE.md`](USER_GUIDE.md). This is the same text, narrowed to - VeilVoice: the desktop application
one program. --- - 1. Installing
Download an archive from [Releases](https://github.com/tilas01/veilvoice/releases), or build it, since a fresh clone needs no secrets: git clone https://github.com/tilas01/veilvoice && cd veilvoice cargo build --release **Verify the - 1. Installing
download before running it.** Instructions are in [`REPRODUCIBLE_BUILDS.md`](REPRODUCIBLE_BUILDS.md) and on the site; there is also an in-browser hash verifier that uploads nothing. Nothing installs a service, writes to a registry, or - 1. Installing
phones home. Delete the folder and it is gone. --- --- - 2. The two programs, and which one you want
A release contains two executables. They overlap on purpose, and which one to reach for depends only on what you have in front of you. | Program | What it is for | |---|---| | `veilvoice` | The command line. Everything the application - 2. The two programs, and which one you want
does, over SSH, in a container, in a script, or on a machine with no graphics toolkit at all. Checking a download is `veilvoice verify`, which was a third binary until 0.1.18. | | `veilvoice-gui` | The window. The same engine with - 2. The two programs, and which one you want
somewhere to click, plus the things that only make sense with a screen: live level meters, the app lock, the microphone monitor, and a Verify tab that runs the same check as `veilvoice verify`. | - Which parts are built in, and which are not
Everything VeilVoice does is in these binaries. There is no runtime to install, no service, no plugin directory, and nothing is downloaded on first run. That includes the parts people expect to be separate: - **The signature check.** The - Which parts are built in, and which are not
signing key is compiled into the programs, and the OpenPGP verification is Rust code in this repository. `veilvoice verify` needs no GnuPG to do its job. - **The audio decoders**, the resampler, the encryption, the key exchange, the - Which parts are built in, and which are not
hashing. All of it is in the binary. - **The at-rest encryption**, including the post-quantum half. Three things are genuinely outside, and each is optional: - **GnuPG**, for a second opinion on a release signature. Worth having, and - Which parts are built in, and which are not
explained under §7. - **A virtual audio cable**, if you want live mode to feed a call. On Linux this is usually PipeWire, which is already there. - **`ffmpeg`**, only if you ask for a video file. VeilVoice draws every picture in the video - Which parts are built in, and which are not
itself and asks `ffmpeg` for the last step only, because a video encoder is a large piece of C and carrying one would end the claim that you can read the whole of this program. Without it, a render still writes the audio, the subtitles, - Which parts are built in, and which are not
the player page and every picture, then prints exactly the command that turns them into the file and exits successfully, because nothing failed. `veilvoice companions` lists all of them, says whether this machine has each, and prints the - Which parts are built in, and which are not
one command that would install it. It never runs somebody else's installer. - What runs where
Eleven platforms get a signed archive with every release, and the table says what is in each one. It is not the same everywhere, and where it is not, that is a limit of what the platform offers rather than something waiting to be written. - What runs where
| Platform | Command line | Desktop app | Live microphone | |---|---|---|---| | Windows 10 and 11, x86-64 | yes | yes | yes, with a virtual cable | | macOS on Intel | yes | yes | yes, with a virtual cable | | macOS on Apple Silicon | yes | - What runs where
yes | yes, with a virtual cable | | Linux, x86-64 and arm64 | yes | yes | yes, through PipeWire | | Linux, statically linked (musl) | yes | yes | yes | | Raspberry Pi and other armv7 | yes | yes | yes | | WSL on Windows | yes | yes, with - What runs where
WSLg | through the Windows side | | FreeBSD, OpenBSD, NetBSD | yes | not shipped | no | **Any Linux distribution.** The `.deb` and `.rpm` are conveniences, not requirements: the plain archive is a folder of binaries that needs no package - What runs where
manager, and the statically linked build needs no system libraries at all, which is the one to reach for on a distribution nothing else fits. **One library the desktop app needs, and why it is worth a paragraph.** The window toolkit opens - What runs where
`libxkbcommon-x11` by name when it starts, rather than linking against it. Nothing that works out dependencies by reading a binary can see that, so a minimal or server install can be missing it and the application will exit at once instead - What runs where
of drawing a window. If that happens, the crash report VeilVoice writes names the library and the package that carries it; the short version is `libxkbcommon-x11-0` on Debian and Ubuntu and `libxkbcommon-x11` elsewhere. The command line - What runs where
needs none of it. **The first time you open it.** After the two settings questions, VeilVoice shows one card per tab saying what that tab is for, which takes about twenty seconds and can be skipped at any point. Two of the nine are worth - What runs where
the card on their own: Monitor is not a level meter, it watches for another program picking up a real microphone while you are being veiled; and Lock is a passphrase on the application rather than on a recording. The last card says whether - What runs where
this copy is **portable** or **installed**, in those words. Portable means it runs from wherever you put it and installs nothing: move the folder and VeilVoice moves with it, delete the folder and it is gone. Installed means it is on this - What runs where
machine for good, on your menu or path, with its settings in your account. Both are fine, and the Install tab is where the decision is made rather than in the tour. After an upgrade the tour comes back only for tabs that did not exist last - What runs where
time, and a release that adds no tab shows nothing. What is stored is the list of tabs you have been shown, which is what "which of these is new to you" is actually asking. **When something goes wrong.** VeilVoice writes a report of a - What runs where
crash to a file beside its settings, and on the next launch it offers it to you above whatever tab you land on: what happened, where the file is, and a button to read the whole of it before you decide anything. Nothing is sent. Nothing - What runs where
here *can* send it, and that is not a policy but a property of the build: this project contains no network client and the build fails if one enters the dependency graph. The ordinary shape of this feature is a reporter that uploads, and - What runs where
that is the wrong shape for a program people use to protect themselves, because a report from a privacy tool is a report about somebody who was being careful. So the panel offers two things instead: copy the report, and open the issue - What runs where
tracker. What happens next is your decision and your clipboard. If you would rather it went away, "dismiss and delete it" removes the file. The report holds the version, your operating system and processor, and the error with its source - What runs where
location. It holds no file names, no settings, no passphrase and nothing about any audio. That list is in the panel too, because "would you like to send this" is only a real question if you can see what "this" is. **The BSDs get the - What runs where
command line only, and the reason is specific.** The audio library VeilVoice uses has no backend for them, so live capture cannot work there and the desktop application is built around a window that would have nothing to listen to. - What runs where
Everything that operates on a file, meaning de-identification, encryption, metadata cleaning and verification, is pure Rust and runs exactly as it does anywhere else. **WSL is Linux**, so the command line runs unchanged. The window needs - What runs where
WSLg, which recent Windows has by default. A microphone belongs to Windows rather than to the distribution, so live mode is the Windows build's job. **Nothing is emulated and nothing is a wrapper.** Every archive is a native build for that - What runs where
processor, compiled from the same source with the same pinned compiler, and built twice in separate directories and compared byte for byte before it ships. - How anything reaches the network, given that nothing here is a network client
VeilVoice bundles no HTTP client, and this is checked rather than claimed: nothing in the workspace links one. Two features nonetheless involve the network, and the way they do it is the point. **Check for updates**, in the desktop - How anything reaches the network, given that nothing here is a network client
application only, asks the operating system's own transfer tool to fetch one small file, and reads a version number out of what it printed. It is a button, it is never automatic, and the command line has no such feature at all. The tool is - How anything reaches the network, given that nothing here is a network client
found by **absolute path**, never through `PATH`. On Windows that is `%SystemRoot%\System32\curl.exe`, which has shipped with Windows since 2018. Elsewhere it is `curl` at `/usr/bin`, `/bin` or `/usr/local/bin`, and `wget` at the same - How anything reaches the network, given that nothing here is a network client
three places if there is no `curl`. That distinction is not fussiness. Windows searches the current directory before `PATH`, so a file called `curl.exe` sitting beside VeilVoice would otherwise be the program that ran, and a privacy tool - How anything reaches the network, given that nothing here is a network client
reaching for the network is the last place to accept a stranger's binary. If none of those paths holds a tool, the button says so and nothing is run. **Installing a companion** does not fetch anything either. It runs the package manager - How anything reaches the network, given that nothing here is a network client
already on the machine, which is the thing your system already trusts to install software, and for anything needing root it prints the command instead of running it. The consequence worth stating: there is no code path in VeilVoice that - How anything reaches the network, given that nothing here is a network client
opens a socket. A firewall rule that blocks it entirely costs you the update button and nothing else. --- --- - 3. The desktop app
`veilvoice-gui`. One tab for each thing it does, in the strip across the top. Every one of them has a section below, and `veilvoice-gui --tab <name>` opens the window on one directly. - anonymise file
Choose a recording, press **anonymise**. | Control | Effect | |---|---| | **intensity** | How far pitch and formants move from the original, 0.0–1.0. Default 1.0, full normalisation. | | **neutralise accent and intonation** | On by - anonymise file
default. Collapses every speaker onto one canonical register and vocal tract. Turning it off is weaker de-identification. | | **seed roll (s)** | How often the modulation stream ratchets forward. Default 2 s; 0 keeps one stream for the - anonymise file
session. Inaudible by construction. | | **strip metadata from the result** | On by default. | | **encrypt the result at rest** | On by default. See below. | - At-rest encryption
The result is **sealed as it is written**, so a file you name `clean.wav` lands as `clean.wav.veil`. Two ways to seal it: - **passphrase**: Argon2id at 256 MiB. Set once and held for the session; **change** clears it, and locking the app - At-rest encryption
clears it too. - **public key**: X25519 + ML-KEM-768 hybrid, to a `.pub` file from `veilvoice keygen`. Nothing to type and nothing to forget; only the matching private key opens it. The **anonymise** button stays disabled until there is - At-rest encryption
something to encrypt with. A tool that quietly wrote plaintext because a field was still empty would make the default worthless. Unticking the box opens a dialogue that must be answered first. The result is still a recording of every word - At-rest encryption
that was said, and on flash storage deleting it afterwards is not a reliable fix, so the question is asked once, plainly. - group
Several people in one recording, each given a **different** destination voice, so a listener can still follow the conversation by ear. Every voiceprint is destroyed as thoroughly as one speaker's would be; what is kept is that the speakers - group
are distinguishable, not who they are. It works on a recording that already exists, not on a live microphone. | Control | What it is | |---|---| | **How you are working** | One person, a group with a voice each, or a group with one voice - group
for everybody. The last is the honest choice when there are more people than there are voices far enough apart to tell apart. | | **open / save project** | A project holds where your files are, who is in the recording and what you called - group
them. No audio and no passwords, so it is safe to keep beside the recording. It does hold the names you typed. | | **group mode** | On for this run. Closing the window turns it off again, so a recording of one person is never rendered - group
against a plan describing several. | | **always start in group mode** | Remembered, for people who are always working this way. | | **the people** | A name and a colour each. **Names are not veiled by anything**: you type them and they go - group
into the subtitles as typed. | | **the recording, and the plan** | The plan says when each person speaks. Without one there is nothing to render against, and audio no turn claims is silenced rather than passed through, so a missing plan - group
gives a silent file rather than an unveiled one. `veilvoice conversation inspect` describes a plan you already have. | | **what a render writes** | Audio, subtitles, a player page, and a video. The first three by default. | | **video** | - group
An MP4 with the same picture the player page draws: a circle per speaker, whoever is talking lit, the level under each name, the waveform and a playhead. Off unless you ask, because it is the one output needing `ffmpeg`, which VeilVoice - group
does not ship. Without it the pictures are still written and you get the command. | | **who was speaking** | A score out of a hundred, beside the voice choice, saying how much the finished recording gives away about which person each turn - group
belongs to. It falls as the group grows. See below for what it does and does not mean. | - The "who was speaking" score
Beside the voice controls is a percentage. **One hundred means the recording says nothing about which of the people in it was talking.** It falls as you add people in "a voice each" mode: two people is 70%, four is 40%, eight is 10%. One - The "who was speaking" score
voice for everybody is 100% at any group size. **What it is not.** It is not a measure of how well anybody's voice is disguised. That is what the engine does, every speaker is mapped onto a canonical destination voice, and it does not get - The "who was speaking" score
weaker because somebody else joined the call: a recording of eight people hides each of their voiceprints exactly as well as a recording of one. It is also not cryptography. There is no key, no work factor and no attacker racing a clock, - The "who was speaking" score
and nothing here gets better with a longer password. **What it is.** A count of one specific thing: how much of the shape of the conversation a listener gets for free. Give eight people eight tellable-apart voices and anybody who hears the - The "who was speaking" score
result can count the participants, follow who said what, and line two recordings of the same group up against each other by voice. Give them all one voice and none of that is there to find. A listener who could not tell the voices apart - The "who was speaking" score
would have to guess which of them spoke each turn, and that guess is worth `log2(voices)` bits; the score is those bits measured against the widest the engine goes. **The part that runs backwards.** Crowding the table makes the recording - The "who was speaking" score
give *less* away, not more, because two people whose voices are too close to separate count as one to a listener. It also makes the recording harder to follow, which is the real cost, and it is shown as its own line rather than folded into - The "who was speaking" score
the score. That is the whole trade the two voice modes exist to let you choose between: followable and more revealing, or private and harder to follow. VeilVoice does not guess who is speaking. Turns come from a plan file or from one - The "who was speaking" score
microphone per person, and that is a deliberate limit: guessing wrongly would put one person's words under another person's name. **Two bars per person, while the render runs.** As the render walks the file it draws, for each person, what - The "who was speaking" score
went into the turn it has just finished and what the engine produced from it, along with how far through their turns it is. One bar would answer "is something being written"; two answer "is this person being veiled", which is the question - The "who was speaking" score
you are actually asking, and two that move differently are the only thing on screen showing the engine is between them. They cannot show that a voice cannot be recovered, and nothing on a screen can. What they catch is the case that - The "who was speaking" score
matters in practice: a person whose input bar moves and whose output bar does not. - studio
Veiling as it happens, and keeping what was said, in one tab. It used to be two: live scramble picked the devices and started the engine, and the Studio recorded through that same engine on another screen, with whichever devices the other - studio
tab happened to be set to. They are one act and they are now in one place. The tab is in two halves. The top half is **the voice**, and it works with the vault shut, because veiling a call has never needed a vault and requiring one would - studio
be a worse program. The bottom half is **the take**, and it needs both passphrases, as it always has. - The voice
Pick an input and an output device and press **start veiling**. A virtual audio cable is preselected as the output when one is installed, because routing there is what lets other applications hear the veiled voice; if none is found you are - The voice
warned rather than silently sent to the speakers. Levels, processing time per block, engine latency and a glitch counter are shown live. **The two devices have to be at the same sample rate.** VeilVoice does not resample a live stream, so - The voice
it puts both on one rate: the rate they already share, the output's if the microphone will take it, or the microphone's if the output will. If neither will move it says so, naming both rates, rather than running them together: a microphone - The voice
at 44.1 kHz feeding an output at 48 kHz gives a voice about a semitone and a half sharp, stuttering, and no way to tell that from the veiling working. Set both to the same rate in your system's sound settings, or choose devices that - The voice
already agree. A room of several microphones is the same rule over all of them at once, and it matters more there: the others sound right, so one microphone at the wrong rate reads as that one person's microphone being bad. - Several microphones, a guest each
An interview with everybody in the room needs a microphone each, and ticking **several microphones, a guest each** turns the input picker into a list of them: a name and a device per person, up to eight. Press **add a guest** for another - Several microphones, a guest each
row and **remove** to take one out. Everything else works as it does with one microphone, including **preview to my headphones**, which previews the mix. **Why not one microphone in the middle of the table.** One microphone carrying four - Several microphones, a guest each
people is one signal. Whatever it is turned into, all four of them are turned into the same thing, and a listener cannot follow who is speaking. A microphone each is what lets each person be veiled into a voice of their own, from the same - Several microphones, a guest each
set a group render hands out. **Two guests cannot share a device.** It is refused before anything opens, by name, because nothing here can separate one signal back into two people. Two guests both left on *system default* is the same - Several microphones, a guest each
refusal: the default is a device, not an absence. **Two bars per guest, and the load.** Each person gets what went into their microphone and what came out of their engine, with the mix under them. The **load** is the one number that says - Several microphones, a guest each
whether this machine can carry this room: every guest's engine runs inside one output callback, so they share one deadline of a few milliseconds and the cost is the sum of them. Below 100 per cent the machine keeps up; past 80 the tab says - Several microphones, a guest each
so and what to do about it, which is one guest fewer or a larger frame size. It is a measurement rather than a limit, because how many voices a machine can carry is a fact about the machine. **The mix can clip, and nothing quietly fixes - Several microphones, a guest each
it.** Several people talking at once is several signals added together, which can go past full scale, and a live path cannot scale the whole conversation afterwards the way a render can. So the blocks that clipped are counted and shown, - Several microphones, a guest each
and nothing compresses or limits them: a limiter is a dynamics processor, it changes the voice, and a second thing changing the voice is exactly what this program is careful about. Turn the microphones down. **A take stores everybody - Several microphones, a guest each
separately.** One take name, and under it the mix, called *(everybody)*, and one recording per guest called after them. The choice of which side to keep is the same one a single microphone has and applies to every guest at once, so - Several microphones, a guest each
choosing the unveiled side in a room of four keeps four recordings of four real voices. - Hearing yourself, and what the meters say
**Hear yourself first.** Beside **start veiling** there is **preview to my headphones**. It runs the same engine and sends the result to this machine's own output rather than to the cable, so you hear the veiled voice and nobody else does. - Hearing yourself, and what the meters say
Use it before an interview begins rather than during one. Use headphones while you do: speakers plus a microphone is a feedback loop. While a preview is running the interface says **preview** in yellow rather than **live** in green, - Hearing yourself, and what the meters say
everywhere it says anything, because somebody who has those two the wrong way round is either speaking to a call in their own voice or speaking to nobody. **The monitor follows you.** While a session is running, a strip along the bottom of - Hearing yourself, and what the meters say
the window shows the level going in and the level coming out, on every tab. It is on by default, because the moment you want it is the moment you are setting up an interview on another tab and are not sure the microphone is still working. - Hearing yourself, and what the meters say
Settings, under *the live monitor*, moves it to a floating card in the corner or switches it off; the Studio keeps its full meters either way. **The Studio offers it where you start the session.** While the voice is being veiled there is a - Hearing yourself, and what the meters say
**keep the meters on top** button beside the live indicator, which is the moment you are about to put a call or a stream in front of this window. Settings has the same choice, under *the live monitor*. **On a call, or streaming, put it - Hearing yourself, and what the meters say
above everything.** The strip and the card are both inside the VeilVoice window, and on a call the call is in front of that window, so the only picture of what your microphone is doing is behind the thing you are talking into. Settings - Hearing yourself, and what the meters say
offers **a small window kept above everything**: its own window, above other windows, off the task bar, which you can drag wherever it suits and resize. Closing it puts the strip back rather than turning the meters off, because pressing a - Hearing yourself, and what the meters say
close button means "not here" and not "never show me my microphone again". On a platform that will not give a second window, it falls back to the floating card. **What the meters can and cannot tell you.** They say sound is arriving and - Hearing yourself, and what the meters say
sound is leaving, which is the thing that usually goes wrong: a muted microphone, the wrong device, a cable nothing is listening to. They cannot tell you the voice has been changed. A working meter and a bypassed engine draw the same bar. - Hearing yourself, and what the meters say
The check for that is listening to the preview and hearing a voice that is not yours. **When something interferes with it, it says so.** A device unplugged or swapped while a session is running, and anything else the platform reports about - Hearing yourself, and what the meters say
either stream, is shown here in red rather than written to a log. A device that has gone does not come back on its own: choose another and start again. On the command line the same thing appears as `INTERRUPTED` on the meter line, and - Hearing yourself, and what the meters say
`veilvoice record` repeats it when the take is sealed. While a take is running, a program **other than VeilVoice** taking the microphone is named on this tab and again with the stored take. That program heard your real voice, whatever - Hearing yourself, and what the meters say
VeilVoice was sending to the cable. **The limit, which is stated beside the warning rather than after it.** This is what VeilVoice's own audio path noticed happening to it. It is not a statement about the machine: a microphone that was - Hearing yourself, and what the meters say
already being intercepted before VeilVoice opened it is intercepted here too, and nothing in this program can see that. The Monitor tab is the wider question of who is holding the microphone, and the honest answer there is also bounded by - Hearing yourself, and what the meters say
what each platform will say. - The take
Record straight into a locked vault, veiled on the way in. What reaches the recorder is the voice the engine produced, and that is what the Studio does unless you say otherwise before you start. **Saying otherwise is possible and is asked - The take
for.** "What to keep" offers the veiled voice, both, or the microphone unveiled, and it says what each costs where it is chosen. Anything that keeps the microphone makes a recording of somebody's real voice: it is sealed in the vault like - The take
everything else, and it is still a recording that anybody who opens the vault can hear who was speaking in. The veiled voice is what is selected, the choice is never remembered between runs, and locking the window puts it back. Two takes - The take
land in the vault when both are kept, and the unveiled one's name ends in `(unveiled)`. That name is the only thing telling them apart, which is worth knowing before renaming either. The recording is assembled in page-locked memory and - The take
handed to the vault to be sealed. It is never a plain file, not even briefly. **While it runs, it says which voice it is keeping.** The choice is made on a form that disappears the moment recording starts, and a take of somebody's real - The take
voice would otherwise look exactly like one that is not for the whole of the recording. The line beside the clock says which, and says it in yellow when the microphone is being kept. **If the device goes, the take is stopped and stored.** - The take
A microphone unplugged, switched away by the operating system, or taken by something with more authority, is a device that will not produce another sample, and a take left running on one records silence while looking exactly like it is - The take
working. So the Studio stops and seals what it has. It does not discard what was captured: everything up to that point is a real recording of something somebody said. It does not retry, and it does not move the recording onto a different - The take
microphone, because you chose that one and a program that quietly records you through another is a program deciding something that is not its to decide. Anything else the platform reports is shown and left alone: an underrun is not a - The take
reason to end a recording. **Starting a take does not interrupt the call, and ending one does not end it.** A recorder cannot be attached to a stream that has already started, so beginning a take and ending one each restart the audio, - The take
which costs a short gap in what is going out. Ending a take leaves the veiling running: somebody who has just stopped recording has not asked to be heard in their own voice again. **stop**, in the voice half, is what ends the veiling, and - The take
it stores a take that is still running rather than discarding it. | Control | What it is | |---|---| | **input / output** | Which microphone the voice comes from and where the veiled voice goes. Locked while a session is running, because - The take
changing the device under a running stream is not a change, it is a restart. | | **several microphones, a guest each** | Turns the input picker into a guest list: a name and a microphone per person, up to eight, each veiled into a voice of - The take
their own and mixed into the output. | | **start veiling** | Begins, at the engine strength shown below the devices. Nothing is kept unless a take is started. | | **preview to my headphones** | The same engine to this machine's own output - The take
and nowhere else. The chosen output is deliberately ignored, so nothing listening on the cable hears it. If this machine's default output *is* a cable, you are told so rather than reassured. | | **stop** | Ends the veiling. A take still - The take
running is stopped and stored first, never discarded. | | **app lock / at rest** | The two passphrases that open the vault. Both, every time. Neither on its own opens anything, and an empty one is refused rather than treated as "no second - The take
factor". | | **call it** | What this take will be called. A name is a label and nothing veils a name; it is sealed with the recording, so it is not readable from the disk, and it is still the thing that says who this is. | | **what to - The take
keep** | The veiled voice, both, or the microphone unveiled. Before the button rather than after it, because a recording of somebody's real voice is not a thing to discover having made. Not remembered between runs, for the same reason - The take
group mode is not: a mode somebody forgets is on eventually records what they did not mean to record. | | **start recording** | Begins a take, at the engine strength the rest of the window is set to, keeping the routing you are already - The take
hearing. | | **stop and store** | Ends the take and seals it into the vault, and the veiling carries on. Samples that were dropped are reported rather than passed over, because a recording that is quietly short is the failure this path - The take
exists to avoid. | | **the two bars** | What is going in, and what is coming out, while it happens. Two rather than one on purpose: a single output meter answers "is something being recorded" and not "is it being veiled", which is the - The take
question you are actually asking. Seeing the input move and the output move differently is the only thing on screen that shows the engine is between them. | Locking the window closes the vault and stops the audio. A take that is still - The take
recording when that happens is stopped and **stored** first, rather than discarded: the vault is still open at that moment, and throwing away a recording because an idle timer fired would be the worst thing the tab could do. - recording an interview
Group mode is about a recording that already exists, so the steps for an interview are: 1. **Set up and check first.** On the Studio tab, choose the microphone, press **preview to my headphones**, and listen. This is where you find out - recording an interview
that the wrong device was selected, or that you are too close to the microphone and clipping. 2. **Start veiling** into the virtual cable, and point whatever is recording or calling at that cable rather than at the microphone. If you want - recording an interview
a copy of the conversation as well, start a take: the vault is on the same tab and the take rides the session that is already running. 3. **Watch the strip.** It stays on screen while you work on other tabs. `in` moving and `out` flat - recording an interview
means the engine has stopped or the cable has gone; `CLIPPED` means the input is too loud and is being cut off, which cannot be undone afterwards. 4. **Afterwards**, if the recording has several people in it and you want each one given a - recording an interview
different voice, that is the **Group** tab and it works on the file. - browser
What is in the vault, without opening any of it. The listing comes from a sealed index, so reading it decrypts one small file rather than every recording. | Control | What it is | |---|---| | **the list** | Each recording's name, size and - browser
the date it was made. The date and not the time: a listing open on a screen in an office already says enough. | | **rename** | Rewrites the index only. The audio is sealed under an identifier rather than a name, so renaming never - browser
re-encrypts anything and cannot lose a recording if it is interrupted. | | **play** | Plays it straight out of locked memory. **Nothing is written to the disk**, so there is no copy to remember to shred afterwards. Stopping releases the - browser
samples, and so does locking the window. | | **remove** | Asks first, and cannot be undone. | | **preview page** | Writes the audio, a self-contained player page and its captions into a folder you pick. The page plays the recording, draws - browser
its waveform, lights whoever is speaking and moves a level under their name, and needs nothing installed. | | **render video** | Writes an MP4 with a black picture, for somewhere that will not accept an audio file. Needs `ffmpeg`, which - browser
VeilVoice does not ship and will not install: without it you get the exact command to run, and the audio it needs, rather than a promise. | | **both** | The page and the video. | **The level under each name, and what it means.** A lit - browser
circle says whose turn it is. It says nothing about whether that person is mid-sentence or mid-pause, and those look identical for as long as the turn lasts, so under each name is a bar that moves with the sound. It is drawn from the same - browser
waveform underneath it, which is why the two can never disagree. It is the loudness of the **mix**, given to whoever the plan says is speaking. A render produces one mixed track, so there is no separate signal per person to measure. While - browser
one person is talking those are the same thing. Where two turns overlap they are not, and both people show the same bar: that is what a listener hears, and it is not a claim that each of them was that loud. Somebody whose turn it is not - browser
shows nothing rather than a small amount, because a bar moving for a person who is not speaking would be the one thing on the picture saying something untrue. Playback decrypts the take **whole**, into page-locked memory, rather than in - browser
pieces. That is a property of the container rather than a shortcut: it is sealed and authenticated as one thing, and an encryption that let you open the first second without the rest would not be authenticating anything. What it buys is - browser
the part that matters, which is that no plaintext file exists at any point. What it does not buy is a footprint smaller than the recording, and an hour of audio is an hour of audio in memory while it plays. Anything taken out is written - browser
**unsealed**, and the tab says so before you press anything. That is not a defect: a video nobody can open is not a video. The voice in it is still veiled, because it was veiled before it was ever stored; what leaves is an ordinary file of - browser
a voice that is not anybody's. The folder is asked for every time rather than remembered. A remembered folder is how the second export goes somewhere the first one was deliberately kept out of. What a vault sitting on a disk gives away is - browser
how many recordings there are and roughly how large each one is. Not their names, not their dates, and not what any of them is. - Decoy vaults
Under the listing is a dropdown that fills the folder with decoys. A decoy is not a vault with weak contents, or one whose passphrase is written down somewhere, or one holding harmless recordings. Any of those is a vault that rewards - Decoy vaults
cracking, and a decoy that rewards cracking teaches an attacker that cracking works. It is a vault whose contents **never existed**: random bytes sealed under a key made inside the call that writes it and dropped before that call returns. - Decoy vaults
Nobody holds it. Cracked, it yields bytes that parse as nothing, which looks exactly like a wrong passphrase. | Control | What it is | |---|---| | **how many** | Starts on what the free space allows: a twentieth of what is actually free - Decoy vaults
where the vaults live, up to thirty-two. Where the system will not say how much is free, the panel says so and the number is a starting point rather than a measurement. | | **make them** | Writes them. Each is the size the open vault is, - Decoy vaults
down to the byte, including the size of its index. | Your vault and its decoys are directories with opaque names, and the real one is found by trying each until one opens, which only both passphrases do. That is why there is no directory - Decoy vaults
called `studio` to look for: a real vault at a fixed name is told from a decoy by reading the name, and the decoys would be worth nothing. **What this buys, and what it does not.** It buys the cost of a search. Somebody who takes the disk - Decoy vaults
sees several vaults, cannot tell which holds anything, and gets no signal from cracking one. It does **not** hide the real vault from somebody watching the screen while you open it, from something already running inside the computer, or - Decoy vaults
from a backup taken before the decoys were made. One consequence worth knowing before you press it: decoys cannot be told from the real vault by looking, which means **you** cannot tell them apart either. Removing one afterwards is - Decoy vaults
removing a directory you cannot open to check first. - verify
Check that a download is the one that was published, without leaving the window. This is the same check `veilvoice verify` does, and §7 walks through it in full. Drop the archive on the window and the hash list and signature beside it are - verify
picked up automatically. One press then checks the signature over the hash list, the archive against that list, every file you extracted out of it, and all of it again through your own GnuPG if you have one. The commands are also printed - verify
for you to run yourself. That is not decoration: a program telling you that a download is genuine came out of that download. Running the commands yourself is the part no program can do for you. **What a pass proves** is written on the tab, - verify
and it is worth reading. A good signature and a matching hash prove the file is the one the holder of that key published. They do not prove it is safe, that the source compiles to it, or that the key belongs to anybody in particular. - settings
Where every choice the window remembers is made, and where they are kept. | Page | What is on it | |---|---| | **Interface** | The colour scheme, which is every palette the website has. Whether the mark in the header animates, and whether - settings
the window icon does. How often the window draws while something is moving, and whether the header carries a live frame-rate readout. Whether the **install** tab is shown at all. | | **Locking** | The app lock and the idle timer that turns - settings
it on. See §5 and §5.5. | | **At rest** | Whether a result is sealed with the app-lock password as well, and where a vault lives if you keep one. See §5.7. | | **Notifications** | How the window tells you a job has finished. | The file - settings
itself is plain text, one `key = value` a line, at `%APPDATA%\veilvoice\settings.conf` on Windows, `~/Library/Application Support/veilvoice/settings.conf` on macOS and `${XDG_CONFIG_HOME:-~/.config}/veilvoice/settings.conf` on Linux. - settings
Nothing in it is secret and none of it is a password. - install
Only there when you are running a portable copy, and it removes itself once VeilVoice is installed: a program offering to install itself when it already is tells you something untrue about what you are running. There is a tick under - install
settings to hide it on a portable copy too. The install it offers is deliberately small. It copies the VeilVoice programs beside this one into your own program directory and adds that directory to your PATH, so that typing `veilvoice` in a - install
terminal works. No administrator rights are asked for, no service is created, and nothing is written outside your own account. **Companion software** is listed on the same tab, and none of it is part of VeilVoice or required by it. Each - install
entry names one program, says who makes it and under what licence, says whether it was found, says what still works without it, and gives the one command that would install it. VeilVoice never runs somebody else's installer, and anything - install
needing root prints the command for you to run in a terminal where you can see what you are approving. The list is `ffmpeg`, a virtual audio cable for your system, GnuPG and Audacity. Where a render tells you `ffmpeg` is missing, it names - install
this tab, because a message about something you cannot act on from where you are standing is only half a message. **Nothing here is downloaded by VeilVoice.** An install runs the package manager your machine already has, which is the same - install
arrangement as everywhere else in this program: no HTTP client is shipped, and three checks in the build say so on every commit. Stopping one part-way and starting again continues rather than beginning from nothing, because your package - install
manager keeps what it had already fetched. That is its behaviour rather than VeilVoice's, and it is said that way round because VeilVoice does not manage those files and will not promise for every installer on every system. **"Look - install
again"** re-runs the search. It looks for each program in turn, which takes a moment on a machine with several of them, so it says it is working while it does. It used to do that inside the frame it was drawing, which made the window look - install
as though it had hung. - monitor
Which applications are holding your microphone and camera, with a log of starts and stops, and an indicator in the header on every tab. On a platform that cannot see this, because macOS exposes no public interface, the tab says so. An - monitor
empty list from a blind monitor is a false reassurance and is never shown as good news. - lock
Set, change or remove the app lock, and lock immediately. See §5. - about
Crate versions, licence, the typeface in use, and a plain statement of what VeilVoice protects and what it does not. - How the window is drawn
Two lines: what was asked of the platform, and what the driver actually gave. VeilVoice asks for a hardware context and accepts a software one. That is why it opens in a virtual machine, over a remote desktop and on a machine with no - How the window is drawn
graphics card at all; demanding hardware would turn every one of those into a program that does not start. The Settings tab has one tick that turns the asking off. It is there for the case asking cannot cover: a driver that accepts the - How the window is drawn
request and then draws badly, which happens on hybrid-graphics laptops that hand over the wrong adapter and on drivers whose OpenGL path is broken in a way that shows as a black window. Nothing in the program can detect that, because from - How the window is drawn
inside it looks like success, so it is a switch rather than something measured. It takes effect at the next launch, because the choice is made before the window exists. - How often it draws
The window draws nothing at all while nothing is happening, which is what keeps it off a laptop battery. While something *is* moving, it draws at the display's own rate. Nothing asks the operating system what that rate is, because neither - How often it draws
library this window is built on will say. It is measured instead: a window that waits for the display cannot draw faster than the display shows, so the interval between frames while something is animating *is* the display's rate, and the - How often it draws
middle value of the last thirty-two of them is the figure the About tab reports. A single slow frame cannot move it. **Settings can pin it**, under animation, to anything from 30 a second up to 1000 instead of matching the display: the - How often it draws
rates panels are sold at, and two above them for displays ahead of that list. Lowering it is a choice to make for a battery rather than for smoothness, since the window waits for the screen either way. Raising it above what the panel does - How often it draws
changes nothing you can see, for the same reason. Matching the display is the default and needs no help: the measurement covers every rate in that list, so a 360 Hz or a 500 Hz panel is found and used without being told. Until the first - How often it draws
thirty-two frames have been timed the window assumes 60. The About tab shows what it is aiming at, what it measured the display to be, how many frames it is drawing a second and how many arrived late. A frame that arrives more than half - How often it draws
again later than it should have is counted late. If that keeps happening for two seconds together the window says so once, with what it is drawing with, because software rendering and a struggling GPU are different problems. Turning on the - How often it draws
header readout puts the same two numbers where you can watch them. - Where this copy keeps things
The tab lists the exact folders in use on this computer: the program itself, the settings folder everything else sits in, the settings file, the app lock, the vaults, the policies, the palettes and the crash report. Each one is worked out - Where this copy keeps things
on the machine rather than written down, and each is different on Windows, macOS and Linux, so a guess in a document would be worse than nothing: somebody told the wrong directory deletes the wrong directory. The app lock's own file - Where this copy keeps things
**names** are not shown, only the folder. They are derived from an index rather than fixed, which is deliberate and is described as obscurity in the source: what it buys is that a search of a disk for a known filename misses, and printing - Where this copy keeps things
the names in a window would hand that back to anybody standing behind you. - Portable, and how to make it so
A line above the list says which of two arrangements is in force. By default the state goes in this platform's own configuration directory. **Put a folder called `veilvoice-data` next to the program and it goes in there instead**, so a - Portable, and how to make it so
copy on a memory stick keeps its settings, its vaults and its lock on the stick. Remove that folder and it goes back to the platform directory. It is opted into rather than detected. "Beside the program if that is writable" would move an - Portable, and how to make it so
ordinary installation's state the day somebody unpacked it somewhere writable, and the symptom would be an empty vault: everything still on the disk and the program looking in the other place. Neither switch moves anything. What is already - Portable, and how to make it so
in the other place stays there, and the About tab is how you see which one is being read. - Installing a portable copy
The Install tab asks what should happen to the folder beside the program, and the install button waits for the answer. There is no sensible default: somebody moving off a stick onto their own machine wants the settings carried over, and - Installing a portable copy
somebody installing on a shared or borrowed machine wants them left where they are, because the wrong guess copies their vault onto a computer that is not theirs. Carried means **copied**, never moved. Anything already in the configuration - Installing a portable copy
directory is left alone and named in the report, and the folder beside the program is untouched, so both copies work afterwards and you decide what to do with the stick. --- --- - 4. The app lock
VeilVoice can sit behind a password of its own, so someone who picks up your unlocked computer cannot open it, see which files you have processed, or start a live scramble. veilvoice lock set # choose a password veilvoice lock status # is - 4. The app lock
one set, and is it rate limited right now? veilvoice lock change veilvoice lock remove The desktop app has the same controls under its **lock** tab, plus a **lock** button in the header that locks immediately and clears the session - 4. The app lock
passphrase. `--path` puts the lock in one named file instead, somewhere of your choosing. Without it, the lock goes where §5.2 describes. - 5.1 Read this part
**The app lock is not tamper-proof, and cannot be.** A program running on your computer has nowhere to hide a secret from that computer. Anyone holding the disk can attack the stored password hash offline, and given enough access can still - 5.1 Read this part
remove the lock entirely. What it does buy is real, and it is worth being precise about which parts are which, because two of the four things below are speed bumps and two are not. **Real, and the reason the lock exists:** - It stops - 5.1 Read this part
**casual access**, meaning the person who sits down at your unlocked session. That is a common threat, and a genuine one. - Three attempts are free, then the wait doubles: 5 s, 10 s, 20 s, up to fifteen minutes, and the count is **written - 5.1 Read this part
to disk**, so killing the app does not reset it. Somebody who edits the file directly still defeats this; see §5.3. - Argon2id at 256 MiB makes each offline guess expensive. That helps a good passphrase and does not save a bad one. **Real, - 5.1 Read this part
and new:** - Each stored lock carries an **authentication tag** computed with a key that exists only while your correct passphrase is in memory. If somebody swaps the stored password for one of their own, or weakens the Argon2id cost so a - 5.1 Read this part
guess becomes cheap, the next time you unlock, VeilVoice tells you. The report is written down, so it survives a restart, and clearing it asks for your passphrase, so the person who caused it cannot dismiss it. - The lock is kept in **two - 5.1 Read this part
copies**, in two directories. Deleting one does not remove the lock: the other puts it back, and you are told it happened. - On Linux and macOS, when VeilVoice is run with administrator rights, the second copy is written under - 5.1 Read this part
`/etc/veilvoice` and is thereafter not writable by an ordinary user. Removing the lock then needs `sudo`. VeilVoice never asks for that privilege and never elevates itself; it uses what it already has. On Windows the equivalent needs an - 5.1 Read this part
access-control list VeilVoice does not set, so there the second copy is a second copy and nothing more. **Not real, and not counted as security anywhere:** - The two files have **unguessable names** and their contents are **masked**, so a - 5.1 Read this part
search of your disk for the string `VEILLOK1` finds nothing and a backup rule written against `applock.bin` misses. The names come from a value in an index file at an obvious path, because something has to be findable or VeilVoice could - 5.1 Read this part
never open its own lock again. Anybody who reads that index, or reads the source, recomputes both names in a second. This is obscurity. It makes careless deletion and casual searching harder and it stops nobody who is paying attention. If - 5.1 Read this part
someone taking your disk is the threat, the answers are full-volume encryption (LUKS, BitLocker, FileVault) and the at-rest encryption above. Not this. - 5.2 Where the lock is kept
In your platform's configuration directory: `%APPDATA%\veilvoice` on Windows, `~/Library/Application Support/veilvoice` on macOS, `$XDG_CONFIG_HOME/veilvoice` or `~/.config/veilvoice` on Linux. Inside it you will find `applock.index`, - 5.2 Where the lock is kept
which is sixteen random bytes, and two files whose names are derived from it. The second copy is in the same directory unless VeilVoice was run with administrator rights, in which case it is under `/etc/veilvoice`. `veilvoice lock status` - 5.2 Where the lock is kept
prints the directory rather than the file names, since the names carry no meaning for a reader. If you delete `applock.index`, both copies become unreachable and the lock is gone. Treat that file as part of the lock rather than as scratch. - 5.3 What the tag does not cover
The failed-attempt counter and its timestamp sit **outside** the authentication tag, and the reason is unavoidable rather than an oversight: they are written at the one moment the tag key does not exist. A wrong passphrase has to be - 5.3 What the tag does not cover
counted, counting it means writing the file, and that write cannot be authenticated by a key only a right passphrase produces. Putting them inside would mean either reporting every honest typo as tampering or not counting failures at all. - 5.3 What the tag does not cover
So the rate limit is exactly as defeatable by a text editor as it always was. The tag covers the parts an attacker actually wants to change: the stored password, the Argon2id cost, and the tamper report itself. Two other things it does not - 5.3 What the tag does not cover
cover. Replacing the lock wholesale with one the attacker created is not detected, because their record is authentic under their own passphrase. The second copy is what stands in the way of that, not the tag. And restoring an older copy of - 5.3 What the tag does not cover
your own lock file, to wind the report back, is not detected either. - 5.4 Two passwords by default, and one if you choose it
The app lock and the recording passphrase are separate secrets by default. If one password did both, opening the app would be the same act as unsealing everything it had ever written. VeilVoice keeps the two derivations domain separated, - 5.4 Two passwords by default, and one if you choose it
so typing the same passphrase in both places still does not produce two copies of one value, though one guess would then open both. **You can now choose to have one.** On the security tab, under how recordings are sealed, there is a third - 5.4 Two passwords by default, and one if you choose it
option beside *passphrase* and *public key*: **app lock**. With it on, every recording VeilVoice writes is sealed with your app-lock password automatically, with nothing else to set up and nothing else to remember. The choice is remembered - 5.4 Two passwords by default, and one if you choose it
between launches. Read this before turning it on: - **One password now opens the application and everything it has ever written.** Somebody who makes you unlock VeilVoice in front of them has opened the archive, not just the session. That - 5.4 Two passwords by default, and one if you choose it
is the entire cost, and it is the reason the two are separate by default. - **Forgetting that password loses the recordings**, not just a session. Without this on, forgetting the app-lock password costs you the lock and the fix is deleting - 5.4 Two passwords by default, and one if you choose it
it. With it on, deleting the lock does not help: the recordings are encrypted, and there is no recovery. - **The recordings do not depend on the lock file.** Each file carries its own salt and cost, so `veilvoice decrypt` opens it with the - 5.4 Two passwords by default, and one if you choose it
same password on any machine, with or without a lock. Removing the lock does not lock you out of anything. - **It takes effect at the next unlock.** The password is taken as the lock opens, because that is the only moment it exists. - 5.4 Two passwords by default, and one if you choose it
Turning the option on mid-session tells you to lock and unlock again, rather than quietly writing the next recording unencrypted. Only offered when an app lock is set, because there is nothing to seal with otherwise. - 5.5 If you forget the app-lock password
Delete `applock.index` and the two files beside it, in the directory §5.2 names. That is not a backdoor: it is the same thing anyone with access to your files could do, which is exactly why the lock is described as protecting against - 5.5 If you forget the app-lock password
casual access rather than as a security boundary. If the second copy was written under `/etc/veilvoice`, removing it needs `sudo`. The unlock screen does not show any of this. It says the app is locked and asks for the passphrase, and - 5.5 If you forget the app-lock password
nothing else. The person reading a locked window is either its owner, who does not need the file's location at that moment, or somebody who picked the machine up, who should not be handed it at all. Forgetting a **recording** passphrase is - 5.5 If you forget the app-lock password
different. There is no recovery, by design. --- --- - 5. Locking the window when you walk away
Off unless you turn it on, under **Settings, Locking**. When it is on, choose how long from the list, which runs from five minutes to two days, or type your own: `90m`, `2h`, `1d`. Typing a value outside the list widens the list to hold - 5. Locking the window when you walk away
it, so the range is yours rather than ours. There is a button to put it back. **Starting a long job does not count as using the window.** That is deliberate, and it is the case the feature exists for: somebody who starts a render and - 5. Locking the window when you walk away
leaves the room has left the room, and the recording being produced is the thing worth locking away. The countdown runs on the window's own clock, not the system one, so changing the machine's time neither brings the lock forward nor - 5. Locking the window when you walk away
pushes it back. --- - 6. VeilVoice checking its own files
The first time VeilVoice runs it writes down what its own program file looks like: the size and a SHA-256. Every launch after that it checks the file against that record and shows the answer on the security tab. Nothing has to be turned on - 6. VeilVoice checking its own files
and there is no command to remember, which was the whole problem with `veilvoice guard init` being a command. **With an app lock set**, the record is sealed under your app-lock passphrase, and the check runs at the moment you unlock, - 6. VeilVoice checking its own files
because that is the one moment the passphrase exists. Somebody who changes the program file then has to change the record too, and to do that they need your passphrase. **With no app lock**, there is no passphrase to seal it with, so the - 6. VeilVoice checking its own files
record is written in the clear and the security tab says so in those words. It still catches a file that changed by accident, a half-finished update, or a careless overwrite. It does not catch somebody who thought to rewrite the record as - 6. VeilVoice checking its own files
well, and a record sealed under a key kept beside it would be a decoration rather than a protection. Either way this **detects**; it does not prevent. And it cannot tell an update you installed from a file somebody swapped, because on disk - 6. VeilVoice checking its own files
those look identical. If you have just updated, a report of a change is the update. The record is taken at first launch rather than at install, because installing a package runs as an administrator and the record belongs to the user - 6. VeilVoice checking its own files
account that will run the program. A record written into the administrator's own configuration directory would describe nothing anybody checks. `veilvoice guard init`, `check` and `status` still work, read the same record, and can watch - 6. VeilVoice checking its own files
more files than the window does. See §4. --- - 7. Saving into a Cryptomator vault or a VeraCrypt volume
VeilVoice can write every veiled recording straight into an encrypted folder you already have, instead of leaving it beside the original. The security tab has a section called **Where recordings go**. It looks for a mounted Cryptomator - 7. Saving into a Cryptomator vault or a VeraCrypt volume
vault or VeraCrypt volume and offers what it finds. If it finds nothing, point at the folder by hand and say which of the two it belongs to; a folder you chose yourself is treated exactly like one that was found, including the question - 7. Saving into a Cryptomator vault or a VeraCrypt volume
below. **VeilVoice never opens or closes these for you, and never asks for their password.** Unlock the volume in its own program first. Mounting your encrypted storage is your act, taken in the tool you chose, and a voice de-identifier is - 7. Saving into a Cryptomator vault or a VeraCrypt volume
not the program to be doing it on your behalf. - The hidden-volume question
If you choose a **VeraCrypt** volume, VeilVoice asks one question before it will write anything, and will not start a job until you answer: > Does this container have a hidden volume inside it? It asks because it cannot tell, and neither - The hidden-volume question
can anything else. A VeraCrypt container can hold a second volume inside the free space of the first, so that somebody forced to hand over a password can open the outer one truthfully while the inner one stays unprovable. That only works - The hidden-volume question
because the two are indistinguishable from outside. The danger is specific. **Writing into the outer volume of a container that has a hidden one can destroy the hidden data**, because the outer filesystem does not know the inner one is - The hidden-volume question
there and will allocate over it. VeraCrypt has a protection mode for this and it needs the hidden volume's password, which VeilVoice does not have and will not ask for. So there are three answers and they do different things: | Answer | - The hidden-volume question
What happens | |---|---| | No hidden volume | VeilVoice writes there | | This is the hidden one | VeilVoice writes there; you are already inside the hidden volume, which is safe | | This is the outer one | VeilVoice refuses, and says why | - The hidden-volume question
Cryptomator is not asked, because it has no such concept and a question with no meaning only teaches people to click through questions. A destination you have not answered for **blocks the job**. It does not quietly fall back to writing - The hidden-volume question
beside the original, because a recording sitting outside a vault while you believe it is inside one is exactly what this is here to prevent. If the volume is locked when you come to use it, VeilVoice says so rather than writing into the - The hidden-volume question
empty mount point. - 5.8 Encrypt the disk as well
An encrypted volume protects the files inside it. It does not protect the temporary files, swap or hibernation image, thumbnails or recently-opened lists your system writes about them, and any of those can outlive the recording. Encrypt - 5.8 Encrypt the disk as well
the whole disk too: | System | Use | |---|---| | Windows | BitLocker | | macOS | FileVault | | Linux | LUKS or LUKS2 | | OpenBSD | `softraid -C` | | FreeBSD | GELI | This is defence in depth, not a second lock on the same door. The volume - 5.8 Encrypt the disk as well
protects the file; the disk protects everything the system wrote about the file without being asked. A veiled recording inside a Cryptomator vault on an encrypted disk is encrypted by two independent tools, and what that buys is not extra - 5.8 Encrypt the disk as well
strength so much as independence: a defect in one is not a defect in both. --- - 8. Things VeilVoice will not do
Read this twice. Misunderstanding it is the only way this software gets someone hurt. - **It does not hide what you said.** The words are preserved on purpose and can be transcribed. Encrypt the file, which is now the default, if the - 8. Things VeilVoice will not do
content must stay secret. - **It does not fully remove a strong accent.** Its melody and colour go; which phonemes you actually produced cannot be changed by any filter. - **It does not sanitise the background.** Room acoustics, other - 8. Things VeilVoice will not do
voices, a passing siren. - **It does not hide your speaking rate** or rhythm. A weak biometric, but a real one. - **It does not help against an attacker already running code on your machine.** - **It does not clean the filename**, the - 8. Things VeilVoice will not do
filesystem timestamps, or the channel you send the file over. - **The app lock is not tamper-proof**, as above. - **Secure erase is not reliable on flash storage.** `shred` overwrites in place, which works on a spinning disk; wear - 8. Things VeilVoice will not do
levelling on an SSD, SD card or USB stick leaves the original blocks where no software can reach them. Full volume encryption is the answer that works. ---
docs/GUIDE_VERIFY.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/guides.py from docs/USER_GUIDE.md. Do not edit: edit the section in the user guide and run the tool. Verified in CI with `python tools/docs/guides.py --check`. - line 1
--> - VeilVoice: checking a download
**`veilvoice verify`.** Read this one first. It is about deciding whether the archive you just downloaded is the one that was published. The short version: unpack the archive, run `veilvoice verify` inside the folder, read the verdict. If - VeilVoice: checking a download
you would rather not use a terminal, the desktop application's Verify tab does the same check with the same code underneath. The whole story, including the parts about the other two programs, is in [`USER_GUIDE.md`](USER_GUIDE.md). This is - VeilVoice: checking a download
the same text, narrowed to one program. --- - 1. The two programs, and which one you want
A release contains two executables. They overlap on purpose, and which one to reach for depends only on what you have in front of you. | Program | What it is for | |---|---| | `veilvoice` | The command line. Everything the application - 1. The two programs, and which one you want
does, over SSH, in a container, in a script, or on a machine with no graphics toolkit at all. Checking a download is `veilvoice verify`, which was a third binary until 0.1.18. | | `veilvoice-gui` | The window. The same engine with - 1. The two programs, and which one you want
somewhere to click, plus the things that only make sense with a screen: live level meters, the app lock, the microphone monitor, and a Verify tab that runs the same check as `veilvoice verify`. | - Which parts are built in, and which are not
Everything VeilVoice does is in these binaries. There is no runtime to install, no service, no plugin directory, and nothing is downloaded on first run. That includes the parts people expect to be separate: - **The signature check.** The - Which parts are built in, and which are not
signing key is compiled into the programs, and the OpenPGP verification is Rust code in this repository. `veilvoice verify` needs no GnuPG to do its job. - **The audio decoders**, the resampler, the encryption, the key exchange, the - Which parts are built in, and which are not
hashing. All of it is in the binary. - **The at-rest encryption**, including the post-quantum half. Three things are genuinely outside, and each is optional: - **GnuPG**, for a second opinion on a release signature. Worth having, and - Which parts are built in, and which are not
explained under §7. - **A virtual audio cable**, if you want live mode to feed a call. On Linux this is usually PipeWire, which is already there. - **`ffmpeg`**, only if you ask for a video file. VeilVoice draws every picture in the video - Which parts are built in, and which are not
itself and asks `ffmpeg` for the last step only, because a video encoder is a large piece of C and carrying one would end the claim that you can read the whole of this program. Without it, a render still writes the audio, the subtitles, - Which parts are built in, and which are not
the player page and every picture, then prints exactly the command that turns them into the file and exits successfully, because nothing failed. `veilvoice companions` lists all of them, says whether this machine has each, and prints the - Which parts are built in, and which are not
one command that would install it. It never runs somebody else's installer. - What runs where
Eleven platforms get a signed archive with every release, and the table says what is in each one. It is not the same everywhere, and where it is not, that is a limit of what the platform offers rather than something waiting to be written. - What runs where
| Platform | Command line | Desktop app | Live microphone | |---|---|---|---| | Windows 10 and 11, x86-64 | yes | yes | yes, with a virtual cable | | macOS on Intel | yes | yes | yes, with a virtual cable | | macOS on Apple Silicon | yes | - What runs where
yes | yes, with a virtual cable | | Linux, x86-64 and arm64 | yes | yes | yes, through PipeWire | | Linux, statically linked (musl) | yes | yes | yes | | Raspberry Pi and other armv7 | yes | yes | yes | | WSL on Windows | yes | yes, with - What runs where
WSLg | through the Windows side | | FreeBSD, OpenBSD, NetBSD | yes | not shipped | no | **Any Linux distribution.** The `.deb` and `.rpm` are conveniences, not requirements: the plain archive is a folder of binaries that needs no package - What runs where
manager, and the statically linked build needs no system libraries at all, which is the one to reach for on a distribution nothing else fits. **One library the desktop app needs, and why it is worth a paragraph.** The window toolkit opens - What runs where
`libxkbcommon-x11` by name when it starts, rather than linking against it. Nothing that works out dependencies by reading a binary can see that, so a minimal or server install can be missing it and the application will exit at once instead - What runs where
of drawing a window. If that happens, the crash report VeilVoice writes names the library and the package that carries it; the short version is `libxkbcommon-x11-0` on Debian and Ubuntu and `libxkbcommon-x11` elsewhere. The command line - What runs where
needs none of it. **The first time you open it.** After the two settings questions, VeilVoice shows one card per tab saying what that tab is for, which takes about twenty seconds and can be skipped at any point. Two of the nine are worth - What runs where
the card on their own: Monitor is not a level meter, it watches for another program picking up a real microphone while you are being veiled; and Lock is a passphrase on the application rather than on a recording. The last card says whether - What runs where
this copy is **portable** or **installed**, in those words. Portable means it runs from wherever you put it and installs nothing: move the folder and VeilVoice moves with it, delete the folder and it is gone. Installed means it is on this - What runs where
machine for good, on your menu or path, with its settings in your account. Both are fine, and the Install tab is where the decision is made rather than in the tour. After an upgrade the tour comes back only for tabs that did not exist last - What runs where
time, and a release that adds no tab shows nothing. What is stored is the list of tabs you have been shown, which is what "which of these is new to you" is actually asking. **When something goes wrong.** VeilVoice writes a report of a - What runs where
crash to a file beside its settings, and on the next launch it offers it to you above whatever tab you land on: what happened, where the file is, and a button to read the whole of it before you decide anything. Nothing is sent. Nothing - What runs where
here *can* send it, and that is not a policy but a property of the build: this project contains no network client and the build fails if one enters the dependency graph. The ordinary shape of this feature is a reporter that uploads, and - What runs where
that is the wrong shape for a program people use to protect themselves, because a report from a privacy tool is a report about somebody who was being careful. So the panel offers two things instead: copy the report, and open the issue - What runs where
tracker. What happens next is your decision and your clipboard. If you would rather it went away, "dismiss and delete it" removes the file. The report holds the version, your operating system and processor, and the error with its source - What runs where
location. It holds no file names, no settings, no passphrase and nothing about any audio. That list is in the panel too, because "would you like to send this" is only a real question if you can see what "this" is. **The BSDs get the - What runs where
command line only, and the reason is specific.** The audio library VeilVoice uses has no backend for them, so live capture cannot work there and the desktop application is built around a window that would have nothing to listen to. - What runs where
Everything that operates on a file, meaning de-identification, encryption, metadata cleaning and verification, is pure Rust and runs exactly as it does anywhere else. **WSL is Linux**, so the command line runs unchanged. The window needs - What runs where
WSLg, which recent Windows has by default. A microphone belongs to Windows rather than to the distribution, so live mode is the Windows build's job. **Nothing is emulated and nothing is a wrapper.** Every archive is a native build for that - What runs where
processor, compiled from the same source with the same pinned compiler, and built twice in separate directories and compared byte for byte before it ships. - How anything reaches the network, given that nothing here is a network client
VeilVoice bundles no HTTP client, and this is checked rather than claimed: nothing in the workspace links one. Two features nonetheless involve the network, and the way they do it is the point. **Check for updates**, in the desktop - How anything reaches the network, given that nothing here is a network client
application only, asks the operating system's own transfer tool to fetch one small file, and reads a version number out of what it printed. It is a button, it is never automatic, and the command line has no such feature at all. The tool is - How anything reaches the network, given that nothing here is a network client
found by **absolute path**, never through `PATH`. On Windows that is `%SystemRoot%\System32\curl.exe`, which has shipped with Windows since 2018. Elsewhere it is `curl` at `/usr/bin`, `/bin` or `/usr/local/bin`, and `wget` at the same - How anything reaches the network, given that nothing here is a network client
three places if there is no `curl`. That distinction is not fussiness. Windows searches the current directory before `PATH`, so a file called `curl.exe` sitting beside VeilVoice would otherwise be the program that ran, and a privacy tool - How anything reaches the network, given that nothing here is a network client
reaching for the network is the last place to accept a stranger's binary. If none of those paths holds a tool, the button says so and nothing is run. **Installing a companion** does not fetch anything either. It runs the package manager - How anything reaches the network, given that nothing here is a network client
already on the machine, which is the thing your system already trusts to install software, and for anything needing root it prints the command instead of running it. The consequence worth stating: there is no code path in VeilVoice that - How anything reaches the network, given that nothing here is a network client
opens a socket. A firewall rule that blocks it entirely costs you the update button and nothing else. --- --- - 2. The verifier, veilvoice verify
One job: deciding whether the download you just made is the one that was published. Until 0.1.18 this was a third binary, `veilvoice verify`, shipped beside the other two so it was usable *before* you trusted them. That argument was always - 2. The verifier, veilvoice verify
thinner than it looked -- the separate program came out of the same archive as everything else, so trusting it was the same act of trust -- and it cost every user a second file to find and a third name to learn. It is part of `veilvoice` - 2. The verifier, veilvoice verify
now. The check is identical: the same code, in `veilvoice-check`, that the desktop application's Verify tab has always called. If you would rather not use a terminal at all, the Verify tab does the whole of this with the same code - 2. The verifier, veilvoice verify
underneath. - Just run it
veilvoice verify With no arguments it looks for a release around it: the folder you are in, the folder above, the one the program itself is in, and Downloads and Desktop. If it finds an archive with a `SHA256SUMS` and a signature beside - Just run it
it, it checks everything without being told anything. That is the intended way to use it. Unpack the archive, run it inside the folder, read the verdict. - What one press actually checks
Four things, in the order that makes each one worth checking: 1. **The signature over the hash list.** Before the hashes, always. Whoever could replace your download could replace `SHA256SUMS` beside it, and the two would agree perfectly. - What one press actually checks
The signature is what makes the list worth comparing against, and the fingerprint is what makes the signature worth checking. 2. **The archive against that list.** 3. **Every file you extracted out of it**, against a signed contents list - What one press actually checks
published with the release. An archive can be genuine and a file inside your extracted copy still be wrong. 4. **All of it again through your own GnuPG**, if you have one. See below. - Pointing it at something specific
veilvoice verify auto ~/Downloads # look here, not wherever I am veilvoice verify file veilvoice.tar.gz # this file, against SHA256SUMS A folder you name that does not exist is an error, not an invitation to go and check somewhere else. - Pointing it at something specific
That distinction cost a finding: the fallback through Downloads and Desktop is right when nobody said where to look, and wrong the moment somebody does. - The second opinion, and why you want one
VeilVoice checks the signature itself, in Rust, with the signing key compiled in. That check needs nothing installed and works on every platform. It also came out of the download it is checking. **A tampered release ships a tampered - The second opinion, and why you want one
verifier.** That is not a bug to be fixed; no program can vouch for itself. So the commands are printed for you to run yourself, and the desktop application will run your GnuPG if you ask it to: veilvoice verify # what to check, and where - The second opinion, and why you want one
the files come from veilvoice verify --script # a shell script that uses gpg and nothing of ours The script is about sixty lines. Read it before running it: the entire reason to use it rather than `veilvoice verify` is that it is not this - The second opinion, and why you want one
project's code. - The same check on every system
`veilvoice verify` on its own is the same command everywhere. It hashes the files itself, checks the signature itself, needs **no GnuPG, no network and no hash tool from your system**, and that is why it is the first thing this section - The same check on every system
offers rather than the last. A reader on FreeBSD, OpenBSD or NetBSD has nothing to translate. The second opinion is where systems differ, because it runs *your* tools rather than ours, and the hash tool is spelled differently on each. Ask - The same check on every system
for the one your system has: veilvoice verify --script # the one for the machine you are on veilvoice verify --script --system bsd # or name it: linux, macos, bsd | Your system | The script it writes | What that script runs to check the - The same check on every system
hashes | |---|---|---| | Linux, and WSL | `verify-veilvoice.sh` | `sha256sum -c SHA256SUMS --ignore-missing` | | macOS | `verify-veilvoice-macos.sh` | `shasum -a 256 -c SHA256SUMS --ignore-missing` | | FreeBSD, OpenBSD, NetBSD | - The same check on every system
`verify-veilvoice-bsd.sh` | `sha256 -c SHA256SUMS` | Those three rows are checked against the program in this project's own test suite, so a command here that the program no longer prints fails a build rather than being read by somebody. - The same check on every system
**Installing GnuPG on a BSD**: `sudo pkg install -y gnupg` is FreeBSD's spelling, and OpenBSD and NetBSD use tools of their own. This project has not run those, so it does not print them: use your system's package manager. If you would - The same check on every system
rather not install anything, `veilvoice verify` with no arguments is the route that needs nothing, and the website's checker is the other one. **The website's checker** hashes the file in your browser, with JavaScript, and uploads nothing. - The same check on every system
It is the same arithmetic on every system, so a BSD reader with a browser has a third route that needs no terminal at all. The reproduce script is per system in the same way, and `veilvoice verify --build-script --system bsd` writes - The same check on every system
`reproduce-veilvoice-bsd.sh`. - The strongest check, which no program here can do for you
Rebuild the release from source and compare: veilvoice verify --build-script > reproduce-veilvoice.sh sh reproduce-veilvoice.sh v0.1.22 A hash proves the file is the one whose hash was signed, and says nothing about what is inside it, - The strongest check, which no program here can do for you
because the same person signed both. Rebuilding moves the question from "do I trust the publisher" to "do I trust the source", and the source is here to read. - What a pass proves, and what it does not
A good signature and a matching hash prove the file is the one the holder of that key published. They do **not** prove it is safe, that the source compiles to it, or that the key belongs to anybody in particular. Compare the fingerprint - What a pass proves, and what it does not
against the website and this README, from somewhere other than the download you are checking: 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A --- --- - 3. Getting help, and checking for yourself
Nothing here asks for trust. The properties above are asserted by the test suite: cargo test --workspace cargo run -p veilvoice-core --example spectrum_report The code has been **audited by tilas01**, who wrote it. That is a maintainer - 3. Getting help, and checking for yourself
audit and is worth what a maintainer audit is worth: it catches what the author can see. **No external firm or independent researcher has reviewed this code.** Until one has, the strongest verification available to you is the source, which - 3. Getting help, and checking for yourself
is written to be read.
docs/INSTALL.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Installing VeilVoice
There are three ways to get VeilVoice, and they differ in who is doing the checking rather than in what you end up with. | | You run | The checking is done by | |---|---|---| | [By hand](#1-by-hand) | four commands | **you**, and you can - Installing VeilVoice
see each one | | [With the install script](#2-with-the-install-script) | one command | the script, which refuses if anything fails | | [With the portable verifier](#2b-with-the-portable-verifier) | one command, no GnuPG needed | a binary - Installing VeilVoice
carrying the key | | [From source](#3-from-source) | `cargo build` | the compiler, plus whatever you read | The by-hand route is first on purpose. The install script does exactly what it describes and nothing else, but "run this script and - Installing VeilVoice
trust it" is a strange thing to ask on behalf of a tool whose entire argument is that you should not have to trust anybody. If you only ever read one section here, read that one. **Status of this document.** The install scripts and the - Installing VeilVoice
portable verifier have been written and tested end to end on Windows against the real published v0.1.8 release -- the verifier checks that release's actual OpenPGP signature with no GnuPG installed -- and the by-hand chain has been checked - Installing VeilVoice
on the same release. They have **not** yet been run by anyone other than the author, nor on a machine that did not build them. Until that has happened they should be treated as working but unproven. See [What is not - Installing VeilVoice
finished](#what-is-not-finished). --- - The fingerprint
Everything below rests on one value: 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A That is the OpenPGP key VeilVoice releases are signed with. It is published in [`README.md`](../README.md), on [the - The fingerprint
website](https://tilas01.github.io/veilvoice/), in the wiki, and in every release's notes, and it is **hardcoded in the install scripts** rather than fetched, because a fingerprint you download alongside the thing it is meant to - The fingerprint
authenticate is not a check, it is a formality. The key's user ID is exactly `tilas01`, with no e-mail address attached. If the fingerprint you see anywhere disagrees with the one above, stop. --- - 1. By hand
Four commands, on any platform with GnuPG and a SHA-256 tool. Replace `v0.1.22` and the archive name with the release and build you want; the [releases page](https://github.com/tilas01/veilvoice/releases) lists them. # 1. Get the key, and - 1. By hand
check its fingerprint against the value above. curl -fsSLO https://tilas01.github.io/veilvoice/assets/veilvoice-signing-key.asc gpg --import veilvoice-signing-key.asc gpg --fingerprint tilas01 Compare what that prints against the - 1. By hand
fingerprint above, **character by character**. This is the only step that anchors any of the others, and it is the one step nothing can do for you. # 2. Get the release, the hash list, and the signature over it. V=v0.1.22 - 1. By hand
B=https://github.com/tilas01/veilvoice/releases/download/$V curl -fsSLO $B/veilvoice-$V-linux-x86_64.tar.gz curl -fsSLO $B/SHA256SUMS curl -fsSLO $B/SHA256SUMS.asc # 3. Verify the signature over the hash list. gpg --verify SHA256SUMS.asc - 1. By hand
SHA256SUMS # 4. Verify the download against the now-trusted hash list. sha256sum -c SHA256SUMS --ignore-missing On macOS use `shasum -a 256 -c SHA256SUMS --ignore-missing`. On Windows, in PowerShell: Get-FileHash -Algorithm SHA256 - 1. By hand
.\veilvoice-v0.1.22-windows-x86_64.zip # then compare that hash against the matching line in SHA256SUMS - Why that order, and not the other one
Step 3 before step 4, always. Checking the hash first proves that your download matches a list. It does not prove anything about the list. Whoever could replace the download could replace `SHA256SUMS` beside it, and the two would agree - Why that order, and not the other one
perfectly. The signature is the only thing that makes the hash list worth comparing against, and the fingerprint is the only thing that makes the signature worth checking. A "good signature" warning about the key not being certified is - Why that order, and not the other one
expected and is not a problem: gpg: Good signature from "tilas01" [unknown] gpg: WARNING: This key is not certified with a trusted signature! That says GnuPG has no web-of-trust path to the key, which is true, and is why you compared the - Why that order, and not the other one
fingerprint yourself in step 1. What matters is `Good signature`. `BAD signature` means stop. --- - 2. With the install script
The scripts live in [`install/`](../install/). Each one downloads the release, performs exactly the checks above in exactly that order, and **refuses, naming the check that failed**, rather than continuing past anything it could not - 2. With the install script
verify. None of them has a flag to skip verification, because an installer with one is an installer whose verification is decorative. Since the archive is checked against the signed list, the installed programs are then made to check - 2. With the install script
themselves one more way: the script runs `veilvoice verify` on the freshly installed binary, which re-checks the signature and every file in the archive through an independent implementation and the signed per-file manifest. If that - 2. With the install script
disagrees with what was installed, the script removes it and stops. - The one command, if you want it
For people who would rather paste one line than download, read and run, here it is per platform. It does everything above and refuses on any failure. Reading the script first, as shown below the one-liners, is safer and recommended, since - The one command, if you want it
piping a script straight into a shell means trusting it before you have seen it. Linux and macOS: curl -fsSL https://raw.githubusercontent.com/tilas01/veilvoice/main/install/install.sh | sh Windows PowerShell: irm - The one command, if you want it
https://raw.githubusercontent.com/tilas01/veilvoice/main/install/install.ps1 | iex - Linux and macOS
curl -fsSLO https://raw.githubusercontent.com/tilas01/veilvoice/main/install/install.sh less install.sh # it is 400 lines and it is meant to be read sh install.sh | Option | Effect | |---|---| | `--yes` | no prompts, and **no** optional - Linux and macOS
components | | `--version v0.1.22` | a specific release rather than the latest | | `--prefix ~/.local` | where to install (default `~/.local`) | | `--with-audacity` | install Audacity too | | `--with-gpg` | install GnuPG if it is missing | - Windows
irm https://raw.githubusercontent.com/tilas01/veilvoice/main/install/install.ps1 -OutFile install.ps1 notepad install.ps1 # read it first powershell -ExecutionPolicy Bypass -File install.ps1 `install.bat` is a wrapper for people who would - Windows
rather double-click; it passes its arguments straight through to `install.ps1` and contains no logic of its own, deliberately, because two implementations of a verification routine means one of them is the stale one, and the stale one is - Windows
the one that will be running when it matters. | Option | Effect | |---|---| | `-Yes` | no prompts, and **no** optional components | | `-Version v0.1.22` | a specific release | | `-Prefix "D:\Tools\VeilVoice"` | where to install | | - Windows
`-WithVBCable` | open the VB-CABLE download page | | `-WithAudacity` | install Audacity through winget | - The optional extras, and why they are questions
VeilVoice needs none of them. Each is offered **once**, as a question that **defaults to no**, and `--yes` / `-Yes` installs none of them at all: `--yes` means "do not ask me", and answering an unasked question by installing software on - The optional extras, and why they are questions
somebody's machine is precisely the behaviour that makes install scripts untrustworthy. - **VB-CABLE** (Windows) is what lets live mode feed a veiled microphone into a call. It is **proprietary donationware** by VB-Audio, not free - The optional extras, and why they are questions
software. The script only *opens their download page*: it will not silently fetch and run a third-party installer, which would be a strange thing to do inside a script whose whole subject is verifying what you run. - **Audacity** is a free - The optional extras, and why they are questions
audio editor, useful for *editing* a recording before veiling it: trimming silence, cutting a section, joining takes. Recording is what the Recording Studio does, and it does it because a take made in another program is a plaintext file - The optional extras, and why they are questions
sitting on your disk until you remember to shred it. Audacity is not bundled because it is GPL-2.0-or-later, which cannot be combined with this project's GPL-3.0-or-later. - **GnuPG**, where missing, because without it the signature cannot - The optional extras, and why they are questions
be checked at all. If you decline it, the script stops rather than falling back to "the hash matched", because a hash checked against an unverified list is not a security check. - What a refusal looks like
REFUSED: the signing key's fingerprint does not match expected 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A found 1234567890ABCDEF1234567890ABCDEF12345678 This is the check that anchors every other one, so nothing further was attempted. - What a refusal looks like
Nothing has been installed. Every refusal says which check failed and installs nothing. There is no partial state to clean up: the download lands in a temporary directory that is removed on exit, and nothing is copied anywhere until every - What a refusal looks like
check has passed. --- - 2b. With the built-in verifier
`veilvoice verify` does the same checks as GnuPG with **nothing else installed** -- the signing key and its fingerprint are compiled into the program. It downloads nothing. Until 0.1.18 this was a separate `veilvoice verify` binary in - 2b. With the built-in verifier
every archive. It is a subcommand now, so there is one fewer file to find and one fewer name to learn; the checks and the code behind them are unchanged. - The short way
Open a terminal in the folder you downloaded to and run it. That is the whole instruction. veilvoice verify It finds the release near it and checks all of it, in this order, each step only if the one before it passed: 1. the signature over - The short way
`SHA256SUMS`; 2. every archive, against `SHA256SUMS`; 3. `CONTENTS.sha256`, against `SHA256SUMS`; 4. **every file you extracted**, against `CONTENTS.sha256`, naming anything in that folder the release never published; 5. all of it again - The short way
through the GnuPG on your machine, if you have one. Step 4 is the one worth having and it is new in v0.1.15. A hash over the archive tells you the *zip* is genuine. This tells you the *program you are about to run* is, which is the - The short way
question anybody actually has. Nothing on disk records which archive a folder was extracted from, so a release now publishes `CONTENTS.sha256` listing every file inside every archive with its SHA-256, staged before `SHA256SUMS` is computed - The short way
so the signature covers it too: SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each file on disk Releases before v0.1.15 carry no contents list and are checked as far as step 2, which the tool says at the time rather than implying - The short way
more. Step 5 adds the VeilVoice public key to your keyring, tells you it did and how to remove it in one command, and runs `gpg --verify`. The signature is then checked by two independent implementations and the run fails if they disagree. - The short way
A GnuPG that cannot run on your machine is **not** counted against the download: that is a fact about the computer and says nothing about the file. The commands in section 2 above are still printed every time, because running GnuPG from - The short way
inside the program you are checking makes the *implementation* independent and only you typing them makes the *invocation* independent. - The long way, one file at a time
veilvoice verify key # prints the fingerprint it carries. Compare it against the one above. veilvoice verify file veilvoice-v0.1.22-linux-x86_64.tar.gz --sums SHA256SUMS --sig SHA256SUMS.asc - The one thing it cannot carry
It cannot embed the expected hash of the file it is checking: a file cannot contain its own digest, because writing the digest in changes the file. So the hash comes from outside, and **where it came from decides what a match proves**. The - The one thing it cannot carry
tool keeps these apart and refuses to run both at once and report one answer. | Hash from | Proves | Rests on trusting | |---|---|---| | the published `SHA256SUMS` | the download is **intact** | whoever signed the release | | somebody - The one thing it cannot carry
else's own build of the same tag | the release is **reproducible** | nobody in particular | The first is what most people want. The second is what makes the first worth anything: it closes the gap that a signed binary could contain - The one thing it cannot carry
something the source does not. It needs a build by somebody who is not the author, which is why this project cannot perform it for you. # the stronger check, once somebody else has published a hash from their build veilvoice verify file - The one thing it cannot carry
veilvoice-v0.1.22-linux-x86_64.tar.gz --sha256 <their hash> veilvoice verify --explain # the difference, at length VeilVoice's own releases are built twice, in separate directories, and compared before they ship. That is the publisher - The one thing it cannot carry
checking their own work -- worth something, and not the same as somebody else checking it. - Why it depends on a large library
`veilvoice verify` uses [`pgp`](https://crates.io/crates/pgp) (rPGP), a pure-Rust OpenPGP implementation, and it is by far the largest dependency in this project. That cost was weighed rather than ignored. The alternative was hand-writing - Why it depends on a large library
OpenPGP packet parsing and RSA PKCS#1 v1.5 verification, and a subtle mistake there would be a **silent accept** in the one tool whose entire job is not to silently accept. A widely used implementation that many people read is the better - Why it depends on a large library
risk. One consequence is recorded honestly rather than buried: that library brings in the `rsa` crate, which carries [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071) (the Marvin attack) with no fixed version available. - Why it depends on a large library
The advisory concerns RSA **private key** operations. This tool only ever verifies a signature against a public key compiled into it -- there is no private key anywhere in this repository, and no secret for a timing side channel to leak. - Why it depends on a large library
That reasoning is written out in `.cargo/audit.toml`, and because it is an argument about *usage* rather than about the crate, CI fails the build if a secret-key or decryption API ever appears in the verifier. --- - 3. From source
A fresh clone needs no secrets and no configuration. git clone https://github.com/tilas01/veilvoice cd veilvoice cargo build --release --workspace The binaries land in `target/release/`. `cargo run -p veilvoice-cli -- info` reports what - 3. From source
the build supports. If you want a binary you can compare against the published one, see [REPRODUCIBLE_BUILDS.md](REPRODUCIBLE_BUILDS.md). --- - 4. After you have it: portable, or installed
Once VeilVoice is unpacked it runs. Nothing has to be installed, nothing is written outside the folder, and deleting the folder removes it. **Portable is the normal case**, not a lesser one. Installing exists for one reason: so that typing - 4. After you have it: portable, or installed
`veilvoice` in a terminal works. It is per-user, needs no administrator, and is reversed exactly. veilvoice install # copy, add to PATH, register for removal veilvoice install --status # what is installed, and which copy is running - 4. After you have it: portable, or installed
veilvoice uninstall --yes # undo exactly those three things The desktop application has the same thing on its **install** tab, with the list of what will change printed beside the button. Both call the same code -- `veilvoice-setup` -- - 4. After you have it: portable, or installed
rather than each having its own idea of how to edit `PATH`. A `PATH` edit is the one operation here that can damage a machine, so there is one implementation of it and one set of tests over it. "Installed" and "you are running the - 4. After you have it: portable, or installed
installed copy" are reported separately, because they are different facts and confusing them is how somebody edits a portable folder and wonders why the installed one did not change. - Companion software
Four programs make VeilVoice easier to live with. **None of them is part of VeilVoice and none of them is required.** | | What it is | Who makes it | Licence | |---|---|---|---| | VB-CABLE | a virtual audio cable for Windows | VB-Audio - Companion software
Software | proprietary donationware | | BlackHole | the same, for macOS | Existential Audio | MIT | | PipeWire | the audio server most Linux distributions already run | the PipeWire project | MIT | | Audacity | a free audio editor and - Companion software
recorder | the Audacity team | GPL-2.0-or-later | A virtual cable is what lets live mode feed a veiled microphone into a call. Without one live mode still runs; you simply have nowhere useful to send it. Audacity is a convenience for - Companion software
editing a file you already have, and is **recommended, never embedded** -- GPL-2.0-or-later cannot be combined with this project's GPL-3.0-or-later. It is not how you make a recording: the Recording Studio captures straight into the - Companion software
encrypted vault, so no unencrypted take is ever written anywhere. veilvoice companions # report only: what is here, and what is not veilvoice companions --install audacity # the explicit yes, one named program The same list is on the - Companion software
desktop application's install tab, one row each. Three rules, and they are enforced in the shared library rather than in each front end, so neither can be more permissive than the other: - **Nothing is ticked, because there is nothing to - Companion software
tick.** There is no "install recommended extras" control, because that is the control through which unwanted software has historically arrived. - **VeilVoice never runs somebody else's installer.** VB-CABLE is proprietary and is a driver, - Companion software
so what is offered is to open VB-Audio's page -- their licence for you to accept, their installer for you to run. Fetching and executing an unverified third-party binary would be a strange thing for a program whose whole subject is - Companion software
verifying what you run. - **Privilege is reported, never requested.** A package manager that needs root has its command printed for you to run in a terminal. Neither front end will ask you for a `sudo` password. Detection has three answers - Companion software
and not two: found (with the path it was found at), not found where it usually installs, or could not tell and here is why. The middle answer is a statement about where VeilVoice looked, not a claim about your machine -- if you keep - Companion software
Audacity somewhere unusual it will say "not found", and that is exactly what the words mean. --- - Where things get installed
| Platform | Default location | |---|---| | Linux, macOS | `~/.local/bin` | | Windows | `%LOCALAPPDATA%\Programs\VeilVoice` | Both are per-user and need no administrator or `sudo`. Nothing is written outside them, no service is installed, - Where things get installed
no registry key is created beyond the user `PATH` entry on Windows, and nothing runs at startup. To uninstall, run `veilvoice uninstall --yes`, or use the desktop application's install tab, or simply delete that directory -- the first two - Where things get installed
also remove the `PATH` entry and the Apps & features registration, which deleting the directory does not. VeilVoice keeps its own configuration under the usual per-user location for the platform; `veilvoice lock status` names the lock - Where things get installed
file's path if you have set one. --- - Known rough edges
**Git for Windows' bundled GnuPG has a path-length limit.** It is an MSYS build, and its agent puts a Unix-domain socket inside the home directory it is given, which cannot exceed about 108 bytes. `install.ps1` keeps its temporary - Known rough edges
directory short for that reason, and says so plainly if your `TEMP` is long enough that even a short name overflows. Gpg4win is a native build and has no such limit. **`~/.local/bin` may not be on your `PATH`.** The script says so if it is - Known rough edges
not, and prints the line to add. **macOS Gatekeeper.** The binaries are not notarised, because notarisation requires an Apple Developer account, which requires a legal identity, which this project does not have. macOS will refuse to run - Known rough edges
them until you allow it explicitly in *System Settings → Privacy & Security*. This is stated rather than worked around: a project that publishes under a pseudonym cannot also be notarised, and pretending otherwise would be worse than the - Known rough edges
inconvenience. --- - What is not finished
Recorded here rather than left for you to discover: - **Nobody but the author has run these scripts.** They are tested end to end on Windows against the real v0.1.8 release, and the by-hand chain is verified on the same release. That is - What is not finished
not the same as having been run on a machine that did not build them, and until it has been, treat "it works" as a claim with one source. - **`install.sh` has now been run on Linux, and not on macOS.** On x86-64 Linux it was run end to end - What is not finished
against the published v0.1.14 release: it found the latest tag, downloaded the archive, checked the key's fingerprint, verified the signature over `SHA256SUMS`, matched the archive's hash against the signed list, installed both binaries, - What is not finished
and the installed `veilvoice info` ran. Its refusals were exercised on the same machine: an unknown option, and a version that is not published, both refusing with a status of 1. Running it found one defect, F-79, in what it says rather - What is not finished
than in what it checks. macOS is still unrun, and its `sh` is not Linux's. - **The packaged installers (WiX, `.deb`, `.rpm`, Flatpak, Homebrew), the OpenBSD and NetBSD builds and the Gentoo ebuild are not built yet.** They are specified in - What is not finished
[`ROADMAP.md`](../ROADMAP.md). macOS Intel and Apple Silicon are already separate builds, and a single Windows executable already covers 10 and 11. - **The portable verifier exists and is tested**, including against the real published - What is not finished
v0.1.8 signature, but like the scripts it has only been run by its author. If you run these on a machine that did not build them, saying so in an issue is genuinely the most useful thing you could contribute.
docs/MEASURED.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/measured/generate.py. Do not edit by hand. --> - Measured
Numbers this project states about itself, taken from the tree rather than from memory. Regenerated and checked by `tools/verify.py`; the documents and the website that quote them are compared against this file, so a claim that drifts fails - Measured
the build instead of ageing quietly. **The test count is a number about one machine.** Some tests are compiled only on one operating system, so running the same tree on another gives a different total: this tree measures 996 on Windows and - Measured
988 on Linux. The row below says which machine produced the number in it, because a count with no platform beside it reads as a fact about the tree and is a fact about a computer. See F-77 in `docs/AUDIT.md`. **Functional lines are a - Measured
different measure from the per-file line counts** in the generated pages, the artwork and the reference links. A functional line is a line holding code, so blank lines and lines holding only a comment are not counted, and a line with code - Measured
and a trailing comment counts once. The per-file counts are the length of the file, blank lines and comments included, and are left exactly as they are: this project is written with a high comment-to-code ratio, so the two numbers are far - Measured
apart, and quietly redefining one to match the other would alter every page and link that carries it. The line count covers **28 crates**, one more than the workspace row above: `fuzz/` is a separate Cargo project at the root holding the - Measured
fuzzing harnesses, and it is Rust written and maintained here. `tools/docs/generate.py` documents it beside the workspace members for the same reason. | What | Measured | |---|---:| | Tests, measured by running them | 1659 | | Crates in - Measured
the workspace | 13 | | Website suites | 20 | | Functional lines of Rust | 62334 | | Findings written up in the audit | 196 | | Highest finding number used | 196 | | Measured on | `x86_64-unknown-linux-gnu` |
docs/PACKAGING.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Packaging
Package definitions for the platforms that have one, in [`packaging/`](../packaging/). - Status: two of them have been built
**Two of these have produced packages, and four have not.** The rest are written against each format's documentation and validated as far as parsing goes: the WiX source and the AppStream metadata are well-formed XML, the Flatpak manifest - Status: two of them have been built
is valid YAML. That is all that can honestly be claimed about them. A spec file that has never produced an RPM is a draft, and this table says which is which: | Format | File | Built? | Installed and run? | |---|---|---|---| | Windows MSI - Status: two of them have been built
| `packaging/wix/veilvoice.wxs` | no | no | | Debian/Ubuntu | `packaging/debian/` | **yes**, on Ubuntu 24.04, x86-64 | **yes**, installed and removed | | Fedora/RHEL/SUSE | `packaging/rpm/veilvoice.spec` | **yes**, on Ubuntu 24.04, x86-64 - Status: two of them have been built
| binaries run; **not** installed by `rpm` | | Flatpak | `packaging/flatpak/` | no | no | | Homebrew | `packaging/homebrew/veilvoice.rb` | no | no | | Gentoo | `packaging/gentoo/` | no | no | - What the RPM build proved, and what it did not
`rpmbuild -bs` produced `veilvoice-0.1.15-1.src.rpm` from a `git archive` tarball, and `rpmbuild -bb` produced two binary packages: `veilvoice-0.1.15-1.x86_64.rpm` and `veilvoice-gui-0.1.15-1.x86_64.rpm`. The thing a build proves that a - What the RPM build proved, and what it did not
parse cannot is that `%files` and `%install` agree. They do: the main package carries `/usr/bin/veilvoice`, the licence and the whole of `docs/`; the `gui` subpackage carries `/usr/bin/veilvoice-gui`, the desktop entry and the icon. - What the RPM build proved, and what it did not
Nothing is listed that is not installed and nothing is installed that is not listed, which is the classic spec defect and is the one that had never been looked for. Extracted with `rpm2cpio`, the packaged `veilvoice --version` and - What the RPM build proved, and what it did not
`veilvoice info` both ran. That build was made at 0.1.15, when a release also carried a third binary for verification. It does not any more: the verifier is a library inside the two above, reached as `veilvoice verify` and from the desktop - What the RPM build proved, and what it did not
application's Verify tab, and the spec has one fewer file in it. The paragraph above describes the packages as they are built today rather than as they were then, because a reader uses it to check a build rather than to date one. What it - What the RPM build proved, and what it did not
did not prove, and the gap is wider than the Debian one. **This was built on Ubuntu, not on Fedora or RHEL or openSUSE**, so `%{dist}`, the system RPM macros and the distribution's own Rust packaging are all untested. It needed `--nodeps`, - What the RPM build proved, and what it did not
because `rpm` on a Debian machine cannot read `dpkg`'s database and so cannot see that `cargo`, `gcc`, `alsa`, `gtk+-3.0` and `xkbcommon` are in fact all installed; every one of them was confirmed present by hand before the flag was used. - What the RPM build proved, and what it did not
It needed `--nocheck`, so the spec's own `%check` stanza has never run as part of an RPM build, although the identical command runs inside the Debian build. It has not been installed with `rpm -i`, because doing that on a Debian machine - What the RPM build proved, and what it did not
tests nothing. Nothing has been uploaded anywhere. - rpmlint, and what it found
`rpmlint` 2.10 has now been run over both binary packages and the source RPM. It found two real defects, both now fixed, and eighteen reports that are its default configuration disagreeing with current RPM practice rather than anything - rpmlint, and what it found
wrong with this spec. Both halves are listed, because a tool's output quoted selectively is worth nothing. **Fixed.** Four `description-line-too-long` errors: two lines of `%description` were exactly 80 characters against a limit of 79. - rpmlint, and what it found
And, separately, `rpmbuild` itself warned `bogus date in %changelog` for `Sun Aug 31 2026`, which was a Monday. Both are the kind of thing a distribution's review would bounce, and neither was visible until the package was built and - rpmlint, and what it found
linted. **Not fixed, with the reason.** These are reported against a default configuration that predates, or disagrees with, how RPM packages are written now. Each is a judgement, and a Fedora reviewer is who would settle it: | Report | - rpmlint, and what it found
Why it stands | |---|---| | `no-group-tag` ×3 | `Group:` is obsolete and Fedora dropped it. Adding one to satisfy a linter would be adding a tag no packaging guide asks for. | | `no-buildroot-tag` | `BuildRoot:` has been unnecessary since - rpmlint, and what it found
RPM 4.6. | | `no-packager-tag` ×3 | `Packager:` identifies the build system rather than the software, and is set by whoever builds, not by the spec. | | `invalid-license GPL-3.0-or-later` ×3 | That is the correct SPDX identifier, and SPDX - rpmlint, and what it found
is what Fedora now requires. The linter's bundled list is what is out of date. | | `no-signature` ×3 | True, and expected: these were built locally. Release artefacts are signed by the release job. | | `manpage-not-compressed bz2` ×3 | The - rpmlint, and what it found
pages are gzipped, which is what these distributions use. | | `no-dependency-on locales-gui` | A heuristic for packages that ship translations. This one ships none. | | `requires-on-release` | The `-gui` subpackage requires the exact - rpmlint, and what it found
release of the base package, deliberately: they are built together and share a version. | What this does not settle is the thing the whole section is about. `rpmlint` reads a package; it does not install one, and it ran on Ubuntu against - rpmlint, and what it found
packages built on Ubuntu. It is one more check than there was, and it is not a Fedora build. - What the Debian build actually proved, and what it did not
`dpkg-buildpackage -us -uc -b` produced `veilvoice_0.1.15-1_amd64.deb` and `veilvoice-gui_0.1.15-1_amd64.deb`. Both installed with `dpkg -i`, the installed `veilvoice --version` reported 0.1.15, `veilvoice info` and the verifier's own help - What the Debian build actually proved, and what it did not
ran, and both packages removed cleanly. The release build and `cargo test --release --workspace` both ran as part of it, because that is what `debian/rules` does. What it did not prove: this was one machine, on x86-64, with the toolchain - What the Debian build actually proved, and what it did not
from rustup rather than from Debian's own `cargo` and `rustc` packages, which is why the build needed `-d` to get past `dpkg-checkbuilddeps`. On a real Debian build machine those packages are what `Build-Depends` names and the flag is not - What the Debian build actually proved, and what it did not
wanted. Nothing has been uploaded anywhere. **`lintian` has now been run**, over both packages. The first run found no errors and five warnings; three of those were `no-manual-page`, one per binary, and that one was a real gap rather than - What the Debian build actually proved, and what it did not
a formality: somebody who installs a package on a Unix system types `man veilvoice` and got nothing. Those three are fixed. `tools/release/manpage.py` derives each page from the binary's own `--help` during `override_dh_auto_install`, so - What the Debian build actually proved, and what it did not
nothing is committed and no page can drift from the command it describes. A second run of `lintian` over the rebuilt packages reports **no errors and two warnings**, both `initial-upload-closes-no-bugs`, which asks the changelog to close - What the Debian build actually proved, and what it did not
an ITP bug and applies to a package being uploaded into Debian's own archive rather than one a project publishes itself. Verified rather than assumed: `dpkg -c` shows `veilvoice.1.gz` in the main package and `veilvoice-gui.1.gz` in the - What the Debian build actually proved, and what it did not
`gui` one, and each was installed and rendered with `groff`. At the time this was run there was a third page, for the verifier when it was still a binary; that binary is gone and so is its page, and the verifier's help now reaches a reader - What the Debian build actually proved, and what it did not
through `veilvoice`'s. That took one detour worth recording, because it looked like a packaging bug and was not: this build machine is a *minimized* Ubuntu image, which carries `path-exclude=/usr/share/man/*` in - What the Debian build actually proved, and what it did not
`/etc/dpkg/dpkg.cfg.d/excludes` and throws manual pages away as it installs them. The pages were in the packages the whole time. Doing it found two defects, F-80 and F-81, both recorded in [`AUDIT.md`](AUDIT.md). The first was that the - What the Debian build actually proved, and what it did not
recipe below could not run at all. The second was that every definition here still named v0.1.9 while the workspace was at v0.1.15, and nothing was watching: `tools/site-tests/packaging.test.js` is watching now. The install scripts and the - What the Debian build actually proved, and what it did not
portable verifier are tested, as [INSTALL.md](INSTALL.md) records, and they are the supported route until the rest of this table changes. If you build one of these and it works, or does not, saying so in an issue is the most useful thing - What the Debian build actually proved, and what it did not
you could contribute. --- - The rule every one of them follows
**Optional third-party software is never installed silently and never by default.** It is the same rule the install scripts follow, and each format expresses it differently: - **WiX** puts VB-CABLE and Audacity in the feature tree at - The rule every one of them follows
`Level="1000"`, above the install level, so neither is selected unless the user turns it on. Even then they install a *shortcut to the download page*, not the software: VB-CABLE is proprietary donationware with its own licence, and this - The rule every one of them follows
installer has no business accepting somebody else's terms on a user's behalf. - **Debian, RPM, Gentoo** do not mention them at all. A distribution package that pulled in proprietary donationware as a dependency would rightly be rejected, - The rule every one of them follows
and should be. - **Flatpak** cannot install them, which is the sandbox working as intended. - Why these build from source
Every definition here compiles the tagged source on the machine that will run it, rather than repackaging a published binary. For a project whose argument is that you can check it yourself, that is the more honest default, and each one - Why these build from source
passes `--locked`, so it builds the dependency versions the project actually tested rather than whatever resolves on the day. A package that silently drifts from the tested graph is not the software that was audited. Homebrew is a - Why these build from source
**formula**, not a cask, for the same reason. It also sidesteps macOS notarisation, which this project cannot obtain: notarisation requires a certificate issued to a verified legal identity, and this is published under a pseudonym. - What each one deliberately does not do
No package installs a service, a scheduled task, a driver, a shell hook, or anything that runs at startup. A privacy tool with a background service is a privacy tool with a background service. There is nothing here that needs one. The - What each one deliberately does not do
Flatpak's permissions are the clearest statement of this, because they are public and checkable without reading any code: # no --share=network VeilVoice has no networking code and CI fails the build if an HTTP client enters the dependency - What each one deliberately does not do
graph. The absence of that line is the form of that claim a user can verify in one command: flatpak info --show-permissions io.github.tilas01.VeilVoice - Signing, and what is not signed
| Artefact | Signed? | |---|---| | `SHA256SUMS` in each release | **yes**, detached OpenPGP, key `8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A` | | The release archives themselves | no, see below | | The MSI (Authenticode) | no, and cannot be - Signing, and what is not signed
| | The macOS binaries (notarisation) | no, and cannot be | Binaries are never signed in place. The signature is over `SHA256SUMS` only, so that signing and reproducibility do not conflict: a signature embedded in a binary changes the - Signing, and what is not signed
binary, and two people building the same source would then produce different files for a reason that has nothing to do with the source. Authenticode and notarisation both require a certificate tied to a verified legal identity. Windows - Signing, and what is not signed
will show "unknown publisher" and macOS Gatekeeper will refuse to run the binaries until allowed explicitly. Both are stated in [INSTALL.md](INSTALL.md) rather than worked around, and both are why the OpenPGP signature over the hash list - Signing, and what is not signed
remains the real check: **verify the archive, then install it.** - Building each one
# Windows MSI (needs: dotnet tool install -g wix) wix build packaging/wix/veilvoice.wxs -arch x64 \ -d Version=0.1.22 -d BinDir=dist/veilvoice-v0.1.22-windows-x86_64 \ -o dist/VeilVoice-0.1.22-x64.msi The WiX source refers to - Building each one
`packaging/wix/LICENSE.rtf` for the licence dialog, which is not committed: WiX needs RTF, and converting `LICENSE` to RTF is a build step rather than a source file. Any converter will do; the text must be the unmodified GPL-3.0. # Debian - Building each one
/ Ubuntu (copy packaging/debian to ./debian first) # # `debian/source/format` is a file in a directory, and this repository keeps it # as `source-format` so that `packaging/debian/` stays a flat directory of # files. The move below is what - Building each one
turns one into the other. # # Add `-d` if your Rust came from rustup rather than from Debian's `cargo` and # `rustc` packages: `dpkg-checkbuilddeps` looks for the packages named in # Build-Depends and cannot see a rustup toolchain. On a - Building each one
real Debian build # machine, leave it off. cp -r packaging/debian debian mkdir -p debian/source && mv debian/source-format debian/source/format dpkg-buildpackage -us -uc -b # Fedora / RHEL / openSUSE rpmbuild -ba - Building each one
packaging/rpm/veilvoice.spec \ --define "_sourcedir $PWD/dist" --define "vv_version 0.1.22" # Flatpak (regenerate cargo-sources.json from Cargo.lock first) python flatpak-cargo-generator.py Cargo.lock -o - Building each one
packaging/flatpak/cargo-sources.json flatpak-builder --user --install build \ packaging/flatpak/io.github.tilas01.VeilVoice.yml # Homebrew brew install --build-from-source packaging/homebrew/veilvoice.rb # Gentoo (from a local overlay) - Building each one
mkdir -p /var/db/repos/local/media-sound cp -r packaging/gentoo/media-sound/veilvoice /var/db/repos/local/media-sound/ ebuild /var/db/repos/local/media-sound/veilvoice/veilvoice-9999.ebuild manifest emerge -av media-sound/veilvoice - Building each one
`packaging/flatpak/cargo-sources.json` is not committed either: it is a mechanical transform of `Cargo.lock` and regenerating it is a single command, whereas a committed copy is one more thing that can silently fall out of step with the - Building each one
lock file. - Platform coverage
Eleven targets are built and published today, OpenBSD among them. It had failed for two releases because of a declared toolchain floor that turned out to be wrong; v0.1.11 is the first release to carry an OpenBSD archive. | Platform | - Platform coverage
Built | Reproducibility checked | |---|---|---| | Windows x86_64 | yes | yes, twice in separate directories | | macOS Apple Silicon | yes | yes | | macOS Intel | yes | yes | | Linux x86_64 (gnu, musl) | yes | yes | | Linux arm64 (gnu, - Platform coverage
musl) | yes | yes | | Linux armv7 (Raspberry Pi) | yes | yes | | FreeBSD x86_64 | yes | yes, twice inside the VM | | OpenBSD x86_64 | yes, since v0.1.11 | yes, twice inside the VM | | NetBSD x86_64 | yes | yes, twice inside the VM | - Platform coverage
Windows 10 and 11 share one executable. They are not split, and will not be unless a measurement says they should be: shipping two identical binaries under different names is a way of looking thorough rather than being it. **OpenBSD failed - Platform coverage
to build for two releases, and the cause was on this side.** Its packaged Rust is 1.94.1, and this workspace declared `rust-version = "1.96"` -- so `cargo` refused with "rustc 1.94.1 is not supported by the following packages" before - Platform coverage
compiling a single line. The documentation here described that as OpenBSD's ports being behind, and an earlier revision of this section recorded that lowering the floor had been "considered and rejected" because "the toolchain floor is a - Platform coverage
property of the code". That reasoning was sound and the premise was never checked. When it finally was -- by installing 1.94.0 and compiling every crate in the workspace, including the GUI -- **everything built without a single error**. - Platform coverage
The declared floor was not a property of the code. It was the version that happened to be current on the day somebody typed it, and it cost two releases of OpenBSD coverage. `rust-version` is now `1.94`, measured rather than assumed. - Platform coverage
`rust-toolchain.toml` still pins a newer toolchain for development and CI; the two are different things, and only the first is a claim about what the code needs. If something in the tree ever does need a newer feature, cargo will say so by - Platform coverage
name, which is a better guard than a number nobody re-tests. The three BSD builds run in emulated VMs on a Linux runner, are the most fragile jobs in the workflow, and are allowed to fail without blocking a release. When one fails the - Platform coverage
release simply ships without that archive. **They are now built twice, like every other platform.** The second build runs inside the same VM, from a copy of the source at a different path, with the same `SOURCE_DATE_EPOCH` and the same - Platform coverage
`--remap-path-prefix` flags the other ten use, and the two binaries are compared byte for byte. The verdict each VM reaches is what its `repro-*.txt` says, so a BSD that stops reproducing is reported in those words rather than quietly - Platform coverage
losing the line. Until v0.1.18 these three said `not-verified (built once, in a VM)`, which was honest and was the only gap in the reproducibility claim. If a BSD job fails before the second build runs, the report says `not-verified (the - Platform coverage
second build did not run)`. That distinction matters: it is the difference between "we checked and it differed" and "we did not get to check", and collapsing the two would make the first look like the second.
docs/REPRODUCIBLE_BUILDS.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Reproducible builds
A privacy tool you cannot verify is a privacy tool you are trusting on faith. Reproducible builds close the gap between "the source is open" and "the binary I downloaded is that source": anyone can rebuild a release and confirm, byte for - Reproducible builds
byte, that it matches what was published. - Verify a release
git clone https://github.com/tilas01/veilvoice && cd veilvoice git checkout v0.1.0 export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) cargo build --release --locked sha256sum target/release/veilvoice Compare against `SHA256SUMS` in the - Verify a release
release. If they differ, something is wrong and we want to hear about it. - What makes it reproducible
| Source of nondeterminism | How it is pinned | |---|---| | Compiler version | `rust-toolchain.toml` pins an exact stable release; rustup honours it automatically | | Dependency versions | `Cargo.lock` is committed and CI builds with - What makes it reproducible
`--locked` | | Codegen unit ordering | `codegen-units = 1` and `lto = "fat"` in the release profile | | Absolute paths in the binary | `--remap-path-prefix`, set by the build *environment*, never hardcoded | | Build timestamps | - What makes it reproducible
`SOURCE_DATE_EPOCH`, derived from the commit date | | Debug info | `strip = true`, `debug = false` | | Incremental compilation | `incremental = false` | | CPU feature detection | No `target-cpu=native`; the default baseline keeps binaries - What makes it reproducible
portable *and* identical across machines | Path remapping deliberately lives in the environment rather than in `.cargo/config.toml`. Hardcoding one contributor's home directory would make the build reproducible only for them, which is the - What makes it reproducible
opposite of the point. - In CI
The release workflow sets `SOURCE_DATE_EPOCH` from the tagged commit and passes `--remap-path-prefix` for both the workspace and `CARGO_HOME`. It then builds each target twice, in different directories, and fails if the two binaries - In CI
differ, so a regression in reproducibility is caught before release, not after somebody reports it. - Known limits
- **Cross-platform binaries differ**, obviously. Reproducibility is per-target: a Linux x86-64 build reproduces a Linux x86-64 build. - **Linker version matters.** A different system linker can produce a different binary from identical - Known limits
object files. CI records the exact runner image in the release notes so a verifier can match it. - **The GUI binary embeds an icon** generated by `assets/generate.py`. That script is deterministic (fixed filter type, fixed zlib level), and - Known limits
the generated assets are committed, so it is not part of the build path. - **Signing is separate.** Signatures are made after the build and are not themselves part of the reproducible artefact. Verify the hash first, then the signature - Known limits
over the hash file. - Release signing
Release artefacts ship with `SHA256SUMS` and a detached OpenPGP signature over it. **The private signing key is not in this repository and never will be**, only the maintainer holds it, and CI reads it from a repository secret that is - Release signing
absent on forks. A fresh clone builds with **no secrets at all**; signing is strictly a release-time step layered on top.
docs/SECURITY.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Reporting a security problem
- Where to send it
**Use GitHub's private vulnerability reporting**, at <https://github.com/tilas01/veilvoice/security/advisories/new>. That opens a thread only you and the maintainer can read, and it becomes a published advisory when a fix ships. Please do - Where to send it
not open a public issue for a vulnerability. The ordinary issue tracker is the right place for everything else, including a bug that is merely embarrassing. There is deliberately no PGP address here. The key this project publishes is a - Where to send it
**signing** key: it verifies signatures and can neither sign nor decrypt anything, so offering it as a way to encrypt a report would be offering something that does not work. GitHub's private reporting is the encrypted channel. - What to expect
This is a one-person project, so an answer comes when that person reads it rather than within a published number of hours, and promising otherwise would be inventing a service-level agreement that nothing backs. What is promised is - What to expect
narrower and can be kept: a report is acknowledged before it is acted on, a fix says what it fixes, and you are credited unless you ask not to be. If a report turns out to describe something already written down as a limitation, you will - What to expect
be pointed at where it is written rather than thanked vaguely and ignored. - Which versions get fixes
The latest release. Versions before it are not patched: this project ships often, every release is reproducible and signed, and maintaining a back-branch would mean testing a second tree that nobody is running. The current version is in - Which versions get fixes
`Cargo.toml` and on the releases page. - What counts
Anything that breaks a claim this project makes about itself, including: - Voice audio, a passphrase or a key reaching the disk in the clear, or reaching the network at all. The offline claim is checked four ways in CI and a hole in it is - What counts
a serious finding. - Recovering a speaker's identity from output the engine has veiled, beyond what `docs/WHITEPAPER.md` already says is possible. - Defeating the encryption: a forged or downgraded header, a tampered ciphertext accepted as - What counts
intact, a key derived from less than it should be. - The app lock or the vault opening without the passphrase they are documented to need. - A released archive whose contents do not match its signed hash list, or a build that is not - What counts
reproducible where the release notes say it is. - Memory unsafety anywhere. Every crate carries `#![forbid(unsafe_code)]`, so a way to break that is itself the finding. - What does not count
These are documented positions rather than oversights, and `docs/WHITEPAPER.md` section 8 has the reasoning: - Anything that assumes the attacker already holds the disk and can edit the lock file, move the clock, or attack the stored hash - What does not count
offline. VeilVoice is not disk encryption and says so. - A recording somebody made of the screen or the speakers. Excluding a window from capture does nothing about a camera pointed at it. - Denial of service against a program the user - What does not count
runs on their own machine and can close. - Findings from a scanner with nothing behind them. A report needs a case: what an attacker does, what they get, and why the code allows it.
docs/SELF_SIGNING.md
- The self-signed code certificate
VeilVoice publishes a **self-signed code-signing certificate**, alongside the OpenPGP key that signs `SHA256SUMS`. This page is what it is for, what it is not, and how to use it. - What it is not
Say this first, because it is the part that is easy to oversell. - It is **not a certificate authority** vouching for anyone. Nobody checked anyone's legal identity to issue it; the project signed its own. - It does **not** make Windows - What it is not
SmartScreen trust VeilVoice on sight. A self-signed certificate is unknown to SmartScreen until you choose to trust it, exactly as it should be. - It does **not replace** the OpenPGP signature over `SHA256SUMS`. That remains the primary - What it is not
check. If you only do one thing, do that one. - What it is for
It is a second identity you can choose to trust once, the same way you already trust the OpenPGP key: **compare its fingerprint by hand, decide to trust it, and then let the tools check against it.** After that, files carrying VeilVoice's - What it is for
signed app manifest can be verified against a certificate *you* decided to accept. That is worth having in two places: - **On a machine or in an organisation that imports it to trusted publishers.** Once imported, this publisher is known, - What it is for
and Windows and some antivirus treat a known publisher differently from an unknown one. That can reduce the low-reputation false positives a brand-new application otherwise runs into. - **As an independent second check.** The OpenPGP key - What it is for
and the code certificate are different keys, verified by different tools. Both agreeing is a stronger statement than either alone. - How it is signed, and why that keeps builds reproducible
The certificate signs a small **detached** manifest, `APPMANIFEST.json`, which lists each binary with its size and SHA-256. It does **not** sign the binaries in place. Signing a binary changes it, and two people building the same source - How it is signed, and why that keeps builds reproducible
would then get different files for a reason that has nothing to do with the source. Keeping the signature detached is the same choice that puts the OpenPGP signature over the hash list rather than inside a binary, and it is why - How it is signed, and why that keeps builds reproducible
reproducible builds still work. - Verifying a download against it
You need three files from the release: `APPMANIFEST.json`, its signature (`.sig` on Unix, `.p7s` on Windows), and the certificate `veilvoice-code-cert.pem` / `.cer`. Unix or WSL: VV_CERT_FPR="<fingerprint from the website>" \ - Verifying a download against it
tools/sign/verify.sh path/to/unpacked/release Windows PowerShell: powershell -File tools\sign\verify.ps1 path\to\unpacked\release ` -ExpectedThumbprint "<thumbprint from the website>" Both check the manifest's signature against the - Verifying a download against it
certificate, the certificate's fingerprint against the one you pass in, and every binary against the manifest. - Importing it as trusted (optional)
Only do this after checking the fingerprint against the copy on the website. It is your decision, and it is only worth making if you understand it: you are telling your system that files signed by this certificate come from a publisher you - Importing it as trusted (optional)
accept. **Windows, trusted publisher (per user):** Import-Certificate -FilePath veilvoice-code-cert.cer ` -CertStoreLocation Cert:\CurrentUser\TrustedPublisher To undo it, open `certmgr.msc`, find "tilas01 / VeilVoice" under Trusted - Importing it as trusted (optional)
Publishers, and delete it. **Linux / macOS:** there is no system-wide "trusted code publisher" store in the Windows sense; verification is done with the `verify.sh` script above and the published certificate. Keep the certificate somewhere - Importing it as trusted (optional)
you control and pass it with `VV_CERT`. - For the maintainer: creating and using it
tools/sign/manifest.py dist/veilvoice-vX.Y.Z-linux-x86_64 # write APPMANIFEST.json tools/sign/selfsign.sh --new-cert # once, ever tools/sign/selfsign.sh sign dist/veilvoice-vX.Y.Z-linux-x86_64 The private key (`veilvoice-code-key.pem`, or - For the maintainer: creating and using it
the Windows certificate store entry) never leaves the maintainer's machine and is never committed. Only the public certificate and its fingerprint are published, on the website next to the OpenPGP fingerprint.
docs/USER_GUIDE.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - VeilVoice: user guide
For people using VeilVoice, rather than reading its source. If you want the argument for *why* any of this works, that is [`WHITEPAPER.md`](WHITEPAPER.md); if you want to know what has and has not been checked, that is - VeilVoice: user guide
[`AUDIT.md`](AUDIT.md). There is a web version of this material at [tilas01.github.io/veilvoice/wiki.html](https://tilas01.github.io/veilvoice/wiki.html). --- - 1. What VeilVoice is for, in one paragraph
It destroys the **biometric voiceprint** of a speaker, meaning pitch, formants, timbre, micro-timing and the melody of an accent, so that neither software nor a human listener can re-identify them, **while the words stay clean and - 1. What VeilVoice is for, in one paragraph
transcribable**. The words surviving is the point, not a compromise: a scrambler you cannot understand is useless. It follows that de-identification alone does not keep the *message* secret, which is why VeilVoice also encrypts what it - 1. What VeilVoice is for, in one paragraph
writes. --- - 2. Installing
Download an archive from [Releases](https://github.com/tilas01/veilvoice/releases), or build it, since a fresh clone needs no secrets: git clone https://github.com/tilas01/veilvoice && cd veilvoice cargo build --release **Verify the - 2. Installing
download before running it.** Instructions are in [`REPRODUCIBLE_BUILDS.md`](REPRODUCIBLE_BUILDS.md) and on the site; there is also an in-browser hash verifier that uploads nothing. Nothing installs a service, writes to a registry, or - 2. Installing
phones home. Delete the folder and it is gone. --- - 2.5 The two programs, and which one you want
A release contains two executables. They overlap on purpose, and which one to reach for depends only on what you have in front of you. | Program | What it is for | |---|---| | `veilvoice` | The command line. Everything the application - 2.5 The two programs, and which one you want
does, over SSH, in a container, in a script, or on a machine with no graphics toolkit at all. Checking a download is `veilvoice verify`, which was a third binary until 0.1.18. | | `veilvoice-gui` | The window. The same engine with - 2.5 The two programs, and which one you want
somewhere to click, plus the things that only make sense with a screen: live level meters, the app lock, the microphone monitor, and a Verify tab that runs the same check as `veilvoice verify`. | - Which parts are built in, and which are not
Everything VeilVoice does is in these binaries. There is no runtime to install, no service, no plugin directory, and nothing is downloaded on first run. That includes the parts people expect to be separate: - **The signature check.** The - Which parts are built in, and which are not
signing key is compiled into the programs, and the OpenPGP verification is Rust code in this repository. `veilvoice verify` needs no GnuPG to do its job. - **The audio decoders**, the resampler, the encryption, the key exchange, the - Which parts are built in, and which are not
hashing. All of it is in the binary. - **The at-rest encryption**, including the post-quantum half. Three things are genuinely outside, and each is optional: - **GnuPG**, for a second opinion on a release signature. Worth having, and - Which parts are built in, and which are not
explained under §7. - **A virtual audio cable**, if you want live mode to feed a call. On Linux this is usually PipeWire, which is already there. - **`ffmpeg`**, only if you ask for a video file. VeilVoice draws every picture in the video - Which parts are built in, and which are not
itself and asks `ffmpeg` for the last step only, because a video encoder is a large piece of C and carrying one would end the claim that you can read the whole of this program. Without it, a render still writes the audio, the subtitles, - Which parts are built in, and which are not
the player page and every picture, then prints exactly the command that turns them into the file and exits successfully, because nothing failed. `veilvoice companions` lists all of them, says whether this machine has each, and prints the - Which parts are built in, and which are not
one command that would install it. It never runs somebody else's installer. - What runs where
Eleven platforms get a signed archive with every release, and the table says what is in each one. It is not the same everywhere, and where it is not, that is a limit of what the platform offers rather than something waiting to be written. - What runs where
| Platform | Command line | Desktop app | Live microphone | |---|---|---|---| | Windows 10 and 11, x86-64 | yes | yes | yes, with a virtual cable | | macOS on Intel | yes | yes | yes, with a virtual cable | | macOS on Apple Silicon | yes | - What runs where
yes | yes, with a virtual cable | | Linux, x86-64 and arm64 | yes | yes | yes, through PipeWire | | Linux, statically linked (musl) | yes | yes | yes | | Raspberry Pi and other armv7 | yes | yes | yes | | WSL on Windows | yes | yes, with - What runs where
WSLg | through the Windows side | | FreeBSD, OpenBSD, NetBSD | yes | not shipped | no | **Any Linux distribution.** The `.deb` and `.rpm` are conveniences, not requirements: the plain archive is a folder of binaries that needs no package - What runs where
manager, and the statically linked build needs no system libraries at all, which is the one to reach for on a distribution nothing else fits. **One library the desktop app needs, and why it is worth a paragraph.** The window toolkit opens - What runs where
`libxkbcommon-x11` by name when it starts, rather than linking against it. Nothing that works out dependencies by reading a binary can see that, so a minimal or server install can be missing it and the application will exit at once instead - What runs where
of drawing a window. If that happens, the crash report VeilVoice writes names the library and the package that carries it; the short version is `libxkbcommon-x11-0` on Debian and Ubuntu and `libxkbcommon-x11` elsewhere. The command line - What runs where
needs none of it. **The first time you open it.** After the two settings questions, VeilVoice shows one card per tab saying what that tab is for, which takes about twenty seconds and can be skipped at any point. Two of the nine are worth - What runs where
the card on their own: Monitor is not a level meter, it watches for another program picking up a real microphone while you are being veiled; and Lock is a passphrase on the application rather than on a recording. The last card says whether - What runs where
this copy is **portable** or **installed**, in those words. Portable means it runs from wherever you put it and installs nothing: move the folder and VeilVoice moves with it, delete the folder and it is gone. Installed means it is on this - What runs where
machine for good, on your menu or path, with its settings in your account. Both are fine, and the Install tab is where the decision is made rather than in the tour. After an upgrade the tour comes back only for tabs that did not exist last - What runs where
time, and a release that adds no tab shows nothing. What is stored is the list of tabs you have been shown, which is what "which of these is new to you" is actually asking. **When something goes wrong.** VeilVoice writes a report of a - What runs where
crash to a file beside its settings, and on the next launch it offers it to you above whatever tab you land on: what happened, where the file is, and a button to read the whole of it before you decide anything. Nothing is sent. Nothing - What runs where
here *can* send it, and that is not a policy but a property of the build: this project contains no network client and the build fails if one enters the dependency graph. The ordinary shape of this feature is a reporter that uploads, and - What runs where
that is the wrong shape for a program people use to protect themselves, because a report from a privacy tool is a report about somebody who was being careful. So the panel offers two things instead: copy the report, and open the issue - What runs where
tracker. What happens next is your decision and your clipboard. If you would rather it went away, "dismiss and delete it" removes the file. The report holds the version, your operating system and processor, and the error with its source - What runs where
location. It holds no file names, no settings, no passphrase and nothing about any audio. That list is in the panel too, because "would you like to send this" is only a real question if you can see what "this" is. **The BSDs get the - What runs where
command line only, and the reason is specific.** The audio library VeilVoice uses has no backend for them, so live capture cannot work there and the desktop application is built around a window that would have nothing to listen to. - What runs where
Everything that operates on a file, meaning de-identification, encryption, metadata cleaning and verification, is pure Rust and runs exactly as it does anywhere else. **WSL is Linux**, so the command line runs unchanged. The window needs - What runs where
WSLg, which recent Windows has by default. A microphone belongs to Windows rather than to the distribution, so live mode is the Windows build's job. **Nothing is emulated and nothing is a wrapper.** Every archive is a native build for that - What runs where
processor, compiled from the same source with the same pinned compiler, and built twice in separate directories and compared byte for byte before it ships. - How anything reaches the network, given that nothing here is a network client
VeilVoice bundles no HTTP client, and this is checked rather than claimed: nothing in the workspace links one. Two features nonetheless involve the network, and the way they do it is the point. **Check for updates**, in the desktop - How anything reaches the network, given that nothing here is a network client
application only, asks the operating system's own transfer tool to fetch one small file, and reads a version number out of what it printed. It is a button, it is never automatic, and the command line has no such feature at all. The tool is - How anything reaches the network, given that nothing here is a network client
found by **absolute path**, never through `PATH`. On Windows that is `%SystemRoot%\System32\curl.exe`, which has shipped with Windows since 2018. Elsewhere it is `curl` at `/usr/bin`, `/bin` or `/usr/local/bin`, and `wget` at the same - How anything reaches the network, given that nothing here is a network client
three places if there is no `curl`. That distinction is not fussiness. Windows searches the current directory before `PATH`, so a file called `curl.exe` sitting beside VeilVoice would otherwise be the program that ran, and a privacy tool - How anything reaches the network, given that nothing here is a network client
reaching for the network is the last place to accept a stranger's binary. If none of those paths holds a tool, the button says so and nothing is run. **Installing a companion** does not fetch anything either. It runs the package manager - How anything reaches the network, given that nothing here is a network client
already on the machine, which is the thing your system already trusts to install software, and for anything needing root it prints the command instead of running it. The consequence worth stating: there is no code path in VeilVoice that - How anything reaches the network, given that nothing here is a network client
opens a socket. A firewall rule that blocks it entirely costs you the update button and nothing else. --- - 3. The desktop app
`veilvoice-gui`. One tab for each thing it does, in the strip across the top. Every one of them has a section below, and `veilvoice-gui --tab <name>` opens the window on one directly. - anonymise file
Choose a recording, press **anonymise**. | Control | Effect | |---|---| | **intensity** | How far pitch and formants move from the original, 0.0–1.0. Default 1.0, full normalisation. | | **neutralise accent and intonation** | On by - anonymise file
default. Collapses every speaker onto one canonical register and vocal tract. Turning it off is weaker de-identification. | | **seed roll (s)** | How often the modulation stream ratchets forward. Default 2 s; 0 keeps one stream for the - anonymise file
session. Inaudible by construction. | | **strip metadata from the result** | On by default. | | **encrypt the result at rest** | On by default. See below. | - At-rest encryption
The result is **sealed as it is written**, so a file you name `clean.wav` lands as `clean.wav.veil`. Two ways to seal it: - **passphrase**: Argon2id at 256 MiB. Set once and held for the session; **change** clears it, and locking the app - At-rest encryption
clears it too. - **public key**: X25519 + ML-KEM-768 hybrid, to a `.pub` file from `veilvoice keygen`. Nothing to type and nothing to forget; only the matching private key opens it. The **anonymise** button stays disabled until there is - At-rest encryption
something to encrypt with. A tool that quietly wrote plaintext because a field was still empty would make the default worthless. Unticking the box opens a dialogue that must be answered first. The result is still a recording of every word - At-rest encryption
that was said, and on flash storage deleting it afterwards is not a reliable fix, so the question is asked once, plainly. - group
Several people in one recording, each given a **different** destination voice, so a listener can still follow the conversation by ear. Every voiceprint is destroyed as thoroughly as one speaker's would be; what is kept is that the speakers - group
are distinguishable, not who they are. It works on a recording that already exists, not on a live microphone. | Control | What it is | |---|---| | **How you are working** | One person, a group with a voice each, or a group with one voice - group
for everybody. The last is the honest choice when there are more people than there are voices far enough apart to tell apart. | | **open / save project** | A project holds where your files are, who is in the recording and what you called - group
them. No audio and no passwords, so it is safe to keep beside the recording. It does hold the names you typed. | | **group mode** | On for this run. Closing the window turns it off again, so a recording of one person is never rendered - group
against a plan describing several. | | **always start in group mode** | Remembered, for people who are always working this way. | | **the people** | A name and a colour each. **Names are not veiled by anything**: you type them and they go - group
into the subtitles as typed. | | **the recording, and the plan** | The plan says when each person speaks. Without one there is nothing to render against, and audio no turn claims is silenced rather than passed through, so a missing plan - group
gives a silent file rather than an unveiled one. `veilvoice conversation inspect` describes a plan you already have. | | **what a render writes** | Audio, subtitles, a player page, and a video. The first three by default. | | **video** | - group
An MP4 with the same picture the player page draws: a circle per speaker, whoever is talking lit, the level under each name, the waveform and a playhead. Off unless you ask, because it is the one output needing `ffmpeg`, which VeilVoice - group
does not ship. Without it the pictures are still written and you get the command. | | **who was speaking** | A score out of a hundred, beside the voice choice, saying how much the finished recording gives away about which person each turn - group
belongs to. It falls as the group grows. See below for what it does and does not mean. | - The "who was speaking" score
Beside the voice controls is a percentage. **One hundred means the recording says nothing about which of the people in it was talking.** It falls as you add people in "a voice each" mode: two people is 70%, four is 40%, eight is 10%. One - The "who was speaking" score
voice for everybody is 100% at any group size. **What it is not.** It is not a measure of how well anybody's voice is disguised. That is what the engine does, every speaker is mapped onto a canonical destination voice, and it does not get - The "who was speaking" score
weaker because somebody else joined the call: a recording of eight people hides each of their voiceprints exactly as well as a recording of one. It is also not cryptography. There is no key, no work factor and no attacker racing a clock, - The "who was speaking" score
and nothing here gets better with a longer password. **What it is.** A count of one specific thing: how much of the shape of the conversation a listener gets for free. Give eight people eight tellable-apart voices and anybody who hears the - The "who was speaking" score
result can count the participants, follow who said what, and line two recordings of the same group up against each other by voice. Give them all one voice and none of that is there to find. A listener who could not tell the voices apart - The "who was speaking" score
would have to guess which of them spoke each turn, and that guess is worth `log2(voices)` bits; the score is those bits measured against the widest the engine goes. **The part that runs backwards.** Crowding the table makes the recording - The "who was speaking" score
give *less* away, not more, because two people whose voices are too close to separate count as one to a listener. It also makes the recording harder to follow, which is the real cost, and it is shown as its own line rather than folded into - The "who was speaking" score
the score. That is the whole trade the two voice modes exist to let you choose between: followable and more revealing, or private and harder to follow. VeilVoice does not guess who is speaking. Turns come from a plan file or from one - The "who was speaking" score
microphone per person, and that is a deliberate limit: guessing wrongly would put one person's words under another person's name. **Two bars per person, while the render runs.** As the render walks the file it draws, for each person, what - The "who was speaking" score
went into the turn it has just finished and what the engine produced from it, along with how far through their turns it is. One bar would answer "is something being written"; two answer "is this person being veiled", which is the question - The "who was speaking" score
you are actually asking, and two that move differently are the only thing on screen showing the engine is between them. They cannot show that a voice cannot be recovered, and nothing on a screen can. What they catch is the case that - The "who was speaking" score
matters in practice: a person whose input bar moves and whose output bar does not. - studio
Veiling as it happens, and keeping what was said, in one tab. It used to be two: live scramble picked the devices and started the engine, and the Studio recorded through that same engine on another screen, with whichever devices the other - studio
tab happened to be set to. They are one act and they are now in one place. The tab is in two halves. The top half is **the voice**, and it works with the vault shut, because veiling a call has never needed a vault and requiring one would - studio
be a worse program. The bottom half is **the take**, and it needs both passphrases, as it always has. - The voice
Pick an input and an output device and press **start veiling**. A virtual audio cable is preselected as the output when one is installed, because routing there is what lets other applications hear the veiled voice; if none is found you are - The voice
warned rather than silently sent to the speakers. Levels, processing time per block, engine latency and a glitch counter are shown live. **The two devices have to be at the same sample rate.** VeilVoice does not resample a live stream, so - The voice
it puts both on one rate: the rate they already share, the output's if the microphone will take it, or the microphone's if the output will. If neither will move it says so, naming both rates, rather than running them together: a microphone - The voice
at 44.1 kHz feeding an output at 48 kHz gives a voice about a semitone and a half sharp, stuttering, and no way to tell that from the veiling working. Set both to the same rate in your system's sound settings, or choose devices that - The voice
already agree. A room of several microphones is the same rule over all of them at once, and it matters more there: the others sound right, so one microphone at the wrong rate reads as that one person's microphone being bad. - Several microphones, a guest each
An interview with everybody in the room needs a microphone each, and ticking **several microphones, a guest each** turns the input picker into a list of them: a name and a device per person, up to eight. Press **add a guest** for another - Several microphones, a guest each
row and **remove** to take one out. Everything else works as it does with one microphone, including **preview to my headphones**, which previews the mix. **Why not one microphone in the middle of the table.** One microphone carrying four - Several microphones, a guest each
people is one signal. Whatever it is turned into, all four of them are turned into the same thing, and a listener cannot follow who is speaking. A microphone each is what lets each person be veiled into a voice of their own, from the same - Several microphones, a guest each
set a group render hands out. **Two guests cannot share a device.** It is refused before anything opens, by name, because nothing here can separate one signal back into two people. Two guests both left on *system default* is the same - Several microphones, a guest each
refusal: the default is a device, not an absence. **Two bars per guest, and the load.** Each person gets what went into their microphone and what came out of their engine, with the mix under them. The **load** is the one number that says - Several microphones, a guest each
whether this machine can carry this room: every guest's engine runs inside one output callback, so they share one deadline of a few milliseconds and the cost is the sum of them. Below 100 per cent the machine keeps up; past 80 the tab says - Several microphones, a guest each
so and what to do about it, which is one guest fewer or a larger frame size. It is a measurement rather than a limit, because how many voices a machine can carry is a fact about the machine. **The mix can clip, and nothing quietly fixes - Several microphones, a guest each
it.** Several people talking at once is several signals added together, which can go past full scale, and a live path cannot scale the whole conversation afterwards the way a render can. So the blocks that clipped are counted and shown, - Several microphones, a guest each
and nothing compresses or limits them: a limiter is a dynamics processor, it changes the voice, and a second thing changing the voice is exactly what this program is careful about. Turn the microphones down. **A take stores everybody - Several microphones, a guest each
separately.** One take name, and under it the mix, called *(everybody)*, and one recording per guest called after them. The choice of which side to keep is the same one a single microphone has and applies to every guest at once, so - Several microphones, a guest each
choosing the unveiled side in a room of four keeps four recordings of four real voices. - Hearing yourself, and what the meters say
**Hear yourself first.** Beside **start veiling** there is **preview to my headphones**. It runs the same engine and sends the result to this machine's own output rather than to the cable, so you hear the veiled voice and nobody else does. - Hearing yourself, and what the meters say
Use it before an interview begins rather than during one. Use headphones while you do: speakers plus a microphone is a feedback loop. While a preview is running the interface says **preview** in yellow rather than **live** in green, - Hearing yourself, and what the meters say
everywhere it says anything, because somebody who has those two the wrong way round is either speaking to a call in their own voice or speaking to nobody. **The monitor follows you.** While a session is running, a strip along the bottom of - Hearing yourself, and what the meters say
the window shows the level going in and the level coming out, on every tab. It is on by default, because the moment you want it is the moment you are setting up an interview on another tab and are not sure the microphone is still working. - Hearing yourself, and what the meters say
Settings, under *the live monitor*, moves it to a floating card in the corner or switches it off; the Studio keeps its full meters either way. **The Studio offers it where you start the session.** While the voice is being veiled there is a - Hearing yourself, and what the meters say
**keep the meters on top** button beside the live indicator, which is the moment you are about to put a call or a stream in front of this window. Settings has the same choice, under *the live monitor*. **On a call, or streaming, put it - Hearing yourself, and what the meters say
above everything.** The strip and the card are both inside the VeilVoice window, and on a call the call is in front of that window, so the only picture of what your microphone is doing is behind the thing you are talking into. Settings - Hearing yourself, and what the meters say
offers **a small window kept above everything**: its own window, above other windows, off the task bar, which you can drag wherever it suits and resize. Closing it puts the strip back rather than turning the meters off, because pressing a - Hearing yourself, and what the meters say
close button means "not here" and not "never show me my microphone again". On a platform that will not give a second window, it falls back to the floating card. **What the meters can and cannot tell you.** They say sound is arriving and - Hearing yourself, and what the meters say
sound is leaving, which is the thing that usually goes wrong: a muted microphone, the wrong device, a cable nothing is listening to. They cannot tell you the voice has been changed. A working meter and a bypassed engine draw the same bar. - Hearing yourself, and what the meters say
The check for that is listening to the preview and hearing a voice that is not yours. **When something interferes with it, it says so.** A device unplugged or swapped while a session is running, and anything else the platform reports about - Hearing yourself, and what the meters say
either stream, is shown here in red rather than written to a log. A device that has gone does not come back on its own: choose another and start again. On the command line the same thing appears as `INTERRUPTED` on the meter line, and - Hearing yourself, and what the meters say
`veilvoice record` repeats it when the take is sealed. While a take is running, a program **other than VeilVoice** taking the microphone is named on this tab and again with the stored take. That program heard your real voice, whatever - Hearing yourself, and what the meters say
VeilVoice was sending to the cable. **The limit, which is stated beside the warning rather than after it.** This is what VeilVoice's own audio path noticed happening to it. It is not a statement about the machine: a microphone that was - Hearing yourself, and what the meters say
already being intercepted before VeilVoice opened it is intercepted here too, and nothing in this program can see that. The Monitor tab is the wider question of who is holding the microphone, and the honest answer there is also bounded by - Hearing yourself, and what the meters say
what each platform will say. - The take
Record straight into a locked vault, veiled on the way in. What reaches the recorder is the voice the engine produced, and that is what the Studio does unless you say otherwise before you start. **Saying otherwise is possible and is asked - The take
for.** "What to keep" offers the veiled voice, both, or the microphone unveiled, and it says what each costs where it is chosen. Anything that keeps the microphone makes a recording of somebody's real voice: it is sealed in the vault like - The take
everything else, and it is still a recording that anybody who opens the vault can hear who was speaking in. The veiled voice is what is selected, the choice is never remembered between runs, and locking the window puts it back. Two takes - The take
land in the vault when both are kept, and the unveiled one's name ends in `(unveiled)`. That name is the only thing telling them apart, which is worth knowing before renaming either. The recording is assembled in page-locked memory and - The take
handed to the vault to be sealed. It is never a plain file, not even briefly. **While it runs, it says which voice it is keeping.** The choice is made on a form that disappears the moment recording starts, and a take of somebody's real - The take
voice would otherwise look exactly like one that is not for the whole of the recording. The line beside the clock says which, and says it in yellow when the microphone is being kept. **If the device goes, the take is stopped and stored.** - The take
A microphone unplugged, switched away by the operating system, or taken by something with more authority, is a device that will not produce another sample, and a take left running on one records silence while looking exactly like it is - The take
working. So the Studio stops and seals what it has. It does not discard what was captured: everything up to that point is a real recording of something somebody said. It does not retry, and it does not move the recording onto a different - The take
microphone, because you chose that one and a program that quietly records you through another is a program deciding something that is not its to decide. Anything else the platform reports is shown and left alone: an underrun is not a - The take
reason to end a recording. **Starting a take does not interrupt the call, and ending one does not end it.** A recorder cannot be attached to a stream that has already started, so beginning a take and ending one each restart the audio, - The take
which costs a short gap in what is going out. Ending a take leaves the veiling running: somebody who has just stopped recording has not asked to be heard in their own voice again. **stop**, in the voice half, is what ends the veiling, and - The take
it stores a take that is still running rather than discarding it. | Control | What it is | |---|---| | **input / output** | Which microphone the voice comes from and where the veiled voice goes. Locked while a session is running, because - The take
changing the device under a running stream is not a change, it is a restart. | | **several microphones, a guest each** | Turns the input picker into a guest list: a name and a microphone per person, up to eight, each veiled into a voice of - The take
their own and mixed into the output. | | **start veiling** | Begins, at the engine strength shown below the devices. Nothing is kept unless a take is started. | | **preview to my headphones** | The same engine to this machine's own output - The take
and nowhere else. The chosen output is deliberately ignored, so nothing listening on the cable hears it. If this machine's default output *is* a cable, you are told so rather than reassured. | | **stop** | Ends the veiling. A take still - The take
running is stopped and stored first, never discarded. | | **app lock / at rest** | The two passphrases that open the vault. Both, every time. Neither on its own opens anything, and an empty one is refused rather than treated as "no second - The take
factor". | | **call it** | What this take will be called. A name is a label and nothing veils a name; it is sealed with the recording, so it is not readable from the disk, and it is still the thing that says who this is. | | **what to - The take
keep** | The veiled voice, both, or the microphone unveiled. Before the button rather than after it, because a recording of somebody's real voice is not a thing to discover having made. Not remembered between runs, for the same reason - The take
group mode is not: a mode somebody forgets is on eventually records what they did not mean to record. | | **start recording** | Begins a take, at the engine strength the rest of the window is set to, keeping the routing you are already - The take
hearing. | | **stop and store** | Ends the take and seals it into the vault, and the veiling carries on. Samples that were dropped are reported rather than passed over, because a recording that is quietly short is the failure this path - The take
exists to avoid. | | **the two bars** | What is going in, and what is coming out, while it happens. Two rather than one on purpose: a single output meter answers "is something being recorded" and not "is it being veiled", which is the - The take
question you are actually asking. Seeing the input move and the output move differently is the only thing on screen that shows the engine is between them. | Locking the window closes the vault and stops the audio. A take that is still - The take
recording when that happens is stopped and **stored** first, rather than discarded: the vault is still open at that moment, and throwing away a recording because an idle timer fired would be the worst thing the tab could do. - recording an interview
Group mode is about a recording that already exists, so the steps for an interview are: 1. **Set up and check first.** On the Studio tab, choose the microphone, press **preview to my headphones**, and listen. This is where you find out - recording an interview
that the wrong device was selected, or that you are too close to the microphone and clipping. 2. **Start veiling** into the virtual cable, and point whatever is recording or calling at that cable rather than at the microphone. If you want - recording an interview
a copy of the conversation as well, start a take: the vault is on the same tab and the take rides the session that is already running. 3. **Watch the strip.** It stays on screen while you work on other tabs. `in` moving and `out` flat - recording an interview
means the engine has stopped or the cable has gone; `CLIPPED` means the input is too loud and is being cut off, which cannot be undone afterwards. 4. **Afterwards**, if the recording has several people in it and you want each one given a - recording an interview
different voice, that is the **Group** tab and it works on the file. - browser
What is in the vault, without opening any of it. The listing comes from a sealed index, so reading it decrypts one small file rather than every recording. | Control | What it is | |---|---| | **the list** | Each recording's name, size and - browser
the date it was made. The date and not the time: a listing open on a screen in an office already says enough. | | **rename** | Rewrites the index only. The audio is sealed under an identifier rather than a name, so renaming never - browser
re-encrypts anything and cannot lose a recording if it is interrupted. | | **play** | Plays it straight out of locked memory. **Nothing is written to the disk**, so there is no copy to remember to shred afterwards. Stopping releases the - browser
samples, and so does locking the window. | | **remove** | Asks first, and cannot be undone. | | **preview page** | Writes the audio, a self-contained player page and its captions into a folder you pick. The page plays the recording, draws - browser
its waveform, lights whoever is speaking and moves a level under their name, and needs nothing installed. | | **render video** | Writes an MP4 with a black picture, for somewhere that will not accept an audio file. Needs `ffmpeg`, which - browser
VeilVoice does not ship and will not install: without it you get the exact command to run, and the audio it needs, rather than a promise. | | **both** | The page and the video. | **The level under each name, and what it means.** A lit - browser
circle says whose turn it is. It says nothing about whether that person is mid-sentence or mid-pause, and those look identical for as long as the turn lasts, so under each name is a bar that moves with the sound. It is drawn from the same - browser
waveform underneath it, which is why the two can never disagree. It is the loudness of the **mix**, given to whoever the plan says is speaking. A render produces one mixed track, so there is no separate signal per person to measure. While - browser
one person is talking those are the same thing. Where two turns overlap they are not, and both people show the same bar: that is what a listener hears, and it is not a claim that each of them was that loud. Somebody whose turn it is not - browser
shows nothing rather than a small amount, because a bar moving for a person who is not speaking would be the one thing on the picture saying something untrue. Playback decrypts the take **whole**, into page-locked memory, rather than in - browser
pieces. That is a property of the container rather than a shortcut: it is sealed and authenticated as one thing, and an encryption that let you open the first second without the rest would not be authenticating anything. What it buys is - browser
the part that matters, which is that no plaintext file exists at any point. What it does not buy is a footprint smaller than the recording, and an hour of audio is an hour of audio in memory while it plays. Anything taken out is written - browser
**unsealed**, and the tab says so before you press anything. That is not a defect: a video nobody can open is not a video. The voice in it is still veiled, because it was veiled before it was ever stored; what leaves is an ordinary file of - browser
a voice that is not anybody's. The folder is asked for every time rather than remembered. A remembered folder is how the second export goes somewhere the first one was deliberately kept out of. What a vault sitting on a disk gives away is - browser
how many recordings there are and roughly how large each one is. Not their names, not their dates, and not what any of them is. - Decoy vaults
Under the listing is a dropdown that fills the folder with decoys. A decoy is not a vault with weak contents, or one whose passphrase is written down somewhere, or one holding harmless recordings. Any of those is a vault that rewards - Decoy vaults
cracking, and a decoy that rewards cracking teaches an attacker that cracking works. It is a vault whose contents **never existed**: random bytes sealed under a key made inside the call that writes it and dropped before that call returns. - Decoy vaults
Nobody holds it. Cracked, it yields bytes that parse as nothing, which looks exactly like a wrong passphrase. | Control | What it is | |---|---| | **how many** | Starts on what the free space allows: a twentieth of what is actually free - Decoy vaults
where the vaults live, up to thirty-two. Where the system will not say how much is free, the panel says so and the number is a starting point rather than a measurement. | | **make them** | Writes them. Each is the size the open vault is, - Decoy vaults
down to the byte, including the size of its index. | Your vault and its decoys are directories with opaque names, and the real one is found by trying each until one opens, which only both passphrases do. That is why there is no directory - Decoy vaults
called `studio` to look for: a real vault at a fixed name is told from a decoy by reading the name, and the decoys would be worth nothing. **What this buys, and what it does not.** It buys the cost of a search. Somebody who takes the disk - Decoy vaults
sees several vaults, cannot tell which holds anything, and gets no signal from cracking one. It does **not** hide the real vault from somebody watching the screen while you open it, from something already running inside the computer, or - Decoy vaults
from a backup taken before the decoys were made. One consequence worth knowing before you press it: decoys cannot be told from the real vault by looking, which means **you** cannot tell them apart either. Removing one afterwards is - Decoy vaults
removing a directory you cannot open to check first. - verify
Check that a download is the one that was published, without leaving the window. This is the same check `veilvoice verify` does, and §7 walks through it in full. Drop the archive on the window and the hash list and signature beside it are - verify
picked up automatically. One press then checks the signature over the hash list, the archive against that list, every file you extracted out of it, and all of it again through your own GnuPG if you have one. The commands are also printed - verify
for you to run yourself. That is not decoration: a program telling you that a download is genuine came out of that download. Running the commands yourself is the part no program can do for you. **What a pass proves** is written on the tab, - verify
and it is worth reading. A good signature and a matching hash prove the file is the one the holder of that key published. They do not prove it is safe, that the source compiles to it, or that the key belongs to anybody in particular. - settings
Where every choice the window remembers is made, and where they are kept. | Page | What is on it | |---|---| | **Interface** | The colour scheme, which is every palette the website has. Whether the mark in the header animates, and whether - settings
the window icon does. How often the window draws while something is moving, and whether the header carries a live frame-rate readout. Whether the **install** tab is shown at all. | | **Locking** | The app lock and the idle timer that turns - settings
it on. See §5 and §5.5. | | **At rest** | Whether a result is sealed with the app-lock password as well, and where a vault lives if you keep one. See §5.7. | | **Notifications** | How the window tells you a job has finished. | The file - settings
itself is plain text, one `key = value` a line, at `%APPDATA%\veilvoice\settings.conf` on Windows, `~/Library/Application Support/veilvoice/settings.conf` on macOS and `${XDG_CONFIG_HOME:-~/.config}/veilvoice/settings.conf` on Linux. - settings
Nothing in it is secret and none of it is a password. - install
Only there when you are running a portable copy, and it removes itself once VeilVoice is installed: a program offering to install itself when it already is tells you something untrue about what you are running. There is a tick under - install
settings to hide it on a portable copy too. The install it offers is deliberately small. It copies the VeilVoice programs beside this one into your own program directory and adds that directory to your PATH, so that typing `veilvoice` in a - install
terminal works. No administrator rights are asked for, no service is created, and nothing is written outside your own account. **Companion software** is listed on the same tab, and none of it is part of VeilVoice or required by it. Each - install
entry names one program, says who makes it and under what licence, says whether it was found, says what still works without it, and gives the one command that would install it. VeilVoice never runs somebody else's installer, and anything - install
needing root prints the command for you to run in a terminal where you can see what you are approving. The list is `ffmpeg`, a virtual audio cable for your system, GnuPG and Audacity. Where a render tells you `ffmpeg` is missing, it names - install
this tab, because a message about something you cannot act on from where you are standing is only half a message. **Nothing here is downloaded by VeilVoice.** An install runs the package manager your machine already has, which is the same - install
arrangement as everywhere else in this program: no HTTP client is shipped, and three checks in the build say so on every commit. Stopping one part-way and starting again continues rather than beginning from nothing, because your package - install
manager keeps what it had already fetched. That is its behaviour rather than VeilVoice's, and it is said that way round because VeilVoice does not manage those files and will not promise for every installer on every system. **"Look - install
again"** re-runs the search. It looks for each program in turn, which takes a moment on a machine with several of them, so it says it is working while it does. It used to do that inside the frame it was drawing, which made the window look - install
as though it had hung. - monitor
Which applications are holding your microphone and camera, with a log of starts and stops, and an indicator in the header on every tab. On a platform that cannot see this, because macOS exposes no public interface, the tab says so. An - monitor
empty list from a blind monitor is a false reassurance and is never shown as good news. - lock
Set, change or remove the app lock, and lock immediately. See §5. - about
Crate versions, licence, the typeface in use, and a plain statement of what VeilVoice protects and what it does not. - How the window is drawn
Two lines: what was asked of the platform, and what the driver actually gave. VeilVoice asks for a hardware context and accepts a software one. That is why it opens in a virtual machine, over a remote desktop and on a machine with no - How the window is drawn
graphics card at all; demanding hardware would turn every one of those into a program that does not start. The Settings tab has one tick that turns the asking off. It is there for the case asking cannot cover: a driver that accepts the - How the window is drawn
request and then draws badly, which happens on hybrid-graphics laptops that hand over the wrong adapter and on drivers whose OpenGL path is broken in a way that shows as a black window. Nothing in the program can detect that, because from - How the window is drawn
inside it looks like success, so it is a switch rather than something measured. It takes effect at the next launch, because the choice is made before the window exists. - How often it draws
The window draws nothing at all while nothing is happening, which is what keeps it off a laptop battery. While something *is* moving, it draws at the display's own rate. Nothing asks the operating system what that rate is, because neither - How often it draws
library this window is built on will say. It is measured instead: a window that waits for the display cannot draw faster than the display shows, so the interval between frames while something is animating *is* the display's rate, and the - How often it draws
middle value of the last thirty-two of them is the figure the About tab reports. A single slow frame cannot move it. **Settings can pin it**, under animation, to anything from 30 a second up to 1000 instead of matching the display: the - How often it draws
rates panels are sold at, and two above them for displays ahead of that list. Lowering it is a choice to make for a battery rather than for smoothness, since the window waits for the screen either way. Raising it above what the panel does - How often it draws
changes nothing you can see, for the same reason. Matching the display is the default and needs no help: the measurement covers every rate in that list, so a 360 Hz or a 500 Hz panel is found and used without being told. Until the first - How often it draws
thirty-two frames have been timed the window assumes 60. The About tab shows what it is aiming at, what it measured the display to be, how many frames it is drawing a second and how many arrived late. A frame that arrives more than half - How often it draws
again later than it should have is counted late. If that keeps happening for two seconds together the window says so once, with what it is drawing with, because software rendering and a struggling GPU are different problems. Turning on the - How often it draws
header readout puts the same two numbers where you can watch them. - Where this copy keeps things
The tab lists the exact folders in use on this computer: the program itself, the settings folder everything else sits in, the settings file, the app lock, the vaults, the policies, the palettes and the crash report. Each one is worked out - Where this copy keeps things
on the machine rather than written down, and each is different on Windows, macOS and Linux, so a guess in a document would be worse than nothing: somebody told the wrong directory deletes the wrong directory. The app lock's own file - Where this copy keeps things
**names** are not shown, only the folder. They are derived from an index rather than fixed, which is deliberate and is described as obscurity in the source: what it buys is that a search of a disk for a known filename misses, and printing - Where this copy keeps things
the names in a window would hand that back to anybody standing behind you. - Portable, and how to make it so
A line above the list says which of two arrangements is in force. By default the state goes in this platform's own configuration directory. **Put a folder called `veilvoice-data` next to the program and it goes in there instead**, so a - Portable, and how to make it so
copy on a memory stick keeps its settings, its vaults and its lock on the stick. Remove that folder and it goes back to the platform directory. It is opted into rather than detected. "Beside the program if that is writable" would move an - Portable, and how to make it so
ordinary installation's state the day somebody unpacked it somewhere writable, and the symptom would be an empty vault: everything still on the disk and the program looking in the other place. Neither switch moves anything. What is already - Portable, and how to make it so
in the other place stays there, and the About tab is how you see which one is being read. - Installing a portable copy
The Install tab asks what should happen to the folder beside the program, and the install button waits for the answer. There is no sensible default: somebody moving off a stick onto their own machine wants the settings carried over, and - Installing a portable copy
somebody installing on a shared or borrowed machine wants them left where they are, because the wrong guess copies their vault onto a computer that is not theirs. Carried means **copied**, never moved. Anything already in the configuration - Installing a portable copy
directory is left alone and named in the report, and the folder beside the program is untouched, so both copies work afterwards and you decide what to do with the stick. --- - 4. The command line
`veilvoice`. Everything the app does, over SSH, in a container, or on a machine with no GUI toolkit at all. veilvoice anonymise interview.mp3 -o clean.wav # writes clean.wav.veil veilvoice anonymise interview.mp3 --encrypt-to friend.pub - 4. The command line
veilvoice anonymise interview.mp3 --encrypt false # warns, then asks veilvoice decrypt clean.wav.veil -o clean.wav veilvoice live --output "CABLE Input (VB-Audio Virtual Cable)" veilvoice devices veilvoice clean photo.jpg # EXIF, GPS, tags - 4. The command line
veilvoice encrypt notes.wav veilvoice keygen veilvoice lock set veilvoice watch # who is using the mic/camera veilvoice shred secret.wav # irreversible veilvoice info Every command takes `--help`. - Flags worth knowing
| Flag | Effect | |---|---| | `--intensity 0.0–1.0` | How far pitch and formants move. Default 1.0. | | `--keep-accent` | Leaves intonation, accent and vocal tract intact. Weaker; use only if you know why. | | `--reseed-secs N` | Seed roll - Flags worth knowing
interval. 0 keeps one stream for the session. | | `--preview` | On `live`: sends the veiled voice to this machine's own output rather than to a virtual cable, so you hear it and nothing else does. Use headphones. | | `--no-monitor` | On - Flags worth knowing
`live`: does not draw the level meters. For a terminal that is being logged or read by something other than a person. | | `--clean-metadata false` | Keeps tags on the written file. On by default. | | `--encrypt false` | Writes the - Flags worth knowing
recording in the clear. On by default; prints what you are giving up and waits for you to type `UNENCRYPTED`. | | `--encrypt-to key.pub` | Seals to a recipient's hybrid public key instead of a passphrase. | | `--yes` | Skips that - Flags worth knowing
confirmation, for scripts that already mean it. | - Where did my WAV go?
`anonymise` seals its output, so `-o clean.wav` produces `clean.wav.veil`. Open it with: veilvoice decrypt clean.wav.veil -o clean.wav If you genuinely want a bare WAV, `--encrypt false` still does that. It will tell you what that costs - Where did my WAV go?
first. --- - 5. The app lock
VeilVoice can sit behind a password of its own, so someone who picks up your unlocked computer cannot open it, see which files you have processed, or start a live scramble. veilvoice lock set # choose a password veilvoice lock status # is - 5. The app lock
one set, and is it rate limited right now? veilvoice lock change veilvoice lock remove The desktop app has the same controls under its **lock** tab, plus a **lock** button in the header that locks immediately and clears the session - 5. The app lock
passphrase. `--path` puts the lock in one named file instead, somewhere of your choosing. Without it, the lock goes where §5.2 describes. - 5.1 Read this part
**The app lock is not tamper-proof, and cannot be.** A program running on your computer has nowhere to hide a secret from that computer. Anyone holding the disk can attack the stored password hash offline, and given enough access can still - 5.1 Read this part
remove the lock entirely. What it does buy is real, and it is worth being precise about which parts are which, because two of the four things below are speed bumps and two are not. **Real, and the reason the lock exists:** - It stops - 5.1 Read this part
**casual access**, meaning the person who sits down at your unlocked session. That is a common threat, and a genuine one. - Three attempts are free, then the wait doubles: 5 s, 10 s, 20 s, up to fifteen minutes, and the count is **written - 5.1 Read this part
to disk**, so killing the app does not reset it. Somebody who edits the file directly still defeats this; see §5.3. - Argon2id at 256 MiB makes each offline guess expensive. That helps a good passphrase and does not save a bad one. **Real, - 5.1 Read this part
and new:** - Each stored lock carries an **authentication tag** computed with a key that exists only while your correct passphrase is in memory. If somebody swaps the stored password for one of their own, or weakens the Argon2id cost so a - 5.1 Read this part
guess becomes cheap, the next time you unlock, VeilVoice tells you. The report is written down, so it survives a restart, and clearing it asks for your passphrase, so the person who caused it cannot dismiss it. - The lock is kept in **two - 5.1 Read this part
copies**, in two directories. Deleting one does not remove the lock: the other puts it back, and you are told it happened. - On Linux and macOS, when VeilVoice is run with administrator rights, the second copy is written under - 5.1 Read this part
`/etc/veilvoice` and is thereafter not writable by an ordinary user. Removing the lock then needs `sudo`. VeilVoice never asks for that privilege and never elevates itself; it uses what it already has. On Windows the equivalent needs an - 5.1 Read this part
access-control list VeilVoice does not set, so there the second copy is a second copy and nothing more. **Not real, and not counted as security anywhere:** - The two files have **unguessable names** and their contents are **masked**, so a - 5.1 Read this part
search of your disk for the string `VEILLOK1` finds nothing and a backup rule written against `applock.bin` misses. The names come from a value in an index file at an obvious path, because something has to be findable or VeilVoice could - 5.1 Read this part
never open its own lock again. Anybody who reads that index, or reads the source, recomputes both names in a second. This is obscurity. It makes careless deletion and casual searching harder and it stops nobody who is paying attention. If - 5.1 Read this part
someone taking your disk is the threat, the answers are full-volume encryption (LUKS, BitLocker, FileVault) and the at-rest encryption above. Not this. - 5.2 Where the lock is kept
In your platform's configuration directory: `%APPDATA%\veilvoice` on Windows, `~/Library/Application Support/veilvoice` on macOS, `$XDG_CONFIG_HOME/veilvoice` or `~/.config/veilvoice` on Linux. Inside it you will find `applock.index`, - 5.2 Where the lock is kept
which is sixteen random bytes, and two files whose names are derived from it. The second copy is in the same directory unless VeilVoice was run with administrator rights, in which case it is under `/etc/veilvoice`. `veilvoice lock status` - 5.2 Where the lock is kept
prints the directory rather than the file names, since the names carry no meaning for a reader. If you delete `applock.index`, both copies become unreachable and the lock is gone. Treat that file as part of the lock rather than as scratch. - 5.3 What the tag does not cover
The failed-attempt counter and its timestamp sit **outside** the authentication tag, and the reason is unavoidable rather than an oversight: they are written at the one moment the tag key does not exist. A wrong passphrase has to be - 5.3 What the tag does not cover
counted, counting it means writing the file, and that write cannot be authenticated by a key only a right passphrase produces. Putting them inside would mean either reporting every honest typo as tampering or not counting failures at all. - 5.3 What the tag does not cover
So the rate limit is exactly as defeatable by a text editor as it always was. The tag covers the parts an attacker actually wants to change: the stored password, the Argon2id cost, and the tamper report itself. Two other things it does not - 5.3 What the tag does not cover
cover. Replacing the lock wholesale with one the attacker created is not detected, because their record is authentic under their own passphrase. The second copy is what stands in the way of that, not the tag. And restoring an older copy of - 5.3 What the tag does not cover
your own lock file, to wind the report back, is not detected either. - 5.4 Two passwords by default, and one if you choose it
The app lock and the recording passphrase are separate secrets by default. If one password did both, opening the app would be the same act as unsealing everything it had ever written. VeilVoice keeps the two derivations domain separated, - 5.4 Two passwords by default, and one if you choose it
so typing the same passphrase in both places still does not produce two copies of one value, though one guess would then open both. **You can now choose to have one.** On the security tab, under how recordings are sealed, there is a third - 5.4 Two passwords by default, and one if you choose it
option beside *passphrase* and *public key*: **app lock**. With it on, every recording VeilVoice writes is sealed with your app-lock password automatically, with nothing else to set up and nothing else to remember. The choice is remembered - 5.4 Two passwords by default, and one if you choose it
between launches. Read this before turning it on: - **One password now opens the application and everything it has ever written.** Somebody who makes you unlock VeilVoice in front of them has opened the archive, not just the session. That - 5.4 Two passwords by default, and one if you choose it
is the entire cost, and it is the reason the two are separate by default. - **Forgetting that password loses the recordings**, not just a session. Without this on, forgetting the app-lock password costs you the lock and the fix is deleting - 5.4 Two passwords by default, and one if you choose it
it. With it on, deleting the lock does not help: the recordings are encrypted, and there is no recovery. - **The recordings do not depend on the lock file.** Each file carries its own salt and cost, so `veilvoice decrypt` opens it with the - 5.4 Two passwords by default, and one if you choose it
same password on any machine, with or without a lock. Removing the lock does not lock you out of anything. - **It takes effect at the next unlock.** The password is taken as the lock opens, because that is the only moment it exists. - 5.4 Two passwords by default, and one if you choose it
Turning the option on mid-session tells you to lock and unlock again, rather than quietly writing the next recording unencrypted. Only offered when an app lock is set, because there is nothing to seal with otherwise. - 5.5 If you forget the app-lock password
Delete `applock.index` and the two files beside it, in the directory §5.2 names. That is not a backdoor: it is the same thing anyone with access to your files could do, which is exactly why the lock is described as protecting against - 5.5 If you forget the app-lock password
casual access rather than as a security boundary. If the second copy was written under `/etc/veilvoice`, removing it needs `sudo`. The unlock screen does not show any of this. It says the app is locked and asks for the passphrase, and - 5.5 If you forget the app-lock password
nothing else. The person reading a locked window is either its owner, who does not need the file's location at that moment, or somebody who picked the machine up, who should not be handed it at all. Forgetting a **recording** passphrase is - 5.5 If you forget the app-lock password
different. There is no recovery, by design. --- - 5.5 Locking the window when you walk away
Off unless you turn it on, under **Settings, Locking**. When it is on, choose how long from the list, which runs from five minutes to two days, or type your own: `90m`, `2h`, `1d`. Typing a value outside the list widens the list to hold - 5.5 Locking the window when you walk away
it, so the range is yours rather than ours. There is a button to put it back. **Starting a long job does not count as using the window.** That is deliberate, and it is the case the feature exists for: somebody who starts a render and - 5.5 Locking the window when you walk away
leaves the room has left the room, and the recording being produced is the thing worth locking away. The countdown runs on the window's own clock, not the system one, so changing the machine's time neither brings the lock forward nor - 5.5 Locking the window when you walk away
pushes it back. - 5.6 VeilVoice checking its own files
The first time VeilVoice runs it writes down what its own program file looks like: the size and a SHA-256. Every launch after that it checks the file against that record and shows the answer on the security tab. Nothing has to be turned on - 5.6 VeilVoice checking its own files
and there is no command to remember, which was the whole problem with `veilvoice guard init` being a command. **With an app lock set**, the record is sealed under your app-lock passphrase, and the check runs at the moment you unlock, - 5.6 VeilVoice checking its own files
because that is the one moment the passphrase exists. Somebody who changes the program file then has to change the record too, and to do that they need your passphrase. **With no app lock**, there is no passphrase to seal it with, so the - 5.6 VeilVoice checking its own files
record is written in the clear and the security tab says so in those words. It still catches a file that changed by accident, a half-finished update, or a careless overwrite. It does not catch somebody who thought to rewrite the record as - 5.6 VeilVoice checking its own files
well, and a record sealed under a key kept beside it would be a decoration rather than a protection. Either way this **detects**; it does not prevent. And it cannot tell an update you installed from a file somebody swapped, because on disk - 5.6 VeilVoice checking its own files
those look identical. If you have just updated, a report of a change is the update. The record is taken at first launch rather than at install, because installing a package runs as an administrator and the record belongs to the user - 5.6 VeilVoice checking its own files
account that will run the program. A record written into the administrator's own configuration directory would describe nothing anybody checks. `veilvoice guard init`, `check` and `status` still work, read the same record, and can watch - 5.6 VeilVoice checking its own files
more files than the window does. See §4. - 5.7 Saving into a Cryptomator vault or a VeraCrypt volume
VeilVoice can write every veiled recording straight into an encrypted folder you already have, instead of leaving it beside the original. The security tab has a section called **Where recordings go**. It looks for a mounted Cryptomator - 5.7 Saving into a Cryptomator vault or a VeraCrypt volume
vault or VeraCrypt volume and offers what it finds. If it finds nothing, point at the folder by hand and say which of the two it belongs to; a folder you chose yourself is treated exactly like one that was found, including the question - 5.7 Saving into a Cryptomator vault or a VeraCrypt volume
below. **VeilVoice never opens or closes these for you, and never asks for their password.** Unlock the volume in its own program first. Mounting your encrypted storage is your act, taken in the tool you chose, and a voice de-identifier is - 5.7 Saving into a Cryptomator vault or a VeraCrypt volume
not the program to be doing it on your behalf. - The hidden-volume question
If you choose a **VeraCrypt** volume, VeilVoice asks one question before it will write anything, and will not start a job until you answer: > Does this container have a hidden volume inside it? It asks because it cannot tell, and neither - The hidden-volume question
can anything else. A VeraCrypt container can hold a second volume inside the free space of the first, so that somebody forced to hand over a password can open the outer one truthfully while the inner one stays unprovable. That only works - The hidden-volume question
because the two are indistinguishable from outside. The danger is specific. **Writing into the outer volume of a container that has a hidden one can destroy the hidden data**, because the outer filesystem does not know the inner one is - The hidden-volume question
there and will allocate over it. VeraCrypt has a protection mode for this and it needs the hidden volume's password, which VeilVoice does not have and will not ask for. So there are three answers and they do different things: | Answer | - The hidden-volume question
What happens | |---|---| | No hidden volume | VeilVoice writes there | | This is the hidden one | VeilVoice writes there; you are already inside the hidden volume, which is safe | | This is the outer one | VeilVoice refuses, and says why | - The hidden-volume question
Cryptomator is not asked, because it has no such concept and a question with no meaning only teaches people to click through questions. A destination you have not answered for **blocks the job**. It does not quietly fall back to writing - The hidden-volume question
beside the original, because a recording sitting outside a vault while you believe it is inside one is exactly what this is here to prevent. If the volume is locked when you come to use it, VeilVoice says so rather than writing into the - The hidden-volume question
empty mount point. - 5.8 Encrypt the disk as well
An encrypted volume protects the files inside it. It does not protect the temporary files, swap or hibernation image, thumbnails or recently-opened lists your system writes about them, and any of those can outlive the recording. Encrypt - 5.8 Encrypt the disk as well
the whole disk too: | System | Use | |---|---| | Windows | BitLocker | | macOS | FileVault | | Linux | LUKS or LUKS2 | | OpenBSD | `softraid -C` | | FreeBSD | GELI | This is defence in depth, not a second lock on the same door. The volume - 5.8 Encrypt the disk as well
protects the file; the disk protects everything the system wrote about the file without being asked. A veiled recording inside a Cryptomator vault on an encrypted disk is encrypted by two independent tools, and what that buys is not extra - 5.8 Encrypt the disk as well
strength so much as independence: a defect in one is not a defect in both. - 5.9 An interview, start to finish
The commonest thing people ask VeilVoice to do, in the order it happens. This is also how to veil the person you were interviewing rather than only yourself. - Step 1: get the sound out of what you recorded
If you recorded in OBS, or anything like it, you have a `.mkv` or `.mp4` holding a video track and an audio track. VeilVoice reads audio: veilvoice import interview.mkv # writes interview.wav That needs `ffmpeg`. VeilVoice does not ship - Step 1: get the sound out of what you recorded
one and will not install one; when it is missing you get the exact command printed, to run yourself or after installing it. `--dry-run` prints it without running anything. Already have a `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a`, `.aac` or - Step 1: get the sound out of what you recorded
`.opus`? Skip this step; those go straight into `anonymise`. - Step 2: write a plan, so each person gets their own voice
Running an interview through `anonymise` gives **both people the same voice**. That is private and useless: nobody can tell a question from its answer. A plan gives each speaker their own destination voice. **VeilVoice will not work out - Step 2: write a plan, so each person gets their own voice
who is talking.** That is speaker diarisation, it needs a trained model, this project ships none and asks no server, and a wrong guess would either merge two people or invent a third with nothing in the output showing it. So you tell it. A - Step 2: write a plan, so each person gets their own voice
plan is a text file: VEILCONV1 title Interview with Sam speaker 0 Me speaker 1 Sam turn 0.000 4.200 0 So, how did it go? turn 4.100 19.050 1 turn 19.000 22.400 0 And after that? Line by line: | Line | What it is | |---|---| | `VEILCONV1` | - Step 2: write a plan, so each person gets their own voice
The first line of every plan, so the file says what it is | | `title` | What the recording is called, shown in the player | | `speaker <n> <name>` | One per person. The number is how turns refer to them | | `turn <from> <to> <speaker> - Step 2: write a plan, so each person gets their own voice
[words]` | One per stretch of speech, in seconds | The words on a turn are optional. With them the subtitles carry what was said; without them they carry the speaker's name, which is still enough to follow a conversation whose voices have - Step 2: write a plan, so each person gets their own voice
all been replaced. Overlapping turns are fine. People talk over each other, and VeilVoice mixes them rather than picking a winner. **Anything no turn claims is silenced, not passed through.** A gap in a plan must never put a real voice - Step 2: write a plan, so each person gets their own voice
into the result, and how much was silenced is printed so you can tell a deliberate pause from a plan that missed a minute. Check a plan before spending time on a render: veilvoice conversation inspect interview.plan It prints who is in it, - Step 2: write a plan, so each person gets their own voice
which voice each gets, and any overlaps. - Step 3: render it
veilvoice conversation render interview.plan interview.wav -o veiled.wav Every speaker comes out with their own voice and every voiceprint is destroyed, including the interviewee's. Subtitles are written beside the audio in both formats, - Step 3: render it
and a self-contained player page comes with it that needs nothing installed. **None of it is encrypted, unlike `anonymise`.** Seal the audio afterwards with `veilvoice encrypt` if it matters. Everything a render writes is created readable - Step 3: render it
only by your account, which is a file permission and nothing more: it does not survive a copy, a backup, or anyone who has the disk. And read the subtitles before sending them anywhere. They carry the names you typed and the words you - Step 3: render it
typed, in plain text, and nothing veils a name. - Step 4: a video, if you need one
Somewhere that will not accept an audio file: veilvoice video veiled.wav # writes veiled.mp4 A black picture for the length of the recording. The picture is not the point and does not pretend to be. Needs `ffmpeg`, same as step 1. - Recording each person on their own microphone instead
If your recording already has one channel per person, the split is exact and there is no plan to write. That is the better arrangement whenever you can manage it: no times to type, and no chance of typing them wrong. - 5.10 Policies: settings somebody else decided
For a newsroom, a clinic, a legal team: one person writes down what VeilVoice must do on every machine, and the machines hold to it. - The one idea worth understanding first
**A policy can only make VeilVoice stricter.** There is no requirement that turns encryption off, none that lowers the de-identification floor, none that disables the app lock, and there is nowhere in the file to write one. That is what - The one idea worth understanding first
makes the whole thing work without a privileged service or a key hidden in the program. A policy has to be readable at every launch to be applied at every launch; if reading it needed a password, you would type one every time. So the file - The one idea worth understanding first
is plain, and the protection is in its *shape*: somebody who edits it without the passphrase can do exactly one thing, which is make that machine stricter than its owner asked for. A nuisance, not a privacy failure. The sealed copy beside - The one idea worth understanding first
it is what proves the policy in force is the one that was written. - Writing one
The file is `policy` in VeilVoice's own folder, and it is plain text: VEILPOLICY1 note Newsroom standard, agreed 2026-03 require encrypt-recordings require clean-metadata require app-lock require minimum-intensity 60 Two details the file - Writing one
is strict about, because it refuses rather than guesses: - **`VEILPOLICY1` on the first line.** A file without it is rejected. - **Two spaces** between a keyword and its value, not one and not an `=`. The same between `minimum-intensity` - Writing one
and its number. `veilvoice policy status` prints the policy in force in exactly this form, so the quickest way to get the syntax right is to write one requirement, run it, and copy what it prints back. The five requirements, and each one - Writing one
only tightens: | `require` | What it insists on | | --- | --- | | `encrypt-recordings` | Every veiled recording is encrypted before it is written. No plaintext output. | | `clean-metadata` | Metadata is stripped from what VeilVoice writes. - Writing one
| | `neutralise-accent` | Accent neutralisation is on, not optional. | | `app-lock` | An app lock must be set. VeilVoice will not run without one. | | `minimum-intensity N` | The de-identification floor, as a whole number from 0 to 100. A - Writing one
user may go higher, never lower. | - Sealing it, so it cannot be quietly rewritten
veilvoice policy status # what is in force, and whether it is sealed veilvoice policy seal # asks for a passphrase, writes the sealed copy veilvoice policy verify # does the plain file still match the seal? `verify` exits non-zero if the - Sealing it, so it cannot be quietly rewritten
two disagree, which is what a scheduled check should look at. Removing a policy is `veilvoice policy remove`, and it asks. - What a sealed policy is not
It is **not enforcement**, and the distinction matters: - Anything that can write VeilVoice's own executable can replace VeilVoice, and no file it reads can stop that. - Anything running as the user can delete the policy outright. What a - What a sealed policy is not
seal buys is that a policy cannot be *quietly rewritten into something weaker*. Deletion is a different question, and it has a different answer: put the policy files into a tamper manifest with `veilvoice guard`, and their removal shows up - What a sealed policy is not
there. - The honest deployment shape
1. Write the policy on one machine and seal it. 2. Copy both files, the plain one and the sealed one, to each machine. 3. Add them to that machine's tamper manifest. 4. Have something run `veilvoice policy verify` on a schedule and report - The honest deployment shape
non-zero. None of that needs a server, and none of it needs VeilVoice to run as anything but the person using it. --- - 5.11 A whole session in the desktop app, start to finish
The path most people actually want, in the window rather than the terminal. **1. Check what you downloaded.** Verify tab. Drop the archive on it, or point it at the folder. It checks the signature over the hash list first, then the archive - 5.11 A whole session in the desktop app, start to finish
against that list, then every file you extracted. Green all the way down before anything else. Section 6.5 says what each step proves. **2. Answer the setup.** On a first run VeilVoice asks four things: how it should look, a password for - 5.11 A whole session in the desktop app, start to finish
the application, a password for your recordings, and whether the window locks itself when you walk away. Every one can be skipped and changed later, and the second one is worth reading rather than clicking past: it is also what encrypts - 5.11 A whole session in the desktop app, start to finish
VeilVoice's own files. A fifth card ends it and asks nothing. It reads this computer and says what it found: where recordings will go and how much room is free there, put as roughly how many hours of veiled audio that is; how many devices - 5.11 A whole session in the desktop app, start to finish
there are to record from and play to; and what the window will ask the graphics driver for, with the tick that turns that off. Nothing on it is a number written into the program, and where the machine will not answer it says so rather than - 5.11 A whole session in the desktop app, start to finish
showing a figure nobody measured. **3. Pick where output goes.** Settings, or the Anonymise tab. If you keep a Cryptomator vault or a VeraCrypt volume, point VeilVoice at it now and answer the hidden-volume question, because it will not - 5.11 A whole session in the desktop app, start to finish
write anything until you have (section 5.7). **4. Veil the recording.** Anonymise tab: choose the file, choose an intensity, render. With a recording password set, what lands on disk is encrypted. **5. Listen to it.** Not optional. Play - 5.11 A whole session in the desktop app, start to finish
the result and satisfy yourself the voice is not recognisable to somebody who knows the speaker. No measurement substitutes for this. **6. Check it back.** Verify tab again if you are sending it somewhere, and `veilvoice guard` if you want - 5.11 A whole session in the desktop app, start to finish
VeilVoice to notice its own files changing. For several speakers at once, that is the Group tab and section 5.9, which walks through an interview from the raw file to a finished render. --- - 5.12 If VeilVoice keeps closing on Windows
A brand-new application that few people have run yet has, in antivirus terms, a low reputation, and a low-reputation program that reads a microphone and writes encrypted files is the shape some scanners are built to be wary of. - 5.12 If VeilVoice keeps closing on Windows
Occasionally one closes VeilVoice by mistake. VeilVoice notices this itself. If a run ends without a clean shutdown and it did not crash on its own, and an antivirus product is installed, the next launch shows a plain notice: which product - 5.12 If VeilVoice keeps closing on Windows
was found, that a new app is sometimes stopped as a precaution, that you would normally have seen an alert from that product too, and that adding an exclusion is worth doing only if you actually keep seeing the problem. Nothing is hidden - 5.12 If VeilVoice keeps closing on Windows
from your antivirus and nothing on your system is changed; it is one paragraph of context so a vanished window is not a mystery. Because VeilVoice is offline, reproducible and signed, you can establish that it is genuine before excluding - 5.12 If VeilVoice keeps closing on Windows
anything: the Verify tab, or `veilvoice verify`, walk through it. An exclusion is your decision to make, and only worth making once you have checked. - 6. Things VeilVoice will not do
Read this twice. Misunderstanding it is the only way this software gets someone hurt. - **It does not hide what you said.** The words are preserved on purpose and can be transcribed. Encrypt the file, which is now the default, if the - 6. Things VeilVoice will not do
content must stay secret. - **It does not fully remove a strong accent.** Its melody and colour go; which phonemes you actually produced cannot be changed by any filter. - **It does not sanitise the background.** Room acoustics, other - 6. Things VeilVoice will not do
voices, a passing siren. - **It does not hide your speaking rate** or rhythm. A weak biometric, but a real one. - **It does not help against an attacker already running code on your machine.** - **It does not clean the filename**, the - 6. Things VeilVoice will not do
filesystem timestamps, or the channel you send the file over. - **The app lock is not tamper-proof**, as above. - **Secure erase is not reliable on flash storage.** `shred` overwrites in place, which works on a spinning disk; wear - 6. Things VeilVoice will not do
levelling on an SSD, SD card or USB stick leaves the original blocks where no software can reach them. Full volume encryption is the answer that works. --- - 6.5 The verifier, veilvoice verify
One job: deciding whether the download you just made is the one that was published. Until 0.1.18 this was a third binary, `veilvoice verify`, shipped beside the other two so it was usable *before* you trusted them. That argument was always - 6.5 The verifier, veilvoice verify
thinner than it looked -- the separate program came out of the same archive as everything else, so trusting it was the same act of trust -- and it cost every user a second file to find and a third name to learn. It is part of `veilvoice` - 6.5 The verifier, veilvoice verify
now. The check is identical: the same code, in `veilvoice-check`, that the desktop application's Verify tab has always called. If you would rather not use a terminal at all, the Verify tab does the whole of this with the same code - 6.5 The verifier, veilvoice verify
underneath. - Just run it
veilvoice verify With no arguments it looks for a release around it: the folder you are in, the folder above, the one the program itself is in, and Downloads and Desktop. If it finds an archive with a `SHA256SUMS` and a signature beside - Just run it
it, it checks everything without being told anything. That is the intended way to use it. Unpack the archive, run it inside the folder, read the verdict. - What one press actually checks
Four things, in the order that makes each one worth checking: 1. **The signature over the hash list.** Before the hashes, always. Whoever could replace your download could replace `SHA256SUMS` beside it, and the two would agree perfectly. - What one press actually checks
The signature is what makes the list worth comparing against, and the fingerprint is what makes the signature worth checking. 2. **The archive against that list.** 3. **Every file you extracted out of it**, against a signed contents list - What one press actually checks
published with the release. An archive can be genuine and a file inside your extracted copy still be wrong. 4. **All of it again through your own GnuPG**, if you have one. See below. - Pointing it at something specific
veilvoice verify auto ~/Downloads # look here, not wherever I am veilvoice verify file veilvoice.tar.gz # this file, against SHA256SUMS A folder you name that does not exist is an error, not an invitation to go and check somewhere else. - Pointing it at something specific
That distinction cost a finding: the fallback through Downloads and Desktop is right when nobody said where to look, and wrong the moment somebody does. - The second opinion, and why you want one
VeilVoice checks the signature itself, in Rust, with the signing key compiled in. That check needs nothing installed and works on every platform. It also came out of the download it is checking. **A tampered release ships a tampered - The second opinion, and why you want one
verifier.** That is not a bug to be fixed; no program can vouch for itself. So the commands are printed for you to run yourself, and the desktop application will run your GnuPG if you ask it to: veilvoice verify # what to check, and where - The second opinion, and why you want one
the files come from veilvoice verify --script # a shell script that uses gpg and nothing of ours The script is about sixty lines. Read it before running it: the entire reason to use it rather than `veilvoice verify` is that it is not this - The second opinion, and why you want one
project's code. - The same check on every system
`veilvoice verify` on its own is the same command everywhere. It hashes the files itself, checks the signature itself, needs **no GnuPG, no network and no hash tool from your system**, and that is why it is the first thing this section - The same check on every system
offers rather than the last. A reader on FreeBSD, OpenBSD or NetBSD has nothing to translate. The second opinion is where systems differ, because it runs *your* tools rather than ours, and the hash tool is spelled differently on each. Ask - The same check on every system
for the one your system has: veilvoice verify --script # the one for the machine you are on veilvoice verify --script --system bsd # or name it: linux, macos, bsd | Your system | The script it writes | What that script runs to check the - The same check on every system
hashes | |---|---|---| | Linux, and WSL | `verify-veilvoice.sh` | `sha256sum -c SHA256SUMS --ignore-missing` | | macOS | `verify-veilvoice-macos.sh` | `shasum -a 256 -c SHA256SUMS --ignore-missing` | | FreeBSD, OpenBSD, NetBSD | - The same check on every system
`verify-veilvoice-bsd.sh` | `sha256 -c SHA256SUMS` | Those three rows are checked against the program in this project's own test suite, so a command here that the program no longer prints fails a build rather than being read by somebody. - The same check on every system
**Installing GnuPG on a BSD**: `sudo pkg install -y gnupg` is FreeBSD's spelling, and OpenBSD and NetBSD use tools of their own. This project has not run those, so it does not print them: use your system's package manager. If you would - The same check on every system
rather not install anything, `veilvoice verify` with no arguments is the route that needs nothing, and the website's checker is the other one. **The website's checker** hashes the file in your browser, with JavaScript, and uploads nothing. - The same check on every system
It is the same arithmetic on every system, so a BSD reader with a browser has a third route that needs no terminal at all. The reproduce script is per system in the same way, and `veilvoice verify --build-script --system bsd` writes - The same check on every system
`reproduce-veilvoice-bsd.sh`. - The strongest check, which no program here can do for you
Rebuild the release from source and compare: veilvoice verify --build-script > reproduce-veilvoice.sh sh reproduce-veilvoice.sh v0.1.22 A hash proves the file is the one whose hash was signed, and says nothing about what is inside it, - The strongest check, which no program here can do for you
because the same person signed both. Rebuilding moves the question from "do I trust the publisher" to "do I trust the source", and the source is here to read. - What a pass proves, and what it does not
A good signature and a matching hash prove the file is the one the holder of that key published. They do **not** prove it is safe, that the source compiles to it, or that the key belongs to anybody in particular. Compare the fingerprint - What a pass proves, and what it does not
against the website and this README, from somewhere other than the download you are checking: 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A --- - 7. Getting help, and checking for yourself
Nothing here asks for trust. The properties above are asserted by the test suite: cargo test --workspace cargo run -p veilvoice-core --example spectrum_report The code has been **audited by tilas01**, who wrote it. That is a maintainer - 7. Getting help, and checking for yourself
audit and is worth what a maintainer audit is worth: it catches what the author can see. **No external firm or independent researcher has reviewed this code.** Until one has, the strongest verification available to you is the source, which - 7. Getting help, and checking for yourself
is written to be read.
docs/USING_THE_CRATES.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Using VeilVoice as a library
Every part of VeilVoice is an ordinary Rust crate. Nothing here needs the desktop app, the command-line tool, or a running VeilVoice process: you can take the de-identification engine on its own, or the container format on its own, and use - Using VeilVoice as a library
it in your own program. **Every example on this page compiles.** They are not written into this document by hand: each one is a real file under `crates/*/examples/`, built by `cargo clippy --workspace --all-targets` on every commit, so an - Using VeilVoice as a library
example that stops compiling fails CI rather than sitting here misleading you. Run any of them: cargo run -p veilvoice-core --example veil_a_buffer --- - The licence, first, because it decides whether you can
VeilVoice is **GPL-3.0-or-later**. That is not a formality and it is worth being blunt about, because it is not the licence most Rust crates use: - If you link any of these crates into a program you **distribute**, that program must also - The licence, first, because it decides whether you can
be released under the GPL-3.0-or-later, with source. - If you only ever run it yourself, internally, on your own machines and not distributed, the GPL places no obligation on you at all. - If you need it under different terms, there is no - The licence, first, because it decides whether you can
dual licence to fall back on. Every *dependency* of these crates is permissive (MIT / Apache-2.0 / BSD / ISC / Zlib), so nothing else complicates this. The obligation comes from VeilVoice itself, deliberately. - Adding a dependency
Not published on crates.io yet, so point Cargo at the repository and pin a tag: [dependencies] veilvoice-core = { git = "https://github.com/tilas01/veilvoice", tag = "v0.1.9" } Pin a **tag**, not a branch. A branch moves under you, and - Adding a dependency
this project's whole argument is that you can check what you are running. | Crate | Take it if you want | |---|---| | `veilvoice-core` | the de-identification engine, and nothing else | | `veilvoice-crypto` | the `.veil` container, - Adding a dependency
Argon2id, hybrid PQ key exchange, secure erase, and the second passphrase that opens an empty one | | `veilvoice-audio` | decoding, WAV writing, device enumeration, live capture | | `veilvoice-meta` | stripping tags, EXIF and GPS | | - Adding a dependency
`veilvoice-conversation` | several speakers in one recording, a voice each, and subtitles | | `veilvoice-video` | the same conversation drawn: a waveform, a circle per speaker, subtitles, and what can encode it here | | `veilvoice-policy` - Adding a dependency
| settings somebody else fixed and only ever stricter, and the profiles a recording can be repeated from | | `veilvoice-setup` | a per-user install that is reversible, the optional companion software, and whether there is a newer release | - Adding a dependency
| `veilvoice-verify` | a hash, a line in a signed list and a detached signature, with no GnuPG installed or through the one you have | | `veilvoice-guard` | file-integrity manifests, canary files, and another program taking a real - Adding a dependency
microphone while you are being veiled | | `veilvoice-watch` | which applications hold the microphone, the camera, the keyboard, the screen, and what is loaded in the kernel | | `veilvoice-gui` | the desktop application; a library so its - Adding a dependency
own tests can reach it, rather than something to build on | --- - 1. De-identify a buffer of samples
The smallest useful thing. `Deidentifier` owns all its state and is allocation-free once built, so it is safe to call from inside an audio callback. use veilvoice_core::{DeidConfig, Deidentifier}; let config = DeidConfig { sample_rate: - 1. De-identify a buffer of samples
48_000.0, ..DeidConfig::default() }; let mut deid = Deidentifier::new(config)?; // Mono `f32` samples, nominally in [-1, 1]. let input: Vec<f32> = vec![0.0; 48_000]; let veiled = deid.process_vec(&input); assert_eq!(veiled.len(), - 1. De-identify a buffer of samples
input.len()); `DeidConfig::default()` is the configuration the application ships with, and it has accent neutralisation **on**. Two things about it are worth knowing rather than discovering: - **Every field is validated.** - 1. De-identify a buffer of samples
`Deidentifier::new` rejects a non-finite or out-of-range configuration rather than building an engine that produces `NaN` for the rest of the session. That was a real defect (F-10) and the validation is the fix, so do not bypass it by - 1. De-identify a buffer of samples
constructing state directly. - **The engine keeps persistent state.** The accent neutraliser's long-term spectrum is an exponential moving average, so a single bad input sample used to poison every later output. Input is sanitised in the - 1. De-identify a buffer of samples
engine now, but the general point stands: one `Deidentifier` per stream, and do not reuse one across unrelated audio if you want the transform to settle honestly. - 2. Read a file, veil it, write it back
use veilvoice_audio::io; use veilvoice_core::{DeidConfig, Deidentifier}; let audio = io::load(std::path::Path::new("interview.mp3"))?; let config = DeidConfig { sample_rate: audio.sample_rate as f32, ..DeidConfig::default() }; let mut deid - 2. Read a file, veil it, write it back
= Deidentifier::new(config)?; let veiled = veilvoice_audio::io::Audio { samples: deid.process_vec(&audio.samples), sample_rate: audio.sample_rate, }; let wav: Vec<u8> = io::wav_bytes(&veiled)?; std::fs::write("clean.wav", &wav)?; - 2. Read a file, veil it, write it back
`io::load` decodes anything `symphonia` handles and gives you mono `f32`. It refuses a file that would decode to more than about twelve hours at 48 kHz rather than exhausting memory, and it pre-flights the header, because a WAV declaring a - 2. Read a file, veil it, write it back
sample rate of zero used to kill the process outright (F-9). Take the sample rate **from the file**. Feeding 48 kHz samples through an engine configured for 44.1 kHz does not fail; it just shifts everything, which is worse than failing. - 3. Seal it, rather than writing a bare WAV
The words survive de-identification on purpose, so an unencrypted result is still a recording of everything that was said. The container is the same one the application uses. use veilvoice_crypto::{container, kdf::KdfParams}; let sealed: - 3. Seal it, rather than writing a bare WAV
Vec<u8> = container::seal_with_password(b"correct horse battery staple", &wav, KdfParams::default())?; std::fs::write(container::veil_path(std::path::Path::new("clean.wav")), &sealed)?; // ... and back again let plain: Vec<u8> = - 3. Seal it, rather than writing a bare WAV
container::open_with_password(b"correct horse battery staple", &sealed)?; assert_eq!(plain, wav); Full runnable version: `cargo run -p veilvoice-crypto --example seal_and_open`. Argon2id with the RFC 9106 profile, XChaCha20-Poly1305 - 3. Seal it, rather than writing a bare WAV
payload, and a header authenticated as associated data so nobody can downgrade the KDF cost without the open failing. The cost parameters travel *in* the file, which is what lets an old container still open after the defaults rise, and is - 3. Seal it, rather than writing a bare WAV
why they are bounded on parse rather than trusted (F-2, F-3, F-20). For a recipient you cannot share a password with, `seal_to_public_key` uses X25519 **and** ML-KEM-768 together: an attacker must break both, and a recording captured today - 3. Seal it, rather than writing a bare WAV
is not readable by a quantum adversary later. - 4. Handle a passphrase without leaving it in memory
If you prompt for a passphrase yourself, put it somewhere that gets wiped. use veilvoice_crypto::Secret; let typed = String::from("correct horse battery staple"); let mut buffer = typed.into_bytes(); let secret = Secret::new(&mut buffer); - 4. Handle a passphrase without leaving it in memory
// `buffer` is zeroed by `new` // `secret` is page-locked where the OS allows it, zeroized on drop, and its // Debug impl prints nothing. Ask whether locking actually worked: if !secret.is_locked() { eprintln!("this passphrase may be - 4. Handle a passphrase without leaving it in memory
written to swap"); } // Reading it is deliberately called `expose`, so the moment is visible at the // call site rather than looking like any other getter. let key_material: &[u8] = secret.expose(); `is_locked()` reports rather than - 4. Handle a passphrase without leaving it in memory
assumes, because page locking genuinely fails on some systems and a library that pretends otherwise is worse than one that does not try. Locking does not survive hibernation, and that is stated rather than glossed. What this does **not** - 4. Handle a passphrase without leaving it in memory
do is wipe the original `String`'s buffer: that needs `unsafe`, and every crate here carries `#![forbid(unsafe_code)]`. The residue is audit item **A-5**, recorded rather than papered over. Shrinking the window from "until the program - 4. Handle a passphrase without leaving it in memory
exits" to "while the user was typing" is the part that was worth doing. - 5. Strip metadata
use veilvoice_meta::{clean_audio_file, clean_image_file}; clean_audio_file(std::path::Path::new("clean.wav"))?; // tags, including ID3 in WAV clean_image_file(std::path::Path::new("photo.jpg"))?; // EXIF, GPS A de-identified voice is worth - 5. Strip metadata
little if the file still records who made it, where and on what. WAV needs a chunk-level cleaner because `lofty` cannot remove ID3v2 from RIFF at all. See `wav.rs`. - 6. Check whether files have been tampered with
use veilvoice_guard::Manifest; let manifest = Manifest::of(&["veilvoice", "veilvoice-gui"])?; std::fs::write("veilvoice.manifest", manifest.to_string())?; // later let recorded = - 6. Check whether files have been tampered with
Manifest::parse(&std::fs::read_to_string("veilvoice.manifest")?)?; let report = recorded.check::<&str>(&[]); // `Report` distinguishes modified, removed and added, so a caller can treat a // new file differently from a changed one. - 6. Check whether files have been tampered with
eprintln!("{report:?}"); This **detects**, it does not prevent, and it says so everywhere it appears. Anything that can modify the files can modify the manifest beside them; the value is in noticing, not in stopping. - 7. See what is holding the microphone
use veilvoice_watch as watch; match watch::support() { watch::Support::Yes => { for user in watch::current()? { println!("{} is using the {:?}", user.process, user.device); } } // Reported honestly rather than as an empty list: an empty - 7. See what is holding the microphone
list from a // blind monitor is a false reassurance. other => println!("cannot see on this platform: {other:?}"), } On Linux this sees only your own processes, because `/proc/<pid>/fd` is readable by the owner and root. That is a kernel - 7. See what is holding the microphone
boundary, not a bug, and `support()` says so rather than letting an empty list imply an empty machine. --- - Things that will bite you
Collected from the audit rather than from theory. Each of these was a real defect in this codebase, so they are the mistakes most available to a caller. **Build with overflow checks on while you develop.** VeilVoice's release profile sets - Things that will bite you
`overflow-checks = false`, which is why one shipped arithmetic overflow was invisible in release and obvious in debug. If you consume these crates as libraries, your profile is yours: leave the checks on. **`panic = "abort"` is a choice - Things that will bite you
you inherit if you make it.** VeilVoice sets it for its own binaries. A decoder panic in a format VeilVoice does not itself parse cannot be caught under it, and no wrapper can, short of decoding in a separate process. If your program must - Things that will bite you
survive a hostile input file, do not set `panic = "abort"`, or decode out of process. **Do not construct configurations field by field and skip validation.** `DeidConfig::checked()` is the single funnel, and `NaN` compares false against - Things that will bite you
every bound, which is exactly how an unvalidated `NaN` sample rate produced a whole session of silent `NaN` output. **One `Deidentifier` per stream.** It is stateful by design; the accent neutraliser's memory is what makes the transform - Things that will bite you
coherent over time. **The two passwords are different secrets.** The app-lock verifier and the container passphrase are domain-separated in the KDF. Never derive one from the other, and never let unlocking an application unseal recordings. - Things that will bite you
--- - What these crates will never do
- **Reach the network.** There is no networking code, and CI fails the build if an HTTP client enters the dependency graph. If you add one, that is yours. - **Hide what was said.** De-identification is not encryption. The words are - What these crates will never do
preserved deliberately and can be transcribed. - **Remove a strong regional accent entirely.** Melody and colour go; which phonemes you produced cannot be changed at the signal level. - **Guarantee an erase on flash storage.** `shred_file` - What these crates will never do
overwrites and unlinks, and the report says what that is and is not worth. The full argument is in [WHITEPAPER.md](WHITEPAPER.md), and every limit above is stated there too, at greater length. If you build something on these crates, please - What these crates will never do
do not describe it as doing more than they do. Several tests in this repository exist purely to fail the build if that wording softens here.
docs/WEBSITE.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - The website
<https://tilas01.github.io/veilvoice/> A static site with no framework, no build step and no bundler. Every page is HTML on disk, every script is a plain ES module the browser loads directly, and nothing is minified. That is deliberate: a - The website
site about a program whose whole claim is that you can check what it does should itself be something you can read without tooling. - Two editions, and why the second is not a stub
**The ordinary site** is `website/`. It uses JavaScript for the things JavaScript is genuinely good at: search, theme switching, replaying recorded terminal sessions, hashing a downloaded file in the browser. **The no-JavaScript edition** - Two editions, and why the second is not a stub
is `website/nojs/`, and it is supported rather than tolerated. A reader with scripts off gets a real index of the whole project, not an apology. `website/nojs/search.html` carries the entire corpus **in the page**, so the browser's own - Two editions, and why the second is not a stub
find-in-page searches it, and it is generated from the same walk as the machine-readable index the scripted search fetches. The two cannot disagree, because one generator writes both. Everything on the ordinary site that needs a script - Two editions, and why the second is not a stub
also has a `<noscript>` answer, and the rule is the one `reveal.js` states about itself: content must never stay invisible. - What writes what
Almost nothing on this site is typed by hand. The generators are listed in dependency order in `tools/verify.py`, and that order matters: the search index is built last, after the SEO pass has edited the pages it indexes. | Tool | Writes | - What writes what
|---|---| | `tools/docs/generate.py` | The reference: a page per crate and per source file, for the site, the repository and the wiki | | `tools/docs/sources.py` | The syntax-highlighted source pages under `website/reference/` | | - What writes what
`tools/docs/guides.py` | The per-program guides, assembled from `docs/USER_GUIDE.md` | | `tools/docs/wiki.py` | The wiki's landing page, sidebar and document pages | | `tools/site/split.py` | The section pages, split out of `index.html` | - What writes what
| `tools/site/roadmap.py` | `roadmap.html`, from `ROADMAP.md` | | `tools/site/faq.py` | `faq.html`, from `docs/FAQ.md` | | `tools/site/releases.py` | `releases.html`, from `CHANGELOG.md` | | `tools/site/demo.py` | - What writes what
`website/js/demo-data.js`, from the recorded sessions | | `tools/site/seo.py` | Canonical addresses, the sitemap and `robots.txt` | | `tools/search-index/generate.py` | `search-index.json` and `nojs/search.html` | Each writes a header - What writes what
saying it is generated. Editing the output is wasted work: the matching `--check` run in CI regenerates into memory and compares. `website/nojs/index.html` is the one page written by hand rather than generated, because it is a summary of - What writes what
the project rather than a view of something else. - The scripts
Twelve modules, 3178 lines, no dependencies and nothing from a CDN. | Module | What it does | |---|---| | `demo-data.js` | Generated. The facts the demonstration is drawn from | | `legal.js` | The welcome dialogue: licence, liability - The scripts
waiver, and the AI-assistance disclosure | | `markdown.js` | A small Markdown renderer and syntax highlighter, written rather than pulled from a CDN | | `prefetch.js` | Quietly fetches the few pages a reader is most likely to open next | | - The scripts
`repo.js` | Live repository data: stars, description, latest release, rendered README | | `reveal.js` | Reveal-on-scroll, under one rule that outranks the effect: content must never stay invisible | | `search.js` | Scores a query against - The scripts
the committed index | | `sessions.js` | Replays five recordings of the real programs at typing speed | | `teleport.js` | Makes a fragment link land on the heading it names, clear of the sticky header | | `theme.js` | Nine palettes, Tokyo - The scripts
Night default, kept in `localStorage` and never sent anywhere | | `verify.js` | SHA-256 of a downloaded archive, computed in the browser | | `walkthrough.js` | Every screen of the application as a photograph, and the command line as a list - The scripts
of jobs | **Why `markdown.js` exists at all.** Pulling `marked` and `highlight.js` off a CDN would have been three lines. It would also have meant two more parties able to change what this site executes, on a site whose subject is not - The scripts
trusting people by default. **Nothing here phones home.** `theme.js` keeps a preference in `localStorage`, which stays in the browser. `verify.js` hashes the file you give it locally: the file never leaves the machine. `repo.js` is the one - The scripts
module that fetches anything across the network, and what it fetches is GitHub's public API about this repository. - Styles and palettes
`website/css/themes.css` holds nine palettes and `main.css` the layout. Both are documented as source pages in the reference, the same as the scripts. The palettes are shared with the desktop application, so a reader who picks one on the - Styles and palettes
site sees the same one in the program. - Running it locally
python tools/site/serve.py It serves `website/` with correct MIME types on a local port. There is an nginx configuration alongside it for anybody who would rather use that. The site tests check the pages without a browser: node - Running it locally
tools/site-tests/run.js Those cover characters, structure, rendering, hostile input, the scroll reveal, that every page is reachable, and that the local site serves every address the sitemap claims. - Addresses
Every page states its own canonical address and appears in `sitemap.xml`; `robots.txt` allows every crawler and names the sitemap. `tools/site/seo.py` writes all three, and `--check` fails if a page has been added without one.
docs/WHITEPAPER.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - VeilVoice: what it destroys, what it keeps, and why
**Updated 2026-08-16**, covering the tree as it stands including at-rest encryption by default and the app lock. This document is the honest version of the pitch. It states what VeilVoice guarantees, how, and, at least as importantly, what - VeilVoice: what it destroys, what it keeps, and why
it does not. A privacy tool that overstates its reach is worse than none, because someone will rely on the part that was never true. --- - 1. The goal, and the contradiction in the obvious version of it
The intuitive ask is "make it impossible to isolate my voice, fill the whole spectrogram with noise." That cannot be built, because it conflicts with the other half of the requirement. Audio that stays **understandable and transcribable** - 1. The goal, and the contradiction in the obvious version of it
must carry the phonemes; noise that covers the phonemes covers the words. The two goals are mutually exclusive, and no amount of engineering reconciles them. So VeilVoice targets the achievable and genuinely useful goal: > **Irreversible - 1. The goal, and the contradiction in the obvious version of it
destruction of the speaker's identity, with intelligibility > preserved on purpose.** The *voiceprint*, meaning fundamental pitch, formant structure, timbre, micro-timing and the melody of an accent, is destroyed. The *words* survive. If - 1. The goal, and the contradiction in the obvious version of it
the message also needs to be secret, that is a different problem with a different solution: encrypt it. --- - 2. Threat model
**Assumed adversary.** Holds the output audio. Has unlimited compute, the full VeilVoice source, and knowledge of every parameter *except* the per-session random seed. May hold reference recordings of the target speaker and use - 2. Threat model
state-of-the-art speaker-recognition models. May store the file indefinitely, including until a cryptographically relevant quantum computer exists. **In scope.** Recovering the original waveform. Recovering the speaker's biometric - 2. Threat model
voiceprint. Matching the output against a reference recording of the same speaker. **Explicitly out of scope.** - **The words.** Preserved deliberately. See "If the message must be secret too" below. - **Background content.** Room - 2. Threat model
acoustics, a doorbell, a colleague's voice, a regional siren. VeilVoice processes the whole signal but does not attempt scene sanitisation. Check what else is in your recording. - **An attacker already on your machine.** If they can read - 2. Threat model
process memory or tap the microphone before VeilVoice does, nothing here helps. The app lock (§8) raises the bar against someone who merely sits down at an unlocked session; it does nothing against someone running code as you, and does not - 2. Threat model
claim to. - **Metadata outside the file.** Filenames, filesystem timestamps and the channel you send it over. `veilvoice-meta` cleans metadata *inside* the file only. --- - 3. Why the transform is one-way
Three independent mechanisms, each individually lossy. Reversal requires defeating all three. - 3.1 Phase is discarded every frame
For each STFT frame VeilVoice keeps only the magnitude spectrum and throws away the measured phase. Phase encodes the precise waveform and the speaker's micro-timing, the excitation pattern that makes one glottis distinguishable from - 3.1 Phase is discarded every frame
another. This is not obfuscation, it is deletion. The information is never written down, never stored, and never derivable from what remains: an infinite family of waveforms shares any given magnitude spectrogram. A fresh, synthetic phase - 3.1 Phase is discarded every frame
is generated in its place. - 3.2 Every speaker is collapsed onto one canonical identity
Three of the strongest biometric features are *normalised*, not randomised: | Feature | What happens | |---|---| | Pitch register | Mapped to one constant canonical fundamental | | Vocal-tract length | Warped so the long-term formant - 3.2 Every speaker is collapsed onto one canonical identity
centroid hits one canonical value | | Long-term spectral tilt | Rotated onto one canonical slope | Each mapping is **many-to-one**. A whole population of speakers lands on the same output value, so the original cannot be inferred from the - 3.2 Every speaker is collapsed onto one canonical identity
result: there is nothing to invert, only a value that many inputs share. This is strictly stronger than randomising those features, which would merely displace them. Crucially, every correction is derived from a **multi-second average**, - 3.2 Every speaker is collapsed onto one canonical identity
never from the current frame. Per-frame spectral shape is what distinguishes /i/ from /u/; normalising it frame-by-frame would erase the vowels along with the accent. The engine's test suite asserts that vowel contrast survives. - 3.3 The residual transform is non-stationary and CSPRNG-driven
The formant ratio changes every frame, drawn from a ChaCha20 stream whose 32-byte seed comes from the OS CSPRNG, lives only in page-locked RAM, and is zeroized on drop. There is no single fixed transform to undo, and the sequence is - 3.3 The residual transform is non-stationary and CSPRNG-driven
unknowable without the seed, which is never written anywhere. The per-bin synthesis phase offsets come from the same stream. - 3.4 The seed rolls forward
The stream does not run for the whole session. Every two seconds by default, the modulator draws a fresh 32-byte seed **from its own output** and restarts on it. ChaCha20 cannot be run backwards, so this is a ratchet: an adversary who - 3.4 The seed rolls forward
somehow obtained the current state could produce everything from that moment on and still could not reconstruct the modulation of any earlier segment. A long recording is therefore not one key stream but a chain of short ones, each sealed - 3.4 The seed rolls forward
permanently once it has passed. The interval is configurable, including off. Rolling is inaudible by construction, because the smoothed parameters are never reset, only their source of future targets, and the per-bin phase offsets ease to - 3.4 The seed rolls forward
their new values over about half a second rather than stepping. Both properties are asserted in the test suite, one of them by comparing the worst sample-to-sample jump against a non-rolling run. The seed is deliberately *not* re-read from - 3.4 The seed rolls forward
the OS CSPRNG on each roll. That would put a syscall inside an audio callback every couple of seconds, and it would make deterministic runs impossible, which the reproducible-build story depends on. The OS seeds the chain once; the ratchet - 3.4 The seed rolls forward
carries it forward. --- - 4. Accent: what is removed, and the limit
Accent is carried by two different kinds of cue, and they get different answers. **Suprasegmental cues: removed.** Intonation contour, pitch range, voice quality, and the vocal-tract scale behind a speaker's vowel space. These are - 4. Accent: what is removed, and the limit
properties of the signal, and the normalisation described above collapses all of them. **Segmental cues: cannot be removed.** *Which phonemes the speaker actually produced*: rhoticity, vowel mergers, dental-fricative substitution, - 4. Accent: what is removed, and the limit
aspiration patterns. At this level the accent **is** the words. Changing it means deciding that a different phoneme was said, which no filter can do: it requires recognising the speech and re-synthesising it. **Therefore: a strong regional - 4. Accent: what is removed, and the limit
accent may still be audible in the output, even though its melody and colour are gone.** VeilVoice does not claim otherwise. The planned text-to-speech mode closes this gap completely, because it never carries the original signal at all. - 4. Accent: what is removed, and the limit
--- - 5. Synthesis, and an honest note on how it sounds
Voiced frames are resynthesised as an ideal harmonic comb at the canonical fundamental, quantised to the nearest FFT bin, passed through the (warped, tilt-corrected) formant envelope. This is the textbook source-filter model. Two - 5. Synthesis, and an honest note on how it sounds
consequences worth stating plainly: - **The output has a synthetic, even quality.** Pitch is constant by design. This is the sound of the identity being gone, not a defect. - **Pitch resolution is limited by the bin grid** (46.875 Hz at - 5. Synthesis, and an honest note on how it sounds
the 48 kHz / 1024 default), so the canonical register lands on the nearest bin. Irrelevant when flattening fully, which is the default; partial flattening steps rather than glides. Lifting this needs window-kernel synthesis, which is - 5. Synthesis, and an honest note on how it sounds
future work. Unvoiced frames keep a channel-vocoder phase, which is the correct model for fricatives and noise. --- - 6. What an attacker can still learn
Stated so nobody is surprised: - **The words.** By design. - **Speaking rate and rhythm.** VeilVoice does not time-warp. Rate is a weak biometric but a real one, and it survives. - **Language, dialect vocabulary, and idiolect.** Word - 6. What an attacker can still learn
choice is content. - **Whether two outputs came from the same *session*.** Within one session the seed is fixed. Different sessions are unlinkable; a single long recording is internally consistent. - **Coarse voice-activity structure**, - 6. What an attacker can still learn
meaning when you spoke and when you did not. --- - 7. The message, and why it is encrypted by default
De-identification and confidentiality are separate problems. The engine solves only the first: the words survive on purpose, so a veiled recording sitting on a disk is still a recording of everything that was said. Writing it in the clear - 7. The message, and why it is encrypted by default
by default would leave the second problem silently unsolved for everyone who did not think to ask. So **`veilvoice anonymise` seals its output** into a `.veil` container, and the desktop app does the same. Turning that off is possible and - 7. The message, and why it is encrypted by default
prints what is being given up first: the CLI waits for the word `UNENCRYPTED` on a terminal; the GUI opens a dialogue that must be answered before the tick box changes. The WAV is encoded **in memory** and sealed there. An encrypted - 7. The message, and why it is encrypted by default
recording never exists on disk in the clear, not even briefly. This matters more than it sounds: a plaintext file that is written and then deleted cannot be reliably taken back on flash storage, because wear levelling leaves the original - 7. The message, and why it is encrypted by default
blocks in cells no write can reach, and the argument is set out in full in `veilvoice-crypto`'s `shred` module. The primitives: - **Argon2id** (RFC 9106 profile) for password-derived keys. - **X25519 + ML-KEM-768 hybrid** for public-key - 7. The message, and why it is encrypted by default
encryption. Hybrid because ML-KEM is young and X25519 falls to a quantum adversary; breaking the construction requires breaking both. This matters for *harvest-now-decrypt-later*: a recording stored today may be attacked decades from now. - 7. The message, and why it is encrypted by default
- **XChaCha20-Poly1305** for the payload, with random 192-bit nonces, and the full container header authenticated as associated data, so an attacker cannot downgrade the stored KDF cost to make cracking cheap. - **Page-locked, zeroizing - 7. The message, and why it is encrypted by default
secrets**, so keys do not reach the swap file. Locking does not survive hibernation and does not stop an attacker who can already read process memory; `Secret::is_locked` reports whether it actually succeeded rather than assuming. One - 7. The message, and why it is encrypted by default
caveat that is stated rather than engineered around: a passphrase **being typed** into a text field or a terminal prompt lives in an ordinary string, because something has to receive the keystrokes. It is moved into a page-locked `Secret` - 7. The message, and why it is encrypted by default
the moment it is confirmed and the buffer is wiped, so the exposure lasts as long as the typing rather than as long as the session, but for those moments it is ordinary memory and could reach swap. Closing the gap entirely needs a custom - 7. The message, and why it is encrypted by default
text widget nobody would audit, which is a worse trade than saying so here. --- - 8. The app lock, and exactly what it is worth
VeilVoice can be put behind a password of its own, so that someone who picks up your unlocked computer cannot open it, see which files you have processed, or start a live scramble. **This is not tamper-proof, and cannot be.** A local - 8. The app lock, and exactly what it is worth
application has nowhere to hide a secret from the machine it runs on. Whoever can read the lock file can also delete it, and deleting it removes the lock. That is not a gap in the implementation; it is what a local lock *is*. The unlock - 8. The app lock, and exactly what it is worth
screen says so on the screen itself rather than in a footnote. What it is, concretely: - **An Argon2id verifier**, not a key. `Argon2id(domain ‖ password, salt)` is stored and compared in constant time. It deliberately encrypts nothing, - 8. The app lock, and exactly what it is worth
because there is nothing here it could usefully encrypt, and implying otherwise would be the overclaim this document exists to avoid. - **Rate limited, and the limit is persisted.** Three attempts are free; after that the wait doubles: 5 - 8. The app lock, and exactly what it is worth
s, 10 s, 20 s … capped at fifteen minutes. The count is written to disk after every attempt, so killing the process does not hand an attacker a fresh budget. - **A separate password from the recording one.** Unlocking the app is not the - 8. The app lock, and exactly what it is worth
same act as unsealing everything it has ever written. The verifier is domain-separated, so a user who types the same passphrase in both places still does not end up with two copies of one value. What it is not: - **Not a defence against - 8. The app lock, and exactly what it is worth
someone holding the disk.** They can delete the lock file, edit the attempt counter, move the clock to defeat the wait, or attack the stored hash offline. Argon2id at 256 MiB makes each offline guess expensive, which helps a good - 8. The app lock, and exactly what it is worth
passphrase and does not save a bad one. - **Not disk encryption.** If the disk is the threat, use LUKS, BitLocker or FileVault, and encrypt the recordings themselves, which VeilVoice now does by default. --- - 9. Verifying these claims yourself
Nothing here asks for trust: cargo test --workspace # the properties above are asserted in tests cargo run -p veilvoice-core --example spectrum_report `spectrum_report` prints where the output partials land, demonstrating the synthesis - 9. Verifying these claims yourself
behaviour directly. The engine's tests assert speaker convergence, vowel-contrast survival, gain neutrality and real-time performance. The crypto tests assert header-downgrade detection, tamper detection on each half of the hybrid, and - 9. Verifying these claims yourself
that a wiped secret is actually wiped. The honest statements above are asserted too, not merely written down: tests check that at-rest encryption is still the default, that a job refuses to start with encryption on and nothing to encrypt - 9. Verifying these claims yourself
with, that the app lock's rate limit has the shape claimed here and survives a reload, and that the warning texts still say the uncomfortable part rather than having been softened into reassurance. **VeilVoice contains no `unsafe` code.** - 9. Verifying these claims yourself
Every crate carries `#![forbid(unsafe_code)]`, including the page-locking path. --- - 10. Status of this document
This is a design and rationale document, not a peer-reviewed security proof. The de-identification argument rests on information destruction that is easy to verify by reading `spectral.rs` and `accent.rs`; the cryptography uses standard, - 10. Status of this document
well-reviewed primitives from the RustCrypto and dalek ecosystems rather than anything invented here. The code has been **audited by tilas01**, who wrote and reviewed it. That is a maintainer audit and is worth exactly what a maintainer - 10. Status of this document
audit is worth: it catches what the author can see. **No external firm or independent researcher has reviewed this code**, and until one has, the strongest verification available to you is the source itself, which is why it is written to - 10. Status of this document
be read. How much that caveat is worth is now a measured quantity rather than a modest noise. The latest audit round, covering parser fuzzing, timing measurement, an adversarial read of the DSP and hostile-input testing of the website, - 10. Status of this document
found **seven defects in code the previous round had called clean**. None broke confidentiality; two aborted the process on a crafted file and one silently turned every subsequent recording into noise. All are fixed and written up in - 10. Status of this document
`docs/AUDIT.md`, including the ones that make the earlier "no vulnerabilities found" look complacent. Better tools found what careful reading had not, which is the argument for someone outside the project looking next.
docs/source/index.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> - The website's own source
Ten files the site invites you to open and read, each with a page of its own: what it does, and the same thing in plain words. | File | Lines | What it is | |---|---:|---| | [`website/js/demo-data.js`](website-js-demo-data-js.md) | 335 | - The website's own source
GENERATED by tools/site/demo.py | | [`website/js/legal.js`](website-js-legal-js.md) | 201 | The welcome dialog: licence terms, liability waiver, and the disclosure that this project was built with AI assistance | | - The website's own source
[`website/js/markdown.js`](website-js-markdown-js.md) | 489 | A small Markdown renderer and syntax highlighter | | [`website/js/prefetch.js`](website-js-prefetch-js.md) | 146 | Fetch, quietly and in the background, the few things a reader - The website's own source
is most likely to open next -- so that clicking them is instant instead of a wait | | [`website/js/repo.js`](website-js-repo-js.md) | 392 | Live repository data: stars, description, latest release, and the README rendered with syntax - The website's own source
highlighting | | [`website/js/reveal.js`](website-js-reveal-js.md) | 125 | Reveal-on-scroll, with one rule that outranks every other consideration: **content must never stay invisible.** | | - The website's own source
[`website/js/search.js`](website-js-search-js.md) | 496 | Search across the whole repository and this website | | [`website/js/sessions.js`](website-js-sessions-js.md) | 219 | The command line, on the page, typed out | | - The website's own source
[`website/js/teleport.js`](website-js-teleport-js.md) | 310 | Following a link to a section of the page lands on that section's heading, with the heading visible | | [`website/js/theme.js`](website-js-theme-js.md) | 104 | Theme switching | - The website's own source
| [`website/js/verify.js`](website-js-verify-js.md) | 211 | In-browser SHA-256 verification for downloaded release archives | | [`website/js/walkthrough.js`](website-js-walkthrough-js.md) | 162 | Every screen of the application as a - The website's own source
photograph you pick between, and the command line as a list of jobs rather than a list of flags | | [`website/css/main.css`](website-css-main-css.md) | 2530 | One stylesheet, no framework, no web fonts, no third-party requests of any kind - The website's own source
| | [`website/css/themes.css`](website-css-themes-css.md) | 174 | Colour schemes |
docs/source/website-css-main-css.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-css-main-css.svg" alt="website/css/main.css" width="100%"> </p> - website/css/main.css
[the source](https://github.com/tilas01/veilvoice/blob/main/website/css/main.css) · 2530 lines - What it does
One stylesheet, no framework, no web fonts, no third-party requests of any kind. A site for a privacy tool has no business loading a CDN: every remote asset is a request that tells someone else you were here. Typography is monospace - What it does
throughout, from the fonts the reader already has. - In plain words
This is what the site looks like: the spacing, the type, the buttons, the diagrams and the animations. There is no framework and no font downloaded from anywhere else. Every remote thing a page loads is a request that tells somebody else - In plain words
you were here, and a site for a privacy tool has no business making them. The type you see is one you already have. - The sections it is made of
| Section | Line | |---|---:| | header | 102 | | header on a phone | 166 | | hero | 285 | | the banner, drawn in CSS | 324 | | the journey: a file in, a file out | 505 | | the demonstration | 719 | | the questions page | 936 | | the - The sections it is made of
recorded terminal | 964 | | tooltips | 1056 | | the cycling fact line | 1186 | | the veil animation | 1253 | | reveal on scroll | 1290 | | the walkthrough | 1309 | | buttons | 1370 | | sections | 1396 | | verifier | 1508 | | repo panel | - The sections it is made of
1556 | | the repository panel, while it loads | 1563 | | the screenshot gallery | 1614 | | wiki | 1679 | | a source file, on this site | 1770 | | the releases page | 1839 | | footer | 1874 | | welcome / legal gate | 1899 | | search | 2016 - The sections it is made of
| | the JavaScript edition toggle | 2186 |
docs/source/website-css-themes-css.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-css-themes-css.svg" alt="website/css/themes.css" width="100%"> </p> - website/css/themes.css
[the source](https://github.com/tilas01/veilvoice/blob/main/website/css/themes.css) · 174 lines - What it does
Colour schemes. Every theme defines the same eleven tokens, so the rest of the stylesheet never names a colour directly and adding a theme is a matter of adding one block here. Tokyo Night is the default and is declared on :root so the - What it does
page has correct colours before any JavaScript runs, including for readers who block it entirely. The others are opt-in via [data-theme] on <html>. Each block also declares `color-scheme`, beside the colours it has to agree with. That is - What it does
the one thing CSS custom properties cannot reach: the browser's *native* controls -- the theme `<select>`'s dropdown list, the verifier's `<progress>` bar, the file picker -- are drawn by the platform, and without this they are drawn light - What it does
on a near-black page. Keeping it in the same block as the palette means a new theme cannot be added with the wrong one. - In plain words
This is the list of colour schemes -- nine of them, twelve colours each. Everything else on the site refers to colours by their job rather than by name: "the background", "a warning", "secondary text". That is why adding a whole new scheme - In plain words
means adding a block here and changing nothing else, and why the desktop application can offer exactly the same nine. - The sections it is made of
_This stylesheet has no section markers._
docs/source/website-js-demo-data-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-demo-data-js.svg" alt="website/js/demo-data.js" width="100%"> </p> - website/js/demo-data.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/demo-data.js) · 335 lines - What it does
GENERATED by tools/site/demo.py. Do not edit. The facts the interactive demonstration is drawn from: the tabs the desktop application has, and exactly what each command line subcommand printed when it was last captured. Both come from the - What it does
source rather than from anybody's memory of it, so the model in the page cannot quietly stop matching the program it is a model of. - In plain words
A list of what is in the app and what each command prints, taken straight from the code, so the demonstration on the website stays honest when the program changes. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-demo-data-js-chart.svg" alt="what calls what in website/js/demo-data.js" width="640"> </p>
docs/source/website-js-legal-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-legal-js.svg" alt="website/js/legal.js" width="100%"> </p> - website/js/legal.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/legal.js) · 201 lines - What it does
The welcome dialog: licence terms, liability waiver, and the disclosure that this project was built with AI assistance. - Shown once per session, and never phoned home
Acceptance is recorded in sessionStorage, so it survives navigation between pages of this site and is gone when the tab closes. It is not a cookie, so it is never attached to a request; there is no server here to receive it and no - Shown once per session, and never phoned home
analytics to correlate it with. That is also why there is no "remember me forever" option -- a permanent record would be more data about you than this site has any business keeping. - Why it is a real gate and not a banner
The waiver's section 4 is the part that matters: it says plainly that this software hides *who said it*, not *what was said*. Someone who assumes the opposite could send a recording believing its contents are protected. A dismissible strip - Why it is a real gate and not a banner
at the bottom of the page does not carry that. The page underneath is inert while the dialog is open -- focus is trapped, and the content is hidden from assistive technology -- so the gate cannot be stepped around by tabbing past it. - In plain words
This is the notice you see the first time you open the site: the licence, what this project does not promise, and the fact that it was built with AI assistance. It remembers that you read it for as long as the tab is open, and forgets when - In plain words
you close it. Nothing about that is sent anywhere. There is no "remember me forever" option because keeping a permanent note about you would be more than a privacy tool's website has any business keeping. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-legal-js-chart.svg" alt="what calls what in website/js/legal.js" width="640"> </p> | Function | Line | |---|---:| | `accepted` | 42 | | `remember` | 46 | | `build` | 50 | | `show` | 140 | | `sync` | 155 - What calls what
|
docs/source/website-js-markdown-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-markdown-js.svg" alt="website/js/markdown.js" width="100%"> </p> - website/js/markdown.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/markdown.js) · 489 lines - What it does
A small Markdown renderer and syntax highlighter. - Why not a library
Pulling `marked` and `highlight.js` off a CDN would be three lines. It would also mean every visitor to a privacy tool's website makes requests to a third party that learns their IP address and what they were reading, and it would mean - Why not a library
trusting code nobody here has read. This is a few hundred lines that does what this page needs and nothing else. - Escaping
Everything is HTML-escaped *first*, and only the tags this renderer itself emits are ever introduced. Raw HTML in the source Markdown is shown as text rather than injected, so the README cannot inject script into this page even if it were - Escaping
altered upstream. - In plain words
This turns the project's plain-text documents into the formatted pages you read, including the colours in the code examples. Most sites borrow somebody else's code from another company's server to do this. That would tell that company your - In plain words
address and what you were reading. This is a few hundred lines written here instead, so nothing about your visit leaves this site. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-markdown-js-chart.svg" alt="what calls what in website/js/markdown.js" width="640"> </p> | Function | Line | |---|---:| | `escapeHtml` | 64 | | `parker` | 95 | | `park` | 98 | | `unpark` | 119 | | - What calls what
`keywordsFor` | 160 | | `highlight` | 164 | | `lang` | 166 | | `safeUrl` | 225 | | `isExternal` | 231 | | `inline` | 307 | | `render` | 360 | | `tableRow` | 366 | | `cells` | 419 |
docs/source/website-js-prefetch-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-prefetch-js.svg" alt="website/js/prefetch.js" width="100%"> </p> - website/js/prefetch.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/prefetch.js) · 146 lines - What it does
Fetch, quietly and in the background, the few things a reader is most likely to open next -- so that clicking them is instant instead of a wait. - Why any of this is in JavaScript at all
Most of it is not. The pages carry `<link rel="prefetch">` in their markup, which is declarative, costs no script, and works in the JavaScript-free edition exactly as it does here. That is deliberate: a reader who runs no scripts should - Why any of this is in JavaScript at all
not get a slower site as a punishment. This file exists for the one thing that should *not* be declared in markup: `search-index.json` is about a megabyte. A `<link rel="prefetch">` for it would be fetched by every visitor on every page, - Why any of this is in JavaScript at all
including somebody on a metered phone connection who never opens the search. So it is fetched from script, where the conditions below can be checked first. - The conditions, and why each one
- `Save-Data`. If the reader has asked their browser to use less data, downloading a megabyte they did not ask for is precisely the thing they asked not to happen. - `prefers-reduced-data`. The same request expressed as a media query, - The conditions, and why each one
which is what Safari implements. - `effectiveType`. On 2G, a megabyte in the background competes with the page the reader is actually trying to read. - Idle time. `requestIdleCallback` means this never runs while the browser has something - The conditions, and why each one
better to do. Without it a prefetch can delay the very page it was meant to make faster. - Same origin only
Every URL here is a path on this site. This is stated because a prefetch is a real network request, and a privacy tool that quietly reached a third party to make itself feel fast would be undermining its own argument. There is no - Same origin only
third-party host in this file and there is nothing to configure. - In plain words
This quietly fetches the one or two pages you are most likely to click next, so that when you do, they are already there. Almost all of it is done by the pages themselves without any code at all. This file exists for the search index, - In plain words
which is about a megabyte -- too much to pull down on a phone on a slow connection for something you might never open. So it asks the browser how good the connection is, and skips it when the answer is "not very". - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-prefetch-js-chart.svg" alt="what calls what in website/js/prefetch.js" width="640"> </p> | Function | Line | |---|---:| | `pageName` | 65 | | `wantsLessData` | 72 | | `alreadyQueued` | 87 | | `prefetch` - What calls what
| 95 | | `whenIdle` | 112 | | `start` | 123 |
docs/source/website-js-repo-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-repo-js.svg" alt="website/js/repo.js" width="100%"> </p> - website/js/repo.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/repo.js) · 392 lines - What it does
Live repository data: stars, description, latest release, and the README rendered with syntax highlighting. - The one third-party request on this site, and why it is opt-in
Everything else here is served from the same origin. This module talks to api.github.com, which learns your IP address and that you looked at this project. GitHub already knows both -- it is serving the page you are reading -- so the - The one third-party request on this site, and why it is opt-in
marginal cost is nil for most visitors. But someone reading over Tor or a mirror is in a different position, so the fetch is announced in the page and can be skipped: the panel degrades to static text and a plain link, and nothing else on - The one third-party request on this site, and why it is opt-in
the site depends on it. No token, no cookies, no credentials. Unauthenticated GitHub API requests are rate-limited by IP to 60/hour, which a documentation page will never approach. - In plain words
This is the panel showing the project's stars, its latest release and its README, read from GitHub as you look at it. It is the only thing on this site that talks to anybody else's server, and the page says so before it does. GitHub learns - In plain words
your address and that you looked at this project -- which it already knows, because it is serving you this page. If you would rather it did not happen at all, the panel turns into a plain link and nothing else on the site notices. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-repo-js-chart.svg" alt="what calls what in website/js/repo.js" width="640"> </p> | Function | Line | |---|---:| | `text` | 38 | | `number` | 43 | | `still` | 48 | | `arrive` | 59 | | `countTo` | 78 | | - What calls what
`frame` | 92 | | `loadMeta` | 104 | | `safeAssetUrl` | 150 | | `loadRelease` | 158 | | `stripHtmlBlocks` | 245 | | `loadReadme` | 283 | | `settleButton` | 345 | | `start` | 351 |
docs/source/website-js-reveal-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-reveal-js.svg" alt="website/js/reveal.js" width="100%"> </p> - website/js/reveal.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/reveal.js) · 125 lines - What it does
Reveal-on-scroll, with one rule that outranks every other consideration: **content must never stay invisible.** The first version of this file broke that rule, and it took rendering the page to notice. An IntersectionObserver fires when an - What it does
element's intersection ratio crosses a threshold. If the viewport *jumps* -- an anchor link from the nav, a browser restoring your scroll position when you come back, a find-in-page hit -- an element can go from below the viewport to above - What it does
it between two frames. It was not intersecting before and is not intersecting after, the ratio never left zero, and no callback ever runs. Because a reveal that re-hides on scroll-up is a gimmick, nothing ever showed it again. Three - What it does
paragraphs of the walkthrough were invisible that way, and one of them was the box explaining that the app lock is not tamper-proof. A page whose entire argument is that it states its limits had made a limit unreadable. So there are two - What it does
mechanisms here, and they are not redundant: 1. The observer, which does the animation and costs nothing while idle. 2. A sweep, which asks a much simpler question -- "is this element at or above the bottom of the viewport?" -- and reveals - What it does
anything that is, whether or not the observer ever saw it cross. It runs on scroll and resize, coalesced into one animation frame, and **both listeners and the observer detach the moment the last element is revealed.** On an ordinary read - What it does
that is a fraction of a second of work in total, and none at all thereafter. The other constraints, unchanged: - Never hide what cannot then be shown. The `.reveal` rule is scoped to `html.js`, set by `theme.js` from a blocking head - What it does
script, so a reader without JavaScript sees everything immediately. - Animate only `opacity` and `transform`, which the compositor handles without laying out the page again. - Obey `prefers-reduced-motion`: someone who asked the system for - What it does
less movement gets the content with no movement at all. - In plain words
This is the gentle fade as sections of the page come into view. It is written around one rule that matters more than the effect: text must never end up invisible. An earlier version could leave whole paragraphs hidden if you jumped down - In plain words
the page, which was found by looking at the page rather than by any test. If anything at all goes wrong now, everything simply shows. If you have asked your computer for less movement, there is no fade at all. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-reveal-js-chart.svg" alt="what calls what in website/js/reveal.js" width="640"> </p> | Function | Line | |---|---:| | `showAll` | 55 | | `reveal` | 74 | | `stop` | 82 | | `sweep` | 90 | | `schedule` | - What calls what
98 |
docs/source/website-js-search-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-search-js.svg" alt="website/js/search.js" width="100%"> </p> - website/js/search.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/search.js) · 496 lines - What it does
Search across the whole repository and this website. The index is built by `tools/search-index/generate.py` and committed, so this file only has to read it. CI regenerates and compares, which means a stale index fails the build instead of - What it does
quietly answering questions about code that no longer looks like that. - This file is pure ASCII, on purpose
`website/js/*.js` is served raw and people are invited to open it. A viewer that guesses CP1252 turns an em dash into mojibake in the middle of a sentence promising the code is honest, so anything non-ASCII is written as a `\uXXXX` escape. - This file is pure ASCII, on purpose
`tools/site-tests/characters.test.js` fails the build otherwise. - Nothing from the index is ever HTML
The index contains text taken verbatim from source files -- including, by construction, this project's own tests for hostile markup, which contain `<script>` and `onerror=` as ordinary content. Every value out of the index therefore - Nothing from the index is ever HTML
reaches the page through `textContent` or `createTextNode` and never through `innerHTML`. Match highlighting is done by splitting a string and appending text nodes and `<mark>` elements built with `createElement`, which is why it looks - Nothing from the index is ever HTML
more long-winded than a `replace` would. - Bounded work
F-22 and F-23 were quadratic blow-ups in the Markdown renderer on text fetched over the network, and the lesson generalises: this file bounds the query, the number of terms, the number of results scored and the number rendered, so no input - Bounded work
makes the tab do an unbounded amount of work. Scoring is a linear pass with a plain `indexOf` -- no regular expression is ever built from user input, so there is no pattern for a query to blow up. - In plain words
This is the search box. It looks through every file in the project -- the code, the documents and this website -- and shows you the lines that match. The searching happens in your browser, on a list that ships with the site. Nothing you - In plain words
type is sent anywhere, and there is nothing here that could collect it. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-search-js-chart.svg" alt="what calls what in website/js/search.js" width="640"> </p> | Function | Line | |---|---:| | `byId` | 64 | | `text` | 66 | | `clear` | 73 | | `appendHighlighted` | 81 | | - What calls what
`parseQuery` | 115 | | `score` | 133 | | `search` | 182 | | `sortResults` | 212 | | `resultUrl` | 239 | | `renderResult` | 247 | | `shapeOf` | 292 | | `render` | 297 | | `scheduleRender` | 350 | | `fillSelect` | 360 | | `readQueryFromUrl` - What calls what
| 374 | | `fail` | 382 | | `fold` | 401 | | `start` | 414 |
docs/source/website-js-sessions-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-sessions-js.svg" alt="website/js/sessions.js" width="100%"> </p> - website/js/sessions.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/sessions.js) · 219 lines - What it does
The command line, on the page, typed out. - What this plays, and why it is not a drawing
Five recordings of the real programs: the bytes `veilvoice` wrote on a real terminal, with the passphrases typed at the real prompts. `tools/shots/ sessions.py` records them into `assets/screenshots/session-*.txt` and `tools/site/demo.py` - What this plays, and why it is not a drawing
turns those into `window.VEILVOICE_DEMO.sessions`, which CI regenerates and compares. Nothing here is written by hand, so nothing here can quietly stop matching the program. The one invented thing is the pacing. A session that arrives all - What this plays, and why it is not a drawing
at once is a paste rather than a session, so the typing is played at about the speed somebody types and the output at about the speed a terminal fills. That is the whole of the fiction and it is stated here rather than implied. - What used to be here instead
A hand-built model of the desktop application, drawn in CSS, opened from a button as an overlay over whichever page the reader was on. It responded to clicks and it was a drawing: the device names, the levels and the panels were all - What used to be here instead
written by hand, and it veiled no audio. It was labelled as a drawing, and a label is a weaker thing than not needing one. It was also redundant. The real window is photographed on every build, nine captures, one per screen, and those are - What used to be here instead
on this page too. Offering a reader a drawing of an interface *and* photographs of the same interface asks them to work out which one to believe, on a site whose argument is that they should not have to take anybody's word for anything. So - What used to be here instead
the drawing is gone and this is what replaced it: the recordings, on the page rather than behind a button, above the photographs of the window. - In plain words
Plays back real terminal sessions at typing speed, so you can see what the program actually prints without installing it. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-sessions-js-chart.svg" alt="what calls what in website/js/sessions.js" width="640"> </p> | Function | Line | |---|---:| | `el` | 51 | | `still` | 58 | | `stop` | 104 | | `whole` | 108 | | `mark` | 114 | - What calls what
| `play` | 124 |
docs/source/website-js-teleport-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-teleport-js.svg" alt="website/js/teleport.js" width="100%"> </p> - website/js/teleport.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/teleport.js) · 310 lines - What it does
Following a link to a section of the page lands on that section's heading, with the heading visible. - The defect this replaces
The header is `position: sticky`, so the top of the viewport is covered by it. `scroll-margin-top` is the property for that, and this site set it to a flat `90px`. The header is not 90px tall. Measured in a browser it is 133px at desktop - The defect this replaces
widths, 171px where the navigation wraps to three rows, 115px on a phone, 143px at 320px, and 81px on the reference pages. Every one of those is a landing that is wrong, and at the common desktop width it is 43px wrong in the direction - The defect this replaces
that hides the heading behind the header. It was also set on `section`, `h2` and `h3` only, so an `h4` or a list item with an id -- which is most of the releases page and every roadmap entry -- had no offset at all and landed a full header - The defect this replaces
height underneath it. The edition of this site that runs no scripts has never had this problem, because it has no sticky header. That is the standard being matched here. - How the offset is decided
By measuring the header, not by writing a number down. `--anchor-offset` is set from the header's own height and kept current by a ResizeObserver, so a header that grows a row, a font that loads late and a phone that is turned sideways all - How the offset is decided
correct themselves. The stylesheet carries a starting value for the moment before this runs, and that value is never the one that matters. - Why the scroll is repeated
A fragment jump happens once, at the moment the browser reads the hash, and the page is not finished at that moment. An image without intrinsic size finishes loading, a web font replaces the fallback, a reveal transition takes its - Why the scroll is repeated
transform off, and the heading that was in the right place is now somewhere else. So the position is checked over the second that follows and corrected if it drifts, and the checking stops the instant the reader scrolls: catching up with a - Why the scroll is repeated
moving page is the job, fighting the reader is not. Reveal transitions are settled up front rather than corrected afterwards. `.reveal` holds an element 18px below where it belongs, and an element scrolled to is an element that has been - Why the scroll is repeated
reached, so the target and anything holding it are shown before the browser scrolls, while the click is still being handled. - What is deliberately left alone
The navigation itself. Clicks are not prevented and no history entry is written here, because the full-size screenshot viewers are opened by `:target`, which follows a real fragment navigation and not a `pushState`. The back button, the - What is deliberately left alone
middle button and copying a link therefore behave exactly as they do with this file absent, which is also what happens if it fails to load: the stylesheet still clears the header, just less exactly. - In plain words
Clicking a link that points further down the same page takes you to exactly that spot, with the heading you asked for on screen rather than hidden under the bar at the top, and a brief highlight so you can see where you landed. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-teleport-js-chart.svg" alt="what calls what in website/js/teleport.js" width="640"> </p> | Function | Line | |---|---:| | `header` | 84 | | `measure` | 89 | | `restingPlace` | 100 | | `snapTo` | 118 | | - What calls what
`settleReveal` | 131 | | `cueFor` | 143 | | `highlight` | 152 | | `settle` | 179 | | `stopSettling` | 195 | | `teleport` | 202 | | `named` | 243 | | `fromHash` | 255 |
docs/source/website-js-theme-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-theme-js.svg" alt="website/js/theme.js" width="100%"> </p> - website/js/theme.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/theme.js) · 104 lines - What it does
Theme switching. Nine palettes, Tokyo Night default. The choice is kept in localStorage, which never leaves the browser. No cookie, so nothing is attached to a request and there is nothing to consent to -- a preference the server never - What it does
sees is not tracking. - In plain words
This is the colour-scheme menu in the corner of the page. Pick a theme and every page on this site changes to it, and stays that way next time you come back. Your choice is kept in your own browser. It is not a cookie, so it is never sent - In plain words
anywhere: this site has no server that could receive it and nothing that could match it to you. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-theme-js-chart.svg" alt="what calls what in website/js/theme.js" width="640"> </p> | Function | Line | |---|---:| | `valid` | 41 | | `stored` | 45 | | `apply` | 55 | | `build` | 60 |
docs/source/website-js-verify-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-verify-js.svg" alt="website/js/verify.js" width="100%"> </p> - website/js/verify.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/verify.js) · 211 lines - What it does
In-browser SHA-256 verification for downloaded release archives. - The file never leaves your machine
Hashing happens locally through WebCrypto (`crypto.subtle.digest`), which is built into the browser. The file is read with FileReader, hashed in memory, and discarded. There is no upload, no fetch, no XHR, and no server that could receive - The file never leaves your machine
it -- you can confirm that by reading this file, which is the whole of the implementation. - Why it streams
A release archive can be tens of megabytes and WebCrypto has no incremental digest API, so the whole file must be in memory at once for `digest()`. Reading it in chunks first lets the progress bar move and keeps the tab responsive, rather - Why it streams
than freezing until the browser finishes. - In plain words
This is the box on the verify page where you drop a file you have downloaded, and it tells you whether it is the one that was published. Your file never leaves your computer. The browser does the arithmetic itself, on the file sitting on - In plain words
your disk, and this file is the whole of how -- there is no upload in it, and you can read it and see that. It reads a big file in pieces rather than all at once, so a large download does not make the page freeze while it works. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-verify-js-chart.svg" alt="what calls what in website/js/verify.js" width="640"> </p> | Function | Line | |---|---:| | `hex` | 37 | | `readFile` | 58 | | `next` | 88 | | `digestAvailable` | 106 | | - What calls what
`expectedFrom` | 115 | | `compare` | 129 | | `got` | 131 | | `handle` | 146 |
docs/source/website-js-walkthrough-js.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by tools/docs/sources.py from the header comment in the source. Do not edit this file: edit the comment at the top of the source file and run the generator again. CI - line 1
verifies it with python tools/docs/sources.py --check --> <p align="center"> <img src="../../assets/sources/website-js-walkthrough-js.svg" alt="website/js/walkthrough.js" width="100%"> </p> - website/js/walkthrough.js
[the source](https://github.com/tilas01/veilvoice/blob/main/website/js/walkthrough.js) · 162 lines - What it does
Every screen of the application as a photograph you pick between, and the command line as a list of jobs rather than a list of flags. - What this is, and what it deliberately is not
Nothing here pretends to run. The pictures are captures of the real window, taken by the build on every commit, and the only thing the reader drives is which one they are looking at. That is a smaller claim than an interactive model of the - What this is, and what it deliberately is not
program, and it is one the page can actually keep. There used to be such a model, drawn in CSS and opened from a button. It is gone: a drawing of an interface is exactly the thing a reader cannot check, and offering it beside photographs - What this is, and what it deliberately is not
of the same interface asked somebody to decide which of the two to believe. `js/sessions.js` replaced it with the recorded terminal sessions, which are the real programs' real output. - Where the content comes from
All of it is in `window.VEILVOICE_DEMO`, which `tools/site/demo.py` writes from the source: the tab list out of `app.rs`, the pictures out of `assets/screenshots`, and every worked command checked against the program's own `--help`. - Where the content comes from
Nothing is typed here, so nothing here can drift. - In plain words
Lets you click through screenshots of the real app, and read what each command line job actually does, without downloading anything. - What calls what
Read out of the source: an edge means the callee's name appears, called, inside the caller's body. It is a syntactic reading, not a resolved one, so a call made through a variable will not appear. <p align="center"> <img - What calls what
src="../../assets/sources/website-js-walkthrough-js-chart.svg" alt="what calls what in website/js/walkthrough.js" width="640"> </p> | Function | Line | |---|---:| | `select` | 45 | | `buildTabs` | 71 | | `buildCases` | 103 | | - What calls what
`fromFragment` | 131 |
fuzz/README.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Coverage-guided fuzzing
- In plain words
This throws deliberately broken files at the program to see whether it falls over. Not the ordinary tests, which check that correct input gives correct output. These generate nonsense -- truncated recordings, impossible headers, files that - In plain words
lie about their own length -- and keep going, on the theory that anything a person can be sent, somebody will eventually send. Six targets, one for each parser in VeilVoice that reads bytes somebody else produced: | Target | What it reads - In plain words
| Why it matters | |---|---|---| | `container_header` | the `.veil` header | a file somebody sent you; carries the Argon2 cost parameters | | `lock_file` | the app-lock file | parsed **before anyone has authenticated**; also carries cost - In plain words
parameters | | `wav_chunks` | the RIFF chunk walker | termination depends on length fields taken from the file | | `wav_preflight` | the WAV header check | stands in front of a decoder crash, so its own robustness is load-bearing | | - In plain words
`guard_manifest` | the integrity manifest | text, sliced by byte offset for display | | `hybrid_keys` | `.pub` keys and encapsulations | a public key arrives from elsewhere by definition | | `release_contents` | the release contents list | - In plain words
its paths decide which files a verifier opens | - Running it
`cargo fuzz` needs a nightly toolchain and libFuzzer, which is why this lives outside the workspace and outside `rust-toolchain.toml`. Nothing else in the repository needs nightly, and installing it is not a prerequisite for anything but - Running it
this directory. rustup toolchain install nightly cargo install cargo-fuzz cargo +nightly fuzz run container_header One target for a fixed time, which is the useful form for a session: cargo +nightly fuzz run wav_chunks -- - Running it
-max_total_time=600 -rss_limit_mb=6144 All seven in turn: for t in container_header lock_file wav_chunks wav_preflight guard_manifest \ hybrid_keys release_contents; do cargo +nightly fuzz run "$t" -- -max_total_time=300 -max_len=65536 \ - Running it
-rss_limit_mb=6144 || exit 1 done - The seed corpus, and why only two targets have one
`fuzz/seeds/container_header/` and `fuzz/seeds/lock_file/` are committed. They are passed to a run automatically, because `cargo fuzz` uses `fuzz/corpus/<target>` and these are copied into it: mkdir -p fuzz/corpus/lock_file cp - The seed corpus, and why only two targets have one
fuzz/seeds/lock_file/* fuzz/corpus/lock_file/ cargo +nightly fuzz run lock_file They exist because these two targets are the expensive ones to start cold. Both parse a header carrying Argon2id cost parameters, so the interesting code is - The seed corpus, and why only two targets have one
behind a magic string, a version byte, three cost fields and a length check, and a fuzzer starting from nothing spends its budget rediscovering that a lock file begins with `VEILLOK1`. Measured on `lock_file`, two minutes each way: | | - The seed corpus, and why only two targets have one
first coverage | after 120 seconds | |---|---|---| | cold, no seeds | 25 | 460, after 64,309 inputs | | seeded | **625** | 630, after 379 inputs | The seeded run begins with more coverage than the cold run reaches in two minutes. That is - The seed corpus, and why only two targets have one
the whole argument. The other five targets have no seeds and do not need any: `wav_chunks`, `wav_preflight`, `hybrid_keys`, `guard_manifest` and `release_contents` manage between twelve million and three hundred million inputs in ten - The seed corpus, and why only two targets have one
minutes, and find their own structure in seconds. `guard_manifest`'s minimised corpus alone is 3.9 MB, which is a lot of committed bytes for a target that rediscovers text in no time at all, and `release_contents` reads a text format of - The seed corpus, and why only two targets have one
the same shape. The seeds are regenerated by running a campaign and minimising what it kept: cargo +nightly fuzz cmin lock_file rm -rf fuzz/seeds/lock_file && cp -r fuzz/corpus/lock_file fuzz/seeds/lock_file A seed is an **input**, which - The seed corpus, and why only two targets have one
is why it is committed while the working corpus and the crash artefacts in `fuzz/artifacts/` are not. Those are outputs: a crash worth keeping becomes a test in the crate it belongs to. A crash is written to `fuzz/artifacts/<target>/`, and - The seed corpus, and why only two targets have one
is reproducible with: cargo +nightly fuzz run <target> fuzz/artifacts/<target>/<file> - Status, stated plainly
**It has now been run, for five minutes per target, once.** That is a great deal more than never and a great deal less than convergence, and both halves of that sentence matter. The run: all six targets, `-max_total_time=300 -max_len=65536 - Status, stated plainly
-rss_limit_mb=6144`, on x86-64 Linux, against the tree at the time. What each target got through in its five minutes, from libFuzzer's own count: | Target | Runs in 300 s | Found | |---|---:|---| | `container_header` | 212,479 | **a - Status, stated plainly
timeout** (F-82) | | `lock_file` | 3,274 | **a timeout** (F-82, through the other door) | | `wav_chunks` | 11,465,356 | nothing | | `wav_preflight` | 110,515,319 | nothing | | `guard_manifest` | stopped early | **a crash** (F-83) | | - Status, stated plainly
`hybrid_keys` | 125,988,977 | nothing | The two low counts are not a fault. `container_header` and `lock_file` run Argon2 on every input, so a run there is a key derivation rather than a parse, and three thousand of them in five minutes is - Status, stated plainly
what that costs. `guard_manifest` has no count because it stopped the moment it found the crash; run again against the fix it completed 3,153,768 runs in its five minutes and found nothing. **What it found, in one line each.** F-82: - Status, stated plainly
`t_cost` had no ceiling, so a header could declare four billion passes and the derivation would not finish. Measured at 74 hours for the input the fuzzer produced, and the app-lock file carries the same field and is read before anyone has - Status, stated plainly
authenticated. F-83: the manifest parser accepted a path containing a carriage return, which rewrites the line when the tamper report is printed, while `Manifest::of` had always refused to *write* one. Both are written up in - Status, stated plainly
`docs/AUDIT.md` and both have a regression test in the deterministic campaign, where they are checked on every commit without nightly. **What is still not claimed.** Five minutes is not convergence, no corpus is kept between runs, and - Status, stated plainly
nobody has run this on Windows or macOS. Three of the six targets have never found anything, which is evidence about five minutes rather than about those three parsers. **`-rss_limit_mb` has to be raised above the memory ceiling.** With - Status, stated plainly
libFuzzer's default of 2048 MB, `container_header` reports an out-of-memory on the first input declaring an `m_cost` near `KdfParams::MAX_M_COST`, which is 4 GiB and is deliberate. That is the target doing what it is documented to do, so - Status, stated plainly
the limit in the commands above is 6144 rather than the artefact being treated as a finding. - Overflow checks are on, deliberately
`fuzz/Cargo.toml` sets `overflow-checks = true` and `debug-assertions = true` in an otherwise optimised profile. This is not an oversight and must not be "tidied up" to match the workspace release profile, which sets `overflow-checks = - Overflow checks are on, deliberately
false`. Two of the defects this project has shipped, F-2 (an Argon2 parallelism overflow) and F-4 (a 32-bit RIFF overflow), were *arithmetic overflows*. With overflow checks off they wrap silently, so a fuzzer running under the release - Overflow checks are on, deliberately
profile would have explored those exact inputs and reported nothing. A campaign that cannot see the class of bug you have already shipped twice is not a campaign. - What this does not cover
- **32-bit targets.** F-4 existed only on ARMv7, and F-11 (the non-terminating erase loop) is also 32-bit only. Neither is reachable from a fuzzer on an x86-64 host, whatever it does. Building the targets for `i686` or `armv7` under - What this does not cover
emulation would help; reading the code is what actually found both. - **The decoders themselves.** `symphonia`, `lofty` and `img-parts` parse untrusted input and are not fuzzed here, because they have their own suites, and duplicating them - What this does not cover
badly would be worse than pointing at them. `wav_preflight` covers the one place VeilVoice stands in front of a decoder crash it cannot otherwise survive. - **Anything stateful.** Every target is a pure function of its input, so a crash is - What this does not cover
reproducible from the artefact alone. `Manifest::check` is deliberately not driven against the real filesystem for that reason. - Relationship to the deterministic campaign
`crates/veilvoice-crypto/tests/parser_fuzz.rs` and `crates/veilvoice-meta/tests/wav_fuzz.rs` are a *different* thing and both are kept. They are seeded, deterministic, need no nightly, and run on every commit in CI on every platform, so - Relationship to the deterministic campaign
they are the check that actually gets run. This directory explores by feedback rather than by construction, and is the deeper but less frequent pass. Neither replaces the other, and neither replaces reading the code: F-4 and F-11 were both - Relationship to the deterministic campaign
found by reading, and no campaign on a 64-bit machine could have reached either.
packaging/README.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - packaging/
Package definitions for each platform that has one. **None of these has been built or installed yet.** They parse, and that is all that is claimed. See [`docs/PACKAGING.md`](../docs/PACKAGING.md) for the status table, the build commands, - packaging/
and what each format deliberately does not do. The tested route to a verified install is [`install/`](../install/) and the portable verifier. See [`docs/INSTALL.md`](../docs/INSTALL.md). | | | |---|---| | `wix/` | Windows MSI. Optional - packaging/
components are off by default and install links, not software. | | `debian/` | `.deb` for Debian and Ubuntu. | | `rpm/` | `.spec` for Fedora, RHEL and openSUSE. | | `flatpak/` | Flatpak manifest and AppStream metadata. No network - packaging/
permission. | | `homebrew/` | A formula, not a cask: it builds from source. | | `gentoo/` | A live ebuild that builds from this repository. | | `veilvoice.desktop` | Shared desktop entry for the Linux packages. |
packaging/aur/README.md
- Arch packaging
Two package definitions, both of which compile VeilVoice on the machine that installs it. No binary is downloaded by either. | File | Package | Builds from | | --- | --- | --- | | `PKGBUILD` | `veilvoice` | the tagged release tarball | | - Arch packaging
`PKGBUILD-git` | `veilvoice-git` | the `main` branch | - Building one here
cd packaging/aur makepkg -si # the release package makepkg -si -p PKGBUILD-git # the live one `makepkg` runs the workspace test suite as part of the build, which is entirely offline and needs no audio device. A build that fails its tests - Building one here
does not produce a package. - Keeping .SRCINFO in step
The AUR reads `.SRCINFO`, not the `PKGBUILD`, so the two have to agree or the web interface shows something the package does not do: updpkgsums # fill in the release tarball's hash makepkg --printsrcinfo > .SRCINFO Both are checked by - Keeping .SRCINFO in step
`tools/verify.py`, which fails if the version, the dependency list or the installed binaries drift apart from the rest of the tree. - What gets installed
`/usr/bin/veilvoice` and `/usr/bin/veilvoice-gui`, a desktop entry, an icon, the manual pages generated from each program's own `--help`, and the contents of `docs/`. There is no `veilvoice-verify` binary. It was one until 0.1.18 and is - What gets installed
now part of `veilvoice`: run `veilvoice verify` to check a release, or use the Verify tab in the desktop application.
Rust source
crates/veilvoice-audio/src/devices.rs
- (module)
Enumerating audio devices, and guessing which of them are virtual cables. # What this is for Live scrambling is only useful if the veiled voice can be routed *into* something else -- a call, a stream, a recorder. The way that is done on - (module)
every desktop platform is a **virtual audio cable**: a driver that presents a playback device on one side and a microphone on the other, so anything that can select a microphone can receive VeilVoice's output. So the list this module - (module)
produces is not merely a list. Picking the wrong output device is the single most common way for live mode to appear broken while working perfectly, and the whole reason [`DeviceInfo::is_virtual_cable`] exists is to put the right entry in - (module)
front of the user. # The detection is name matching, and that is a limitation, not an oversight There is **no portable way to ask an audio device whether it is virtual**. CPAL does not expose it because the underlying APIs largely do not - (module)
either. So [`VIRTUAL_CABLE_HINTS`] matches on name fragments, which means: * a cable this list has never heard of is reported as an ordinary device; * a real device whose name happens to contain "loopback" or "virtual" is flagged when it - (module)
should not be. Both are wrong in the harmless direction: the flag reorders and annotates a list, it never restricts what the user may choose. A heuristic that hides options would be a different and worse thing than one that highlights - (module)
them, and this is deliberately the second. The alternative -- showing an unsorted list of identically named endpoints and letting the user find the right one -- was tried and is worse. # Enumeration can fail, and does Device lists come - (module)
from the OS and are not stable: a device can disappear between being listed and being opened, a host may have no devices at all, and on Linux a machine with no sound server is entirely normal. Every function here returns a [`crate::Error`] - (module)
rather than panicking or quietly returning an empty list, because an empty list and a failed query mean very different things to somebody trying to work out why they cannot be heard. # In plain words This asks your computer which - (module)
microphones and speakers it has, and works out which of them are **virtual cables**. A virtual cable is a small piece of software that pretends to be a speaker on one side and a microphone on the other. It is how a veiled voice gets into a - (module)
call: VeilVoice plays into the cable, and the calling program picks the cable as its microphone and never knows the difference. Working out which device is a cable is done by recognising the names the common ones use, so it is a good guess - (module)
rather than a certainty. Nothing depends on the guess being right: it decides which device is *suggested*, never which ones you are allowed to choose. - enum Direction
Which direction a device carries audio. - struct DeviceInfo
A device the user can choose. - const VIRTUAL_CABLE_HINTS
Name fragments used by the common virtual audio cables. Routing the veiled voice into one of these is what lets any other application, whether a call, a stream or a recorder, receive it as if it were a microphone. Matching on the name is - const VIRTUAL_CABLE_HINTS
crude, but there is no portable way to ask an audio device whether it is virtual, and the alternative is making the user hunt through a list of identically-named endpoints. - fn looks_virtual
Whether a device's name suggests it is a virtual cable rather than real hardware. A guess from a name, and treated as one everywhere it is used: it decides what to suggest, never what to refuse. - fn name_of_opt
A device's name as the platform reports it, or `None` when it will not say. `cpal` 0.18 replaced `Device::name` with a whole `DeviceDescription`, of which the name is the only field anything here wants. Unwrapping it once keeps that detail - fn name_of_opt
in one place rather than at all five call sites. - fn list
List the devices available in one direction. - fn find_virtual_cable
Find the first output device that looks like a virtual audio cable. Returns `None` rather than an error when none is installed: that is a normal state, and the caller should offer to install one rather than fail. - fn name_of
The name of an opened device, or a placeholder when the OS will not say. Saves every caller from depending on `cpal` just to print a device name. - fn open
Look up a device by exact name, or the host default when `name` is `None`. - mod tests
- fn virtual_cable_names_are_recognised
- fn ordinary_devices_are_not_mistaken_for_cables
- fn matching_ignores_case
- fn enumeration_is_safe_without_audio_hardware
Enumeration must not panic or hang on a machine with no sound hardware, which is exactly what CI runners look like. - fn missing_virtual_cable_is_not_an_error
- fn opening_an_unknown_device_reports_its_name
crates/veilvoice-audio/src/io.rs
- (module)
Reading and writing audio files. Decoding goes through `symphonia`, which is pure Rust and covers WAV, MP3, FLAC, OGG/Vorbis, MP4/AAC and friends without shelling out to a codec library. Writing is WAV only, on purpose: VeilVoice's job is - (module)
to hand back audio that has not been degraded, and re-encoding to a lossy format after de-identification would throw away quality for no benefit. Callers who want MP3 can transcode with whatever they already trust. # A decoder is a parser, - (module)
and this one reads files somebody else made `symphonia` is the largest attacker-facing surface in this crate: it is handed whole files of a format VeilVoice does not itself define. It is pure Rust, which removes the memory-corruption class - (module)
outright, but it does not remove panics -- and this workspace builds with `panic = "abort"`, so a panic inside a decoder is not an error a caller can handle, it is the process ending. That is why a pre-flight check runs before a file - (module)
reaches the decoder, and why the honest position is recorded in the audit rather than glossed: decoding in a separate process is the only complete answer to "the next malformed file in a format we do not parse ourselves", and it is not - (module)
built. # Writing is WAV only, and that is a decision Re-encoding to a lossy format after de-identification would throw away quality for no privacy benefit -- the voiceprint is already gone, and what remains is the words, which is the part - (module)
worth keeping intact. # In-memory encoding, so plaintext never reaches the disk [`wav_bytes`] exists so a recording can be encoded and then sealed without ever being written in the clear. It has an awkward shape for a real reason: - (module)
`hound::WavWriter::finalize` consumes the writer, so the encode has to borrow the cursor (`WavWriter::new(&mut cursor, spec)`) and read the bytes back through `cursor.into_inner()` after the writer has been dropped. # In plain words This - (module)
opens sound files and writes them back out. It can read the usual formats, and it always writes plain WAV. That is deliberate: WAV throws nothing away. Saving as MP3 after the voice has been veiled would lose quality for no reason, and - (module)
anybody who wants a smaller file can convert it afterwards with whatever they already use. Opening a sound file means reading a file somebody else made, which is the part of any program most worth being careful in. A file that is damaged, - (module)
or built on purpose to cause trouble, is refused with a reason rather than being allowed to bring the program down. - const MAX_DECODED_SAMPLES
The most decoded audio [`load`] will hold, in mono `f32` samples. Roughly twelve hours at 48 kHz, and about eight gigabytes of `f32`. The ceiling exists because compressed formats expand: a mono MP3 at 32 kbit/s decodes to 48 000 `f32` per - const MAX_DECODED_SAMPLES
second, which is a **forty-eight-fold** expansion, so a hundred-megabyte download becomes some five gigabytes of samples. Rust aborts the process when an allocation fails, so an unbounded decode turns "someone sent me a recording", the - const MAX_DECODED_SAMPLES
ordinary use this tool is for, into a way to kill it. Twelve hours is far past any interview or call anyone will veil, and the refusal names the limit rather than truncating silently, because a recording that quietly lost its second half - const MAX_DECODED_SAMPLES
is worse than one that would not open. - fn preflight
Reject a file whose own header carries a value that will crash the decoder, before the decoder is given the file. Public so that a caller holding bytes rather than a path can run the same check [`load`] runs, and so the fuzz target in - fn preflight
`fuzz/` can reach it. Pass as much of the start of the file as is convenient; a few kilobytes is plenty, and a short buffer is not an error. This exists for one confirmed case and is deliberately narrow. A WAV whose `fmt ` chunk declares a - fn preflight
**sample rate of zero** makes `symphonia` panic inside `Probe::format`, at `TimeBase::new`, before this crate is handed anything it could check. VeilVoice's release profile sets `panic = "abort"`, so that panic is not an error a caller can - fn preflight
handle. It is the process ending. `veilvoice anonymise` on a four-kilobyte file somebody sent you was enough. Every other malformed value tried during the audit, meaning zero channels, 65535 channels, zero or 65535 bits per sample and a - fn preflight
mismatched format tag, is already refused cleanly by `symphonia` itself, so nothing else is duplicated here. Checking what the decoder already checks would be a second parser to keep in step, which is its own bug source. **The residual is - fn preflight
stated rather than engineered around:** this cannot protect against a panic in the decoder for a format whose header VeilVoice does not parse. Under `panic = "abort"` no wrapper can, short of decoding in a separate process. The mitigations - fn preflight
are this check, keeping `symphonia` current, and the fact that it is a widely used pure-Rust decoder rather than a C library. - struct Audio
Mono audio in memory. - impl Audio
- fn duration_secs
Duration in seconds. - fn peak
Peak absolute sample value. - fn load
Decode any supported audio file to mono `f32`. Multi-channel input is averaged down to mono. VeilVoice's engine is single-channel by design: a stereo image is itself a recording-setup fingerprint, and collapsing it removes one more way to - fn load
match a file to the room and hardware that produced it. - fn read_up_to
Fill as much of `buf` as the file has, tolerating short reads. A single `read` is allowed to return fewer bytes than asked for even when more are available, and `read_exact` would fail outright on a file shorter than the buffer, which most - fn read_up_to
test fixtures are. - fn wav_bytes
Encode mono `f32` audio as a 16-bit PCM WAV, in memory. The in-memory form is what makes encrypt-at-rest honest: a recording that is going to be sealed must never touch the disk in the clear first, because a plaintext file that is written - fn wav_bytes
and then deleted is exactly the thing [`veilvoice_crypto::shred`](../../veilvoice_crypto/shred/index.html) explains cannot be reliably taken back on flash storage. Samples are clamped rather than allowed to wrap: a sample past full scale - fn wav_bytes
would otherwise flip sign and produce a loud click. - fn save_wav
Write mono `f32` audio to a 16-bit PCM WAV file. - mod tests
- fn tone
- fn wav_round_trip_preserves_audio
- fn the_in_memory_encoder_matches_the_file_it_would_have_written
The in-memory encoder is what the encrypt-at-rest path uses, so it must produce exactly the file the on-disk one would. - fn out_of_range_samples_clamp_instead_of_wrapping
- fn stereo_is_averaged_to_mono
- fn metadata_helpers_are_sane
- fn a_missing_file_is_an_io_error
- fn handmade_wav
Build a WAV by hand so the header can say things `hound` would refuse to write. - fn a_wav_declaring_a_zero_sample_rate_is_refused_rather_than_crashing
The regression, and the reason `preflight` exists. A WAV declaring a sample rate of zero made `symphonia` panic inside `Probe::format`, at `TimeBase::new`, before this crate saw anything it could check. Under the shipped `panic = "abort"` - fn a_wav_declaring_a_zero_sample_rate_is_refused_rather_than_crashing
profile that is the process dying, not an error, so `veilvoice anonymise` on a file somebody sent you was enough to kill it. If this test ever *panics* rather than failing an assertion, the pre-flight has stopped running. - fn the_preflight_passes_everything_it_is_not_for
The pre-flight must not become a second, divergent WAV parser. Values `symphonia` already refuses properly are left to it, and ordinary files must pass straight through. - const _
The decode ceiling has to sit far above anything anyone will really veil, or it stops being a guard against hostile expansion and starts being a limitation. Checked at compile time so it cannot drift. - fn a_non_audio_file_is_rejected
- fn sample_rate_is_preserved_across_rates
crates/veilvoice-audio/src/lib.rs
- (module)
# veilvoice-audio Everything between the sound hardware and [`veilvoice_core`](../veilvoice_core/index.html): device enumeration, file import and export, and the real-time capture → de-identify → playback path. - [`io`], to decode any - (module)
common audio file to mono `f32`, write 16-bit WAV, or encode one in memory so it can be encrypted without ever landing on disk in the clear. - `devices`, to enumerate inputs and outputs and spot a virtual audio cable. - `live`, to run the - (module)
engine live between two devices. ## The `live` feature `devices` and `live` sit behind the default-on `live` feature. They are the only part of this crate that needs `cpal`, and `cpal` has no backend for the BSDs. Everything else, meaning - (module)
decoding, encoding and running the engine over a buffer, is pure Rust and builds anywhere, so turning the feature off keeps file processing working on platforms that cannot do live capture rather than failing to build at all. ## Routing, - (module)
and why a virtual cable matters Scrambling a microphone is only useful if other applications can hear the result. Selecting a virtual audio cable as the output makes the veiled voice appear as an ordinary microphone to any call, stream or - (module)
recorder on the machine, with no per-application setup. [`devices::find_virtual_cable`] detects an installed one so the UI can offer it directly. # In plain words This is the plumbing between your microphone, your speakers and the part - (module)
that changes the voice. It finds the sound devices you have, opens the recording you point at whatever kind of file it is, and writes the result back out. For live use it does the whole loop while you talk -- in from the microphone, - (module)
through the engine, out to whatever else is listening -- fast enough that a conversation still works. It also reports how loud things are, which is what the level bars in the program are drawing. - mod devices
- mod io
- mod live
- mod playback
- mod record
- mod room
Several microphones at once, a guest each. **Roadmap item 147.** - mod meter
- const VERSION
Crate version string, surfaced in the About panel. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl From<hound::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- fn deidentify
De-identify a whole buffer of audio in one call. Convenience for file processing: it builds an engine at the buffer's own sample rate, runs it, and trims the engine's start-up delay so the output lines up with the input rather than - fn deidentify
beginning with a frame of silence. - mod tests
- fn speech_like
- fn deidentify_preserves_length_and_rate
- fn output_is_aligned_not_delayed_by_a_silent_frame
Trimming the group delay matters: without it every processed file would start with a frame of silence and drift against the original. - fn output_is_audible_but_not_runaway
- fn works_at_several_sample_rates
- fn an_invalid_configuration_is_reported
crates/veilvoice-audio/src/live.rs
- (module)
Live microphone scrambling. # Structure Capture and playback run as two independent callbacks driven by the audio hardware, joined by a lock-free SPSC ring buffer. The de-identification runs inside the *output* callback, which is the - (module)
shortest path: adding a worker thread would mean a second buffer and a second scheduling delay for no benefit, and [`veilvoice_core::Deidentifier::process`] is explicitly allocation-free and safe to call from an audio callback. # Rules the - (module)
callbacks follow An audio callback that blocks produces a dropout, so neither callback ever allocates, locks, or waits. Statistics are published through a mutex the callback only ever *tries* to take: if the UI thread happens to hold it, - (module)
the update is skipped rather than the audio stalling. # Latency Total latency is the input buffer, plus the ring backlog, plus the engine's one-frame group delay (~21 ms at the defaults), plus the output buffer. The ring is intentionally - (module)
short, enough to absorb jitter between two clocks that are not synchronised, not enough to accumulate a delay the user would notice while speaking. # In plain words This is live mode: your microphone in one end, a voice that is not yours - (module)
out the other, fast enough to hold a conversation. Sound arrives from the microphone in small pieces, and each one has to be dealt with before the next arrives. There is no room to be late. So the veiling happens on the same short path the - (module)
sound is already travelling, with nothing queued up behind it and nothing that could pause to allocate memory or wait for another part of the program. If the computer ever cannot keep up, that is counted and shown rather than hidden. A gap - (module)
in the sound you can see explained is far better than one you cannot. - const RING_MILLIS
How much jitter the ring absorbs before it starts dropping samples. - enum Side
Which side of the engine something happened to. - impl Side
- fn word
The word for this side, as a person reading a warning would meet it. - struct Interference
Something that happened to the audio path while it was running. **Roadmap item 132.** The platform reports these on a callback of its own, and until now the only thing done with one was `eprintln!`: on Windows the desktop application is - struct Interference
built with no console at all, so a microphone unplugged in the middle of a call was silent, and a recording carried on being made of nothing. Not `Copy`, and deliberately kept out of [`LiveStats`]: the meters are read sixty times a second - struct Interference
and this holds a `String`. The count is in the stats, so a caller learns that something happened at meter speed and asks what it was only when the answer changed. - struct LiveStats
A snapshot of what the live path is doing, safe to read from the UI. - struct Keeping
Which sides of the engine a session keeps. Both are named at construction rather than passed as a pair of positional flags, which is the property **roadmap item 131** asked for and which two separate arguments used to give: no caller - struct Keeping
reaches a recording of somebody's real voice without writing the word `plain` next to it. The default keeps neither, which is what [`LiveSession::start`] is. - impl Keeping
- fn is_anything
Whether anything at all is being kept. - struct Kept
The recorders a session was asked for, one per side of [`Keeping`]. Built by the session rather than by the caller, because the rate they are built at has to be the rate the device agreed to and only the session knows that. See - struct Kept
[`LiveSession::start_recording`] for what went wrong when callers built their own. - struct LiveSession
A running live-scramble session. Dropping it stops the audio. - struct Shared
- impl Shared
- fn report
Record what the platform said about a stream. Called from cpal's error callback, which is **not** the realtime data callback: it runs when something has gone wrong rather than every block, so allocating a string and taking a lock here - fn report
costs nothing that is being timed. The guard that forbids both reads the data callbacks and is right not to object to this one. - fn rate_they_agree_on
Which sample rate a set of devices can all run at, if any. **F-168.** Pure arithmetic over what the platform reported, so it is decided and tested without opening anything: F-163 and F-165 are why nothing here touches a device to answer a - fn rate_they_agree_on
question that does not need one. `inputs` is each microphone's default rate and the ranges it supports; the output's are given separately because its rate is preferred. It is what the person hears through and what anything listening on a - fn rate_they_agree_on
virtual cable expects, so moving *it* to suit a microphone is the change more likely to surprise somebody. The order is: the rate everything is already on, then the output's, then each microphone's in turn. `None` means no rate every - fn rate_they_agree_on
device will accept, which is a thing to refuse rather than to work around, because nothing in this crate resamples. - fn covers
Whether a device that reports these ranges will take `rate`. - fn input_ranges
The ranges a device reports, as plain numbers. - fn at_rate
One of a device's configurations at `rate`, preferring `f32`. The streams in this crate are built as `f32` whatever the reported format says, so a configuration in that format is the one to take. Anything else at the right rate is returned - fn at_rate
rather than nothing, so that cpal refuses with its own words about the format instead of this refusing with a sentence about the rate, which would be the wrong reason. - fn agree_on_a_rate
A microphone and an output, configured to one rate. # F-168: nothing here resamples, and nothing used to check The engine runs at one rate and the ring between the callbacks holds samples at one rate. A microphone delivering 44 100 samples - fn agree_on_a_rate
a second into a ring emptied 48 000 times a second is a ring that starves for ever and a voice shifted up by nine per cent, stuttering. That is what this did. The input stream was built from `default_input_config`, the engine and the ring - fn agree_on_a_rate
from `default_output_config`, and the two rates were never compared. A laptop whose microphone defaults to 44.1 kHz and whose speakers default to 48 kHz is an ordinary machine, not a contrived one. - fn agree_on_a_rate_for
The same, for any number of microphones. **Roadmap item 147.** One rate for the whole room. Several microphones each running at their own rate into one mix is the F-168 problem once per guest, and it is worse than the single case: the - fn agree_on_a_rate_for
others sound right, so the fault reads as one person's microphone being bad rather than as a mismatch nobody checked. - impl LiveSession
- fn start
Start scrambling from `input` into `output`. `config.sample_rate` is overwritten with the rate the hardware actually agrees to, so the engine is never configured for a rate the device is not running at. - fn start_recording
Start scrambling, keeping the sides of it [`Keeping`] asks for. Each side is taken from the callback where its samples exist and from nowhere else. The veiled voice comes from inside the output callback, which is the only place it exists - fn start_recording
before it reaches the device; the microphone comes from inside the input callback, after the downmix to mono and before anything else sees it. Taking either anywhere else would mean a second copy of the audio living somewhere unprotected, - fn start_recording
which is the thing [`record`](crate::record) is for avoiding. **Roadmap item 131.** [`Keeping::plain`] is the one thing in this crate that records the real voice, and it is named at the call site for that reason: a caller cannot reach it - fn start_recording
without writing the word. It is false in every path that has not been asked for it, and the interface that offers it says what it is before it is started. # The recorders are built here, and F-166 is why This used to take a pair of - fn start_recording
already-built sinks, which meant the caller chose the rate the recording would be written at. Both callers passed the rate they had *asked* for, `config.sample_rate`, and this function then overwrites that with the rate the hardware agreed - fn start_recording
to. On any device not running at 48 kHz the two disagreed, and a WAV header that disagrees with its samples plays back at the wrong speed and the wrong pitch: on a de-identified recording, a second voice change nobody chose. So the caller - fn start_recording
no longer has a rate to get wrong. It says which sides to keep, and gets back the recorders for them, built from the rate this function is about to run the engine at. [`Sink::write`](crate::record::Sink::write) is realtime-safe, so each - fn start_recording
costs its callback a memcpy into an already-allocated ring and nothing else. A sink that cannot keep up drops samples and counts them rather than stalling the audio somebody is speaking into. - fn stats
Read the current statistics, resetting the peak meters. Peaks reset on read so a meter shows the level since the last frame rather than the loudest moment since the session began. - fn interference
What the platform last reported about either stream. `None` until something goes wrong. Asked when [`LiveStats::interfered`] moves, rather than every frame: this clones a `String`. - mod tests
- fn stats_start_empty
- fn ring_length_is_a_sane_compromise
The ring must be long enough to absorb a typical device buffer, but not so long that it becomes an audible delay on its own. - fn the_recorder_is_fed_from_the_engine_and_from_nowhere_else
**Roadmap item 132.** What the recorder is given is what the engine produced. The row this comes from asked for the samples reaching the recorder to be *checked* against the engine's output. They cannot differ, and a check would be a - fn the_recorder_is_fed_from_the_engine_and_from_nowhere_else
buffer compared with itself: the veiled sink is written from inside the output callback, from the same slice `Deidentifier::process` has just written into, and there is nothing between the two to interfere with. That is a property worth - fn the_recorder_is_fed_from_the_engine_and_from_nowhere_else
keeping rather than one worth measuring, so it is read out of the source. A change that took the recorder's samples from anywhere else, the device being the obvious candidate, would be a second unprotected copy of the audio and would fail - fn the_recorder_is_fed_from_the_engine_and_from_nowhere_else
here. - fn devices_agree_on_a_rate_or_there_is_none_to_agree_on
**F-168.** Two devices, one rate, or a refusal that says why. The decision, without a sound card: this is arithmetic over what the platform reported, and the whole reason it is a separate function is that a test can reach it. F-163 and - fn devices_agree_on_a_rate_or_there_is_none_to_agree_on
F-165 are why nothing here opens a device to answer a question that does not need one. - fn trouble_is_recorded_rather_than_only_printed
A stream error becomes something a caller can show. **Roadmap item 132.** Before this, both error callbacks were `eprintln!` and nothing else: on Windows the desktop application has no console, so a device unplugged mid-call was silent and - fn trouble_is_recorded_rather_than_only_printed
the recording carried on.
crates/veilvoice-audio/src/meter.rs
- (module)
The scale a level meter is drawn on. # Why this is here rather than in a front end [`crate::live::LiveStats`] reports a peak, and both front ends draw it. They drew it *differently*: both were linear, and the desktop one printed a decibel - (module)
number beside a bar filled linearly, so the number said -12 dB and the bar showed a quarter. Two meters disagreeing about the same reading is worse than one bad meter, because it makes the reader doubt the number. The scale belongs with - (module)
the measurement. This module owns the arithmetic; the front ends own how it looks. # Why not linear Loudness is not linear, and a meter that is has almost no useful range. Ordinary speech recorded at a sensible level peaks around **-12 - (module)
dBFS**, which is 0.25 linear: a quarter of the bar, which reads as near-silence. The only way to fill a linear bar is to be clipping. Every real meter is logarithmic for exactly this reason. # What it measures, and what it does not - (module)
**Sample peak**, since the last read. It is not a loudness meter: RMS, LUFS and anything else that correlates with how loud a thing *sounds* needs a window and a weighting curve, and answers a different question. This one answers "am I - (module)
being recorded, and am I clipping". It also cannot see an **inter-sample peak**, which is a waveform that passes above full scale between two samples and clips in a converter without any single sample exceeding 1.0. Catching those needs - (module)
oversampling. A front end may say `CLIP` when a sample reaches full scale, and must not imply it caught the ones it cannot see. # In plain words This decides how a level meter is drawn, so that the bar and the number beside it always - (module)
agree. They did not, once. Both the window and the terminal drew the bar filling evenly with the signal, while the number beside it was in decibels, which do not rise evenly at all. So the number could read -12 dB while the bar looked a - (module)
quarter full, and a reader who noticed stopped trusting both. One piece of arithmetic, in one place, used by both. Now they cannot disagree. - const FLOOR_DB
The quietest level worth drawing. Below this is silence. Sixty decibels is the range a person can usefully read off a short bar. A meter that went to -90 would spend a third of itself on room tone. - const CLIP_DB
At or above this, the level is called clipping. -0.1 dBFS rather than exactly 0. A sample at full scale in a 16-bit file has no larger neighbour to reach, so waiting for a mathematically perfect 1.0 means never saying so about a signal - const CLIP_DB
that is plainly clipped. Decibels near the top of the scale are much finer than they look in linear terms, and this is the number that shows it: a *linear* 0.99 is already -0.087 dBFS, which is inside this threshold. - fn dbfs
Level in decibels relative to full scale. Silence is [`FLOOR_DB`] rather than negative infinity: a caller wants a number to place on a bar, and a meter is not the place to introduce an infinity into arithmetic that has to keep running. # - fn dbfs
The two ways a reading can be nonsense, answered differently **NaN** is not a level at all, and is read as silence, because nothing can be inferred from it. **Positive infinity** is read as **full scale**. Both are wrong readings, and a - fn dbfs
meter should be wrong in the direction that gets looked at: pinned at the top it is noticed in a second, and pinned at the bottom it looks exactly like a microphone that is not plugged in. - fn position
Where a level sits along a bar: 0.0 at the floor, 1.0 at full scale. - fn clipping
Whether a reading counts as clipping. - mod tests
- fn full_scale_is_zero_and_silence_is_the_floor
- fn halving_the_amplitude_is_six_decibels
Halving the amplitude is six decibels. This is the check that the scale is a decibel scale rather than something that merely curves. - fn ordinary_speech_lands_in_the_middle_of_the_bar
The defect this module exists to fix, stated as a test. Speech at a sensible recording level peaks near -12 dBFS; on a linear meter that filled a quarter of the bar and read as near-silence. - fn the_position_is_bounded_whatever_it_is_given
- fn the_clip_threshold_is_just_below_full_scale
The numbers here were checked rather than assumed: a linear 0.99 is -0.087 dBFS, already inside a tenth of a decibel of full scale.
crates/veilvoice-audio/src/playback.rs
- (module)
Playing a recording that is only in memory, and never on disk. # What this is for A take in the studio vault is sealed. Hearing it back means decrypting it, and the obvious way to hear a WAV is to write it somewhere and hand the path to - (module)
something that plays files. That would put an unencrypted recording on the disk, which is the one thing the vault exists to prevent, and it would leave it there until somebody remembered to shred it. So this takes the samples as they - (module)
already are, in page-locked memory, and plays them from there. Nothing is written. When playback stops the buffer is dropped and `veilvoice_crypto::Secret` wipes itself. # The samples are held, not streamed from the vault Be plain about - (module)
the shape rather than implying a stronger one. The whole take is decrypted into locked memory before a note is heard, because the container is sealed and authenticated as one piece: there is no way to open the first second of it without - (module)
opening all of it, and an AEAD that let you would not be authenticating anything. What that buys is still the thing that matters: **no plaintext file, at any point.** What it does not buy is a smaller footprint than the recording, and an - (module)
hour of audio is an hour of audio in RAM. A format sealed in blocks would change that and is not what the vault writes today. # In plain words Plays a recording straight out of protected memory, so listening to one never leaves a copy on - (module)
the disk for somebody to find later. - struct Shared
What both sides can see while a take is playing. - struct Playing
A take being played, for as long as this is held. Dropping it stops the audio and releases the samples. That is deliberate: there is no `stop` that leaves the buffer alive, because a buffer of somebody's recording outliving the reason it - struct Playing
was decrypted is exactly the leak this module is avoiding. - impl Playing
- fn position
How far in, in seconds. - fn duration
How long the take is, in seconds. - fn finished
Whether it has reached the end. - fn peak
The loudest sample since this was last called, and it resets. - fn start
Start playing `samples` at `rate` on the default output device. The samples are moved into the callback. The caller's copy is gone, which is what keeps there from being two: the one being played and one left behind. - mod tests
- fn nothing_to_play_is_refused_rather_than_started
- fn a_rate_of_zero_is_refused_rather_than_divided_by
crates/veilvoice-audio/src/record.rs
- (module)
Recording the veiled voice without it ever reaching unprotected memory. # What this is for [`live`](crate::live) sends the veiled voice to a device and keeps nothing. This keeps it, and the whole difficulty is *where*. A recording that is - (module)
accumulated in a `Vec`, encoded with a library that returns a `Vec`, and then sealed, has existed in unlocked, unzeroized memory three times over by the time it is encrypted, and the operating system may have written any of those copies to - (module)
the page file. Sealing it afterwards does not take that back. So the recording lives in a [`Tape`] from the first sample to the last, the WAV is assembled inside a [`Secret`], and the only thing that leaves this module is that sealed-ready - (module)
`Secret`. There is no route here that produces a plain `Vec` of the audio, because a route that existed would eventually be taken. # Never a plaintext file, either Nothing here writes to disk at all. The caller seals the [`Secret`] and - (module)
writes the result. A recorder that wrote a WAV and encrypted it afterwards would leave a plaintext file that [`veilvoice_crypto::shred`](../../veilvoice_crypto/shred/index.html) explains cannot be reliably taken back on flash storage, - (module)
which is the whole reason at-rest encryption is the default rather than an option. # The two halves, and why they are split [`Sink`] is handed to the audio callback and [`Recorder`] is kept by the caller. They are joined by a lock-free - (module)
ring buffer, for the reason the [`live`](crate::live) module documentation gives: a callback that allocates or waits produces a dropout, and locking a page or growing a tape does both. So the callback only ever pushes into a buffer that is - (module)
already allocated, and the slow, careful work of moving those samples into locked memory happens on the caller's thread in [`Recorder::drain`]. A caller that stops draining does not stall the audio. The ring fills, and samples are counted - (module)
as dropped rather than waited for, because a glitch in a recording is better than a glitch in the live output somebody is speaking into. [`Recorder::dropped`] reports it rather than letting the recording be quietly short. # In plain words - (module)
Keeps the veiled voice as it is produced, in memory the operating system has been asked not to write to disk, and hands it over ready to be encrypted. It never writes an unencrypted recording anywhere, not even briefly, because a file that - (module)
is written and deleted can still be recovered from the disk afterwards. - const HEADER
Bytes in a canonical 16-bit PCM WAV header. - const WAV_MAX_DATA
The most PCM data a RIFF/WAVE file can describe, in bytes. Not a limit this module chose. A WAV header states its sizes in unsigned 32-bit fields, so the format itself cannot describe more, and the `RIFF` size field has to hold the data - const WAV_MAX_DATA
plus the 36 bytes of header around it. At 48 kHz, 16-bit, mono, this is a little over twelve hours. It is checked rather than wrapped. A cast would produce a header claiming a fraction of the real length, and that file opens, plays, and is - const WAV_MAX_DATA
silently short: the worst shape a defect can take on a recording somebody made once and cannot make again. - const SLACK_SECONDS
How much audio the ring holds before samples are dropped, in seconds. Generous on purpose. The ring exists to absorb the gap between an audio callback that runs every few milliseconds and a caller that drains when it gets round to it, and - const SLACK_SECONDS
a caller doing a screen redraw between drains is normal. Sized in seconds rather than samples so the slack does not shrink when the device runs at a higher rate. - struct Sink
The writing half, handed to the audio callback. Every method is safe to call from a realtime audio callback: no allocation, no locking, no syscall, no waiting. - impl Sink
- fn write
Take a block of veiled samples. Samples that do not fit are counted and discarded rather than waited for. Blocking here would stall the output callback and glitch the audio the speaker is producing, to protect a recording of it, which is - fn write
the wrong way round. - struct Recorder
The reading half: moves samples out of the ring and into locked memory. - fn start
Start a recorder and the sink that feeds it. `sample_rate` is the rate the device actually agreed to, not the one that was asked for: it is written into the WAV header, and a header that disagrees with the samples plays back at the wrong - fn start
speed and the wrong pitch, which on a de-identified recording would be a second voice change nobody chose. - impl Recorder
- fn drain
Move everything waiting in the ring into the tape. Returns how many samples moved. Call this regularly: the ring holds [`SLACK_SECONDS`] and drops what does not fit. Samples are converted to 16-bit here rather than at the end, so the tape - fn drain
holds exactly the bytes the WAV will carry and the final step is a copy rather than a second pass over the whole recording. Values are clamped rather than allowed to wrap, for the reason [`io::wav_bytes`](crate::io::wav_bytes) gives: a - fn drain
sample past full scale that wrapped would flip sign and produce a loud click. - fn samples
Samples recorded so far, as of the last [`Recorder::drain`]. - fn seconds
Length so far in seconds, as of the last [`Recorder::drain`]. - fn dropped
Samples lost because the caller did not drain in time. Non-zero means the recording is short by this many samples and has a gap rather than a glitch. Worth reporting: a recording that is quietly missing a second of speech is worse than one - fn dropped
that says so. - fn fully_locked
Whether every page holding the recording is locked out of swap. False is not a failure, as [`veilvoice_crypto::tape`] explains: the operating system's lock budget is small and unprivileged processes cannot raise it. It is surfaced so the - fn fully_locked
caller can say what was actually obtained rather than imply a guarantee. - fn sample_rate
The sample rate written into the WAV header. - fn wav
Drain what is left and hand over the recording as a WAV, in a [`Secret`], ready to be sealed. Returns a `Secret` rather than a `Vec` deliberately. This is the moment the recording is complete and therefore at its most worth protecting, and - fn wav
it is exactly the moment a convenient `Vec` would put all of it into memory that can be paged to disk and is never wiped. The header is written by hand rather than through the WAV library for the same reason: that library builds its output - fn wav
in a `Vec` it grows and returns, which is the copy this module exists to avoid. - fn discard
Wipe the recording held so far and start again from nothing. - fn wipe_scratch
Clear the drain scratch. The one place this is written. `wav`, `discard` and the destructor all call it rather than each clearing the buffer themselves, so there is no second copy of the operation to fall out of step with the others, and a - fn wipe_scratch
test of this function is a test of what the destructor actually runs. - impl Drop for Recorder
- fn drop
Wipe the drain scratch. The tape wipes itself: every chunk of it is a [`Secret`]. The scratch buffer is not, and it holds up to one drain's worth of veiled audio in ordinary heap memory. Without this, abandoning a recording (Ctrl-C, an - fn drop
error on the way to sealing, or simply dropping the recorder) frees those samples without clearing them, and the allocator is then free to hand that memory, contents intact, to anything else in the process. # What this does not reach The - fn drop
ring buffer between [`Sink`] and [`Recorder`] also holds veiled samples, up to [`SLACK_SECONDS`] of them, and `ringbuf` exposes no way to clear its backing storage. Those bytes are freed unwiped and this module cannot prevent it. It is - fn drop
written down rather than left for somebody to discover: the exposure is veiled audio, which is the same audio the file holds and is not key material, but it is not nothing and claiming the recorder wipes everything would be false. - fn write_header
Write a canonical 44-byte mono 16-bit PCM WAV header into `out`. `out` must be at least [`HEADER`] bytes; callers here always size it from the same constant. - mod tests
- fn decode
Decode a WAV back to samples with the same library the rest of the crate reads files with, so the header is checked by something other than the code that wrote it. - fn the_header_this_writes_is_one_a_wav_reader_accepts
- fn what_was_spoken_is_what_comes_back
- fn a_sample_past_full_scale_is_clamped_rather_than_wrapped
- fn draining_in_pieces_gives_the_same_recording_as_draining_at_the_end
- fn a_recording_nobody_drained_reports_what_it_lost_rather_than_going_short_in_silence
- fn an_empty_recording_is_a_valid_wav_of_no_length
- fn the_length_reported_matches_the_samples_recorded
- fn discarding_leaves_a_recorder_that_records_again_from_nothing
- fn the_recording_is_handed_over_in_protected_memory
- fn a_recording_too_long_for_the_format_is_refused_rather_than_truncated
- fn the_header_stays_wrong_rather_than_plausible_if_the_guard_is_ever_lost
- fn abandoning_a_recording_does_not_leave_the_scratch_buffer_full_of_audio
- fn a_zero_sample_rate_does_not_divide_by_it
crates/veilvoice-audio/src/room.rs
- (module)
**Roadmap item 147.** Several microphones at once: a guest each, veiled each, mixed once. # What this is for An interview with everybody in the room on their own microphone. Each guest gets their own engine with their own seed and their - (module)
own destination voice, exactly as a group *render* gives them, and the veiled results are mixed into the one output the call or the recorder is listening to. [`live`](crate::live) opens one input. That is the right shape for one person on - (module)
a call and the wrong shape for a room, because one microphone carrying four people is one signal: whatever it is turned into, everybody in it is turned into the same thing, and a listener can no longer follow who is speaking. # One engine - (module)
per guest is one FFT chain per guest, on one deadline The output callback runs every guest's engine before it returns. They share a deadline of a few milliseconds, so the cost is the sum, and this is the honest account the roadmap item - (module)
asked for rather than a paragraph promising it is fine: [`RoomStats::load`] is that sum measured against that deadline, drawn where somebody can see it. At 1.0 the engines have used the whole of the time the block had, and what follows is - (module)
dropouts. It is a measurement rather than a limit, because the number of guests a machine can carry is a fact about the machine. [`MAX_GUESTS`] is a bound on the arithmetic, not a claim about performance. # The mix is summed and clipped, - (module)
and never limited Two people talking at once is two signals added together, which can go past full scale. A render fixes that afterwards by scaling the whole file by one factor, which a live path cannot do because it cannot see the rest of - (module)
the conversation. So the sum is clipped, the peak *before* clipping is reported, and the number of blocks that clipped is counted. There is deliberately **no limiter**: a limiter is a dynamics processor, it changes the voice, and this - (module)
program's entire claim is about what changes a voice and what does not. Adding one to avoid a warning would mean a second thing altering the sound that nobody asked for. The warning is the honest end of that. # Every microphone has its own - (module)
clock Two USB microphones are two oscillators, and neither is the output's. Each guest gets their own ring, of the same length [`live`](crate::live) uses, which absorbs the jitter and reports what it could not: a guest whose device runs - (module)
slightly fast fills their ring and drops samples, counted against that guest, and one running slow starves and is padded with silence. What is **not** absorbed is a rate mismatch, which is F-168 and is refused before anything starts: see - (module)
`live::agree_on_a_rate_for`. # In plain words Everybody in the room speaks into their own microphone. Each voice is disguised separately, so they still sound like different people, and the result is mixed together into one signal for the - (module)
call or the recording. Disguising four voices at once is four times the work, on the same short deadline, so the screen shows how much of that deadline is being used. If it reaches the top, the computer cannot keep up and the sound will - (module)
break. - const MAX_GUESTS
The most microphones one room will open at once. A bound on the arithmetic rather than a claim about any machine: what a machine can actually carry is [`RoomStats::load`], measured while it runs. Eight is past the number of people who can - const MAX_GUESTS
hold one conversation, and every guest costs a device, a ring, an engine and a place in the mix. - struct Guest
One guest: the microphone they speak into and the voice they become. - struct GuestStats
What one guest's half of a running room is doing. - struct RoomStats
What a running room is doing, safe to read from the interface. Not `Copy`: it holds one entry per guest. Read once a frame, as [`crate::LiveStats`] is. - struct KeptRoom
The recorders a room was asked for. - struct RoomSession
A running room. Dropping it stops every stream. - struct Shared
- impl Shared
- fn report
Record what the platform said about one of the streams. The same shape as the single-microphone path's, and not a realtime callback: cpal calls this when something has gone wrong rather than every block. - struct Voice
Everything one guest's engine needs, owned by the output callback. Built before the stream starts. The callback only indexes into it: nothing here is allocated, resized or locked once the audio is running. - impl RoomSession
- fn start
Open every guest's microphone, veil each, and mix into `output`. `mixed` asks for a recorder fed with what the output actually receives, which is the one recording that has everybody in it. Refuses an empty room, more than [`MAX_GUESTS`], - fn start
and any set of devices with no sample rate they all accept. - fn guests
How many guests this room opened. - fn stats
Read the counters, resetting the peak meters. Peaks reset on read so a meter shows the level since the last frame rather than the loudest moment since the room opened. - fn interference
What the platform last reported about any of the streams. **Roadmap item 132**, the same report the single-microphone path gives. It says which side, not which guest: cpal's error callback is per stream and this keeps the most recent one, - fn interference
which is the one worth showing. - mod tests
- fn a_room_starts_empty_and_says_nothing
- fn the_guest_limit_is_a_bound_rather_than_a_promise
The bound is on the arithmetic, and the honest number is measured. Eight is past the number of people who can hold one conversation. What a machine can carry is [`RoomStats::load`], and no constant here is a claim about that. - const _
- const _
- fn the_load_is_what_every_engine_costs_together
**Roadmap item 147.** The load is a sum, because the deadline is shared. One engine at 0.2 of realtime is comfortable. Four of them is 0.8, on the same block, and that is the number worth showing rather than the 0.2 each of them would - fn the_load_is_what_every_engine_costs_together
report about itself. - fn the_mix_clips_and_says_so_rather_than_limiting
No limiter, ever. A limiter is a dynamics processor: it changes the voice. This program's whole claim is about what changes a voice, so a limiter added to avoid a clipping warning would be a second thing altering the sound that nobody - fn the_mix_clips_and_says_so_rather_than_limiting
asked for. The mix clips, says so, and counts it. - fn every_buffer_is_sized_before_the_streams_start
Nothing in the callbacks allocates. Checked here as well as by the workspace guard, because this file is where the temptation is: a room has a variable number of guests, and a `Vec` per block would be the obvious way to write it.
crates/veilvoice-cli/src/accel.rs
- (module)
`veilvoice accel` reports the graphics hardware here, and what it is good for. # In plain words Lists the graphics devices on this computer and says which of them can encode video, so you can pick one when making a video of a conversation. - (module)
It also says, with the measurement behind it, why the voice changing itself does not use a graphics card: it is already about a hundred times faster than real time, and moving that work onto a card would slow it down. - fn show
Show what this machine has.
crates/veilvoice-cli/src/appctl.rs
- (module)
`veilvoice appctl` learns what normally runs, so it can notice what does not. Every subcommand prints the scope note. Not once at setup, not behind a flag: **every time**, because the one thing a reader must not come away believing is that - (module)
this stopped something. It did not and it cannot, and a warning shown once is a warning forgotten by the second week. # In plain words Run `learn` for a while and VeilVoice writes down which programs you normally use. Run `check` - (module)
afterwards and it tells you about anything running that was not on that list. It does not block anything. It is a way of noticing. - fn baseline_path
Where the baseline is kept. - fn load
- fn save
- fn scope
The note that goes with every answer. - fn running
What is running now, through the shared listing. - fn learn
Record what is running as ordinary. - fn check
Compare what is running against the baseline. - fn allow
Allow a program, for a while or for good. - fn revoke
Withdraw a grant. - fn log
Show the decision log.
crates/veilvoice-cli/src/atrest.rs
- (module)
Encryption at rest for the recordings VeilVoice writes, and the passphrase prompts that feed it. # Why this is the default De-identification and confidentiality are different problems, and VeilVoice only solves the first: the words survive - (module)
on purpose, so a veiled recording sitting on disk is still a recording of everything that was said. Writing it in the clear by default would quietly leave the second problem unsolved for everyone who did not think to ask. So the result is - (module)
sealed into a [`container`], with Argon2id or the X25519 plus ML-KEM-768 hybrid, unless the user asks for plaintext, and asking for plaintext prints [`PLAINTEXT_WARNING`] and, on a terminal, waits for an answer. # Never through a plaintext - (module)
file The WAV is encoded in memory and sealed there. It is never written to disk and then encrypted, because a plaintext file that is created and deleted is precisely what [`veilvoice_crypto::shred`] explains cannot be reliably taken back - (module)
on flash storage. # In plain words Asks for a passphrase and encrypts the recording VeilVoice has just written. It is on by default, and the reason is worth stating: the words survive de-identification on purpose, so an unencrypted result - (module)
is still a recording of everything that was said. Veiling the voice and leaving the file open protects the speaker and not the conversation. Writing one unencrypted is allowed, and asks first. - const PLAINTEXT_WARNING
What the user is told before a recording is written in the clear. Kept here as data rather than inline `println!`s so the test suite can assert it still says the uncomfortable part. - enum Recipient
How a recording is to be sealed. - fn seal_to_disk
Seal `plaintext` and write it to `<path>.veil`, returning where it landed. - fn confirm_plaintext
Print the plaintext warning and, on an interactive terminal, require an explicit answer before continuing. Non-interactive callers, meaning scripts, pipelines and CI, still see it on stderr but are not blocked on a prompt nobody is there - fn confirm_plaintext
to answer. They asked for plaintext on the command line, which is as explicit as it gets. - fn into_secret
Move a typed password into page-locked, zeroizing storage, wiping the `String` it arrived in. `rpassword` hands back an ordinary `String`, which is an ordinary heap allocation that can be paged out and is not wiped when it is dropped. That - fn into_secret
is a window this crate cannot remove, because something has to receive the keystrokes. It can be made as short as possible, which is what this does: copy into a [`Secret`], wipe the copy, wipe the original, and hand back the only remaining - fn into_secret
version. No `unsafe`, so the intermediate `Vec` is a real second copy for a moment. It is wiped by `Secret::new` before this returns. Writing through `String::as_bytes_mut` would avoid it and is not worth an `unsafe` block in a crate that - fn into_secret
has none. - fn no_terminal
What to say when there is no terminal to ask on. **F-109.** Every one of these prompts used to surface the operating system's own error, so `veilvoice anonymise recording.wav` run from a script, a cron job, a CI step or anything with its - fn no_terminal
input redirected failed with: ```text ✗ No such device or address (os error 6) ``` That is `ENXIO` from opening the console, and it says nothing: not what was being asked for, not why it failed, and not one of the three ways to proceed. - fn no_terminal
The message is also different on Windows, so nobody could search for it and find the same answer twice. `confirm_plaintext` in this same file already got this right, checking for a terminal before asking anything. The prompts did not, - fn no_terminal
which is the same defect in the same file with a different door, and is the shape this project has recorded most often. - fn can_prompt
Whether a passphrase can be asked for at all. Checked before prompting rather than after failing, so the answer is the same on every platform. `rpassword` reports a missing console differently on Windows and Unix, and a message a reader - fn can_prompt
can search for should not depend on which. - fn prompt_secret
Prompt once, without echoing, and keep the answer in a [`Secret`]. - fn read_new_password
Read a password twice, without echoing it, and check the two agree. - mod tests
- fn the_warning_states_the_actual_consequence
The warning has one job. If it is ever softened into reassurance, this is what stops it shipping. - fn sealed_output_goes_beside_the_recording_with_a_veil_suffix
- fn a_sealed_recording_does_not_contain_its_plaintext
A sealed recording must not contain its own audio in the clear. This is the property the whole default exists for. - fn a_missing_public_key_is_reported_rather_than_panicking
- mod no_terminal_tests
- fn the_no_terminal_message_says_what_was_wanted_and_how_to_proceed
**F-109.** No terminal is explained, not reported as an errno. `veilvoice anonymise recording.wav` from a script, a scheduled job or anything with its input redirected used to fail with `No such device or address (os error 6)`, which is - fn the_no_terminal_message_says_what_was_wanted_and_how_to_proceed
`ENXIO` from opening the console. It names nothing that was being asked for, nothing about why, and none of the ways on. It is also a different string on Windows, so nobody could search for it and get the same answer twice. - fn both_prompts_refuse_with_the_same_explanation
The same guidance whichever prompt could not run. Two functions prompt, and both refuse through here. A reader who hits one and then the other should not get two different accounts of the same situation.
crates/veilvoice-cli/src/capture.rs
- (module)
`veilvoice capture` -- which screen recorders are running, and which of them you have said you meant to run. The command-line front end to [`veilvoice_watch::capture`]. That crate holds the table, the allowlist and the honest account of - (module)
the three things it cannot do; this file decides where the allowlist lives and prints the result. # Where the allowlist lives ```text <config>/veilvoice/capture/allow.txt ``` Beside everything else this program keeps. Plain text, nothing - (module)
secret in it: an allowlist is a note to yourself about which notifications you have already read. # The exit code `veilvoice capture check` exits non-zero when something **not allowed** is running, so it can be a step in a script that - (module)
refuses to start recording something sensitive while a recorder is open. Allowing a program is precisely how you tell that script you meant it. It exits zero when the listing itself failed, and says so on the way past. A check that cannot - (module)
see is not a check that passed, but neither is it a reason to fail a script. The difference is in the words, and the words are printed. # In plain words Tells you which screen recorders are running, and lets you say you meant to start one - (module)
so it stops being mentioned. It cannot tell whether a program is actually recording, only that it is open, and it says so. A meeting application being open is not somebody watching your screen. - fn capture_dir
Where the allowlist is kept. Derived from the app lock's location rather than resolved again, so there is one answer to "where does VeilVoice keep things". - fn allow_path
Where the screen-recorder allowlist is kept for this user. - fn load
The allowlist, or an empty one when none has been written yet. - fn save
Write the allowlist back, readable by its owner and nobody else. - fn status
What is running, what is allowed, and what this cannot see. - fn calls
Every program in the table, whether it is running or not. Where to point a calling program so your voice goes through VeilVoice. Prints the route, which program to change and where, and -- as plainly as the rest -- the two things it does - fn calls
not do. - fn list
`veilvoice capture list`: the screen recorders this build knows how to name. Prints the limit with the list, because a program not on it is not reported and a reader who does not know that would take an empty result for an all-clear. - fn allow
Stop notifying about one program. - fn deny
Start notifying about one program again. - fn check
Look now, and let the exit code answer. Returns `true` when something not allowed is running. - mod tests
- fn the_capture_directory_sits_beside_the_app_lock
- fn every_state_directory_is_distinct
Every state directory this program keeps must be its own, or two features share a folder and each other's filenames. - fn the_program_list_prints_every_entry
Both listings must render without touching the allowlist on disk.
crates/veilvoice-cli/src/conversation.rs
- (module)
`veilvoice conversation` -- several speakers, a voice each, and subtitles. The command-line front end to [`veilvoice_conversation`]. That crate holds the plan, the renderer and the honest account of what a conversation keeps; this file - (module)
reads the audio, writes the results and prints what happened. # What comes out Three files beside the output you name: ```text out.veiled.wav the audio, one destination voice per speaker out.veiled.vtt subtitles for a browser - (module)
out.veiled.srt subtitles for everything else out.veiled.html with `--page`: a player that needs nothing installed ``` # The page, and why it is not a video `--page` writes a self-contained HTML player: the waveform, a circle per speaker - (module)
that lights when they speak, and the subtitles. It references the audio and the WebVTT track by relative name rather than embedding them, so the files move together and the page does not double the size of a recording already sitting - (module)
beside it. A *video* file needs an encoder, and this project ships no codec. `preview` prints the `ffmpeg` command that would make one, and says whether `ffmpeg` is on this machine -- it never runs it. Nothing here silently depends on a - (module)
program the user did not know they were running. The subtitles are written whether or not anybody wrote down the words. With every voice replaced, a caption track saying *who* is talking is often the only way to follow a recording at all. - (module)
# The one warning this command will not let you miss Audio no turn claims is **silenced**, never passed through. A gap in the plan is a span nobody assigned to a speaker, so it has not been veiled, and putting it into the result would - (module)
place a real voice inside a file whose whole purpose is that it contains none. The amount is printed, loudly, because a plan with a hole in it is something to fix rather than something to discover later. # This does not encrypt what it - (module)
writes Unlike `anonymise`, which seals its output at rest by default. A conversation render produces a set of files -- audio and two subtitle tracks -- and the container this project uses seals one thing. Rather than invent a half-answer, - (module)
the files are written in the clear and the command says so: `veilvoice encrypt` seals the audio afterwards, and the subtitles hold whatever names were typed and are not veiled by anything. # In plain words The command line for recordings - (module)
with several people in them: a voice each, subtitles, a picture, and the command that would turn it into a video. All the actual work lives in the shared crate; this is the part that reads what you typed, prints what it is about to do, and - (module)
reports what it wrote. - const WAVE_COLUMNS
How many columns of waveform to reduce a recording to. One per two pixels of a 1280-wide picture, which is finer than any display resolves and coarse enough that the path stays a few kilobytes. The page scales it to whatever width was - const WAVE_COLUMNS
asked for. - enum Fix
One correction to make to a plan. A plain enum rather than the clap type, so this module is testable without building a command line. `main.rs` converts. - fn fix
Correct a plan in place, and say exactly what changed. # Written back only after it worked The plan is read, changed in memory, and only then written. A correction that is refused leaves the file byte for byte as it was, so a mistyped - fn fix
command cannot leave a plan half-edited. A half-edited plan is the worst outcome available here: it looks fine, and it renders somebody in the wrong voice, which nobody can hear. # What is printed The before and the after, for the spans - fn fix
that changed. A correction that says only "done" leaves somebody to open the file and check, and the whole point of these commands is that checking by ear is not possible. - fn describe
Every span as `speaker, start to end`, for reporting what a change replaced. - fn look_from
Turn the picture flags into a [`Look`], or explain why they do not describe a picture that can be drawn. `checked()` is the crate's own refusal, and it refuses rather than clamps: somebody who asked for a 200-pixel render of nine speakers - fn look_from
meant something, and quietly drawing an illegible one answers a question they did not ask. - fn inspect
Show a plan without rendering anything. - fn run
Render a recording according to a plan. `look` is `Some` when `--page` was asked for, and it decides what the page looks like. `None` writes the audio and the subtitles and nothing else. - fn preview
A still of what the page will look like, and the command that would make a video of it. Nothing is rendered and nothing is encoded. This exists so that the answer to "what will I get" costs a second rather than the length of the recording. - fn file_name
The last component of a path, for writing into a page as a relative link. A page that referenced the absolute path would break the moment the three files were moved together, which is the one thing they are meant to survive. - fn load_plan
Read a plan, and say where it went wrong rather than only that it did. - fn write_private
Replace the last extension, keeping any `.veiled` before it. Write a file only this account can read, naming it if that fails. Everything a conversation produces goes through here. The audio, both subtitle tracks and the page all used the - fn write_private
ordinary `fs::write`, which is 0644, while `anonymise` wrote its result 0600 even when somebody had turned encryption off on purpose. The conversation output is the one carrying more: the subtitles hold the names and the words as typed, - fn write_private
and the timings say who spoke when. It had the weaker permission because one path called the hardened write and the other called the default, not because anybody decided it should. - fn with_extension
- mod tests
- fn plan_file
- fn wav_file
- fn inspecting_a_plan_needs_no_audio
- fn a_missing_plan_says_which_file
- fn rendering_writes_the_audio_and_both_subtitle_tracks
The whole command, end to end: audio in, audio and two subtitle tracks out, and the output the same length as the input. - fn everything_a_render_writes_is_readable_only_by_this_account
Nothing a render writes is readable by another account. All four files used the ordinary `fs::write`, which is 0644, while `anonymise` wrote its result 0600 even where somebody had turned encryption off on purpose. The conversation output - fn everything_a_render_writes_is_readable_only_by_this_account
is the one carrying the names and the words as typed, so it had the weaker permission on the more revealing file, and nobody had decided that: one path called the hardened write and the other called the default. - fn the_default_output_sits_beside_the_input
The output name is derived from the input when none is given. - fn a_plan_with_no_speakers_is_refused_before_the_audio_is_read
- fn a_page_is_written_beside_the_audio_and_links_to_it_by_name
`--page` writes a fourth file, and it points at the other three by name. - fn without_the_flag_no_page_is_written
No `--page`, no page. The default is three files, as it always was. - fn a_look_that_cannot_be_drawn_is_refused_with_the_numbers_in_it
A picture nobody could read is refused rather than drawn. - fn a_background_that_is_neither_a_colour_nor_a_file_is_named_in_the_error
`--background` takes a colour or a file, and says so when it is neither. - fn black_overrides_a_background_colour
`--black` wins over `--background`, as its help says. - fn no_theme_means_tokyo_night
The default is Tokyo Night, and it is what a picture with no `--theme` is drawn in. - fn a_named_theme_takes_the_background_with_it
A named theme reaches the drawing, and takes the background with it. A Gruvbox picture on a Tokyo Night page is not what anybody asked for. - fn a_background_given_by_hand_survives_a_theme
Unless the background was asked for separately, in which case both requests are honoured. - fn an_unknown_theme_is_refused_and_lists_the_real_ones
An unknown name is refused, and the refusal says what it could have been. An error that only says "no" leaves the reader guessing at a spelling. - fn the_theme_changes_the_colours_in_the_drawing
The colours actually reach the markup. Without this the flag could be plumbed all the way through and change nothing anybody can see. - fn a_preview_draws_without_any_audio_at_all
A preview needs no recording, and writes a still that is real SVG. - fn the_subtitle_names_follow_the_audio_extension
crates/veilvoice-cli/src/decoy.rs
- (module)
`veilvoice decoy`, and what a second passphrase is worth and what it is not. # In plain words Explains the decoy passphrase: what it does, the two things it cannot do, and the rules a pair has to satisfy. It does not set one up. Choosing a - (module)
passphrase is done where passphrases are already handled, and printing one into a terminal's history would be a poor start. - fn explain
Explain the feature and its limits.
crates/veilvoice-cli/src/failsafe.rs
- (module)
`veilvoice failsafe` is the safety catch. What it can and cannot do. # In plain words Shows whether the safety catch is on, what it would do, and what is holding a microphone right now. It is the same guard the desktop application runs; - (module)
this is how to look at it from a terminal. The limits are printed every time, because the one thing a reader must not come away believing is that this stops their computer handing a microphone to another program. It notices, and it acts, - (module)
and there is a moment between the two. - fn show
Show what Failsafe would make of this machine right now. - fn microphone_holders
Everything holding a microphone, through the shared watch layer. - fn describe
A named finding, for the tests to reach without a machine.
crates/veilvoice-cli/src/guard.rs
- (module)
`veilvoice guard` -- record what VeilVoice's files should be, and check them. Detection, not prevention. See [`veilvoice_guard::SCOPE`], which every path through this module prints, for the same reason the app lock prints its own: a - (module)
protection someone over-trusts has made them less safe, not more. # What the three steps actually do * **`init`** walks the files that make up this installation and records a SHA-256 for each. Optionally sealed with a passphrase, so the - (module)
record itself cannot be quietly rewritten to match tampered files. * **`check`** re-walks and reports what is **modified**, **removed** and **added**. All three matter: an added file in the installation directory is as interesting as a - (module)
changed one. * **`blame`** tries to say *which process* made a change, and says plainly when it cannot. # Why attribution usually fails, and why that is reported rather than hidden Attribution needs the operating system to have been - (module)
recording. On Linux that means an `auditd` watch; on Windows a SACL on the path plus the audit policy enabled, and reading it needs elevation. Neither is on by default on a normal machine. So the common answer is "something changed this - (module)
file and I cannot tell you what", and this module prints exactly that rather than an empty list. An empty list reads as *nothing happened*, which is the opposite of the truth, and is the same mistake as a monitor reporting an empty machine - (module)
because a registry query silently matched nothing. # The bound, again A manifest running as the user protects nothing from that user, and detects rather than prevents even when it works. Anything that can write these files can write the - (module)
manifest beside them. That is why the passphrase-sealed record exists, why [`veilvoice_guard::SCOPE`] is printed on every path through this module, and why the word "tamper-proof" appears nowhere in it. # In plain words Writes down what - (module)
VeilVoice's own files should look like, and checks later that they still do. It notices changes. It does not prevent them, and every path through it says so, because a check somebody believes is a lock is worse than no check. - enum Action
- fn manifest_path
Where the manifest lives, beside the app lock. - fn sealed_path
A sealed manifest sits beside the plain one, with a different suffix. - fn print_scope
Say what the integrity record detects and what it cannot, before it is used. - fn default_targets
The files worth watching when the user names none: the running binary, and the app lock beside it. - fn run
Dispatch `veilvoice guard` to the subcommand that was asked for. - fn init
`veilvoice guard init`: record what these files are now. Everything afterwards compares against this moment, so a record taken on a machine that was already interfered with records the interference as normal. That is printed rather than - fn init
left to be discovered. - fn load
Load whichever form of the record exists, asking for a passphrase only if the sealed one is the one that is there. - fn check
`veilvoice guard check`: compare the files against the record and report. Where the platform allows it, a changed file comes with a best-effort guess at what changed it, and the guess is labelled as one. - fn status
`veilvoice guard status`: what the record covers, and when it was taken. - mod tests
- fn the_sealed_record_sits_beside_the_plain_one
- fn an_explicit_path_wins_over_the_platform_default
- fn a_record_notices_a_changed_file
The end-to-end flow, through the same manifest the subcommands drive. The passphrase prompts need a terminal, so the sealed path is covered by the crate's own tests instead. - fn checking_without_a_record_explains_rather_than_panicking
crates/veilvoice-cli/src/gui.rs
- (module)
`veilvoice gui` opens the desktop application from the command line. # Why this is not simply `Command::new("veilvoice-gui")` A bare name is resolved through `PATH`, and on Windows the **current directory is searched first**. So `veilvoice - (module)
gui`, run inside a downloads folder that happens to contain something called `veilvoice-gui.exe`, would start that instead. This is the one command whose whole job is to launch another program, which makes it a poor place to be relaxed - (module)
about which. So the search is explicit and in a stated order: 1. **Beside this binary.** A portable folder holds both programs together, and somebody who unpacked a release and typed `veilvoice gui` means the one they unpacked. 2. **Where - (module)
an installation puts it**, from `veilvoice_setup::install`. 3. **`PATH`**, last, and only through the system's own resolver. If none of those has it, that is said plainly with the places that were looked in, rather than a "not found" that - (module)
leaves somebody guessing. # It does not wait The window is started and the command returns. A terminal held open for as long as a desktop application runs is a terminal somebody cannot use, and closing it would then close the window. # In - (module)
plain words Type `veilvoice gui` (or `veilvoice g`) and the VeilVoice window opens. The terminal is yours again immediately; closing it will not close the window. It looks for the application next to the command first, then where an - (module)
install would have put it, then on your system path. If it cannot find it anywhere, it tells you exactly where it looked. - fn gui_name
The executable's name on this platform. - fn candidates
Everywhere this looks, in order, and whether each had it. Returned rather than printed so the caller decides how much to say, and so the "not found" message can list every place rather than the last one. - fn find
The desktop application, wherever it is. - fn on_path
Ask the system where the program is, if anywhere. - fn open
Open the window. - mod tests
- fn the_name_has_this_platforms_extension
- fn the_search_order_starts_beside_this_program
Beside this program comes first. A portable folder holds all three together, and somebody who unpacked a release means the one they unpacked -- not an older installed copy. - fn the_application_is_never_started_by_a_bare_name
**Never a bare name.** `PATH` on Windows searches the current directory first, so `veilvoice gui` run inside a downloads folder holding something called `veilvoice-gui.exe` would start that instead. This is the one command whose entire job - fn the_application_is_never_started_by_a_bare_name
is launching another program. - fn not_finding_it_says_where_it_looked
A failure names every place that was tried, so somebody is not left guessing where it should have been. - fn the_window_is_started_and_not_waited_for
It starts the window and returns. Waiting would hold the terminal for as long as the application runs.
crates/veilvoice-cli/src/input.rs
- (module)
`veilvoice input` shows which running programs can see your keyboard and mouse. The whole command is arranged around one risk: that somebody runs it, sees nothing listed, and concludes their machine is clean. That conclusion is wrong and - (module)
acting on it makes them less safe than not having looked, so the limits are printed **with** the result rather than behind a flag, and they are printed whether anything was found or not. # In plain words This tells you which of the - (module)
programs currently open on your computer are able to see what you type or where you click. Most of them will be things you installed on purpose, such as a password manager, a screen reader, remote support software you use for work. It - (module)
cannot tell you whether anything is actually recording your typing. No program can, and this one says so every time rather than letting a short list look like good news. - fn look
Show what can see input on this machine. - fn known
Everything this build knows how to recognise, whether running or not. Separate from [`look`] because they answer different questions, and because a reader who gets an empty result deserves to be able to see *what* was looked for rather - fn known
than taking the emptiness on trust.
crates/veilvoice-cli/src/lock.rs
- (module)
`veilvoice lock` manages the application lock from the command line. The lock guards the desktop app: with one set, VeilVoice asks for a password before it will show anything or start a live scramble. Managing it from here exists because a - (module)
headless machine still has a config directory, and because anything the GUI can do to a file on disk should be inspectable without the GUI. Every path through this module prints [`veilvoice_crypto::lock::SCOPE`], for one reason: a lock the - (module)
user believes is stronger than it is has made them *less* safe, not more. # In plain words Sets, changes and clears the passphrase that opens the desktop application, from a terminal. It is the same lock the window uses and the same file, - (module)
so the two cannot get out of step. What it is worth is printed with it: it stops somebody who picks up your unlocked computer, and it does not stop somebody holding your disk. - enum Action
- enum Site
Where the lock is kept for this invocation. Without `--path` the lock lives in the vault: two copies under names derived from a per-installation value, one of them administrator-owned where the platform allows it. With `--path` it is one - enum Site
plain file at the path given, which is what a script or a test wants and what this command has always done. The two are kept apart rather than blended, because a command that silently wrote somewhere other than the path it was handed would - enum Site
be worse than either. - impl Site
- fn resolve
Where the lock is kept: the path given on the command line, or the platform default. - fn describe
What to print as the location. The vault's own file names are derived and would mean nothing to a reader, so the directory is what is shown. - fn open
Open the lock, and say whether a missing copy had to be rebuilt. - fn create
Make a lock here for the first time, deriving the verifier from `password`. - fn print_scope
Print the honest scope note, wrapped for a terminal. - fn wrap
Greedy word wrap. The scope note is single-sourced from the crypto crate, so it arrives as one long string and has to be broken here rather than there. - fn run
Dispatch `veilvoice lock` to the subcommand that was asked for. - fn status
`veilvoice lock status`: whether a lock is set, and where it lives. Says nothing about the passphrase, not even how long it is. What a reader learns here is what somebody holding the machine already knows. - fn set
`veilvoice lock set`: make a lock, asking for the passphrase twice. Refuses if one is already set rather than replacing it. Overwriting a lock is `change`, which asks for the old passphrase first, and the difference matters: a `set` that - fn set
silently replaced would lock somebody out of their own vault. - fn change
`veilvoice lock change`: replace the passphrase, after proving the old one. - fn remove
`veilvoice lock remove`: take the lock off, after proving the passphrase. - fn open_or_explain
The lock store, or a message saying what to do rather than a bare error. - mod tests
- fn wrapping_keeps_every_word_and_respects_the_width
- fn wrapping_handles_an_empty_string
- fn an_explicit_path_wins_over_the_platform_default
- fn no_path_means_the_vault_rather_than_one_named_file
Without `--path` the command must go to the vault, not to the single file the vault replaced. Getting this wrong would leave the CLI and the window looking at two different locks. - fn a_lock_can_be_set_proven_and_removed
The lock lifecycle through the same store the subcommands drive. The prompts themselves need a terminal, so this exercises the layer beneath.
crates/veilvoice-cli/src/main.rs
- (module)
`veilvoice`, the command-line interface. Everything VeilVoice does, available without a desktop: it runs over SSH, in a container, and on machines that have no GUI toolkit at all. The same engine backs both this and the graphical app. # - (module)
What is here Twenty subcommands, and they divide into five groups: * **Audio** -- `anonymise` a file, `live` scramble a microphone, list `devices`, `conversation` for a recording with several people in it. * **Privacy of the files - (module)
themselves** -- `clean` metadata, `encrypt`, `decrypt`, `keygen`, `shred`. * **Watching the machine** -- `watch` the microphone and camera, `guard` VeilVoice's own files against tampering, `sentry` for canaries and how fast a folder is - (module)
changing, `capture` for which screen recorders are running. * **The app lock** -- `lock set|status|change|remove`, and `policy` for settings somebody has fixed so the interface cannot turn them off. * **Getting it onto the machine** -- - (module)
`install`, `uninstall`, `companions`, and `gui` to open the desktop application. That last group is a front end over [`veilvoice_setup`], which the desktop application's setup tab also calls. The careful part -- editing `PATH` -- has one - (module)
implementation and one set of tests, rather than one per front end. # Two behaviours that surprise people, on purpose **`anonymise` writes `<out>.veil`, not a bare WAV.** Recordings are encrypted at rest by default. `--encrypt=false` opts - (module)
out and requires `--yes`, because an unsealed recording is the thing somebody later wishes they had not produced. The wiki explains where the WAV went. **The front-ends refuse rather than downgrade.** Asked to encrypt with nothing to - (module)
encrypt with, this exits with an error instead of writing plain audio and mentioning it. Quiet degradation to a weaker posture is the defect class this project has found in itself most often. # Passphrase prompts cannot be piped - (module)
`rpassword` needs a real console; piping a passphrase in blocks on `CONIN$` rather than reading it. That is a property of terminal input, not a bug here, and it means anything that prompts cannot be smoke-tested from a non-interactive - (module)
shell. The layer *beneath* each prompt is therefore tested instead -- see [`crate::atrest`] and [`crate::lock`], where the logic lives precisely so it can be reached without a terminal. # A clap ordering rule worth knowing An argument - (module)
declared beside `#[command(subcommand)]` must precede the subcommand on the command line unless it is marked `global = true`. So `veilvoice lock --path X status` parses and `veilvoice lock status --path X` does not, except that `--path` is - (module)
now global specifically so both do. # In plain words This is VeilVoice without a window. Everything the program does, typed instead of clicked: disguise a recording, scramble a microphone while you talk, seal a file, strip a photograph's - (module)
hidden labels, handle a recording with several people in it. It is the same code underneath, so it works the same way -- over a remote connection, on a machine with no desktop, or from a script that runs it a thousand times. - mod accel
- mod appctl
- mod atrest
- mod capture
- mod conversation
- mod decoy
- mod failsafe
- mod guard
- mod gui
- mod input
- mod lock
- mod mandate
- mod priv_mode
- mod record
- mod meter
- mod policy
- mod sentry
- mod theme
- struct Cli
- enum Command
- impl From<FixCommand> for conversation::Fix
What `veilvoice conversation` can do. - fn from
- enum FixCommand
The corrections `veilvoice conversation fix` can make. - enum ConversationCommand
- enum AppctlCommand
What `veilvoice capture` can do. - enum InputCommand
- enum CaptureCommand
- enum MandateCommand
What `veilvoice mandate` can do. - enum PolicyCommand
What `veilvoice policy` can do. - enum SentryCommand
What `veilvoice sentry` can do. - enum CleanPolicy
- impl From<CleanPolicy> for Policy
- fn from
- fn flavour_for
The verification script's spelling for a system, in one place. **F-167.** This mapping existed twice, both times as a `match` with a catch-all, and both catch-alls sent the BSDs to the Linux script. Written once, exhaustively, so a system - fn flavour_for
added to one enumeration and not the other is a compile error rather than a wrong instruction. - fn explain_verification
What checking a release actually involves, and who does which part. Written out here rather than left to the website, because somebody on a machine with no browser is exactly the person most likely to be checking a download by hand. - fn main
Parse the command line and turn a failure into an exit code and a message. - fn run
Carry out one subcommand. Everything the command line can ask for arrives here already parsed and validated, so this is a dispatch rather than a place where input is checked. - fn run_ffmpeg
Roadmap items 87 and 88. Run an ffmpeg command, or print it when there is no ffmpeg to run. The same bargain the rest of this project makes about other people's software: VeilVoice does not ship a codec, does not install one, and does not - fn run_ffmpeg
fail silently when one is missing. It says what it would have run, so somebody can run it themselves or install the tool and try again. The command is printed either way, before it runs. A tool that shells out and shows only the result - fn run_ffmpeg
leaves nobody able to check what it did. - fn list_volumes
Report the encrypted volumes this machine is offering. Roadmap items 81 to 85, from the command line. Reporting only: the same rule the window follows, and for the same reason. A hidden-volume question cannot be answered here because there - fn list_volumes
is nothing to remember it against, so this prints what a volume would need before the desktop application would write to it rather than pretending to settle it. - fn list_companions
Report every companion that means anything on this platform. Reporting is all this does. The licence and the author are printed beside each one because somebody deciding whether to install software is entitled to both before they decide, - fn list_companions
not afterwards in a manual. - fn offer_line
One line describing what VeilVoice can do about a missing companion. - fn install_companion
Act on one named companion. The name is the explicit yes. - struct Tuning
The engine settings a user can reach from the command line. - fn reseed_range_from
Turn `--reseed-range` into a range, or into the reason it was not one. Three answers, and the middle one is the point of F-73: * `Some(text)` -- parse it, and **refuse** anything unusable rather than adjusting it to fit. * `None` -- draw a - fn reseed_range_from
range from the operating system's random source, so the shipped interval is a property of this launch rather than of the binary. * `"fixed"` -- the caller has asked for the old fixed interval by name, which is the only way to get a - fn reseed_range_from
predictable ratchet. - fn config
The de-identification settings a `Tuning` describes, with every figure clamped. Clamping here rather than at each flag is deliberate: these flags are the only way in, so one place that cannot be bypassed beats a check per flag. - fn describe_reseed_range
How a randomised roll range reads in the output. Reports [`DeidConfig::effective_reseed_range_ms`] rather than what was asked for: the ratchet can only fire on a frame boundary, so the range that takes effect is quantised, and printing the - fn describe_reseed_range
request would tell somebody their interval varies over a span it does not. - fn describe_reseed
How the seed-rolling setting reads in the output. - struct AtRest
What to do with the result once it exists. - fn anonymise
`veilvoice anonymise`: veil a recording and write it somewhere else. - fn live
`veilvoice live`: veil the microphone as it is heard, with an optional preview. - const WIDTH
- fn list_devices
`veilvoice devices`: every input and output this machine reports. - fn read_named
Read a file, naming it if that fails. `std::io::Error` carries no path. `e.to_string()` on a missing file is the bare sentence "No such file or directory (os error 2)", and that is what three of these commands printed: ```text $ veilvoice - fn read_named
encrypt clip.wav --to nokey.pub ✗ No such file or directory (os error 2) ``` Which file? The command names two. `anonymise` answered that question, because `atrest.rs` formats the path in, and `encrypt` and `decrypt` did not, because they - fn read_named
did not. The same bug as the verifier's unnamed verdict, in a place where the person can at least see their own command line, which is the only reason it is smaller. A function rather than a fourth copy of the format string, because that - fn read_named
is how the copies got out of step in the first place. - fn video_plan
Read a `--size` and an `--fps` into a render plan, saying what was decided. **The note is printed rather than swallowed.** `monitor` on a machine with no display server silently became 1080p otherwise, which reads as a detection and is a - fn video_plan
fallback. Somebody rendering on a server should be told that the size they got is a default and not a measurement of anything. The display is not asked here at all: a command line has no window, and the platform interfaces that answer this - fn video_plan
question want one. The window passes its own display size to the same resolver, so both front ends share the arithmetic and differ only in whether there is a screen to ask. - fn write_named
Write a file, naming it if that fails. The failures here are the ones a person can act on: a directory that does not exist, a read-only disk, a name they cannot write to. All of them need the path to be actionable at all. - fn clean
`veilvoice clean`: strip the metadata a file carries, in place. - fn encrypt
`veilvoice encrypt`: seal a file, to a passphrase or to a public key. - fn decrypt
`veilvoice decrypt`: open a sealed file, given the passphrase or the secret key. - fn load_secret_key
Load a private key file, which is itself a password-locked container. - fn keygen
`veilvoice keygen`: write a new key pair, refusing to overwrite either file. - fn watch
Report, and keep reporting, what is using the microphone and camera. - fn shred
Destroy a file's contents, then delete it. Gated behind a typed confirmation rather than a y/n prompt. There is no undo, and a reflexive "y" is exactly the mistake this is guarding against. - fn info
`veilvoice info`: the versions, and what this build was compiled to do. - mod tests
- fn every_command_the_documentation_shows_exists
Every command the documentation shows is a command this program has. # The class this exists to close F-110 was an example plan in `docs/USER_GUIDE.md` that the parser refused. F-101 was a download page linking two files that were never - fn every_command_the_documentation_shows_exists
published. F-103 was a screenshot compared against a file written by the same command, so neither could catch the other. F-71 was two hand-typed copies of a number that drifted together. All the same shape: a document describing the - fn every_command_the_documentation_shows_exists
program, and nothing comparing the two. Prose about behaviour goes stale silently, because the compiler never reads it and the reader who would notice is the one who has already been misled. So the documentation is read here and checked - fn every_command_the_documentation_shows_exists
against clap's own tree. Not against a list kept beside it, which would be another copy to drift: against the definition the program is built from. # Telling a command from a sentence The word "veilvoice" appears in these documents as - fn every_command_the_documentation_shows_exists
prose, as sample output and as a command, and only the last is checkable. The first version of this test walked every occurrence and quietly skipped anything it could not resolve, which made it blind to exactly the defect it was written - fn every_command_the_documentation_shows_exists
for: `veilvoice frobnicate` was added to the guide and the test passed. An invocation is therefore taken to be one that a reader could copy: the whole of an inline code span, or a line inside a fenced block, beginning with `veilvoice`. - fn every_command_the_documentation_shows_exists
That excludes the alert `● veilvoice is now using your microphone`, which is in a fence and is output rather than something to type. # What it can and cannot say It checks that a documented subcommand exists and that a documented long flag - fn every_command_the_documentation_shows_exists
belongs to the subcommand it is shown with. That is the half a machine can settle. Whether the example *works* is not checkable here and is what running it is for: F-110 needed somebody to type it. - fn cli_definition_is_valid
- fn tuning
- fn intensity_is_clamped_into_range
- fn keep_accent_disables_neutralisation
- fn reseed_interval_reaches_the_engine_and_cannot_go_negative
- fn recordings_are_encrypted_at_rest_by_default
Encryption at rest is a *default*, not a flag the careful user has to find. If someone ever flips this, this test is what stops it shipping. - fn asking_for_a_recipient_and_for_plaintext_at_once_is_refused
Refused before anything is read or written, so a contradictory command line cannot half-happen. - fn reseed_setting_reads_clearly
- fn meter_scales_and_never_panics
crates/veilvoice-cli/src/mandate.rs
- (module)
`veilvoice mandate` -- the two things VeilVoice insists on, and how to stop. The command-line front end to [`veilvoice_policy::Mandate`]. That module holds the data and the history; this file decides what is printed, and makes relaxing a - (module)
requirement a deliberate act rather than a flag somebody typed once. # This is the opposite tool to `veilvoice policy` A sealed policy can only ever make VeilVoice **stricter**, and is meant for somebody setting rules for somebody else. - (module)
The mandate is your own baseline for your own machine, and it is the only thing here that can be **relaxed**. The two compose in one direction only: the effective requirement is the mandate **or** whatever the sealed policy adds. So - (module)
relaxing the mandate cannot switch off something an administrator has fixed, and `status` says so when that is what is happening, rather than reporting "not required" at somebody who will then find it is still required. # Why relaxing asks - (module)
twice Turning off encryption at rest means every recording VeilVoice writes from then on is a plain file of everything that was said. De-identification took the voiceprint out; it did not take the words out. That is a real choice somebody - (module)
may have real reasons to make, so it is offered, but it is not offered casually, and it is written down with the date. # In plain words VeilVoice asks for a password for itself and encrypts your recordings unless you tell it not to. This - (module)
is where you tell it not to, and where you can see when you did. - fn path
Where the mandate file lives. - fn load
The sealed policy and where it was read from, so an error can name the file. - fn sealed_also_requires
Whether the sealed policy independently fixes this requirement on. Read so that `status` can tell the difference between "you are insisting on this" and "you stopped insisting, and it is required anyway". Reporting the second as though it - fn sealed_also_requires
were "not required" would be the more dangerous error in the opposite direction: a user relaxing a requirement and being told it is off when it is not. - fn describe
One requirement in the words a person would use for it, not the field name. - fn status
What is required now, and how it got that way. - fn history
The log of every change, oldest first. - fn relax
Stop insisting on one or both requirements. - fn insist
Insist on one or both requirements again. - fn wanted
The fields the flags name, refusing when they name none. An empty selection is an error rather than a no-op, because `veilvoice mandate release` with no flag reads as "release everything" and must not quietly do nothing instead. - fn change
Tighten or release the sealed policy, after saying what that means. - fn reset
Back to insisting on both. - mod tests
- fn the_mandate_sits_beside_the_app_lock
- fn naming_no_field_is_refused_rather_than_treated_as_all_of_them
- fn naming_one_field_selects_only_that_one
- fn every_field_has_a_sentence_saying_what_it_costs
crates/veilvoice-cli/src/meter.rs
- (module)
Level meters for `veilvoice live`, on a scale that means something. # Why the old one was wrong The first meter was linear: a peak of 0.5 filled half the bar. That is arithmetically fine and useless as a meter, because loudness is not - (module)
linear. Ordinary speech recorded at a sensible level peaks around **-12 dBFS**, which is 0.25 linear, so three of twelve blocks. Somebody speaking normally saw a meter that looked like near-silence, and the only way to fill the bar was to - (module)
be clipping. Every real meter is logarithmic for that reason, and this one is too: -60 dBFS at the left, 0 dBFS at the right. Speech now sits in the middle of the bar where a person can see it move. # What it measures, and what it does not - (module)
**Sample peak, since the last read.** `veilvoice-audio` keeps the largest absolute sample seen since the meter was last looked at and resets it on read, so nothing between two reads is missed. It is **not** a loudness meter. RMS, LUFS and - (module)
everything else that correlates with how loud a thing *sounds* need a window and a weighting curve, and they answer a different question: this one is for "am I being recorded, and am I clipping", which is a peak question. It also cannot - (module)
see an **inter-sample peak**, a waveform that passes above full scale between two samples and clips in a converter or an encoder without any single sample exceeding 1.0. Catching those needs oversampling. The meter says `CLIP` when a - (module)
sample actually reaches full scale, and says nothing about the ones it cannot see, which is the honest half of a true peak meter rather than a claim to be one. # Peak hold A bar that only shows the current moment cannot show a transient: - (module)
the loud syllable is gone before a human eye finishes moving. The highest level of the last [`HOLD`] is kept and drawn as a single marker, and it decays rather than sticking, so the bar stays honest about what is happening *now* while - (module)
still showing what just happened. # In plain words Draws the input and output level meters during a live session. The bar and the decibel number beside it are worked out from the same piece of arithmetic the window uses, so the two halves - (module)
of VeilVoice cannot disagree about the same reading. They did once, and a meter you have caught contradicting itself is a meter you stop believing. - const HOLD
How long a peak marker is held before it falls back. - const EIGHTHS
The eighth-block characters, so a bar of `n` characters has `8n` steps. Twelve characters at one step each is a meter that moves in jumps of five decibels, which reads as broken rather than as coarse. The same twelve characters at eighths - const EIGHTHS
move in jumps of well under one. - fn render
One meter: the bar, the peak marker, and the number. `width` is in characters, and does not include the number after it. - struct Channel
One channel's meter, keeping the peak between reads. - impl Default for Channel
- fn default
- impl Channel
- fn update
Take a new reading and give back the meter to print. - fn has_clipped
Whether this channel has clipped at any point in the session. Sticky on purpose. Clipping is destructive and it is over in a millisecond; a warning that disappears before the person looks up is a warning that was never given. - mod tests
- fn the_bar_is_the_width_it_was_asked_for
- fn full_scale_fills_it_and_silence_empties_it
- fn a_held_peak_shows_as_a_marker_beyond_the_current_level
A held peak is drawn as a marker, and only where the bar is empty -- inside the fill it would be saying what the fill already says. - fn the_hold_rises_at_once_and_falls_back_after_a_while
The hold falls back rather than sticking, or the meter slowly becomes a picture of the loudest thing that ever happened. - fn clipping_is_remembered_for_the_session
Clipping is sticky: it is over in a millisecond and it is destructive. - fn nothing_here_panics_on_anything
crates/veilvoice-cli/src/policy.rs
- (module)
`veilvoice policy` -- settings that can only be tightened. The command-line front end to [`veilvoice_policy`]. That crate holds the logic and the honest account of what a sealed policy is worth; this file decides where the two files live - (module)
and prints them. # Where the policy lives ```text <config>/veilvoice/policy/policy.txt read at every launch, no passphrase <config>/veilvoice/policy/policy.sealed the same policy under a passphrase ``` Per-user, beside everything else this - (module)
program keeps. **Not** a machine-wide location: writing to one would need administrator rights, and a policy this program applies to itself does not become enforcement by living somewhere only root can write. What it would become is a - (module)
thing that looks like enforcement, which is worse than the honest version. # Why `remove` needs no passphrase Because it could not meaningfully require one. Anybody who can run this command can delete the two files with the file manager, - (module)
and a program that pretends otherwise is teaching its user something false. `--yes` is there so it is not done by accident, and the message says plainly what the passphrase is and is not for. # In plain words The command line for settings - (module)
that can only be tightened. It shows what is currently required, and what a job would actually run with once those requirements are applied, so a value shown is a value used. - fn policy_dir
Where the policy files live. Derived from the app lock's location rather than resolved again, so there is one answer to "where does VeilVoice keep things". - fn dir
- fn status
What is in force, and what is known about the seal. - fn seal
Write a policy and seal it. - fn verify
Check the plain policy against its sealed copy. - fn remove
Delete both files. - mod tests
- fn the_policy_directory_sits_beside_the_app_lock
- fn the_policy_and_sentry_directories_are_distinct
The two state directories must not be the same one, or a canary record and a policy would share a folder and each other's names.
crates/veilvoice-cli/src/priv_mode.rs
- (module)
`veilvoice privilege` shows what VeilVoice runs with, and what it can see. # In plain words Most of VeilVoice needs no special permissions. The parts that watch your machine see more when it is run as an administrator, and this says which - (module)
of those you are getting. It will not raise its own privileges for you, and it prints the command and you decide. - fn show
Report the privilege level and what it means.
crates/veilvoice-cli/src/record.rs
- (module)
`veilvoice record` -- capture the veiled voice straight into an encrypted file. # What this is, next to the commands beside it `veilvoice live` veils the voice and sends it to a device, keeping nothing. `veilvoice anonymise` veils a - (module)
recording somebody already made, which means the original exists, in the clear, on their disk, and stays there unless they remember to shred it. This is the third case and the one that leaves the least behind: the microphone goes in, the - (module)
veiled voice comes out, and the only file that ever exists is the encrypted one. # Never a plaintext file, not even briefly The recording is accumulated in a `Tape`, encoded into a `Secret`, and sealed from there. At no point is there a - (module)
WAV on disk to be deleted afterwards, because a plaintext file that is written and deleted is exactly what `veilvoice_crypto::shred` explains cannot be reliably taken back on flash storage. Writing one and encrypting it afterwards would - (module)
leave the original recoverable and the file merely tidy. # Why it stops on a keypress rather than on Ctrl-C Ctrl-C ends the process, and a recording that ends by killing the process is a recording that is never sealed and never written. - (module)
Catching the signal instead would mean a signal-handling dependency for one command, and this project argues at length against dependencies nobody has read. So it stops on Enter, or after `--seconds`. Both are ordinary control flow, reach - (module)
the sealing step, and need nothing new in the dependency tree. Ctrl-C still works and still abandons the recording, which is the correct thing for it to do: it is how somebody says "stop, and keep nothing". # In plain words Records you - (module)
with your voice already disguised, and saves it encrypted. There is never an unencrypted copy of the recording anywhere, not even for a moment, so there is nothing to delete afterwards and nothing to recover from the disk. - struct Sealing
How the recording is to be protected once it is made. - fn run
Run a recording session and seal what it captured. - const WIDTH
- fn report
What was captured, and what was actually obtained for it. The locking line is the honest one: it says what the operating system granted rather than what was asked for, because [`veilvoice_crypto::tape`] cannot promise a lock and neither - fn report
can this. - fn stop_signal
A flag that becomes true when the recording should stop. Either after `seconds`, or when Enter is pressed. The reading thread is detached and blocks on stdin: it is never joined, because a fixed-length recording must not wait for a - fn stop_signal
keypress that is not coming. - fn destination
Where the recording goes, defaulting to a timestamped name here. The default carries the moment it was made rather than a counter, so two recordings never race for the same name and the file says when it happened without depending on a - fn destination
filesystem timestamp that a copy would not preserve. - fn stamp
`YYYYMMDD-HHMMSS` in UTC, for a filename. Built from the same civil-date arithmetic the mandate history uses, rather than from a date crate, for the reason recorded there: one line of output does not justify a dependency nobody has read. - mod tests
- fn the_default_name_is_a_timestamp_a_filesystem_accepts
- fn a_timestamp_is_the_shape_the_name_promises
- fn an_explicit_destination_is_used_as_given
- fn an_empty_destination_is_refused_rather_than_turned_into_a_default
crates/veilvoice-cli/src/sentry.rs
- (module)
`veilvoice sentry` -- canaries, baselines, and what changed since. The command-line front end to [`veilvoice_guard::sentry`]. All of the logic is in that crate; this file decides where the state lives, prints it, and chooses an exit code. - (module)
# Where the state lives Beside the app lock, under the platform's usual per-user configuration directory: ```text <config>/veilvoice/sentry/nest.txt the planted canaries <config>/veilvoice/sentry/<16 hex>.txt one baseline per watched - (module)
directory ``` Baselines are named from a digest of the directory they describe, so two directories cannot silently overwrite each other's baseline and the state directory's listing does not say what somebody is watching. Each file records - (module)
its own root, so `check` reads them rather than needing an index. # The exit code answers one question and not the other `veilvoice sentry check` exits non-zero when **a canary tripped**, because that is a fact: a file nothing uses was - (module)
changed, moved or removed. It exits zero for churn at any level, however high, because churn is a question -- a backup restore produces the same numbers as anything else, and a command that fails a scheduled task every time somebody copies - (module)
a folder is a command somebody removes from the scheduled task. # This detects, and stops nothing [`veilvoice_guard::sentry::SCOPE`] is printed by `status` rather than paraphrased here, so there is one wording and the tests guard it. # In - (module)
plain words The command line for the tripwires: the decoy files that should never change, and how much of a folder has changed since you last looked. Both are early warnings and neither stops anything. What they buy is finding out quickly. - fn state_dir
Where the canaries and baselines are kept. Derived from the app lock's location rather than resolved again, so there is one answer to "where does VeilVoice keep things" and it cannot drift. - fn nest_path
- fn load_nest
Read the nest, treating "no file yet" as "nothing planted". A missing file is the ordinary state before anything is planted and must not read as an error. A file that exists and will not parse is a different matter and is reported: quietly - fn load_nest
starting again from an empty nest would lose the record of every canary and report none of them as gone. - fn save_nest
- fn baselines
Every saved baseline, with the path it came from. - fn status
What is planted, what is watched, and what this is worth. - fn plant
Put a canary in `dir`. - fn pull_up
Stop watching a canary, and delete it. - fn baseline
Record what `dir` holds now, as the thing to compare against later. - fn check
Look at every canary and every baseline. Returns `true` when a canary tripped, which is what the exit code reports. Churn never sets it -- see the note at the top of this file. - fn wrap
Wrap `text` to `width` columns on spaces, for the scope note. A paragraph printed as one line is a paragraph nobody reads in an eighty-column terminal, and the scope note is the paragraph here that most needs reading. Shared with - fn wrap
[`crate::policy`], which prints its own crate's scope note the same way. One wrapper rather than two that drift over what a column is. - mod tests
- fn the_state_directory_sits_beside_the_app_lock
- fn a_baseline_filename_is_derived_and_not_the_path
Baselines are found by their own recorded root, so the filename never has to be reversed back into a path. - fn wrapping_keeps_every_word_and_respects_the_width
- fn wrapping_handles_nothing_and_one_long_word
crates/veilvoice-cli/src/theme.rs
- (module)
Tokyo Night colouring for the terminal. The same palette the GUI uses, so the two halves of VeilVoice look like one program. Colour is suppressed when the output is not a terminal, when `NO_COLOR` is set (the widely-honoured convention), - (module)
or when `TERM=dumb`, so piping to a file or a log never produces escape-code soup. # Why a command-line tool has a palette at all Because the two front-ends are one program. Somebody who uses the desktop application and then runs the - (module)
binary over SSH should recognise what they are looking at, and the colours carry meaning consistently in both: green for a result, amber for a caveat, red for a refusal, muted for the scope notes that qualify a claim. # Colour is - (module)
suppressed rather than assumed Three independent conditions turn it off, and all three are checked: output that is not a terminal, `NO_COLOR` set to anything at all (the widely-honoured convention), and `TERM=dumb`. The check runs once - (module)
through a [`std::sync::OnceLock`] rather than per call, because this is used inside loops that print a line per file. Escape sequences in a log file are worse than no colour: they survive into bug reports, pasted output and issue trackers, - (module)
where they are noise that obscures the message somebody was trying to show you. # In plain words The colours and the layout of what the terminal prints. The same palette the window uses, so the two halves of VeilVoice look like one - (module)
program. Colour is dropped automatically when the output is going into a file or another program rather than to a person, because escape codes in a log are noise. - mod colour
Tokyo Night, as 24-bit foreground escape sequences. The whole palette is defined even though a given build may not use every entry, because the device listing is behind the `live` feature, so its colour goes unused on platforms without an - mod colour
audio backend. Keeping the set complete means it stays a straight mirror of the GUI's palette and of `css/themes.css`, which is what makes the three front-ends look like one program. - const MUTED
Muted comment grey, for secondary text. - const BLUE
Foreground blue, for headings and prompts. - const CYAN
Cyan, for values and figures. - const GREEN
Green, for success. - const YELLOW
Yellow, for warnings. - const RED
Red, for errors. - const PURPLE
Purple, for accents. - const RESET
Reset to the terminal default. - fn enabled
- static ENABLED
- fn paint
Wrap `text` in `colour`, or return it unchanged when colour is off. - fn ok
A success line. - fn warn
A warning line. - fn err
An error line. - fn heading
A section heading. - fn field
A `label: value` line with the value highlighted. - mod tests
- fn colour_is_disabled_when_not_a_terminal
Tests capture stdout, so colour is off and `paint` must be a no-op, which is exactly the property that keeps escape codes out of pipes. - fn helpers_include_their_text
- fn field_shows_both_halves
crates/veilvoice-conversation/src/edit.rs
- (module)
Correcting a plan: who is speaking when, what they are called, and in what colour. # Why a plan needs correcting at all A plan says which speaker each span of a recording belongs to, and something had to decide that. Splitting a - (module)
multi-track recording by channel is exact. Everything else is a guess: a person listening and typing timestamps mis-hears, a plan written from a meeting tool's own speaker labels inherits that tool's mistakes, and two people talking over - (module)
each other defeat both. A wrong span is the one mistake in this program that **cannot be heard in the result**. Both voices are unfamiliar by construction, so a listener has nothing to compare against: thirty seconds of Ada rendered in - (module)
Grace's voice sounds exactly like Grace talking. Nobody notices, which is why the correction has to happen before the render and why it has to be easy. # What can be corrected Every operation here works on a plan somebody already has, and - (module)
none of them touches the audio: 1. **Reassign** a span to a different speaker, by index or by naming a moment inside it. This is the wrong-voice fix. 2. **Split** a span at a moment, for when one span turns out to hold two people, which is - (module)
what a missed hand-over looks like. 3. **Merge** two neighbouring spans belonging to the same speaker, for when a split was wrong or a detector chopped one sentence into three. 4. **Move** a span's edges, for a hand-over caught a second - (module)
late. 5. **Rename** a speaker, and give them **a colour**. # Nothing half-applies Every operation checks everything before it changes anything, so a refusal leaves the plan exactly as it was. A half-applied edit to a plan is worse than a - (module)
refused one: it is a plan that looks fine and renders somebody in the wrong voice, which is the failure this whole module exists to prevent. # In plain words Fixing a plan before you render it. If a stretch of the recording has been given - (module)
to the wrong person, this is how you move it. You can also cut one stretch in two where the hand-over was missed, join two back together, nudge where one starts or ends, and change what somebody is called and what colour they are. This - (module)
matters more than it sounds. If the wrong person is on a stretch of audio, the finished recording will not sound wrong to anybody, because every voice in it is a voice nobody has heard before. There is nothing to notice. So it has to be - (module)
right before you render, and that means it has to be easy to correct. - fn check_colour
A colour a speaker can be given, as `#rrggbb`. Checked here rather than where it is drawn. A colour reaches an SVG, an HTML page and a subtitle file, and a value that is not a colour would land in all three as text: `fill="red; }"` closes - fn check_colour
an attribute somebody else opened. - impl Conversation
- fn turn_at
Which span covers `secs`, if any. The **first** one when spans overlap, which is the earliest-starting speaker at that moment. Overlaps are ordinary here, so this is a stated rule rather than an assumption: a caller correcting an overlap - fn turn_at
works on one span at a time and can ask again after each. - fn turns_at
Every span covering `secs`, earliest first. - fn reassign
Give a span to a different speaker. The timing is untouched: this is the fix for "the right stretch of audio, the wrong person", which is the mistake that cannot be heard. - fn reassign_at
Give the span covering `secs` to a different speaker. The form a person actually wants: they heard the wrong voice at a timestamp and can say when, not which numbered span. - fn split_at
Cut the span covering `secs` in two at that moment. Both halves keep the speaker; the caller reassigns whichever half was wrong, which is what a missed hand-over needs. Returns the indices of the two halves, earliest first. **The text - fn split_at
stays with the first half** and the second gets none. There is no way to know where in a sentence a moment falls, and splitting the words at a guess would put half a sentence under the wrong speaker's name in the subtitles. Losing it from - fn split_at
one half is visible; splitting it wrongly is not. - fn merge
Join two spans of the same speaker into one. They have to belong to the same speaker and to touch or overlap. Anything else would silently hand somebody else's audio to whichever speaker was named first, which is the mistake this module - fn merge
exists to fix rather than to cause. The text of both is joined with a space where both have one. - fn move_edges
Move a span's edges, keeping its speaker and its text. For a hand-over caught a second late. Validated exactly as a new span is, so a move cannot produce something [`Conversation::add_turn`] would have refused. - fn set_name
Rename one speaker. The whole-list form is [`Conversation::rename_speakers`], which is for a front end holding every name. This is for correcting one. - fn set_colour
Give a speaker a colour, or take it away again. `None` returns them to the colour their slot gets from the palette, which is what every speaker starts with. - fn nearest_hint
Where to look, for a timestamp that landed in no span. An error saying only "nothing there" sends somebody to open the file and count, so this names the nearest span and when it runs. - mod tests
- fn plan
Three speakers and four spans, the shape most corrections happen on. - fn shape
Every span in order, as `(start, end, speaker)`, for comparing shapes. - fn a_moment_finds_the_span_holding_it
- fn the_wrong_voice_is_moved_without_touching_the_timing
- fn a_moment_in_no_span_says_where_the_nearest_one_is
- fn reassigning_to_a_speaker_who_is_not_there_is_refused
- fn a_missed_handover_is_cut_in_two_and_half_is_moved
- fn the_words_stay_with_the_first_half_rather_than_being_guessed_at
- fn a_cut_on_an_edge_is_refused_rather_than_making_an_empty_span
- fn two_spans_of_one_speaker_join_back_together
- fn joining_is_the_same_whichever_order_the_two_are_named
- fn joining_two_different_speakers_is_refused_by_name
- fn joining_across_a_gap_says_how_much_silence_it_would_hand_over
- fn a_span_can_be_nudged_and_a_bad_nudge_puts_it_back
- fn a_colour_is_checked_before_it_can_reach_a_drawing
- fn a_name_and_a_colour_can_be_changed_and_taken_away
- fn a_name_that_could_forge_a_record_is_refused
- fn a_chosen_colour_survives_the_round_trip_through_a_plan_file
- fn a_colour_for_a_speaker_who_has_not_been_declared_is_refused
- fn a_colour_that_is_not_one_is_refused_by_the_parser_too
- fn every_correction_leaves_the_spans_in_time_order
- fn an_overlap_reports_every_speaker_at_that_moment
crates/veilvoice-conversation/src/lib.rs
- (module)
# veilvoice-conversation Several people in one recording: a plan of who spoke when, a distinct destination voice for each of them, and subtitles that carry their names. ## Why this exists VeilVoice's whole argument is that every speaker is - (module)
mapped onto **one** canonical voice, so many inputs give one output and there is no inverse to compute. Run an interview through it and both people come out as the same voice, which is perfectly private and completely unusable, because a - (module)
listener cannot tell a question from its answer. This crate keeps the property and fixes the usability. Each speaker is assigned a **slot**, each slot has its own canonical destination ([`veilvoice_core::voices`]), and every speaker in a - (module)
slot is normalised onto that destination exactly as thoroughly as a lone speaker is normalised onto the default one. There are ten buckets instead of one; each is still many-to-one. ## What a conversation costs, said plainly * **The number - (module)
of speakers survives.** Three voices in the output means three people were in the room. * **The turn-taking survives.** Who spoke when, for how long, who interrupted whom, the rhythm of the exchange. That is preserved on purpose, since it - (module)
is what makes the result worth listening to, and it is information about the conversation. * **Names are whatever you type.** A subtitle saying "Alex" contains the string "Alex". The audio is veiled; a caption is not, and this crate cannot - (module)
veil a name for you. * **The voiceprints do not survive.** Each speaker is destroyed as thoroughly as in single-speaker mode. ## VeilVoice does not decide who is talking Working that out from audio alone is speaker diarisation and needs a - (module)
trained model. There is no model here, there is no server to ask, and guessing would be worse than not offering it: a wrong guess either merges two people or invents a third, and neither would be visible in the output. So the plan comes - (module)
from the user, as a channel per person or a list of turns. See [`plan`]. ## The modules | Module | What it owns | |---|---| | [`plan`] | Who is in the recording, when they speak, and the text format | | [`render`] | One engine per speaker, - (module)
spliced back onto the timeline | | [`subtitles`] | WebVTT and SubRip, from the same plan | # In plain words This is for a recording with more than one person in it. Given a note of who speaks when, it gives each person a different voice -- - (module)
every one of them just as thoroughly disguised as a single speaker would be -- and writes subtitles saying who said what. It will not guess who is talking. Working that out needs a trained model, and this project ships none, so it is told: - (module)
either one microphone per person, or a list of turns. Any part of the recording nobody claims is silenced rather than passed through, because audio nobody claimed has not been disguised. - mod edit
- mod mode
- mod plan
- mod render
- mod subtitles
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What this crate does to a recording, in the words a front end should show. Single-sourced and asserted by the tests, exactly as every other scope note in this project is, so it cannot quietly turn into a promise. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn the_scope_note_states_what_is_kept_as_well_as_what_is_destroyed
The claim must keep stating what a conversation costs. If somebody edits this into a promise, this is what stops it shipping. - fn too_many_speakers_explains_why_it_is_refused
- fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-conversation/src/mode.rs
- (module)
How many voices a group gets, and the trade between the two answers. # The limit is measured, not chosen The engine holds ten destination voices and all ten are different. Only **eight** are far enough apart that somebody following a - (module)
conversation can tell which is which: adding the ninth brings the closest pair to 1.1842 -- two voices with exactly the same rendered pitch and vocal tracts 18 % apart -- and that is under the three-semitone floor a listener needs when the - (module)
two voices are half a minute apart rather than side by side. [`veilvoice_core::voices::clear_voices`] computes it from the configuration in force, because a coarser frame grid collapses registers onto each other and eight stops being true. - (module)
# Two ways to be told apart, and the second one is safer [`VoiceMode::Distinct`] gives each speaker their own voice. It is the obvious arrangement and it is what most recordings want, and it is capped at the measured limit, because handing - (module)
two people voices nobody can separate produces a recording in which two speakers sound like one, discovered only after the recording exists. [`VoiceMode::Uniform`] gives **everybody the same voice**, and the speakers are told apart by - (module)
their names in the subtitles and by which circle lights up in the picture. That has two consequences worth stating plainly, one of each kind: * **It is more private.** In distinct mode the output carries one bit of structure the input had: - (module)
*this is speaker three*. Anybody who obtains two recordings of the same group can align them by voice slot. Uniform mode does not have that structure to leak, because every speaker is the same voice, so there is nothing to align. * **It is - (module)
harder to follow by ear alone.** A listener with no subtitles and no picture cannot tell who is speaking. That is the price, and it is why this is not the default. Uniform mode has **no speaker limit** from voices, because there is no - (module)
second voice to collide with. The plan's own ten-speaker limit still applies, because ten names is already a great deal to follow. # In plain words How many people can be in one recording, and the choice between two ways of handling them. - (module)
Give everybody a different voice and a listener can follow the conversation by ear, but only so many of the available voices are far enough apart to actually be told apart. That number was measured rather than picked, and the limit is - (module)
real: past it, two people would sound like one person and you would only find out by listening to the finished recording. Give everybody the *same* voice and there is no limit, and it is more private, because the result no longer carries - (module)
even the fact of who was speaker three. The price is that names and pictures become the only way to tell who is talking. - enum VoiceMode
Whether speakers get different voices or one voice between them. - impl VoiceMode
- fn label
A short name, for a picker. - fn speaker_limit
The most speakers this mode can carry under `config`. For [`VoiceMode::Distinct`] this is the measured clear limit. For [`VoiceMode::Uniform`] it is the plan's own limit, because voices are no longer what bounds it. - fn voice_for
The voice a slot gets in this mode. Uniform mode returns slot 0's voice for everybody. Slot 0 rather than a new one: it is a voice the table already contains and the tests already cover, and inventing an eleventh just to be the shared one - fn voice_for
would be a voice nobody had measured. - fn note
What this mode costs and buys, in the words a front end should show. - struct Exposure
What the finished recording says about **who** was speaking. # The question this answers, and the one it does not It does **not** measure how well any one person's voice is disguised. That is what the engine does, every speaker is mapped - struct Exposure
onto a canonical destination, and it does not get weaker because somebody else joined the call. A recording of eight people hides each of their voiceprints exactly as well as a recording of one. What it measures is the *other* leak, the - struct Exposure
one that does grow with the group: **how much of the conversation's structure a listener gets for free.** Give eight people eight tellable-apart voices and anybody who hears the output can count the participants, follow who said what, and - struct Exposure
line two recordings of the same group up against each other by voice. Give them all one voice and none of that is there to find. # It is not cryptography, and this does not pretend it is There is no key here, no work factor and no - struct Exposure
adversary bounded by computation. Nothing about this number gets better with a longer key or worse with a faster machine. It is an information count about one specific thing: which of the speakers a given turn belongs to. Calling that - struct Exposure
"cryptographic strength" would be the kind of sentence this project spends most of its documentation refusing to write. # Where the bits come from A listener who cannot tell the voices apart has to guess which of `classes` speakers - struct Exposure
produced a turn, and that guess costs `log2(classes)` bits. When the voices *are* tellable apart the output hands those bits over. So the leak is `log2(classes)` bits per turn: zero when everybody shares one voice, one bit for two, three - struct Exposure
bits for eight. `classes` is not simply the number of speakers. Two speakers whose voices fall within [`voices::CLEAR_SEPARATION`] of each other cannot reliably be separated by ear, so they count as one class. That is the part of this that - struct Exposure
runs the other way from intuition: crowding the table makes the recording *leak less* and *follow worse* at the same time, which is the trade the two modes exist to let somebody choose between. - impl Exposure
- fn of
What this recording gives away, for `speakers` people under `config`. - fn crowded
Whether the voices handed out are too close to be told apart. The usability failure, not the privacy one. A recording in this state leaks *less* and is harder to follow, which is why it is reported rather than folded into the score. - fn note
One sentence for the interface, saying what the number means here. - fn separable
How many of the first `count` voices a listener can actually separate. Voices are handed out in table order, so this walks them and puts each into an existing class when it is within [`voices::CLEAR_SEPARATION`] of one already there. - fn separable
Greedy and in table order deliberately: that is the order slots are given out in, so this counts the classes that will actually exist rather than the best packing of the same voices. - enum TooMany
Why a group cannot be rendered as asked. - impl std::fmt::Display for TooMany
- fn fmt
- impl std::error::Error for TooMany {}
- fn check
Whether this many speakers can be rendered in this mode. - mod tests
- fn distinct_is_the_default_because_most_recordings_want_it
- fn distinct_mode_stops_at_the_measured_clear_limit
The measured number, and the reason this module exists. - fn the_refusal_names_the_alternative
The refusal has to point at the way out, or it is a dead end. - fn uniform_mode_carries_everybody_a_plan_can_hold
Uniform mode has no voice-based limit, because there is no second voice. - fn neither_mode_goes_past_what_a_plan_can_hold
And the plan's own limit still applies to both. - fn uniform_gives_every_slot_the_same_measured_voice
Uniform means uniform: every slot is the same voice, and it is one the table already contains rather than an eleventh nobody measured. - fn distinct_gives_every_slot_its_own
- fn a_coarser_grid_lowers_the_distinct_limit_and_not_the_uniform_one
A coarser frame grid collapses registers, so the distinct limit has to fall with it rather than keep promising eight. - fn every_note_states_the_price_as_well_as_the_benefit
Both notes have to say what the mode costs, not only what it gives. - mod exposure_tests
- fn a_bigger_group_gives_more_away
- fn one_voice_for_everybody_gives_nothing_away
One voice for everybody says nothing about who is who, at any size. - fn the_bits_are_the_logarithm_of_the_classes
The bits are the count they claim to be. A listener who cannot separate the voices guesses which of `classes` spoke, and that guess costs `log2(classes)`. Checked against the arithmetic rather than against a table somebody typed. - fn voices_nobody_can_separate_are_one_class_and_leak_as_one
Two people given voices too close to separate count as one class. This is the part that runs the other way from intuition, so it is checked rather than asserted in a comment: crowding the table makes the recording leak *less* while making - fn voices_nobody_can_separate_are_one_class_and_leak_as_one
it harder to follow, which is the trade the two modes exist to let somebody choose between. - fn one_speaker_is_never_reported_as_crowded
One voice has nothing to be confused with. `closest_pair` answers 1.0 when given fewer than two voices, which is below the separation floor and means "nothing to compare" rather than "too close". Read without that in mind, every solo - fn one_speaker_is_never_reported_as_crowded
recording carried a warning that two of its voices sounded alike. - fn the_note_does_not_claim_to_be_about_the_voices_themselves
The note says which question the number answers. The one thing this must never be read as is a claim about how well a voice is disguised, so the words that would invite that reading are checked for absence.
crates/veilvoice-conversation/src/plan.rs
- (module)
Who is in the recording, and who is speaking when. # VeilVoice does not work out who is talking, and will not guess Deciding which person is speaking at each moment is *speaker diarisation*, and doing it from the audio alone needs a - (module)
trained model. This project ships no model, talks to no server, and is not about to start doing either, so the turns come from the user, and there are exactly two honest ways to get them: * **One microphone each.** If the recording has a - (module)
channel per person, the split is already there and is exact. [`Conversation::from_channels`] builds the plan from that. * **A list of turns.** Times and speakers, in a text file, written by whoever was there or produced by whatever tool - (module)
they already use for transcripts. What would be worse than either is guessing. A wrong guess maps two people onto one voice, which is a privacy *improvement* and a usability disaster, or splits one person across two voices, which invites a - (module)
listener to believe there was somebody in the room who was not. Neither failure would be visible in the output, and both would be blamed on the recording rather than on the tool. # Format Text, one record per line, for the same reason - (module)
everything else here is text: a file describing who said what is worth more if it can be read, checked and edited without this program. ```text VEILCONV1 title Two people, one microphone speaker 0 Alex speaker 1 Sam portrait.png turn 0.000 - (module)
4.200 0 Hello -- how did it go? turn 4.100 9.050 1 ``` Times are seconds with a decimal point. The text on a turn is optional: with it, subtitles carry the words; without it they carry the speaker's name and nothing else, which is still - (module)
enough to follow a conversation whose voices have all been replaced. Overlapping turns are allowed, because people talk over each other, and [`crate::render`] mixes them rather than picking a winner. # In plain words A list of who is in a - (module)
recording and when each of them speaks. VeilVoice does not work this out for itself. Deciding who is talking at any moment is a hard problem that needs a trained model, and this project does not ship one, so it asks instead. You either - (module)
write the times down, or you record each person on their own microphone. That is less convenient and it is honest. A program that guessed would sometimes put one person's words in another person's voice, and you would not find out by - (module)
listening, because the result would sound perfectly fine. - const MAGIC
Magic first line. The digit is a format version. - struct Speaker
One person in the recording. - impl Speaker
- fn named
A speaker with a name and no picture. - struct Turn
A span of the recording belonging to one speaker. - impl Turn
- fn duration
How long this turn lasts, in seconds. - struct Conversation
The whole plan: who is in the recording, and when each of them speaks. - fn split_word
The first whitespace-separated word, and everything after it. `None` when the line has no whitespace at all, which is a line with only a keyword on it and nothing for the keyword to act on. - impl Conversation
- fn new
An empty plan. - fn add_speaker
Add a speaker, and return the index they were given. The index is also the destination-voice slot, so the first speaker added gets [`veilvoice_core::voices::voice`] 0 and the second gets voice 1, which are the two furthest apart in the - fn add_speaker
table, because two people is the common case. - fn add_turn
Add a turn. - fn rename_speakers
Rename everybody, in slot order, keeping every turn where it is. For a front end that holds the names and reads the turns from a plan file somebody wrote earlier. The names are the ones just typed; the turns are the plan's, and nothing - fn rename_speakers
here touches them. # Refused rather than reconciled The count has to match exactly. A plan naming three speakers renamed from a list of two would either leave one person with a stale name or silently drop a slot, and a dropped slot means - fn rename_speakers
somebody's audio comes out in another person's voice, which is the one mistake here that cannot be heard in the result, because both voices are unfamiliar. Every name is validated exactly as [`Conversation::add_speaker`] validates one, and - fn rename_speakers
for the same reasons: an empty name labels nobody, and a name containing a line break can forge a record in the plan file. Nothing is changed unless every name passes, so a refusal leaves the plan exactly as it was rather than half-renamed. - fn speakers
The speakers, in the order they were added. - fn turns
The turns, in time order. - fn len
How many speakers there are. - fn is_empty
Whether there is nobody in the plan. - fn speakers_mut
The speakers, for an edit that has already checked what it is doing. `pub(crate)` on purpose. Everything outside this crate reaches speakers through [`Conversation::speakers`], which cannot change them, or through [`crate::edit`], which - fn speakers_mut
validates first. Handing out a mutable slice would let a caller write an empty name or a colour that is not one, and the point of the validation is that there is no way round it. - fn turns_mut
The spans, for an edit that has already checked what it is doing. `pub(crate)` for the same reason, and with one extra rule: **anything that changes a start time has to keep the list sorted**, because rendering, the subtitles and every - fn turns_mut
report read it in order and none of them sorts for itself. Going through [`Conversation::add_turn`] does that; changing `start` in place does not. - fn colour_of
The colour to draw a speaker in, chosen or from the palette. `palette` is the slot colour to fall back to, which the caller supplies because this crate draws nothing and does not know which scheme is in force. - fn voice
The destination voice for a speaker. - fn mode
Whether every speaker gets their own voice, or one between them. Not persisted in the plan file, and deliberately so. A plan says *who is in the recording and when they speak*; how they are rendered is a decision made at render time, by - fn mode
whoever is doing the rendering. Writing it into the file would mean a plan somebody shared could silently change what a later render sounds like. - fn set_mode
Render every speaker as the same voice, or as their own. Refuses when this plan holds more speakers than the mode can carry -- which for [`crate::mode::VoiceMode::Distinct`] is how many voices are far enough apart to be told apart, - fn set_mode
measured under `config`. - fn duration
When the last turn ends, in seconds. - fn overlaps
Turns where two people are speaking at once. Reported rather than refused: people talk over each other, and a plan that forbade it would be a plan that cannot describe a real conversation. [`crate::render`] mixes the overlap. - fn self_overlaps
Spans where a speaker's turns overlap **their own** other turns. Distinct from [`Conversation::overlaps`] and much more likely to be a mistake: one person cannot be in two places in their own recording, so this is usually a typed time - fn self_overlaps
rather than an interruption. - fn from_channels
A plan for a recording with one microphone per person. The only split VeilVoice can make on its own, because it is not a guess: if each person had their own channel then each channel *is* one person, and the whole recording is one turn per - fn from_channels
channel. The names are the ones supplied; there must be one per channel. - fn to_text
Serialise to the text format described at the top of this module. - fn parse
Parse the text format. An unknown keyword is refused rather than skipped. A plan is a statement about who is in a recording, and honouring half of one written by a newer build would put somebody's speech in the wrong voice without saying - fn parse
anything. - fn save
Write the plan to `path`. - fn load
Read a plan written by [`Conversation::save`]. - mod tests
- fn two_people
- fn speakers_are_numbered_in_the_order_they_are_added
- fn the_first_two_speakers_get_the_two_most_distinct_voices
The first two speakers must get the two voices furthest apart in the table, because two people is the common case. - fn there_can_be_no_more_speakers_than_there_are_voices
- fn a_nameless_speaker_is_refused
- fn a_turn_naming_an_undeclared_speaker_is_refused
- fn an_impossible_turn_is_refused
A zero-length turn would leave that speech in whichever voice was next, silently. Refused. - fn turns_are_kept_in_time_order_however_they_arrive
- fn an_overlap_between_two_people_is_reported_rather_than_refused
People talk over each other, so an overlap is reported and not refused. - fn a_speaker_overlapping_themselves_is_reported_separately
One person cannot be in two places in their own recording, so this is almost always a typed time -- reported separately, and more loudly. - fn a_channel_per_person_needs_no_diarisation
The one split VeilVoice can make on its own, because it is not a guess. - fn a_plan_survives_a_round_trip_through_text
- fn a_plan_survives_a_round_trip_through_a_file
- fn a_saved_plan_is_readable_only_by_this_account
A saved plan is readable only by the account that saved it. A plan holds every speaker's name and every word typed into it, which is the same content as the subtitle tracks a render produces from it. Those are written owner-only; writing - fn a_saved_plan_is_readable_only_by_this_account
the source of them 0644 would protect the copy and leave the original. - fn a_picture_survives_the_round_trip
- fn turns_before_speakers_still_parse
A file that lists its turns before its speakers must still read. - fn a_repeated_speaker_number_is_refused
A hand-edited file with two speakers numbered 0 would otherwise give one of them the other's voice, silently. - fn a_malformed_plan_is_refused_rather_than_half_read
- fn blank_lines_are_tolerated
- fn renaming_leaves_every_turn_alone
Renaming keeps the turns exactly where they were. - fn a_different_number_of_names_is_refused
The one mistake here that cannot be heard in the result: a mismatch would put somebody's audio in another person's voice, and both voices are unfamiliar, so nobody would notice. - fn a_bad_name_leaves_the_plan_exactly_as_it_was
Every name is checked the way `add_speaker` checks one, and nothing is changed unless all of them pass. - fn names_are_trimmed_the_same_way_they_are_when_added
- fn uniform_mode_gives_every_speaker_one_voice
Uniform mode gives every speaker the same voice. This is the whole of what the mode does to the sound, so it is asserted directly. - fn distinct_mode_is_refused_past_the_measured_limit
A plan with more speakers than there are separable voices cannot be put into distinct mode, and the refusal says what to do instead. - fn the_mode_is_not_carried_in_the_file
The mode is not written to the plan file. A plan says who speaks when; how it is rendered is the renderer's decision, and a mode hidden in a shared file would change what somebody else's render sounds like. - fn a_turn_reports_its_own_length
- mod guide_tests
- fn the_plan_in_the_user_guide_parses
**F-110.** The plan printed in the user guide has to parse. The guide is where somebody writing their first plan copies from, and the example it prints was rejected: `unknown keyword "turn 19.000"`. The parser wanted exactly two spaces - fn the_plan_in_the_user_guide_parses
between every field, and the guide's third turn line uses one, because `19.000` is a digit wider than `4.100` and the columns were lined up by eye. Neither was wrong on its own. The example is what a person would write and the parser was - fn the_plan_in_the_user_guide_parses
stricter than it needed to be about the fields that cannot contain a space, and nothing compared the two. So this reads the guide rather than a copy of it. A copy would drift, which is the failure it is here to prevent: F-71 is the same - fn the_plan_in_the_user_guide_parses
shape, and so is F-103. - fn the_numbers_may_be_separated_by_any_whitespace
One space, two spaces, and a tab all separate the numbers. The columns in a hand-written plan are lined up by eye, so what falls between two numbers is whatever made them line up that day. - fn names_and_subtitles_keep_their_own_spaces
A name and a subtitle keep the single spaces inside them. This is what the two-space rule was for, and it still holds: the fields that can contain a space are still separated by two.
crates/veilvoice-conversation/src/render.rs
- (module)
Turning a plan and a recording into veiled audio, one engine per speaker. # One engine each, and why that is not merely convenient Every speaker gets their own [`veilvoice_core::Deidentifier`], built with their slot's destination voice and - (module)
**its own seed**. Three consequences, and all three are the point: * Each speaker arrives at their own canonical register and vocal tract, so the output is followable. * Each speaker's modulation stream is independent, so the ratchet in - (module)
one voice tells an adversary nothing about another. * Each speaker's engine keeps its state **across their own turns**. The accent neutraliser needs a few seconds to measure a speaker before its corrections reach full strength; carrying - (module)
that across the turns of one person means it converges once, rather than warming up again every time they take a breath. # A speaker needs a few seconds before they arrive at their voice The accent neutraliser ramps its corrections in over - (module)
[`veilvoice_core::WARMUP_S`] of **voiced** audio, from nothing. Until it has finished, a speaker is only partly moved toward their destination register -- so a slot that should sound like 187 Hz sounds like something between the original - (module)
speaker and 187 Hz. This was found by measuring the fundamental of a rendered file rather than by reading the code: ten slots given one second each came out at three distinct pitches, and the same ten given five seconds each came out at - (module)
four, exactly where the table says. Nothing was wrong with the table the second time; the first measurement was of the ramp. Keeping one engine per speaker across all of their turns is what makes this bearable -- the ramp happens **once - (module)
per speaker for the whole recording** rather than once per turn. A speaker whose total time is shorter than the ramp never finishes it, so [`Rendered::notes`] says so by name. What is **not** affected: the phase discard and the CSPRNG - (module)
modulation are unconditional and full strength from the first frame. Those are the two reasons the transform is one-way. The ramp weakens the *normalisation onto a canonical register* early on, which costs distinguishability between - (module)
speakers, and leaves some of the original pitch contour in the first couple of seconds. # Audio nobody claimed is silenced, and the amount is reported A gap between turns is a span the plan does not assign to anybody. It is **silenced**, - (module)
never passed through. That is the one decision in this file that is not a trade-off. Passing unassigned audio through unveiled would put somebody's real voice into a file whose entire purpose is that it contains no real voice, because of a - (module)
gap in a text file, silently, in the middle of an otherwise veiled recording. Silence loses content and can be seen; a raw voice cannot be unheard. [`Rendered::unassigned_secs`] says how much went, so a plan with a hole in it is a thing - (module)
you find out about rather than a thing you notice later. # Latency is removed, so a turn lands where the plan said The engine has a fixed algorithmic latency, because the STFT cannot emit a sample until it has a frame around it. Each span - (module)
is therefore processed with a tail of silence and the first `latency_samples` of output are dropped, which puts the veiled audio back exactly where the original was. Without this every turn would drift later than the subtitle describing - (module)
it, by about 16 ms at the default frame size, and a subtitle 16 ms late is a subtitle that looks wrong. # Boundaries are faded, because a splice is a click Cutting audio at an arbitrary sample and starting different audio there produces a - (module)
step, and a step is broadband noise. Each rendered span is faded in and out over a few milliseconds. Short enough not to swallow a syllable, long enough to remove the click. # Every speaker renders at the same time Speakers are independent - (module)
by construction, with a separate engine, a separate seed and a separate destination, so there is nothing to share between them and nothing to lock. Each one is given a thread and they all run at once, which on an ordinary machine turns a - (module)
four-person recording into roughly the work of one. [`std::thread::scope`] rather than a pool or an async runtime. The number of threads is bounded by [`veilvoice_core::MAX_VOICES`], which is ten, so there is nothing for a pool to - (module)
schedule; and a scoped thread can borrow the input slice directly, so nothing is copied to hand it over. It also needs no dependency, which for this project is not a small consideration: the `offline` CI job that checks what is in the - (module)
dependency graph is part of what the front page is claiming. Each thread writes into its own buffer and the merge happens afterwards, in slot order. That is what makes the result **identical to the sequential one, bit for bit**, because - (module)
floating-point addition is not associative, so a merge in completion order would give a different file on every run and the render would stop being reproducible from its seeds. A test holds it. # Overlaps are mixed, and the mixing is - (module)
admitted Two people talking at once is two engines writing into the same samples, so their outputs are summed. A sum can exceed full scale; when it does, the whole output is scaled down by one factor and [`Rendered::gain_applied`] records - (module)
it. One factor for the whole file rather than a limiter that acts only where it clipped, because a limiter changes the relative loudness of the speakers and this crate has just spent considerable effort making them distinguishable. # In - (module)
plain words This takes a recording and a plan of who speaks when, and produces the veiled version with each person in a different voice. Each speaker gets their own separate copy of the engine, with its own settings and its own stream of - (module)
randomness. That is not just tidiness: it means nothing carries across from one person to another, so there is nothing shared that could be used to line two speakers up or work out that they came from the same recording. Any moment the - (module)
plan does not account for comes out silent, rather than being passed through as it was. A missing line in the plan should cost you a gap, not somebody's real voice. - type SpeakerSpans
One speaker's finished spans: where each starts, and the veiled samples. Named rather than written out at the call site, which clippy asks for and which is the right ask -- the shape is the contract between the threads and the merge, and - type SpeakerSpans
it is worth being able to point at. - struct Settings
How to render. - impl Default for Settings
- fn default
- struct Progress
What a render has done so far, readable while it is still running. **Roadmap item 133.** The row asked for two bars per speaker, what went in and what came out, so the difference is visible rather than asserted. It asked for them *live*, - struct Progress
in group mode, and neither word survived reading the code: group mode works on a recording that already exists and never opens a device. This is the half of it that can be built from what is here, and the moment it applies to is the - struct Progress
render. Every field is an atomic and nothing here allocates or locks, so a front end may read it every frame from another thread while the render threads write to it. Written with [`Ordering::Relaxed`] throughout: these are numbers to draw - struct Progress
a bar with, and a bar that is one frame behind is a bar nobody can tell from a bar that is not. A default one has no speakers and every write to it is dropped, which is what [`render`] passes when the caller did not ask to watch. - struct SpeakerProgress
One speaker's share of a running render. - impl Progress
- fn for_speakers
Room for a render of `speakers` people. - fn len
How many speakers this was made for. - fn is_empty
Whether it was made for none, which is what a default one is. - fn levels
What the most recently finished turn for `slot` measured: the peak that went in and the peak that came out, both in `[0, 1]`. `None` for a slot this was not made for. `(0.0, 0.0)` before that speaker's first turn has finished, which is a - fn levels
bar at rest rather than a bar that is lying. - fn done
How far through this speaker's turns the render is, in `[0, 1]`. `0.0` for a speaker with no turns, rather than a division by zero: a speaker the plan never gives a turn to has nothing to be part of the way through. - fn seconds
How many seconds of this speaker's audio have been rendered. - fn expect
Say how many turns a speaker has, before any of them is rendered. - fn finished
Record a finished turn. - fn peak
The loudest sample in a span, as a peak in `[0, 1]`. One pass over a slice the engine has just walked twice, so it is in cache and costs nothing measurable next to an FFT. Roadmap item 126 asks for work that can be done once to be done - fn peak
once; this is new work rather than repeated work, and it is the feature. - struct Rendered
What came back. - impl Rendered
- fn has_unassigned
Whether some of the recording was silenced because no turn claimed it. - fn render
Render `input` according to `plan`. `seeds` supplies one seed per speaker for a deterministic render; `None` draws each from the OS CSPRNG, which is what a front end should do. The deterministic form exists so this can be tested at all, - fn render
because a de-identifier whose output cannot be reproduced cannot be checked. - fn render_watched
[`render`], with somewhere to report what it is doing as it does it. **Roadmap item 133.** `progress` is written to from every speaker's thread as each turn finishes, and may be read from another thread at the same time: see [`Progress`]. - fn render_watched
Pass one made by [`Progress::for_speakers`] with as many slots as the plan has, or a default one to be told nothing. The render is otherwise identical, and deliberately: a watched render and an unwatched one that produced different audio - fn render_watched
would be two renderers. - fn seconds_to_index
A time in seconds as a sample index, clamped into the recording. - fn process_span
Run one span through one engine and give back audio aligned with the input. The engine cannot emit a sample until it has a frame around it, so the span is followed by a tail of silence and the leading `latency_samples` of output are - fn process_span
dropped. What comes back lines up with what went in. - fn fade_ends
Fade the first and last `fade` samples, so a splice is not a click. - mod tests
- const RATE
- fn settings
- fn tone
A tone, so a span that was processed is obviously not silence. - fn rms
- fn two_people
- fn seeds
- fn the_output_is_the_same_length_as_the_input
- fn audio_no_turn_claimed_is_silenced_and_never_passed_through
The one decision in this file that is not a trade-off. - fn a_plan_that_covers_the_recording_reports_no_gap
A plan with no gaps leaves nothing unassigned. - fn every_speakers_span_carries_veiled_audio
Each speaker must actually be processed, and their spans must carry audio rather than silence. - fn two_speakers_do_not_render_alike
Two speakers must not come out as the same audio. If they did, the whole crate would be pointless. - fn rendering_in_parallel_is_still_reproducible
The merge is in slot order, so the result does not depend on which thread finished first. Floating-point addition is not associative, and a render that changes between runs cannot be checked by anybody. Run repeatedly, with heavy overlap - fn rendering_in_parallel_is_still_reproducible
so that the summation order is what would differ, and every run must agree bit for bit. - fn one_speakers_turns_are_processed_in_order
A speaker's own turns must be processed in time order, because the engine keeps state -- out of order they would arrive at their voice at the wrong end of the recording. - fn the_same_seeds_render_the_same_audio
The same seeds must give the same audio, or nothing here is testable. - fn different_seeds_render_different_audio
Different seeds must not, or the modulation is not doing anything. - fn a_watched_render_reports_every_speakers_turns
**Roadmap item 133.** A watched render reports what it is doing per speaker. Checked after it has finished rather than while it runs, which is a choice about what can be asserted: a test that read the bars mid-render would be racing the - fn a_watched_render_reports_every_speakers_turns
threads it is measuring, and the flake would be in the test rather than in the code. What is worth asserting is that every turn is accounted for, that the time adds up to the same figure the finished render reports, and that both bars - fn a_watched_render_reports_every_speakers_turns
carry a level for a speaker whose audio was not silence. - fn watching_a_render_does_not_change_it
A render nobody is watching is the same render. The default `Progress` has no slots, so every write to it is dropped. That path is the one `render` itself takes, and it must not be a second renderer: the same seeds have to give the same - fn watching_a_render_does_not_change_it
audio either way. - fn a_progress_too_small_for_the_plan_is_survived
A `Progress` made for fewer speakers than the plan has drops what it cannot hold rather than panicking. The window builds one from the panel's speaker count and the plan comes off disk, so the two can disagree. A render that stopped - fn a_progress_too_small_for_the_plan_is_survived
because a bar had nowhere to go would be the worst possible trade. - fn each_speaker_is_credited_with_their_own_time
- fn an_overlap_is_mixed_and_kept_inside_full_scale
Overlapping speech is summed and then kept inside full scale by one factor for the whole file. - fn a_turn_boundary_does_not_step
A splice must not click. A click is a step, and a step is broadband, so this checks that no single sample-to-sample jump is large. - fn every_rendered_sample_is_finite
Everything the engine produces must be a real number, whatever the plan. - fn a_turn_beyond_the_recording_is_reported
A turn past the end of the audio is reported, not silently dropped. - fn a_speaker_with_too_little_audio_is_named
A speaker given less audio than the accent ramp needs is named, because they arrive somewhere short of their destination register and nothing in the audio shows it. - fn an_empty_plan_is_refused
- fn the_wrong_number_of_seeds_is_refused
- fn an_impossible_sample_rate_is_refused
- fn an_empty_recording_renders_to_nothing
An empty recording is not an error: a plan can legitimately describe a file that turned out to be empty, and the answer is an empty render. - fn a_very_short_span_is_faded_once_rather_than_twice
A span shorter than two fades must not be multiplied down twice in the middle. - fn a_time_outside_the_recording_clamps_rather_than_panicking
crates/veilvoice-conversation/src/subtitles.rs
- (module)
Subtitles, from the same plan the audio is rendered from. # Two formats, both written out here **WebVTT** is what a browser plays alongside a `<video>`, and it is the one to use with anything this project renders. **SubRip** (`.srt`) is - (module)
what every other player on earth reads. They differ in three small ways, being a header, a cue counter, and a comma instead of a full stop in the timestamp, so both come from one function with a flag rather than from two that drift. No - (module)
library. The workspace carries no subtitle crate and this is forty lines; adding a dependency to the graph for it would cost more than it saves, and the `offline` CI job that checks what is in that graph is one of the things this project - (module)
is worth trusting for. # What goes in a cue when nobody wrote down the words VeilVoice does not transcribe. Where a turn has no text, the cue carries the **speaker's name and nothing else**, which is still worth having: after every voice - (module)
has been replaced, a caption track saying who is talking is often the only way to follow a recording at all. # A name in a caption is not veiled Worth saying twice, because it is the mistake this feature invites. The audio has had its - (module)
voiceprints destroyed. The subtitle file is a text file containing whatever names were typed into it, sitting next to the recording. If the names matter, use labels rather than names, because the plan does not care which, and - (module)
[`crate::SCOPE`] says so where a user will read it. # In plain words Writes the subtitles, from the same plan the audio came from. Both files come out of one source, so the words on screen and the voice you hear cannot drift apart or - (module)
disagree about who is speaking. Two formats are written: the one browsers use for video on a web page, and the older one nearly every video player and editor understands. The names in the subtitles are the ones you typed. Nothing veils - (module)
those, and the application says so where you type them. - enum Format
Which subtitle format to write. - impl Format
- fn extension
The conventional file extension, without the dot. - fn timestamp
A timestamp as the subtitle formats want it: `HH:MM:SS.mmm`. Negative and non-finite inputs become zero rather than producing a cue no player will accept. A plan cannot contain either, because [`Conversation::add_turn`] refuses both, so - fn timestamp
this is the belt to that braces, and it is here because the alternative failure is a subtitle file that silently does not load. - fn write
Render the plan as subtitles. - fn one_line
Flatten anything that would break a cue into one line. A cue ends at a blank line and a timestamp line is found by its arrow, so a name or a line of text containing either could end one cue early and start something a player would try to - fn one_line
read as a timestamp. The plan already refuses line breaks; the arrow is caught here. - mod tests
- fn two_people
- fn webvtt_opens_with_its_header_and_subrip_does_not
- fn subrip_numbers_its_cues_and_webvtt_does_not
- fn the_timestamp_separator_differs_between_the_two
- fn hours_minutes_seconds_and_milliseconds_are_all_right
Over an hour, so the hour field is exercised rather than assumed. - fn an_impossible_time_becomes_zero_rather_than_a_broken_cue
A cue no player accepts is worse than a wrong one, because it fails silently on somebody else's machine. - fn a_turn_with_no_words_still_says_who_was_speaking
The whole point of a subtitle track when every voice has been replaced. - fn an_arrow_in_the_text_cannot_forge_a_timestamp_line
An arrow inside a name or a line of text would start something a player reads as a timestamp. - fn the_title_is_carried_into_webvtt_as_a_note
- fn an_empty_plan_produces_a_valid_empty_file
- fn the_extensions_are_the_conventional_ones
- fn every_cue_is_terminated
Every cue must be separated by a blank line, or players merge them.
crates/veilvoice-core/examples/spectrum_report.rs
- (module)
Where do the output partials actually land? Prints the strongest partials of a synthetic speaker before and after processing, as multiples of the STFT bin grid. It is the quickest way to see the two synthesis modes described in - (module)
`spectral.rs`: * **accent off**, the legacy channel-vocoder path. Each harmonic smears into a cluster of neighbouring grid frequencies (187.5 *and* 234.4 Hz around one partial), which is what makes it sound metallic. * **accent on**, an - (module)
exact harmonic series at the canonical register (140.6 Hz and its multiples), whatever pitch went in. Run with `cargo run -p veilvoice-core --example diag_spectrum`. # In plain words A small program that runs the engine over a recording - (module)
and prints what changed about its frequencies. It is here so that the claims about what VeilVoice does to a voice can be checked by anybody, rather than taken on trust from a paragraph of prose. - fn speaker
- fn peaks
- fn main
crates/veilvoice-core/examples/veil_a_buffer.rs
- fn main
crates/veilvoice-core/src/accent.rs
- (module)
Accent and speaker-trait neutralisation. # What an accent is made of, and what a signal-level transform can remove Accent is carried by two very different kinds of cue, and they have opposite answers here: * **Suprasegmental cues**, - (module)
meaning intonation contour and pitch range, long-term voice quality and spectral tilt, and the fixed vocal-tract scale behind a speaker's vowel space. These are *properties of the signal*, they are strongly speaker- and region-identifying, - (module)
and this module removes them by mapping every speaker onto one canonical target. * **Segmental cues**, meaning *which phonemes the speaker actually produced*: rhoticity, vowel mergers, dental-fricative substitution, aspiration patterns. - (module)
These are not a colouration laid over the words; at this level they **are** the words. Removing them means deciding a different phoneme was said, which no filter can do: it requires recognising the speech and re-synthesising it (see the - (module)
planned text-to-speech mode, which sidesteps this entirely by never carrying the original signal at all). So this module makes every speaker land on the same pitch register, the same apparent vocal-tract length and the same long-term - (module)
spectral tilt, which removes the accent's melody and colour and a large part of its perceived origin. It does not, and cannot, re-articulate phonemes. `docs/WHITEPAPER.md` must state that limit plainly rather than claim accent removal is - (module)
total. # Why this also strengthens de-identification Every step here is *many-to-one*: a whole population of input f0 contours, spectral tilts and vocal-tract lengths is collapsed onto a single canonical value. That destroys information - (module)
rather than displacing it, so it composes with the phase discard in [`crate::spectral`], because the two are independent one-way steps, and normalising the speaker's *mean* pitch and vocal-tract length removes two of the strongest - (module)
biometric features there are. # Preserving intelligibility The critical design rule is that every correction is derived from a **long-term** average, never from the current frame. Per-frame spectral shape is what distinguishes /i/ from - (module)
/u/; normalising it frame-by-frame would erase the vowels along with the accent. Vocal-tract and tilt corrections therefore use multi-second time constants, so they track the speaker and leave the phonemes moving freely underneath. # In - (module)
plain words This is the part that works on accent, and it is careful about what it claims. An accent is two different things at once. Some of it is in the sound: how high the voice sits, how it rises and falls, the shape of the vowels. - (module)
That part can be changed here, and is. The rest of it is in the words themselves, and in the choices somebody makes between them. No amount of altering sound touches that, because it is not in the sound. So VeilVoice says accent removal is - (module)
**partial**, and means it. - const CENTROID_LO_HZ
Lower edge of the band used to measure vocal-tract scale, in hertz. - const CENTROID_HI_HZ
Upper edge of the band used to measure vocal-tract scale, in hertz. - const LTAS_LO_HZ
Lower edge of the band whose long-term tilt is normalised. - const LTAS_HI_HZ
Upper edge of the band whose long-term tilt is normalised. - const TILT_REF_HZ
Reference frequency of the canonical spectral-tilt line, in hertz. - const MAX_SHAPE_DB
Maximum long-term shaping applied to any bin, in decibels. - const TAU_PROSODY_S
Time constant for the intonation correction, in seconds. - const TAU_VTLN_S
Time constant for the vocal-tract estimate, in seconds. Deliberately long: it must track the *speaker*, not the current vowel. - const TAU_LTAS_S
Time constant for the long-term average spectrum, in seconds. - const WARMUP_S
Seconds of voiced audio over which corrections fade in from nothing. Seconds of **voiced** audio before the corrections reach full strength. Public because it is load-bearing outside this crate: a speaker who is only given a second of - const WARMUP_S
audio never arrives at their canonical register, and anything splicing several speakers together needs to be able to say so. Counted in voiced frames, not wall clock, so real speech takes longer than this in seconds -- silence and unvoiced - const WARMUP_S
consonants do not advance it. - struct AccentConfig
How aggressively accent and speaker traits are normalised. Each strength is in `[0, 1]`, where 0 leaves that trait untouched and 1 maps every speaker fully onto the canonical target. - impl Default for AccentConfig
- fn default
- struct AccentStats
Live read-out of what the neutraliser is currently doing, for the UI. - struct AccentNeutralizer
Maps any speaker onto one canonical pitch register, vocal-tract scale and long-term spectrum. - impl AccentNeutralizer
- fn new
Build for a given spectrum size and sample rate. `half` is `n/2 + 1`. - fn enabled
Whether neutralisation is active. - fn stats
Live read-out for the UI. - fn observe
Feed this frame's f0 estimate and update the intonation correction. The estimate is produced by the chain rather than here, because the harmonic-locked resynthesis in [`crate::spectral`] needs the same reading even when accent - fn observe
neutralisation is switched off. - fn prosody_ratio
Pitch ratio to apply to the excitation this frame. - fn measure_envelope
Measure the speaker's vocal-tract scale from the *unwarped* envelope and update the VTLN ratio. Uses a multi-second average, so it tracks the speaker rather than the current vowel. - fn vtln_ratio
Formant ratio to apply to the envelope this frame. - fn shape
Rotate the already-warped envelope toward the canonical spectral tilt, then fold the result back into the running average. Measuring *after* the correction closes a slow feedback loop: the stored curve converges on whatever is needed to - fn shape
put the output's long-term tilt on the canonical slope, which automatically accounts for the pitch and formant warping applied upstream. The correction is renormalised to preserve the frame's energy exactly. Centring the ramp only makes it - fn shape
*approximately* level-neutral, because the centroid is an energy-weighted quantity and a dB-symmetric curve is not energy-symmetric, so the level is pinned explicitly rather than assumed. - fn recompute_shape
Rebuild the correction curve from the current long-term average. The curve is deliberately **a straight line in log-frequency and nothing more**: the measured long-term spectrum is reduced to a single slope, and the correction is the - fn recompute_shape
rotation that carries that slope onto the canonical one. This is the strongest form of long-term colour correction that is *structurally incapable* of damaging intelligibility, because a smooth monotone ramp adds the same offset to every - fn recompute_shape
speaker's vowels, so formant-scale contrast passes through untouched. Matching the long-term spectrum bin-by-bin would remove more speaker colour, but it also flattens the per-frame differences that distinguish one vowel from another, - fn recompute_shape
which is not a trade this project is willing to make. - fn log_centroid
Energy-weighted geometric-mean frequency of `env` over `[lo, hi]` bins. The geometric (log-frequency) mean is the right measure here: vocal-tract length scales the whole spectrum multiplicatively, so a log-domain centroid moves by a - fn log_centroid
constant offset when the tract scales, and the ratio of two centroids is exactly the warp factor between two speakers. - fn gain_to_db
A linear gain as decibels. The tiny addition keeps the logarithm of silence finite rather than negative infinity. - fn db_to_gain
Decibels back to a linear gain. The inverse of [`gain_to_db`] for everything except exact silence. - mod tests
- const SR
- const N
- const HOP
- const HALF
- struct Rig
The neutraliser plus the pitch tracker the chain normally drives it with, so tests can feed raw frames the way `Deidentifier` does. - impl Rig
- fn observe_frame
- impl std::ops::Deref for Rig
- type Target
- fn deref
- impl std::ops::DerefMut for Rig
- fn deref_mut
- fn neutralizer
- fn voiced_frame
A voiced frame: sawtooth excitation shaped by a vocal tract of a given scale (`vtl` > 1 = longer tract = lower formants). - fn envelope_of
Smooth spectral envelope of a frame, matching what the chain feeds in. - fn settle
Run a synthetic speaker through the neutraliser and report the ratios it settles on, plus the centroid it measured. - fn two_speakers_converge_to_one_pitch_register
- fn two_speakers_converge_to_one_vocal_tract
- fn flatten_zero_leaves_intonation_alone
- fn full_flatten_hits_the_canonical_register
- fn disabled_is_a_true_bypass
- fn shaping_is_gain_neutral_and_finite
- fn per_frame_vowel_contrast_survives
- fn warmup_ramps_corrections_in_from_nothing
- fn unvoiced_input_leaves_state_untouched
crates/veilvoice-core/src/chain.rs
- (module)
The assembled de-identification chain and its live performance statistics. Every other module in this crate does one job. This is the file that puts them in order and decides what happens to a block of samples, so it is the one to read - (module)
first if you want to know what VeilVoice actually *does* to audio. # The signal path [`Deidentifier::process`] takes a block of input and writes an equal-length block of output. Everything below happens inside it, per STFT frame: 1. **Roll - (module)
the modulation stream** if this frame is the one where the ratchet fires. See "forward secrecy" below. 2. **Draw this frame's modulation** from the CSPRNG -- a pitch ratio and a formant ratio, glided toward fresh random targets rather than - (module)
jumped, so the scrambling is inaudible as scrambling. 3. **Track the fundamental** from the newest hop of *time-domain* samples. This cannot be done in the frequency domain: at any frame size with usable latency, the FFT's bin spacing - (module)
cannot tell 100 Hz from 140 Hz. The tracker keeps its own longer history and is fed only what is new. 4. **Let the accent neutraliser observe** that estimate, so its long-term picture of the speaker stays current. 5. **Transform the - (module)
spectrum** -- this is the irreversible step, and it lives in [`crate::spectral`]. Measured phase is discarded and resynthesised; pitch, vocal-tract scale and spectral tilt are mapped onto canonical values. Then, once per block rather than - (module)
per frame, a short time-domain tail: soft clip, chorus, reverb. Those are cosmetic. **They are not what makes the output unlinkable** and nothing here should be read as though they were. # Why it is one-way, in one paragraph Two - (module)
independent reasons, and both are needed: * **The mapping is many-to-one.** Every speaker is pushed toward the same pitch register, the same vocal-tract scale and the same long-term spectrum. Many different inputs produce the same output, - (module)
so there is no inverse to compute -- not "an inverse that is hard to find", none. * **The phase is gone.** The measured phase of every frame is discarded and replaced. Phase carries the precise waveform and a speaker's micro-timing; it is - (module)
never stored, so nothing downstream can restore it. The CSPRNG modulation on top means there is not even one fixed transform to characterise. That is a third reason, and it is the weakest of the three: randomness alone would be reversible - (module)
by anyone holding the seed. The seed never leaves the process and is zeroized on drop, but the argument does not rest on that. # Forward secrecy, and what `reseed_secs` is really for The modulation stream rolls onto a fresh seed every - (module)
[`DeidConfig::reseed_secs`] (two seconds by default), drawing the new seed from the stream it replaces. ChaCha20 cannot be run backwards, so obtaining the current state tells an adversary nothing about the modulation that drove any earlier - (module)
segment: a long recording is a chain of short independently-sealed streams rather than one long one. **This is forward secrecy, not irreversibility.** Rolling more often does not make the output harder to invert -- the phase discard and - (module)
the many-to-one mapping already did that, and they do not depend on the ratchet at all. Setting `reseed_secs` to `0.0` keeps one stream for the session and the output is exactly as unlinkable as before. # A roll cannot happen faster than a - (module)
frame, and the interface must say so [`DeidConfig::reseed_range_ms`] asks for the interval to be drawn fresh from a range at every roll, in milliseconds, rather than fixed. The gap is drawn from the modulation stream itself, so it is - (module)
unpredictable and costs neither a syscall nor an allocation. It is **quantised to whole frames**, and the grain is coarser than people expect. The engine produces one set of modulation parameters per STFT hop: 256 samples at the default - (module)
frame size, which is 5.33 ms at 48 kHz. There is nothing between two frames to change, so a request for a 0.7 ms interval does not roll seven times inside a frame -- it rolls once, at the frame boundary, exactly as a request for 5 ms - (module)
would. Making the frame short enough for a sub-millisecond roll would mean a 128-point transform, which is 375 Hz per bin: too coarse to locate a formant, and moving formants is the thing being done. The trade is not available. So - (module)
[`DeidConfig::effective_reseed_range_ms`] reports what a requested range actually comes to on this configuration, and a front end shows that rather than the number that was typed. Quietly accepting 0.7 ms and rolling at 5.33 ms would be a - (module)
setting that lies about itself. The roll is deliberately cheap: no syscall, no allocation, no lock. It has to be, because it happens inside an audio callback. # Real-time constraints [`Deidentifier::process`] is allocation-free and safe to - (module)
call from an audio callback. That is a property of this file and it is easy to lose: a `Vec` grown inside the per-frame closure, a lock taken, or a log line written would each turn a working live path into audible dropouts on somebody - (module)
else's machine and not on yours. [`Deidentifier::process_vec`] is the convenience form that *does* allocate. It is for offline processing; do not reach for it in a callback. [`ProcessStats`] records what each block cost -- last, worst, and - (module)
an exponential moving average -- so a front-end can show a real-time factor instead of guessing. `worst_block_ms` is the one that matters for live use: the average being comfortable says nothing about whether the worst block missed its - (module)
deadline. # Configuration is validated in one place [`DeidConfig::checked`] is the single funnel, and nothing should bypass it. Two shipped defects are the reason it exists in that shape: a configuration value once made every output sample - (module)
silently `NaN` (F-10), and parameters read from a file and handed to a library without a bound killed the process (F-2, F-3). The engine keeps persistent state, so a bad value is not one bad block -- it is every block from then on. # In - (module)
plain words This is the file to read first if you want to know what VeilVoice actually does to a voice. Every other file in the engine does one job. This one puts them in order and decides what happens to each piece of sound: what is - (module)
measured, what is thrown away, what is replaced, and in which order. It also keeps count of how long the work is taking, which is what live mode needs in order to tell you honestly if the computer is not keeping up. - struct DeidConfig
User-facing configuration for the de-identifier. - impl Default for DeidConfig
- fn default
- const MIN_RESEED_MS
The narrowest randomised roll range this engine will accept, in milliseconds. Below one frame the range has no room to vary in. Public so that a front end refusing a typed value can name the limit. A refusal that will not say what the - const MIN_RESEED_MS
bound is leaves somebody guessing, which is only marginally better than the clamp it replaced. - const MAX_RESEED_MS
The widest, in milliseconds. Ten minutes is far past any use for a ratchet and stops an absurd value producing a frame count that overflows. - enum RangeError
Why a ratchet range typed by a person was not accepted. **Every one of these is a refusal, never a correction.** Clamping a typed number to something legal is how somebody ends up running on a setting they did not choose and cannot see: - enum RangeError
they typed a value, nothing complained, and the program used a different one. For a control whose entire purpose is that the interval should not be predictable, silently substituting a value would be the worst available failure -- and the - enum RangeError
roadmap marker asks for exactly this, in these words: *invalid input refused rather than clamped*. - impl std::fmt::Display for RangeError
- fn fmt
- impl std::error::Error for RangeError {}
- fn parse_reseed_range
Read a `low,high` ratchet range in milliseconds, or say why not. A comma or a dash may separate the two, because both are what people type. Everything else is **refused with the reason** and nothing is ever adjusted to fit -- see - fn parse_reseed_range
[`RangeError`]. The range is not quantised here. One frame is the smallest gap that can happen at all, so a range narrower than a frame collapses to a single value once it meets the engine; [`DeidConfig::effective_reseed_range_ms`] is what - fn parse_reseed_range
a range really comes to and is what an interface should display beside it. - impl DeidConfig
- fn hop
How far the analysis window moves between frames, in samples. At least one, whatever the configuration says, because a hop of zero would never advance through the input. - fn frame_ms
How long one analysis frame is, in milliseconds. The grain of everything the modulation does. No parameter can change more often than this, because only one set of them exists per frame. - fn frames_for_ms
The number of frames a millisecond interval comes to, at least one. - fn effective_reseed_range_ms
What [`DeidConfig::reseed_range_ms`] actually comes to on this configuration, after quantising to whole frames. Show this, not the number the user typed. A request for 0.7 ms to 2.7 ms comes back as 5.33 ms to 5.33 ms at the default frame - fn effective_reseed_range_ms
size, because a frame is the grain and the whole requested range is finer than one. That is not a failure -- it is the fastest this can honestly roll -- but an interface that displayed "0.7 ms" would be claiming something that is not - fn effective_reseed_range_ms
happening. - fn reseed_range_is_finer_than_a_frame
Whether the requested range is finer than one frame, so the whole of it collapses onto a single interval. A front end should say so where the control is, rather than leaving somebody to wonder why moving the slider changes nothing. - fn with_random_reseed_range
This configuration with a roll range drawn from the OS CSPRNG. The shipped interval is then a property of this launch rather than a number compiled into the binary -- which is the point: a fixed ratchet period is a fixed thing to observe, - fn with_random_reseed_range
and every copy of VeilVoice having the same one makes it a property of the *program* rather than of the session. The range is centred somewhere between one frame and about two seconds, and both ends are drawn, so neither the period nor the - fn with_random_reseed_range
spread is the same twice. Falls back to leaving the configuration alone if the OS CSPRNG cannot be read, because a de-identifier that refuses to start over the *ratchet* -- which is forward secrecy, not irreversibility -- would be trading - fn with_random_reseed_range
the whole feature for a nicety. - fn scaled
Scale a `(lo, hi)` ratio range toward 1.0 by `intensity`. - const MAX_SAMPLE_RATE
The largest sample rate this engine will build for, in Hz. Every value in here is reachable from a file: a WAV's `fmt ` chunk carries a **`u32`** sample rate, and `symphonia` passes whatever it finds straight through. That number then - const MAX_SAMPLE_RATE
sizes the delay lines in `effects.rs`, where `Reverb`'s comb is `0.0297 × sample_rate` samples and the chorus voices are similar, so a four-kilobyte file declaring `u32::MAX` asks for roughly two gigabytes of buffers before a single sample - const MAX_SAMPLE_RATE
is processed. A failed allocation in Rust aborts the process, which is the same shape as F-3: opening a hostile file kills the program. 768 kHz is chosen well above anything real. Professional converters top out at 384 kHz and DSD-rate PCM - const MAX_SAMPLE_RATE
at 705.6 kHz; nothing legitimate asks for more, and the largest buffer this permits is a few megabytes. - const MAX_FRAME_SIZE
The largest FFT size this engine will build for. Bounded for the same reason as the sample rate: `frame_size` sizes every internal buffer and the FFT plan, and there was previously no upper limit at all, so a caller could ask for a - const MAX_FRAME_SIZE
`usize::MAX / 2` transform. 65536 is eight times the largest size anyone uses for speech. - fn checked
Validate and normalise; returns an error string on impossible values. Every float is checked for finiteness, not merely for range. A `NaN` compares false against every bound, so a bare `self.sample_rate < 8_000.0` test *passes* `NaN`, and - fn checked
an engine built at a `NaN` sample rate produced `NaN` for every output sample, for the whole session, with nothing reported. That is F-5 arriving through a second door: F-5 sanitised the samples, and nothing sanitised the configuration - fn checked
they were processed under. - fn clamp_ratio_bounds
Keep a `(lo, hi)` ratio pair inside a range a resampler can act on, and in the right order. - const MIN
- const MAX
- struct ProcessStats
Rolling performance statistics, surfaced live to the UI. - impl ProcessStats
- fn last_block_ms
Most recent block processing time in milliseconds. - fn worst_block_ms
Worst block processing time in milliseconds. - fn ema_block_ms
Smoothed block processing time in milliseconds. - fn last_realtime_factor
Processing time divided by the block's real-time duration. < 1.0 means the machine keeps up with real time; the headroom is `1 - factor`. - struct Deidentifier
The complete, irreversible voice de-identification chain. Feed it mono `f32` samples; it returns mono `f32` samples of equal length, delayed by [`Deidentifier::latency_samples`]. Not real-time-thread cheap to *construct* (allocates FFT - struct Deidentifier
plans), but `process` performs no heap allocation and is safe to run inside an audio callback. - impl Deidentifier
- fn new
Build with a fresh, unpredictable seed from the OS CSPRNG. - fn from_seed
Build with an explicit seed (deterministic; for tests or seed-from-key). - fn latency_samples
Fixed algorithmic latency in samples. - fn stats
Live performance statistics (copy). - fn accent_stats
Live accent-neutralisation read-out (detected f0, applied ratios). - fn process
Process `input` into `output` (equal length). Allocation-free; safe for an audio callback. Updates [`Deidentifier::stats`]. - fn process_vec
Convenience: process a whole buffer and return a new `Vec`. - fn reseed_range_from
Turn two ratios in `0.0..=1.0` into a reseed range in milliseconds. One frame at the fast end, about two seconds at the slow end, the two draws sorted so the range is never reversed, and **never narrower than one frame**. # Why the minimum - fn reseed_range_from
width is not a nicety Each draw is sixteen bits, so one launch in 65,536 draws the same number twice and the range comes out with no width at all. `checked` accepts that, because it only refuses a range that is backwards, and a front end - fn reseed_range_from
showing it would show two identical numbers. What it means is a **fixed** reseed interval, which is precisely the fixed ratchet period this function exists to avoid: the whole argument for drawing the range is that the period is a property - fn reseed_range_from
of the session rather than of the program. So a collision is widened by one frame, from whichever end has room. One frame because that is the resolution the engine actually has: `reseed_range_is_finer_than_a_frame` exists to report a range - fn reseed_range_from
that collapses onto a single interval, and a drawn range should never be one. Found by `a_drawn_range_is_always_valid` failing once, in a verification run that had already passed eleven times. The test was right and the code was wrong; - fn reseed_range_from
sixty-four draws a run against a one in 65,536 event is a coin that comes up about once in a thousand runs. F-188. - mod tests
- fn rms
- fn a_frame_is_the_documented_length
One frame is the grain of everything the modulation does, and the documented figure -- 5.33 ms at 48 kHz and the default frame size -- is the number every claim about roll intervals rests on. - fn a_range_finer_than_a_frame_collapses_and_admits_it
The request that started this: 0.7 ms to 2.7 ms. The whole range is finer than one frame, so it collapses onto exactly one interval -- and the engine must say so rather than displaying the number typed. - fn a_range_wider_than_a_frame_survives_quantisation
A range wide enough to hold several frames keeps its width. - fn a_backwards_range_is_refused_rather_than_reordered
A reversed range is a typo about what a recording is doing, so it is refused rather than quietly sorted. - fn a_randomised_interval_changes_as_the_audio_runs
The interval in force is reported, and with a randomised range it actually changes as the recording runs. Without that, the feature is a setting that does nothing. - fn a_fixed_interval_is_reported_and_stays_put
A fixed interval must still report itself, and must not wander. - fn rolling_off_reports_no_interval
Rolling off means no interval at all, not an interval of zero length. - fn the_same_seed_draws_the_same_intervals
A randomised range is still deterministic from a seed, or the test suite could not hold anything about it. - fn a_randomised_launch_range_is_valid_and_not_a_constant
The launch-time randomiser must produce something the engine accepts, every time, and something that is not the same on two calls. - fn config_rejects_impossible_values
- fn output_finite_and_length_preserved
- fn loudness_roughly_preserved_no_runaway
- fn different_seeds_produce_different_output
- fn speaker
Harmonically rich voiced speech from a speaker with a given pitch and vocal-tract scale (`vtl` > 1 = longer tract = lower formants). - fn accent_only
Isolate the accent path: no random modulation, no time-domain effects. - fn measure_f0
- fn accent_neutralisation_converges_speakers_end_to_end
The end-to-end claim: two speakers who differ sharply in register go in, and come out sharing one canonical register. - fn accent_neutralisation_can_be_switched_off
- fn accent_stats_are_populated
- fn accent_tracking_is_a_small_part_of_what_the_chain_costs
Accent tracking must not cost the real-time budget: it is an addition to the spectral work, not a multiple of it. # Why this is a ratio and not a number This asserted an absolute real-time factor under 0.5 until it failed, and what it was - fn accent_tracking_is_a_small_part_of_what_the_chain_costs
measuring was the machine. A debug build under QEMU on the armv7 job reported 0.557 while the same commit passed on every native target: an emulated 32-bit target is far slower than the runner hosting it, and there is no single number that - fn accent_tracking_is_a_small_part_of_what_the_chain_costs
is generous enough there and tight enough to catch anything here. The claim worth defending does not depend on the machine. The regression this exists to catch, an un-decimated pitch search, is an order of magnitude, and an order of - fn accent_tracking_is_a_small_part_of_what_the_chain_costs
magnitude is still an order of magnitude on a slow processor. So the same audio is run twice on the same machine in the same test, once with the neutraliser bypassed, and the two are compared. Both runs are preceded by an unmeasured pass - fn accent_tracking_is_a_small_part_of_what_the_chain_costs
so that neither is paying for a cold cache. The bound is deliberately loose. This is a timing measurement on a shared build machine, and a test that fails when somebody else's job gets busy teaches people to re-run it rather than read it. - fn rolling_the_seed_introduces_no_clicks
The property that makes rolling usable at all: it must be inaudible. A discontinuity in the phase offsets would show up as a sample-to-sample jump far larger than the signal ever produces on its own. - fn rolling_changes_the_audio_but_keeps_it_sane
- fn rolling_stays_deterministic_for_a_given_seed
Rolling must not cost determinism, because reproducible builds and the whole test suite depend on `from_seed` being repeatable. - fn reseed_interval_is_validated
- fn stats_are_populated
- mod reseed_range_tests
- fn both_front_ends_draw_a_random_range_at_launch
**F-73.** The front ends must actually draw a range at launch. [`DeidConfig::reseed_range_ms`]'s own documentation said "the front ends call [`DeidConfig::with_random_reseed_range`] at launch, which is what makes the shipped interval - fn both_front_ends_draw_a_random_range_at_launch
something other than a number compiled in". Nothing called it. It was written, documented, tested in isolation, and reached by no code path for two releases, so every shipped copy rolled on the same fixed two-second period -- exactly the - fn both_front_ends_draw_a_random_range_at_launch
thing the sentence said was not happening. A comment cannot be tested, so this tests the code the comment is about. It reads both front ends and fails the build if the call is gone. - fn a_drawn_range_is_not_the_same_twice
A drawn range is different from run to run. If it were not, it would be a compiled-in number wearing a random-looking coat. - fn two_equal_draws_still_give_a_range_with_width_in_it
Two equal draws must not give a range of no width, which is a fixed interval wearing a range's clothes. - fn any_two_draws_give_a_usable_range
The same invariant over every pair of draws, not only equal ones. - fn a_drawn_range_is_always_valid
A drawn range is usable: the right way round, inside the bounds, and wide enough to survive quantisation. - fn bad_input_is_refused_with_a_reason_and_never_corrected
Everything a person can type that is not a range is **refused**, and the refusal says which thing was wrong. Nothing is adjusted to fit: that is the whole of roadmap item 28's wording. - fn a_usable_range_survives_unchanged
The shapes people actually type are accepted, and accepted exactly -- the numbers that come back are the numbers that went in. - fn the_effective_range_is_quantised_to_whole_frames
What an interface shows is what the engine will do, not what was asked for. The ratchet only fires on a frame boundary, so a range is quantised, and displaying the request would describe a spread that does not exist.
crates/veilvoice-core/src/effects.rs
- (module)
Light time-domain effects applied after resynthesis. These run on the continuous output stream (not per FFT frame) and exist to (a) further decorrelate the signal from the original, and (b) add a few detuned "voices" so the spectrogram is - (module)
densely filled rather than showing a clean harmonic stack, without harming intelligibility, so every mix defaults low. None of them are invertible in a way that recovers the source voice. # How little these contribute, said plainly It - (module)
would be easy to read a chorus and a reverb as part of the anonymity argument. They are not, and this crate should not let anybody think they are. **The voiceprint is destroyed in [`crate::spectral`]** -- by discarding measured phase and - (module)
by mapping every speaker onto one canonical pitch register and vocal-tract scale. That has already happened before a single sample reaches this file. What these three add is *decorrelation at the margins*: a denser spectrogram, a few - (module)
detuned voices where there was a clean harmonic stack, and some odd harmonics smearing whatever residual cues survived. Useful, cheap, and nowhere near sufficient alone. Set all three mixes to zero and the output is exactly as unlinkable - (module)
as before. The reason to be exact about this is that a filter chain of precisely this shape -- clip, chorus, reverb -- is what a *voice changer* ships, and a voice changer offers no anonymity whatsoever. Everything that separates this - (module)
project from that one happens upstream of this file. # Why every mix defaults low Intelligibility is a requirement, not a preference. Each of these effects trades clarity for density, and past a fairly low mix the words start to cost more - (module)
than the added decorrelation is worth. The defaults sit where a listener does not notice the effect is there at all; they are a starting point a user may raise, not a recommendation to raise them. # Real-time constraints These run per - (module)
output sample inside an audio callback, on the continuous stream rather than per FFT frame. Every buffer is allocated once at construction: `process` allocates nothing, takes no lock and reads no clock. [`Chorus`] and [`Reverb`] own their - (module)
delay lines and index them with wrapping arithmetic, so changing sample rate means building a new one rather than resizing a live one. # In plain words A few small finishing touches applied to the sound after the main work is done. They do - (module)
two things. They loosen what remains of the connection between the result and the original recording, and they fill in the picture a spectrogram would show, so it looks like a dense, ordinary voice rather than something obviously - (module)
processed. Every one of them is set gently by default, because all of them can hurt how clear the words are if pushed, and clear words are the point. - struct SoftClip
Symmetric soft-clip (tanh) waveshaper. `drive` sets curvature, `mix` blends dry/wet. Adds gentle odd harmonics that smear residual identity cues. - impl SoftClip
- fn new
Build a shaper. `drive` is clamped away from zero so the normalisation below cannot divide by it, and `mix` to the dry/wet range. - fn process
Shape one sample. Dividing by `tanh(drive)` keeps the loudest output at the same level whatever the drive is, so turning it up changes the timbre without changing the volume. - struct DelayVoice
A single modulated delay line, summed into a small ensemble to create the impression of several slightly different voices. - impl DelayVoice
- fn new
One delay line, sized once here for the deepest sweep it can be asked for. The buffer is rounded up to a power of two so the read and write positions wrap with a mask rather than a division. - fn process
Write one sample and read one back from where the sweep currently points, interpolating between the two neighbouring samples so the moving read position does not step audibly. - struct Chorus
Detuned chorus ensemble. - impl Chorus
- fn new
Build the ensemble. Every buffer it will ever use is allocated here, because [`Chorus::process`] runs in the audio callback. - fn process
Sum the voices, average them, and blend that against the dry sample. - struct Reverb
Minimal Schroeder-style reverb: one feedback comb + one all-pass. Kept very light so speech stays dry enough to transcribe. - impl Reverb
- fn new
Size both delay lines for this sample rate, once. The lengths are the classic Schroeder figures: 29.7ms for the comb, 5ms for the all-pass. - fn process
One sample through the comb and then the all-pass, blended against the dry sample. Both delay lines are already the right size, so nothing here allocates. - mod tests
- fn softclip_is_bounded_and_finite
- fn chorus_and_reverb_finite
crates/veilvoice-core/src/lib.rs
- (module)
# veilvoice-core The security-critical heart of VeilVoice: an **irreversible, cryptographically modulated voice de-identification** engine. ## What it guarantees (and what it deliberately does not) VeilVoice destroys the *biometric - (module)
voiceprint*, meaning fundamental pitch, formant structure, timbre, accent and micro-timing, so that neither software nor a human can re-identify the speaker or reconstruct the original waveform. It does **not** hide the words: - (module)
intelligibility is preserved on purpose, because a scrambler you cannot understand or transcribe is useless. "Fill the whole spectrogram with white noise" and "stay transcribable" are mutually exclusive; see `docs/WHITEPAPER.md` for the - (module)
full argument. ## Accent [`AccentConfig`] additionally maps every speaker onto one canonical pitch register, vocal-tract scale and long-term spectrum, so the *melody and colour* of an accent, along with two of the strongest biometric - (module)
features there are, do not survive. What no signal-level transform can remove is the **segmental** side of an accent: which phonemes were actually produced. At that level the accent and the words are the same thing, and changing it means - (module)
changing what was said. See [`AccentConfig`] for the full argument and the limit, which the whitepaper must state rather than overclaim. ## Why it is one-way Every STFT frame has its **measured phase discarded** and resynthesised from - (module)
scratch (see [`spectral`]). The original excitation phase, which encodes the precise waveform and a speaker's micro-timing, is never stored and never reused, so no downstream process can recover it. On top of that, the pitch and formant - (module)
shifts are driven every frame by a ChaCha20 CSPRNG ([`modulation`]) whose seed never leaves the process and is zeroized on drop, so there is not even a single fixed transform to invert. ## Example ``` use veilvoice_core::{Deidentifier, - (module)
DeidConfig}; let mut deid = Deidentifier::new(DeidConfig::default()).unwrap(); let input = vec![0.0f32; 4800]; let output = deid.process_vec(&input); assert_eq!(output.len(), input.len()); // Live processing cost, e.g. for a latency - (module)
read-out: let _ms = deid.stats().last_block_ms(); ``` # In plain words This is the part that actually changes the voice. A recording goes in and a recording comes out. The words are the same and you can still understand every one of them; - (module)
the voice is not yours any more, and there is no setting, no key and no clever program that turns it back. What made it recognisably *you* -- the pitch, the shape of your mouth and throat, the timing, the music of your accent -- is not - (module)
hidden. It is thrown away, and everybody who goes through it comes out sounding like the same handful of people. What it does not do is keep your words secret. It is not meant to: a voice nobody can understand would be no use to anyone. If - (module)
what you said would identify you, this has not touched that. - mod accent
- mod chain
- mod effects
- mod modulation
- mod pitch
- mod spectral
- mod stft
- mod voices
- mod window
- const VERSION
Crate version, surfaced in the About panel.
crates/veilvoice-core/src/modulation.rs
- (module)
Cryptographically-seeded modulation of the effect parameters. The pitch and formant ratios are never constant: a ChaCha20 CSPRNG picks a new random target every `frames_per_target` STFT frames, and a one-pole filter glides continuously - (module)
toward it. Because the transform is therefore non-stationary and unpredictable, an attacker cannot "undo" it by assuming a single fixed shift: there is no single shift to undo, and the target sequence is unknowable without the seed (which - (module)
never leaves the process and is zeroized on drop). The seed does not stay put either. It is rolled forward every couple of seconds by default (see [`Modulator::reseed`]), so the stream driving any given stretch of audio is closed off - (module)
permanently once that stretch is past. # In plain words The amount by which the voice is altered is never held still. It drifts, constantly and unpredictably. The drift comes from the same kind of random number generator used for - (module)
encryption, so it cannot be guessed, worked out from what came before, or reproduced by somebody who has the recording. It slides between values rather than jumping, so nothing about it can be heard. This is what stops the transform from - (module)
being reversed by anybody who works out the settings, because there is no single setting to work out. - struct Param
One smoothly-varying parameter bounded to `[lo, hi]`. - impl Param
- fn new
Start in the middle of the range, so the first frames are not a slide from an edge the caller never asked for. - fn retarget
Draw the next value to move towards. The draw is the only place the keystream is consumed, which is what makes the modulation reproducible from a seed and unpredictable without one. - fn step
Move one step of the way towards the target and report where that is. A one-pole approach rather than a jump: a parameter that steps is a parameter you can hear stepping. - struct ModValues
The values handed to the spectral transform for one frame. - struct Modulator
Non-stationary parameter generator. - impl Modulator
- fn from_seed
Build from an explicit 32-byte seed (deterministic; used by tests and by session-key-derived seeding). - fn fill_phase_offsets
The 32 fixed per-bin phase offsets consumer needs are derived from the same stream; expose a helper that fills `out` with values in [0, 2π). - fn reseed
Roll onto a fresh seed, drawn from the current stream. # Why a ratchet rather than fresh OS entropy The new seed is 32 bytes of ChaCha20 output from the stream being replaced. That buys the property that matters, **forward secrecy**. - fn reseed
ChaCha20 is not invertible, so an adversary who somehow learned the current seed could generate everything from this moment on but could not walk backwards to recover any earlier one. Each roll permanently closes off the segment before it. - fn reseed
Reading fresh entropy from the OS instead would mean a syscall inside an audio callback every couple of seconds, which is exactly the kind of thing that causes a dropout, and it would make [`crate::Deidentifier::from_seed`] - fn reseed
non-deterministic, and losing the reproducibility the test suite depends on. The chain is seeded from the OS CSPRNG once at construction; the ratchet carries that unpredictability forward without ever going back to the kernel. The smoothed - fn reseed
parameter values are deliberately **not** reset. Only the source of future targets changes, so the glide continues through a roll and there is no discontinuity to hear. - fn draw_frames
Draw a whole number of frames uniformly from `lo..=hi`. Used for the randomised roll interval: the gap before the next ratchet is itself drawn from the stream, so it is as unpredictable as everything else here and costs no syscall and no - fn draw_frames
allocation -- which it cannot, because it is drawn inside an audio callback. `hi` below `lo` is treated as `lo`. That is not input validation -- [`crate::DeidConfig::checked`] refuses a reversed range long before this is reached -- it is - fn draw_frames
this function being total so that a caller cannot produce a panic in an audio thread by arithmetic. - fn next_frame
Advance one STFT frame and return the parameters to apply. - impl Drop for Modulator
- fn drop
- mod tests
- fn mk
- fn values_stay_in_bounds
- fn same_seed_is_deterministic
- fn different_seed_diverges
- fn reseeding_changes_the_stream
- fn the_ratchet_is_deterministic
The ratchet must stay deterministic, or `from_seed` stops being reproducible and the reproducible-build story goes with it. - fn reseeding_does_not_jump_the_parameters
A roll must not jolt the smoothed values: the glide is what keeps the transform inaudible at the seam. - fn a_roll_replaces_the_retained_seed
- fn parameters_actually_move
crates/veilvoice-core/src/pitch.rs
- (module)
Monophonic fundamental-frequency tracker (decimated YIN). Accent neutralisation needs to know the speaker's *current* f0 so the intonation contour can be replaced with a canonical one (see [`crate::accent`]). Two constraints shape this - (module)
implementation: * **The STFT frame is too short to resolve f0 directly.** At the default 1024-point FFT / 48 kHz the bin spacing is ~47 Hz, so a spectral peak-pick cannot tell 100 Hz from 140 Hz. This tracker therefore works in the time - (module)
domain over its own rolling history, which may be longer than one STFT frame without adding any output latency, because the window still *ends* at the current frame, so it stays causal. * **It must be cheap enough for an audio callback.** - (module)
The signal is decimated to ~8 kHz first (pitch lives in the low harmonics), which cuts the difference-function cost by the square of the decimation factor. At the default settings it costs on the order of 8 M flops/s, well under 1 % of one - (module)
core, and allocates nothing after construction. The algorithm is YIN's cumulative mean normalised difference function (de Cheveigné & Kawahara, 2002) with parabolic interpolation, minus the optimisations that only matter for offline - (module)
accuracy. # In plain words This works out how high or low somebody is speaking, moment by moment. It is needed for the accent work: to replace the rise and fall of somebody's voice with a flatter, more ordinary pattern, you first have to - (module)
know what the rise and fall currently is. It is built to be quick rather than perfect, because it has to keep up with a live conversation. When it is not sure, it says so instead of guessing, and the accent work simply leaves that moment - (module)
alone. - const F0_MIN_HZ
Lowest fundamental the tracker will report, in hertz. - const F0_MAX_HZ
Highest fundamental the tracker will report, in hertz. - const DECIMATED_HZ
Target sample rate after decimation, in hertz. - const WINDOW
Analysis window length in decimated samples (~40 ms at 8 kHz, at least two periods of the lowest supported f0). - const YIN_THRESHOLD
`d'(tau)` below this counts as a confident voiced period. - const SILENCE_RMS
Frames quieter than this (RMS) are treated as unvoiced regardless. - struct PitchEstimate
One f0 measurement. - struct PitchTracker
Rolling, allocation-free f0 tracker. - impl PitchTracker
- fn new
Build a tracker for input at `sample_rate` hertz. - fn push
Feed new input samples (anti-aliased and decimated internally). - fn estimate
Estimate f0 over the newest history. Returns the previous estimate unchanged until enough samples have accumulated. - fn parabolic
Sub-sample refinement of the minimum at `tau` by fitting a parabola through its two neighbours. - mod tests
- fn saw
A sawtooth is richly harmonic, like voiced speech excitation. - fn track
- fn tracks_male_and_female_range
- fn resolves_pitches_a_single_fft_bin_cannot
- fn silence_is_unvoiced
- fn white_noise_is_not_confidently_voiced
- fn works_at_other_sample_rates
- fn history_stays_bounded
crates/veilvoice-core/src/spectral.rs
- (module)
Frequency-domain de-identification transform. For every STFT frame we: 1. take the magnitude spectrum and **discard the measured phase**. This is the irreversible step, it permanently erases the speaker's waveform / micro-timing; 2. - (module)
estimate a smooth spectral **envelope** (the vocal-tract / formant structure, i.e. the biometric identity) and the **excitation** residual (glottal source + phonetic detail that carries the words); 3. shift the excitation by a - (module)
cryptographically-modulated *pitch* ratio and warp the envelope by an independent *formant* ratio, so the identity is moved somewhere it never was while the phonemes stay legible; 4. resynthesise a fresh phase, plus a fixed random per-bin - (module)
offset. ## Voiced frames: an explicit harmonic comb Step 4 has two modes. On **unvoiced** frames each bin accumulates its own centre frequency, which is the classic channel-vocoder phase, exactly right for fricatives and noise. On - (module)
**voiced** frames that alone is not enough, and it is audible. Bin centres are multiples of `sample_rate / n` (46.875 Hz at the default settings), and a harmonic peak spans several bins, so a plain channel vocoder turns each partial into a - (module)
cluster of independent grid-frequency sinusoids with unrelated phases. A 210 Hz voice comes out beating around 187.5 and 234.4 Hz: metallic, and with a pitch that cannot be steered, which would make the canonical register [`crate::accent`] - (module)
aims for unreachable. So when the frame is voiced and accent neutralisation is active, the excitation is not resampled at all. It is **replaced** by an ideal harmonic comb at the canonical fundamental, quantised to the nearest whole bin. - (module)
This is the textbook source-filter model of voiced speech (an impulse train through the vocal-tract filter), and because every comb line then sits exactly on a bin centre, the existing per-bin phase advance is precisely the right advance - (module)
for it: successive frames overlap-add coherently and each harmonic emerges as one clean partial. The envelope still supplies the formants, so the vowels are untouched. Snapping to the bin grid is what buys that coherence, and it costs - (module)
pitch resolution, so the grid step is coarse. That is not a problem for the default configuration, which maps every speaker onto a *single constant* register that need only be snapped once; it does mean any residual intonation - (module)
(`prosody_flatten` below 1.0) is quantised to the same grid. Lifting that restriction needs window-kernel synthesis, noted as future work in the project roadmap. None of this weakens irreversibility. The measured phase is still discarded - (module)
in full, and pinning the output to one canonical fundamental destroys *more* pitch information than randomising it would, because a constant carries nothing. Between steps 2 and 4 the optional [`crate::accent`] neutraliser folds in its - (module)
long-term corrections: it reads the unwarped envelope to measure the speaker's vocal-tract scale, contributes extra pitch and formant ratios, and rotates the warped envelope toward a canonical spectral tilt. The measured phase is never - (module)
reused, so no amount of downstream processing can reconstruct the original excitation phase: the transform is one-way. # In plain words This is the part that actually destroys the voiceprint, and the step that cannot be undone. Sound - (module)
carries two things: which frequencies are present, and how they line up in time. The second one, the timing, is a great deal of what makes a voice recognisably yours, and it is thrown away here and replaced. It is not scrambled or hidden; - (module)
it is discarded, and there is nothing left to recover it from. What is kept is enough for the words to stay clear. That is the whole trade: the sentence survives, the speaker does not. - struct SpectralState
Persistent per-instance state for the spectral transform. - impl SpectralState
- fn new
`n` = FFT size, `hop` = analysis/synthesis hop, `rand_phase` = fixed per-bin phase offsets in radians (length n/2+1) drawn from the CSPRNG. - fn retarget_phase_offsets
Aim the per-bin phase offsets at fresh values. Called when the modulation stream rolls onto a new seed. The offsets are **glided** to, never assigned: they are added directly to each bin's synthesis phase, so replacing them outright would - fn retarget_phase_offsets
step every partial's phase at once, which is an audible click, every couple of seconds, forever. Gliding turns that step into a brief, tiny detune instead. The move is taken the short way around the circle, so the worst case is half a turn - fn retarget_phase_offsets
spread over about half a second: under one hertz of momentary shift, which is inaudible. - fn transform
Rewrite `spec` (length n/2+1) in place, given the current modulation. * `pitch_ratio` > 1 raises the voice (excitation shifted up) * `formant_ratio` > 1 shrinks the apparent vocal tract (formants up) * `accent` optional neutraliser, - fn transform
contributing its own ratios and long-term envelope shaping on top of the random modulation * `pitch` current f0 estimate; when voiced (and `accent` is active) the excitation is replaced by a harmonic comb instead of being resampled - fn resample_linear
Linear resampling of a non-negative spectral function. `dst[k] = src[k / ratio]` with linear interpolation. Destination bins whose source position falls outside `src` roll off to zero (band edges), which is exactly what we want when - fn resample_linear
shifting energy up or down the spectrum. - fn box_smooth
In-place-ish box smoother using a running sum. `radius` is the half-width; window length is `2*radius+1`. Edges use symmetric clamping. - mod tests
- fn resample_identity
- fn box_smooth_preserves_constant
- fn transform_is_finite_and_nonnegative_magnitude
- fn voiced_frames_synthesise_a_grid_aligned_comb
Voiced frames must come out as a sparse comb on the bin grid, which is what makes the partials coherent under overlap-add. - fn unvoiced_frames_keep_per_bin_phase
Unvoiced frames must keep the channel-vocoder behaviour, which is the right model for fricatives and noise.
crates/veilvoice-core/src/stft.rs
- (module)
Streaming short-time Fourier transform with overlap-add resynthesis. Structure follows the classic FIFO/overlap-add pipeline (as popularised by Bernsee's SMB pitch shifter): samples flow in and out one-for-one with a fixed latency of `n - - (module)
hop` samples, and a full frame is analysed/synthesised every `hop` input samples. The caller supplies a closure that rewrites the complex spectrum in place, keeping the FFT plumbing and the de-identification maths cleanly separated. The - (module)
closure also receives the raw (unwindowed) analysis frame. Accent neutralisation needs a time-domain view to track f0, because the FFT resolution at useful frame sizes is far too coarse for that, and handing over the frame that produced - (module)
the spectrum keeps the two perfectly aligned. Its newest `hop` samples are the tail. # In plain words Sound arrives as a long stream of numbers. To change a voice you have to look at it in terms of pitch and tone rather than raw numbers, - (module)
and this is the part that converts back and forth. It takes a short slice of sound, works out which frequencies are in it, hands that picture to the code that alters it, and turns the result back into sound. The slices overlap and are - (module)
faded together, so the joins cannot be heard. Everything else in the engine is written in terms of those pictures. This file is the door between the two ways of looking at the same thing. - struct StftEngine
Reusable streaming STFT engine (single channel). - impl StftEngine
- fn new
`n` must be even; `hop` must divide evenly for constant overlap-add (typical: hop = n/4). - fn latency_samples
End-to-end algorithmic latency (group delay) in samples. Empirically, and as the identity-reconstruction test asserts, the FIFO/overlap-add path delays the signal by exactly one frame (`n`), which is what the UI reports to the user. - fn latency_samples
(`self.latency = n - hop` is the separate *internal* FIFO offset used for indexing.) - fn process
Process `input` into `output` (equal length). `transform` is invoked once per analysed frame with the half-complex spectrum to rewrite in place and the raw analysis frame it came from (length `n`, newest samples last). - fn process_frame
Window, transform, hand the spectrum to `transform`, and overlap-add the result back into the output queue. Every buffer this touches was sized when the chain was built. It is called from the audio callback, so it allocates nothing and - fn process_frame
locks nothing. - mod tests
- fn identity_reconstructs_input
With an identity spectral transform the engine must reconstruct its input (delayed by the algorithmic latency) to high accuracy. This validates the windowing/overlap-add/normalisation maths. - fn output_is_finite
- fn callback_frame_matches_the_analysed_window
The frame handed to the closure must be the exact analysis window, so the pitch tracker stays aligned with the spectrum it accompanies.
crates/veilvoice-core/src/voices.rs
- (module)
Destination voices: several canonical registers instead of one. # What this is for By default every speaker VeilVoice processes comes out as **the same voice**, with one pitch register, one vocal-tract scale and one long-term spectrum. - (module)
That is the many-to-one mapping the whole project rests on, and for a single speaker it is exactly right. It is wrong for a conversation. Two people veiled into one indistinguishable voice produce a recording nobody can follow: the words - (module)
survive and the turn-taking does not, so a listener cannot tell a question from its answer. This module hands out a small table of **distinct destination voices**, so a recording with three people in it comes out with three voices in it. # - (module)
The security property, and how it survives The property that matters is that **the output voice is a function of the slot, not of the speaker**. Every input mapped onto slot 3 comes out as voice 3, whoever they were. The mapping is still - (module)
many-to-one, with infinitely many inputs per output, so there is still no inverse to compute. What changes is the number of buckets, from one to at most ten. This is why a voice is **never derived from the speaker**. Choosing a destination - (module)
by measuring the input is the obvious implementation, and the one that would sound most natural. It would make the output voice a function of the input voice, which is precisely the linkage the project exists to destroy. Slots are assigned - (module)
by turn order, and turn order is something the *user* supplies. # What a conversation leaks that a monologue does not Stated plainly, because it is a real cost and nobody should have to work it out for themselves: * **How many people were - (module)
talking.** Ten voices in the output means ten speakers in the input. * **Who spoke when, and for how long.** Turn-taking structure is preserved on purpose, because it is the thing that makes the result usable, and turn structure is - (module)
information about a conversation. Overlaps, interruptions, the length of each answer and the rhythm of the exchange all survive. * **Nothing about who they were.** The voiceprint of each speaker is destroyed exactly as thoroughly as in - (module)
single-speaker mode: the same phase discard, the same many-to-one normalisation onto the slot's canonical values. # A register has to land on a bin, and that is the whole constraint The first version of this table picked five registers - (module)
about 26 Hz apart on the reasoning that the just-noticeable difference for the fundamental of speech is around 8 Hz, so 26 Hz would be three times that. The reasoning was sound and the table was wrong, because it measured the wrong thing. - (module)
When accent neutralisation is on, [`crate::spectral`] does not resample the excitation. It **replaces** it with a harmonic comb at the canonical fundamental, quantised to the nearest whole FFT bin so that every comb line sits on a bin - (module)
centre and the frames overlap-add coherently. The rendered fundamental is therefore not the number in the table. It is ```text round(target_f0 / bin_hz) * bin_hz, bin_hz = sample_rate / frame_size ``` At the default 1024-point frame and 48 - (module)
kHz that spacing is **46.875 Hz**, so the five registers 105, 131, 157, 183 and 209 Hz render as 93.75, 140.625, 140.625, 187.5 and 187.5, which is three distinct pitches, not five. Two pairs of speakers would have shared a register with - (module)
nothing in the interface saying so. It was found by measuring the fundamental of an actual rendered file, not by reading the code, and the tests below now measure the same thing the ear would. So the registers are **bin-exact by - (module)
construction**: each is a whole number of bins at the default configuration. Inside the range where a resynthesised voice stays intelligible, roughly 90 to 240 Hz, below which the comb has too few harmonics under the vowels and above which - (module)
it stops being a speaking register, there are exactly four: | bin | rendered | |---:|---:| | 2 | 93.75 Hz | | 3 | 140.625 Hz | | 4 | 187.5 Hz | | 5 | 234.375 Hz | # Ten voices, from four registers and three vocal tracts The second axis is - (module)
the canonical vocal-tract scale, which is a continuous warp and is **not** quantised, so it is free to take values the ear can separate: 620, 760 and 900 Hz, each about 22 % from its neighbour, all inside the range where the vowels stay - (module)
natural. Four registers times three tracts is twelve, and [`MAX_VOICES`] ships ten of them. Twenty, which was asked for, is not available: it would need either registers a single bin apart at a frame size four times longer, which - (module)
quadruples the latency, or vocal tracts close enough to be heard as the same person on a different day. # If you change the frame size, check the table again The registers are exact at the *default* configuration. A caller who changes - (module)
[`crate::DeidConfig::frame_size`] or the sample rate moves the bin grid underneath them, and two registers can collide again. [`Voice::rendered_f0_hz`] reports what a given configuration will actually produce, and [`distinct_voices`] - (module)
counts how many of the ten survive it, so a front end can say "this frame size gives you six distinguishable voices" rather than handing out ten labels for six sounds. # In plain words The set of voices a recording can be turned into. By - (module)
default everyone comes out as the same one. That is on purpose: if every speaker sounds identical, there is nothing in the result that distinguishes one from another, and nothing to trace back. When a recording has several people in it - (module)
that becomes a problem, because a listener cannot follow who is who. So there is a small set of destination voices to hand out instead, chosen to be as far apart as the arithmetic allows, and a measured limit on how many of them can - (module)
genuinely be told apart by ear. - const MAX_VOICES
How many distinct destination voices this engine hands out. Ten of the twelve the table can express. See the module documentation for why it is not twenty. - struct Voice
One destination voice: the canonical values every speaker in this slot is mapped onto. - impl Voice
- fn applied_to
Apply this voice to an [`AccentConfig`]. Only the three canonical targets are replaced. The *strengths*, meaning how hard the neutraliser pushes toward them, are left alone, because they are the user's setting and a slot is a destination, - fn applied_to
not a policy about how firmly to arrive at it. - fn rendered_f0_hz
The fundamental this voice will **actually** be rendered at, under `config`. The voiced excitation is a harmonic comb snapped to the FFT bin grid, so the rendered fundamental is the requested one rounded to the nearest whole bin. This is - fn rendered_f0_hz
the number to compare two voices by, and the number to show anybody who asks what a slot sounds like. - fn checked
Whether this voice is inside the range the engine can render usefully. Checked rather than clamped: a caller who built a voice out of range meant something, and silently moving it would give them a different speaker from the one they asked - fn checked
for without saying so. - fn describe
A short label for an interface: "low register, narrow tract". Describes the *destination*, never the speaker. There is deliberately no vocabulary here for who somebody was: "man", "woman", "child", "older" are all statements about an input - fn describe
this crate has just finished destroying, and a label that reintroduced one would undo the point. The hertz figure quoted is the one that will be **rendered** at the default configuration, not the one requested, so the label and the ear - fn describe
agree. - const F0_MIN_HZ
The lowest fundamental a resynthesised voice stays intelligible at. - const F0_MAX_HZ
The highest fundamental that still reads as a speaking register. - const CENTROID_MIN_HZ
The narrowest canonical vocal tract offered. - const CENTROID_MAX_HZ
The widest canonical vocal tract offered. - const TILT_MIN_DB_OCT
The steepest permitted long-term slope, in dB per octave. - const TILT_MAX_DB_OCT
The flattest permitted long-term slope, in dB per octave. - fn bin_hz
The FFT bin spacing of a configuration, in hertz. The grid every canonical register is snapped to. Public because a front end that lets somebody change the frame size needs to be able to explain what changed about the voices. - const REGISTERS_HZ
The four registers, each a whole number of bins at the default configuration: bins 2, 3, 4 and 5 of a 1024-point frame at 48 kHz. Written out rather than computed from the default, so that changing the default frame size makes a **test** - const REGISTERS_HZ
fail rather than silently moving every voice in every recording anybody has already made. - const TRACTS
The three vocal-tract scales, about 22 % apart. Not quantised, because the warp is continuous, so these render as asked. - const TABLE
The ten destination voices, in the order they are handed out. The order is chosen, not incidental. Slot 0 and slot 1 are the two furthest apart in both dimensions, because a **two-person conversation is the common case** and the two people - const TABLE
in it should be the easiest pair in the table to tell apart. The table then works inward, so it degrades gracefully as more speakers are added rather than saving its clearest contrasts for a tenth speaker who is usually not there. Two of - const TABLE
the twelve combinations are unused, which is slack rather than a stretch: nothing here is reaching for a tenth voice it cannot really make. - fn voice
The destination voice for slot `index`. Wraps rather than failing past [`MAX_VOICES`]: an eleventh speaker gets the first voice again. That is a real collision, with two people sharing one output voice, and it is why [`MAX_VOICES`] is - fn voice
stated and why a front end should refuse rather than rely on this. Wrapping is here so the function is total, not because reusing a voice is acceptable. - fn all
Every destination voice, in the order they are handed out. - fn separation
How far apart two voices are, as the **larger** of their two separations. Both axes are expressed as a ratio, because hearing is ratio-based on both: a 20 Hz pitch difference is enormous at 90 Hz and inaudible at 400, and the same is true - fn separation
of a vocal-tract scale. # Why the larger and not the smaller The first version of this took the *smaller*, reasoning that two voices are only as separable as their closest resemblance. Measuring it showed that to be backwards. Slots 0 and - fn separation
4 have exactly the same rendered pitch and vocal tracts 45 % apart -- one sounds like a much larger person than the other, and nobody would confuse them -- and the minimum called them **identical**, because one axis matched. Taking the - fn separation
minimum reported that three voices were already indistinguishable, which is plainly false if you listen. A listener separates two voices by whichever cue is strongest. Two voices are confusable only when they are close on *both* axes, - fn separation
which is what the maximum expresses. `1.0` means identical on both axes. `1.19` means the stronger axis differs by 19 %, which is three semitones of pitch. - fn ratio
The larger of two numbers over the smaller, so the answer does not depend on which way round they were given. Anything that is not a positive finite number answers 1.0, which reads as "no difference" and keeps a bad input from being - fn ratio
reported as a large one. - const CLEAR_SEPARATION
The separation below which two voices should not be handed to two people. **Three semitones, a ratio of 1.19.** A semitone is about 6 % and is audible when two sounds are played back to back for comparison. That is not the task here. The - const CLEAR_SEPARATION
task is following a conversation: hearing one voice, then a different one thirty seconds later, and knowing without being told that the speaker changed. That needs a margin, not a threshold, and three semitones is the smallest interval - const CLEAR_SEPARATION
that is unmistakable rather than merely detectable. Deliberately conservative, because being wrong in the other direction is worse. A group set up with two voices the listener cannot separate produces a recording in which two people sound - const CLEAR_SEPARATION
like one, which is not a privacy failure but is a failure of the thing the feature is *for*, and it is only discovered after the recording exists. - fn clear_voices
How many voices can be handed out before two of them are too alike. Slots are given out in table order, so this asks: taking them one at a time, at what point does a new voice come within [`CLEAR_SEPARATION`] of one already given out? - fn clear_voices
Everything up to that point is safe to use. This is a stricter question than [`distinct_voices`], which only asks whether two voices are *different*. Different is not the same as tellable apart, and a table of ten technically-different - fn clear_voices
voices can still contain a pair nobody can separate by ear. - fn closest_pair
The closest pair among the first `count` voices, as a ratio. For a front end that wants to say *how* clear a given group size is rather than only whether it passed. `1.0` for fewer than two voices, since one voice has nothing to be - fn closest_pair
confused with. - fn distinct_voices
How many of the ten are still distinguishable under `config`. Two voices count as the same when they would be **rendered** with the same fundamental and the same vocal tract. At the default configuration the answer is [`MAX_VOICES`]; at a - fn distinct_voices
shorter frame size it is fewer, because the bin grid coarsens and registers collapse onto each other. A front end that lets somebody change the frame size should call this and say what it returns. Handing out ten labels for six sounds is - fn distinct_voices
the failure this function exists to make visible. - mod tests
- fn default_config
- fn eight_voices_are_clearly_separable_and_the_ninth_is_not
**Eight**, at the default configuration. Measured, not chosen. The table holds ten and all ten are *different*; eight is how many are far enough apart that a listener following a conversation can tell which is which. Adding the ninth - fn eight_voices_are_clearly_separable_and_the_ninth_is_not
brings the closest pair to 1.1842 -- slots 4 and 8, which have exactly the same rendered pitch and vocal tracts only 18 % apart -- and that is under the three-semitone floor. This number is the one a front end should cap a group at. If it - fn eight_voices_are_clearly_separable_and_the_ninth_is_not
moves, something about the table or the frame size moved with it, and the front end's limit has to move too. - fn distinct_is_a_weaker_test_than_clear
Being *different* and being *tellable apart* are different questions, and this is the gap between them: ten against eight. - fn separation_is_symmetric_and_one_against_itself
The separation of a voice with itself is 1.0, and the measure is symmetric. Both are obvious and both would be silently wrong if the ratio helper picked up a sign. - fn two_voices_differing_on_one_axis_only_are_still_separable
The first version of this took the smaller of the two axes, which reported three voices as already indistinguishable. Slots 0 and 4 are why that was wrong: identical pitch, vocal tracts 45 % apart -- one sounds like a much larger person, - fn two_voices_differing_on_one_axis_only_are_still_separable
and nobody would confuse them. - fn a_group_too_small_to_confuse_reports_no_confusion
One voice has nothing to be confused with, and no voices is not an error. Both are reachable from a front end with an empty group. - fn a_coarser_frame_grid_reduces_the_clear_count
A coarser frame grid collapses registers onto each other, and the clear count has to fall with it rather than keep promising eight. - fn there_are_exactly_ten_and_they_are_all_different_as_written
- fn all_ten_are_still_distinct_after_the_bin_grid_has_had_them
**The test the first version of this table did not have.** Being different as written is not enough: the voiced excitation is a comb snapped to the FFT bin grid, so two registers a few hertz apart can render as the same pitch. The first - fn all_ten_are_still_distinct_after_the_bin_grid_has_had_them
table had five registers that rendered as three, and two pairs of speakers would have shared a voice with nothing saying so. This compares what comes *out*. - fn every_register_is_bin_exact_at_the_default_configuration
Each register must be a whole number of bins at the default configuration, so what is asked for is what is rendered. If somebody changes the default frame size, this is what tells them the voice table needs choosing again. - fn the_registers_that_were_wrong_are_still_wrong_for_the_same_reason
The measurement that found the bug, kept as a test. These are the registers the first table shipped, and what they actually rendered as. - fn a_shorter_frame_reports_fewer_distinguishable_voices
A coarser grid must be reported as fewer voices rather than silently handing out ten labels for a smaller number of sounds. - fn the_vocal_tracts_are_far_apart_and_are_not_quantised
The vocal tract is a continuous warp, not a quantised one, so its three values must survive any frame size. - fn the_first_two_slots_are_the_easiest_pair_to_tell_apart
A two-person conversation is the common case, so slots 0 and 1 must be the furthest apart in the table. - fn every_shipped_voice_is_within_range
Every voice in the table must be one the engine will accept. - fn a_voice_out_of_range_is_refused_rather_than_moved
- fn applying_a_voice_changes_only_the_destination
Applying a voice replaces the destination and nothing else. The strengths are the user's setting. - fn a_slot_is_the_same_voice_every_time
The slot is a function of the index and of nothing else. If this ever takes anything derived from the input, the output voice becomes a function of the input voice and the whole exercise is undone. - fn asking_past_the_table_wraps_onto_a_voice_already_in_use
Past the table it wraps rather than panicking, and the collision is real: slot 10 is slot 0 again. - fn no_label_says_anything_about_the_original_speaker
A label describes where the voice arrived, never who was speaking. - fn a_label_quotes_the_rendered_fundamental
A label must quote the fundamental that will be heard, not the one that was asked for -- otherwise the interface and the ear disagree. - fn every_label_is_distinct
- fn an_impossible_configuration_reports_no_bin_spacing_rather_than_dividing_by_zero
crates/veilvoice-core/src/window.rs
- (module)
Analysis and synthesis windowing, and the one constant that keeps overlap-add honest. Two functions, both small, both easy to get subtly wrong in ways that do not look wrong. # Why the *periodic* Hann window There are two Hann windows and - (module)
they differ by one sample. The **symmetric** variant (`cos(2*pi*i / (n-1))`) is the right one for designing filters, and it is what most textbook snippets show. The **periodic** variant (`cos(2*pi*i / n)`, which is what [`hann`] returns) - (module)
is the right one for an STFT, because it is the one that tiles: shifted copies of it sum to a constant, and the symmetric one does not quite. Using the wrong one does not produce an obvious failure. It produces a faint periodic amplitude - (module)
ripple at the hop rate -- a quiet buzz that sounds like a codec artefact rather than like a bug, and that nothing in a test suite notices unless the test is looking for exactly it. # Why the gain is computed rather than assumed VeilVoice - (module)
windows **twice**: once on analysis and once on synthesis. That is deliberate -- a synthesis window suppresses the discontinuities that modifying a spectrum introduces at frame edges, and this crate modifies every spectrum it touches. The - (module)
cost is that the reconstruction gain is no longer the familiar Constant-Overlap-Add value, it is `sum(w^2) / hop`. [`ola_gain`] returns the reciprocal of that, so a caller multiplies rather than divides -- one multiply per sample in a hot - (module)
loop instead of one divide. It is derived from the window actually in use rather than hardcoded for a particular size and overlap, so changing either cannot silently change the output level. The zero-length and single-sample cases are - (module)
handled explicitly, and the degenerate `sum(w^2) == 0` returns unity rather than infinity: this is a gain that gets multiplied into every output sample, and one non-finite value entering an engine with persistent state is permanent. # In - (module)
plain words Two small pieces of arithmetic that decide how the overlapping slices of sound are faded in and out. They are short and they are easy to get subtly wrong in a way that does not look wrong: the sound still comes out, and it - (module)
quietly has a faint hum through it, or the volume ripples. So both are written here once, with the reason, and checked. - fn hann
Periodic Hann window of length `n` (the correct variant for STFT overlap-add, as opposed to the symmetric variant used for filter design). `w[i] = 0.5 - 0.5 * cos(2*pi*i / n)` - fn ola_gain
Overlap-add normalisation for a window applied on **both** analysis and synthesis at the given `hop`. For a Constant-Overlap-Add window the steady-state reconstruction gain is `sum(w^2) / hop`; dividing synthesis frames by it yields unity - fn ola_gain
gain. We return the reciprocal (`hop / sum(w^2)`) so callers can multiply. - mod tests
- fn hann_endpoints_are_zero
- fn ola_gain_positive_and_finite
crates/veilvoice-crypto/examples/seal_and_open.rs
- fn main
crates/veilvoice-crypto/src/aead.rs
- (module)
Authenticated encryption with XChaCha20-Poly1305. XChaCha20 rather than plain ChaCha20 because its 192-bit nonce can be drawn at random with no practical collision risk. The 96-bit nonce of RFC 8439 ChaCha20-Poly1305 requires a counter and - (module)
careful state tracking to stay unique across runs; getting that wrong is catastrophic, and a random 192-bit nonce removes the failure mode entirely. Every call is authenticated over associated data as well as the plaintext, which is how - (module)
the container header in [`crate::container`] is bound to its ciphertext: flipping a bit in the stored KDF parameters produces a decryption failure rather than a silently different key. # In plain words This is the encryption itself: it - (module)
turns a recording into something unreadable, and it can tell whether the result was tampered with afterwards. Those two jobs go together on purpose. Encryption on its own hides what a file says but does not stop somebody changing it, and a - (module)
changed file that still decrypts into something is a worse outcome than one that refuses to open. Here, any alteration at all means it will not open, and says so. - const NONCE_LEN
Nonce length for XChaCha20-Poly1305, in bytes. - const TAG_LEN
Poly1305 authentication tag length, in bytes. - fn random_nonce
Draw a fresh random nonce from the OS CSPRNG. - fn cipher
The cipher for this key, refusing a key that is not 32 bytes. The length is checked here rather than at each call site, so there is one place a wrong-sized key can reach the construction and it says no. - fn seal
Encrypt `plaintext`, authenticating `aad` alongside it. Returns ciphertext with the 16-byte tag appended. - fn open
Decrypt and verify. Any tampering with the ciphertext, the tag, the nonce or `aad` fails here rather than returning wrong plaintext. - fn open_secret
Decrypt and verify **into protected memory**, never into an ordinary `Vec`. # Why this exists beside `open` `open` asks the `aead` crate for the plaintext and gets back a plain `Vec<u8>`. That vector is ordinary pageable heap: the kernel - fn open_secret
may write it to swap, and nothing wipes it until somebody copies it somewhere safer and wipes it by hand. For a passphrase-sized secret that window is small. For a recording it is the whole recording, in the clear, in memory the operating - fn open_secret
system is free to put on disk, for as long as it takes to copy several megabytes. The Studio vault exists precisely so that a recording is never anywhere unprotected, and it was decrypting through `open`, so the guarantee had a hole in it - fn open_secret
the size of the file. This closes it: the buffer is a [`Secret`] from the start, so it is page-locked where the operating system allows and wiped on drop, and the ciphertext is decrypted **in place** inside it. Nothing is copied afterwards - fn open_secret
because there is nothing to copy from. A failed tag check drops the buffer, so the partially decrypted bytes are wiped rather than returned or left lying about. `open` is kept for the callers whose plaintext is small and immediately - fn open_secret
parsed, where a `Vec` is the honest shape and the protection this adds would be a `Secret` around four bytes of header. # In plain words The same decryption, writing the result straight into the protected memory rather than into ordinary - fn open_secret
memory and moving it afterwards. - mod tests
- fn key
- fn round_trip_recovers_the_plaintext
- fn empty_plaintext_round_trips
- fn decrypting_into_protected_memory_gives_the_same_plaintext
- fn protected_decryption_refuses_the_same_things_the_other_one_does
- fn an_empty_plaintext_round_trips_through_protected_memory
- fn tampering_with_the_ciphertext_is_detected
- fn tampering_with_the_tag_is_detected
- fn changing_the_associated_data_is_detected
The property the container format depends on: header bytes are bound to the ciphertext, so editing them cannot go unnoticed. - fn wrong_key_or_nonce_fails
- fn nonces_do_not_repeat
- fn wrong_key_length_is_rejected
- fn same_plaintext_encrypts_differently_each_time
crates/veilvoice-crypto/src/amnesia.rs
- (module)
Amnesic secret storage: page-locked, zeroized, and never printed. # No `unsafe`, even here Locking pages out of the swap file is a raw syscall, `VirtualLock` on Windows and `mlock` on Unix, and it is the one place a project like this - (module)
usually has to reach for `unsafe`. It does not here: the `region` crate exposes a safe, cross-platform wrapper. VeilVoice therefore contains **no `unsafe` code at all**, and every crate keeps `#![forbid(unsafe_code)]`. # What locking does - (module)
and does not buy Locking keeps key material out of the page file, so a secret cannot be recovered later by reading swap off the disk. It does **not** protect against an attacker who can already read this process's memory, and it does not - (module)
survive hibernation, which writes RAM to disk wholesale. Locking can also fail outright, because unprivileged Linux users get a small `RLIMIT_MEMLOCK` budget, so it is best-effort hardening and never a precondition. [`Secret::is_locked`] - (module)
reports what actually happened, so the UI can tell the user the truth rather than imply a guarantee that was not obtained. Zeroization, by contrast, always happens. # Why each secret owns whole pages Locking has *page* granularity, not - (module)
byte granularity. If two secrets share a 4 KiB page, both lock it, and the first one dropped unlocks the page out from under the second, which is still live and now swappable. Each [`Secret`] therefore over-allocates and locks a - (module)
page-aligned, page-sized span lying entirely within its own allocation. No other allocation can occupy those bytes, so none can be inside those pages: lock and unlock are exact, and locking a secret never drags unrelated data into physical - (module)
memory alongside it. # Why the lock is not held by an RAII guard `region::lock` hands back a guard that unlocks on drop, but its destructor **panics** if unlocking fails, and unlocking can fail for reasons that are nobody's fault: Windows - (module)
does not reference-count `VirtualLock` and may drop pages from a process working set on its own, after which `VirtualUnlock` reports `ERROR_NOT_LOCKED`. A type whose entire job is holding key material must not abort the process while being - (module)
dropped. The lock is released explicitly instead, and a failure to unlock is ignored: it leaves pages pinned, which is harmless, rather than unwinding out of a destructor. # In plain words A place to hold a passphrase or a key while it is - (module)
being used, which tries hard to forget it afterwards. It asks the operating system not to write that memory out to disk, wipes it as soon as it is finished with, and refuses to print itself. That last one matters more than it sounds: - (module)
secrets most often escape not by being stolen but by appearing in an error message or a log that somebody later sends on. Comparisons take the same amount of time whether or not they match, so nothing is given away by how long an answer - (module)
took. - struct Secret
A byte buffer holding key material. Page-locked where the OS allows it, zeroized on drop, compared in constant time, and deliberately opaque to `Debug` so a secret can never reach a log line by accident. - fn lock_pages
Lock `span` bytes at `at` out of swap, answering whether it happened. # Why this is a function rather than the call it wraps Under Miri it does nothing and answers false. Miri interprets the program rather than running it, and it cannot - fn lock_pages
call a foreign function it has no shim for: `mlock` is one of those, so the first `Secret` any test built ended the run with "unsupported operation", and the whole crate was therefore unexaminable by the one tool this project has for - fn lock_pages
finding undefined behaviour. That is a large amount of parsing, framing and encoding left unchecked in exchange for exercising one system call that Miri could not have executed anyway. Nothing is weakened by it. Locking has always been - fn lock_pages
best effort here, as the module note says at length: a machine with no lock budget, a platform with no such call, and an allocation that will not align all take this same path already, `is_locked` answers false, and correctness has never - fn lock_pages
depended on the answer. The interpreter is one more environment where the lock does not happen, and it is the only one that is a build rather than a machine. The zeroing is untouched and is the part that matters for the guarantee this type - fn lock_pages
makes, so Miri still sees every write and every drop. - fn unlock_pages
Release a lock taken by [`lock_pages`]. Never reached under Miri, because nothing is locked there for `Drop` to release. - impl Secret
- fn new
Wrap `bytes`, taking ownership and wiping the caller's copy. - fn zeroed
Allocate `len` zero bytes, ready to be filled in place. - fn random
Fill `len` bytes from the operating-system CSPRNG. - fn is_locked
Whether the pages were successfully locked out of swap. False is not an error, as the module documentation says, but it is worth surfacing to the user rather than claiming a guarantee that was not actually obtained. - fn len
Length in bytes. - fn is_empty
Whether the secret is empty. - fn expose
Borrow the raw bytes. Callers must not copy them into unprotected storage. - fn expose_mut
Borrow mutably, for filling in place. - fn wipe
Wipe the contents now, before the value goes out of scope. Zeroizes the slice rather than the `Vec`: `Zeroize for Vec` also truncates to length zero, which would throw away the allocation. - impl Drop for Secret
- fn drop
- impl Clone for Secret
- fn clone
- impl PartialEq for Secret
Constant-time equality: comparison never leaks how much of the secret matched through timing. - fn eq
- impl Eq for Secret {}
- impl std::fmt::Debug for Secret
Deliberately opaque, so a secret cannot reach a log line through `{:?}`. - fn fmt
- mod tests
- fn the_page_lock_is_taken_in_exactly_one_place
The page lock goes through `lock_pages`, so the interpreter can be told about it once. A direct call elsewhere leaves this crate unexaminable. - fn locking_or_not_changes_nothing_about_what_is_stored
A secret holds and wipes the same bytes whether or not the pages locked. - fn new_wipes_the_callers_copy
- fn random_is_not_all_zeroes_and_differs_each_time
- fn equality_is_value_based
- fn debug_never_reveals_contents
- fn wipe_clears_in_place
- fn clone_is_independent
- fn locking_is_reported_and_never_panics
Locking is best-effort, so this asserts only that it is reported and never panics, not that it succeeded, which depends on privileges. - fn many_small_secrets_can_coexist_and_drop_in_any_order
Regression: locking has page granularity, and `region`'s lock guard panics if unlocking fails. Between them, sharing a page across secrets could unlock a live secret's memory or abort from inside a `Drop`. Creating and dropping many small - fn many_small_secrets_can_coexist_and_drop_in_any_order
secrets out of order must be boring. - fn locked_span_is_page_aligned
The locked span must be page aligned and inside the secret's own allocation, so no other allocation can share a locked page with it. - fn zero_length_is_handled
- fn contents_survive_the_alignment_offset
crates/veilvoice-crypto/src/container.rs
- (module)
The `.veil` encrypted container format. A single self-describing blob: everything needed to decrypt except the password or private key travels with the ciphertext, so a file stays readable after the default KDF costs are raised. ```text - (module)
offset size field 0 8 magic "VEILVOX1" 8 1 format version (1) 9 1 mode: 1 = password, 2 = hybrid public key 10 2 reserved, must be zero 12 4 Argon2id m_cost (KiB, little-endian) password mode only 16 4 Argon2id t_cost password mode only 20 - (module)
4 Argon2id p_cost password mode only 24 16 Argon2id salt password mode only 40 24 XChaCha20 nonce 64 4 encapsulation length (little-endian) 68 N encapsulation hybrid mode only 68+N … ciphertext ‖ Poly1305 tag ``` **The entire header is the - (module)
AEAD's associated data.** Editing any byte of it by downgrading the KDF cost, swapping the mode or corrupting the salt, makes decryption fail rather than silently changing behaviour. Unused fields are written as zero and are still - (module)
authenticated, so they cannot be used as a covert channel or a downgrade vector. # In plain words The shape of an encrypted `.veil` file. Everything needed to open it travels inside it, apart from the passphrase or the key. That means a - (module)
file made today still opens years later, even after VeilVoice has changed how hard it makes the encryption by default: the file remembers what it was made with. The part at the front that describes the file is itself covered by the tamper - (module)
check, so it cannot be edited to make the rest open more easily. - const MAGIC
Magic bytes at the start of every container. - const FORMAT_VERSION
Format version this build writes. - const HEADER_LEN
Fixed header length in bytes, before any encapsulation. - const MODE_PASSWORD
- const MODE_HYBRID
- enum Mode
How a container is locked. - struct Header
A parsed container header. - impl Header
- fn to_bytes
Serialise exactly as it appears on disk. This byte string is also the AEAD associated data. - fn parse
Parse a header, returning it with the offset at which ciphertext starts. - fn veil_path
The conventional path of the sealed form of `path`. `.veil` is *appended* rather than substituted, so `recording.veiled.wav` becomes `recording.veiled.wav.veil` and the original name, including what kind of file it is, survives decryption - fn veil_path
without being guessed at. # Except when it is already there A path that already ends in `.veil` is returned unchanged. This is not tidiness: `-o` is a name the user chose, and ```text veilvoice anonymise interview.wav -o veiled.veil - fn veil_path
--encrypt-to key.pub ``` wrote `veiled.veil.veil`, reported that name back, and left the person with a file that is not the one they asked for. Found by recording the command for the website's demonstration and reading what it printed, - fn veil_path
which is the only way this was ever going to be noticed: every test of this function passed a path that does not end in `.veil`, because that is the case the rule above describes. - fn seal_with_password
Encrypt `plaintext` under a password. - fn seal_to_public_key
Encrypt `plaintext` to a recipient's hybrid public key. - fn finish
Seal `plaintext` under `header` and return the whole file. The header is authenticated as associated data and then written in front of the ciphertext, so the parameters a reader needs in order to open the file are the same bytes the tag - fn finish
was computed over. Editing any of them breaks the tag rather than changing how the file is opened. - fn open_with_password
Decrypt a password-locked container. - fn open_with_password_within
Decrypt a password-locked container, refusing one that declares a memory cost above `max_m_cost`. The cost travels with the file so that a container written years ago still opens after the defaults are raised. The price is that a *hostile* - fn open_with_password_within
file can declare a legitimate-but-large cost and make itself slow and expensive to open. See F-3's residual in `docs/AUDIT.md`. When a person chose the file and can stop waiting, that is an acceptable price and [`open_with_password`] pays - fn open_with_password_within
it. When nothing human is present, such as a batch job, a service or anything handed files it did not choose, pass [`kdf::KdfParams::UNATTENDED_MAX_M_COST`] here and get [`Error::KdfCostRefused`] instead of the memory. - fn open_with_secret_key
Decrypt a container addressed to `recipient`. - mod tests
- fn weak
- const MSG
- fn raw_header
A header built byte by byte, so a test can say exactly what is wrong with it. `Header::to_bytes` cannot express the malformed cases: it takes the encapsulation's length from the vector, so it can never produce a password-mode header that - fn raw_header
claims one. These bytes can. - fn the_length_tests_in_parse_are_exact_at_the_boundary
The boundaries of every length test in `parse`, each from both sides. **Round thirty-three.** Mutation testing replaced `<` with `<=` in both length tests, and `!=` with `false` in both mode guards, and every one of those five mutants - fn the_length_tests_in_parse_are_exact_at_the_boundary
survived the suite: the parser was tested for what it accepts and for input that is wildly wrong, and not at the one byte where an off-by-one lives. These are the five cases that kill them. - fn password_round_trip
- fn wrong_password_fails
- fn hybrid_round_trip
- fn a_different_key_cannot_open_it
- fn downgrading_the_kdf_cost_is_detected
The central property of the format: the header is authenticated, so an attacker cannot downgrade the KDF cost to make cracking cheap. Both downgrade routes are covered. A cost Argon2 still accepts derives a different key and fails the - fn downgrading_the_kdf_cost_is_detected
AEAD; a cost below Argon2's own minimum is refused outright. Either way the file does not open, which is the property that matters. - fn tampering_with_the_salt_or_nonce_is_detected
- fn tampering_with_the_ciphertext_is_detected
- fn modes_are_not_interchangeable
- fn malformed_containers_are_rejected_cleanly
- fn header_round_trips_exactly
- fn empty_payload_round_trips
- fn veil_paths_append_and_keep_the_original_extension
Appending rather than replacing keeps the original extension, so an opened container is still recognisably a WAV. - fn a_path_that_is_already_sealed_is_left_alone
- fn an_unattended_caller_can_refuse_an_expensive_container
The cost ceiling for a caller with nobody watching. A hostile container can declare a legal-but-expensive cost; an unattended caller must be able to decline before the memory is asked for, not after. - const _
The published unattended ceiling has to be usable: comfortably above this crate's own default, comfortably below the absurd-value cap. Checked at compile time, so tightening either constant past the other fails the build rather than a test - const _
run. - fn the_unattended_ceiling_admits_this_crates_own_default
- fn two_seals_of_the_same_input_differ
crates/veilvoice-crypto/src/decoy.rs
- (module)
A second passphrase that opens a different, empty VeilVoice. # What this is for, and what it is honestly worth Somebody can be made to unlock a program. A decoy passphrase gives them something true to say: it opens VeilVoice, the - (module)
application works, and there is nothing in it. **It does not give you deniability, and anyone who tells you otherwise is selling something.** VeilVoice is open source. This file is published. An adversary who knows what they are looking at - (module)
knows the feature exists, can read exactly how it works, and can simply ask for the other passphrase. What a decoy buys is a way to *comply* without revealing; what it does not buy is any argument that there is nothing more to reveal. - (module)
[`SCOPE`] says that in the words a front end must show, and it is the most important thing this crate produces. # The destructive duress passphrase is deliberately not here The roadmap asked for two things: a decoy, and a duress passphrase - (module)
that destroys data. The second is not shipped, and [`WHY_NO_DESTRUCTION`] is the reason in full. In short: VeilVoice cannot promise a file is gone. On flash storage a write does not overwrite. The controller maps a logical block to a new - (module)
physical page and leaves the old one holding the data until it is garbage-collected, which may be minutes or may be never, and no program running as an ordinary user can reach it. This project already documents that about its own - (module)
secure-erase feature and refuses to overstate it there. A destructive duress passphrase would be believed in exactly the situation where being wrong costs the most. Somebody types it expecting the recordings to be gone; the ciphertext is - (module)
still in unmapped pages; and they then behave as though it is not. **A control people rely on and that does not work is worse than no control at all.** So there is not one. # Typing the wrong one by mistake The other failure the roadmap - (module)
named, and the reason this shape was chosen. Because the decoy destroys nothing, typing it by accident costs a relaunch and nothing else. There is no state to recover and no decision that cannot be taken back. That is not a happy accident; - (module)
it is why the destructive design was rejected rather than made safer. # Both passphrases are checked the same way Which one matched must not be visible in how long the check took. Both are derived with the same Argon2id parameters and - (module)
compared in constant time, and **both are always derived** even when the first one matches: returning early would make a real passphrase measurably faster than a decoy, which tells an observer with a stopwatch which of the two they just - (module)
watched somebody type. # In plain words You can set a second passphrase. Typing it opens VeilVoice normally, except that it is empty: no recordings, no projects, no history. It is there for the situation where somebody is standing over you - (module)
asking you to unlock your computer. Two honest warnings, and please read them. It does **not** hide the fact that a second passphrase might exist. This program's source code is public and this feature is described in it, so anybody who - (module)
recognises VeilVoice can ask you for the other one. It buys you a way to hand something over. It does not buy you an argument. And there is **no passphrase that destroys your recordings**, deliberately. On modern storage, deleting a file - (module)
does not reliably remove it, so a feature that claimed to would be lying to you at the worst possible moment. - enum Opened
Which passphrase was given. - struct Pair
A pair of passphrase verifiers, checked together. Holds no passphrase and no key: only the Argon2id output of each, and the salt each was derived with. - struct Verifier
One passphrase's stored form. - impl Verifier
- fn create
- fn matches
Derive and compare. Always does the full derivation. - fn constant_time_eq
Compare without letting the time taken depend on where they differ. - const LEAST_DIFFERENCE
How similar two passphrases may be before the pair is refused. A decoy that differs from the real passphrase by one character is not a decoy: somebody watching a keyboard learns both at once, and somebody typing under pressure gives away - const LEAST_DIFFERENCE
the wrong one. - enum Refused
Why a pair was refused. - impl std::fmt::Display for Refused
- fn fmt
- impl std::error::Error for Refused {}
- fn differences
How many positions two passphrases differ in. Length difference counts, so `"hunter2"` against `"hunter2222"` is three apart rather than zero. - impl Pair
- fn only_real
A real passphrase with no decoy. This is the ordinary case. - fn with_decoy
A real passphrase and a decoy. Refuses a decoy too close to the real one. See [`Refused::TooAlike`]: this is the one check that decides whether the feature is worth having. - fn has_decoy
Whether a decoy is set at all. - fn open
Which passphrase this is. **Both are always derived**, even when the first matches. Returning early would make the real passphrase measurably faster than the decoy, and somebody with a stopwatch would learn which of the two they had just - fn open
watched being typed. Argon2id at the configured cost takes long enough for that difference to be obvious. - const SCOPE
What a decoy is worth, in the words a front end must show. - const WHY_NO_DESTRUCTION
Why no passphrase destroys anything, and why that is the honest choice. - mod tests
- fn params
- fn the_real_passphrase_opens_the_real_thing
- fn the_decoy_opens_the_empty_one
- fn a_decoy_too_close_to_the_real_one_is_refused
**The check that decides whether the feature is worth having.** A decoy one character from the real passphrase is not a decoy. - fn a_longer_version_of_the_same_passphrase_is_still_too_close
Length counts as difference, or "hunter2" and "hunter2222" would pass. - fn matching_the_real_one_still_derives_the_decoy
**Both are always derived.** Returning as soon as the real one matches would make it measurably faster than the decoy, and Argon2id takes long enough that somebody with a stopwatch would see it. - fn a_pair_with_no_decoy_still_does_the_second_derivation
Having a decoy and not having one must take the same time, or an observer learns that this copy has one configured. - fn nothing_here_destroys_anything
Nothing in this crate deletes anything. The argument for that is only as good as the code continuing not to. - fn the_scope_note_refuses_to_promise_deniability
**The most important thing this crate outputs.** A reader who takes a decoy for deniability is worse off than one who never had it. - fn the_refusal_to_destroy_explains_the_storage_and_not_only_the_choice
The refusal to ship destruction states the mechanism, not just the conclusion, because the conclusion alone reads as laziness. - fn comparing_is_constant_time_and_length_safe
crates/veilvoice-crypto/src/hoard.rs
- (module)
The obfuscated program folder: what VeilVoice keeps on disk, under names that mean nothing and beside files that hold nothing. # What this buys, stated before anything else Somebody who opens VeilVoice's folder without the app-lock - (module)
passphrase sees a few dozen files with names like `k7Qa1mXv9pLd0RtYbN3zHwFe`, all of them full of bytes that look random, all of them one of a handful of sizes. They cannot tell which files hold settings, which hold measurements, which - (module)
hold anything at all, and which are junk this module wrote precisely so that the question has no answer from outside. That is the whole claim. It is worth having and it is smaller than it sounds, so here is the other half, in the same - (module)
breath: - **It does not hide that you use VeilVoice.** The folder is called `veilvoice`, the lock file sits in it under its own name, and the application is on disk. Anybody looking knows. - **It does not hide how much you have.** File - (module)
count and the bucket sizes are visible. Decoys blur that number; they do not erase it. - **It is not protection from someone who has your passphrase**, and it is not protection while the application is open and unlocked. At that moment - (module)
everything here is readable, because it has to be. - **It does not stop deletion.** Anybody who can read this folder can empty it. What they cannot do is empty it *quietly*: see the roster below. - **Somebody who knows VeilVoice knows what - (module)
these files are.** The format is public, this file is the specification, and a forensic examiner who recognises it will recognise it here. Obfuscation is not steganography and this module does not pretend otherwise. What it does buy is the - (module)
thing the app lock could not previously offer: a reason to exist beyond a password prompt. Before this, the lock verified a passphrase and guarded a window; the files behind it sat in the clear under their own names, and deleting the lock - (module)
file removed the whole obstacle. Now the passphrase derives the key that names and opens these records, so deleting the lock does not reveal them -- it destroys the only copy of the salt they were derived through, and takes them with it. - (module)
That is a real change in what the lock is worth, and also a real way to lose your data, which is why [`crate::lock`] keeps a second copy and the interface says so. # How a record is found Every record has a *logical* name that only the - (module)
program uses: `settings`, `measured`, `tour`. The file it lives in is named ```text base64url(weave(HMAC-SHA256(store_key, "veilvoice/hoard/name" || logical)[..18])) ``` where `weave` is one of a dozen byte-level encodings chosen from the - (module)
name's own bytes, so it is stable across launches. The result is twenty-four characters of base64 with no padding and no extension. Only length-preserving encodings are allowed there, and that restriction is load-bearing: a name that came - (module)
out longer or shorter would announce which encoding produced it, and would separate records from decoys at a glance. Eighteen bytes rather than a round sixteen so the encoding comes out exact: twenty-four characters with nothing to pad, - (module)
which is one less thing to tell a name apart from a decoy. The derivation is deterministic, so the program does not search: it computes the name it wants and opens that file. This is what makes the selection *cryptographic* rather than a - (module)
lookup table. There is no index mapping `settings` to a filename, because an index is exactly the thing an attacker would want. Without the key there is no way to run the derivation, and with the key there is no need to store it. # What is - (module)
inside one ```text [24-byte nonce][ChaCha20-Poly1305 over: [2-byte marker][4-byte length][data][junk]] ``` The data is first put through one of twenty-seven encodings drawn at random on every write -- base91, z-base-32, yEnc, a - (module)
move-to-front transform, and two dozen others -- and the marker says which, from inside the sealed region so the choice is not visible either. [`crate::weave`] carries the full argument; the short version is that **it adds no cryptographic - (module)
strength**, because the AEAD already makes this indistinguishable from random. What it adds is that plaintext escaping by some route that is not the cipher -- a core dump, a swap file, a future bug in this framing -- does not read as - (module)
anything. The padding is computed from the *original* length rather than the encoded one, so a file's size never depends on which encoding was drawn. Otherwise a record rewritten repeatedly would move between buckets and the smallest one - (module)
ever seen would pin its true length, which is exactly what the padding is there to prevent. The junk pads every record up to one of a few fixed sizes, so a file's length says which bucket it fell in and nothing finer. The additional data - (module)
for the AEAD is the filename itself, which binds a record to its name: two real files cannot be swapped without the swap being detected, because each one authenticates the name it is supposed to be under. The per-record key is a separate - (module)
HKDF branch, so one record's key says nothing about another's. # The roster, and the one deletion claim this can honestly make A record can be modified, and the AEAD catches that: any edit fails to authenticate and [`Hoard::audit`] reports - (module)
the record as tampered with. Deletion is harder, because a file that is not there looks exactly like a file that was never written. So the hoard keeps one more record, the roster, listing the logical names that should exist. It is stored - (module)
like any other record, under a derived name, encrypted and padded, so it is not identifiable from outside either. That gives a real answer for deletion of *some* of the folder: a record in the roster whose file is gone was deleted, and - (module)
audit says so. It gives no answer for deletion of *all* of it, including the roster, and nothing stored in this folder ever could -- at that point the only evidence is that the folder is empty, which you can see for yourself. The roster is - (module)
missing while the lock exists is itself reported, which is the closest honest approximation, and it is stated as what it is rather than dressed up. # In plain words VeilVoice's own files are encrypted and given meaningless names, and a - (module)
pile of decoy files sits among them so nobody can tell which is which. Only the program, once you have unlocked it, can work out which file is which. Anybody looking at the folder still knows you use VeilVoice, and can still delete the - (module)
lot. What they cannot do is read any of it, work out how much of it there is, or change any of it without VeilVoice telling you next time you unlock. - const INFO_NAME
HKDF label for the filename key. Distinct from every other label in the project so a name can never coincide with a key. - const INFO_REC
HKDF label prefix for a record's own encryption key. - const NAME_BYTES
How many bytes of the name HMAC end up in the filename. Eighteen encodes to exactly twenty-four base64 characters with no padding. 144 bits is far past any collision concern for a few dozen files; the choice is driven by the encoding - const NAME_BYTES
coming out clean. - const LEN_PREFIX
The length prefix inside the padded plaintext. - const MAX_EXPANSION
The most any encoding in [`crate::weave`] can grow its input. Morse is the widest, at eight bytes out per byte in -- each byte spelled as eight dots and dashes. Used to size the padding from the *original* length, so a file's on-disk size - const MAX_EXPANSION
never depends on which encoding was drawn. The cost is real and small: a record is padded for the worst-case expansion even when a compact encoding was chosen, so these files reserve more room than they use. They are settings and - const MAX_EXPANSION
measurements -- kilobytes -- and a size that gives nothing away is worth more than a tight one. `no_encoding_expands_past_the_allowance` measures every encoding against this so it cannot quietly become a lie. - const MARKER
The inner encoding marker, which sits before the length. Inside the sealed region rather than beside it, so which of the encodings in [`crate::weave`] was used before encryption is not visible from outside. - const OUTER_MARKER
The outer encoding marker, the first two bytes of the file. Outside the seal, because the outer encoding is applied after encryption and has to be undone before the seal can be opened. It says nothing secret: which length-preserving - const OUTER_MARKER
transform was applied to already-random ciphertext. - const BUCKETS
The sizes a record is padded up to, in bytes of plaintext. A record's file length reveals which of these it landed in and nothing finer. Beyond the largest, records round up to a whole mebibyte. - const ROSTER
The logical name of the roster record. - struct StoreKey
The key that names and opens everything in the hoard. Derived from the app-lock passphrase alongside the verifier and the tag key, under its own HKDF label, so it is independent of both. It exists only while the application is unlocked. - impl StoreKey
- fn from_secret
Wrap raw key material. The caller is trusted to have derived it. - fn expand
Derive a subkey under a label. - fn base64url
Base64url, no padding. Sixty-four characters, none of which needs escaping in a filename on any platform this project targets. - const ALPHABET
- fn bucket_for
The bucket a payload of this length pads up to. - struct Audit
What an audit found. - impl Audit
- fn is_clean
Whether anything was found that a user should be told about. - struct Hoard
An obfuscated store rooted at a directory. - impl Hoard
- fn open
Open the hoard in `dir`. Nothing is read or written until asked. - fn name_for
The filename a logical record lives under. Deterministic in the store key, which is what lets the program find a record without keeping an index that would give the game away. - fn path_for
The full path of a logical record. - fn write
Encrypt and store `data` under `logical`, padded and named so that neither its content nor its purpose is visible from outside. The roster is updated so a later deletion of this record is detectable. - fn write_raw
Encode, seal and write one record under its obfuscated name. The steps and the reason for each are in the body: what is written is not the caller's bytes, is not stored under the caller's name, and is not the size of the caller's data. - fn read
Read a record back, or `None` if it was never written. A file that is present but does not authenticate returns [`Error::Decrypt`] rather than `None`: that is tampering, and it must not be reported as absence. - fn open_bytes
Undo [`Hoard::write_raw`] for bytes already read off the disk. Split out from the read so that a record can be opened from memory, which is what the tests do rather than going through the filesystem. - fn remove
Remove a record and drop it from the roster. - fn roster
The logical names the roster says should exist. - fn save_roster
Write the list of logical names, itself as an ordinary record. The roster goes through the same encoding, sealing and padding as anything else, so the file that says what is stored is not distinguishable from the files it names. - fn sow_decoys
Write decoy files: names of the same shape, contents of the same character, holding nothing. A decoy is random bytes under a random name. It is not a valid record under any logical name, so the program never mistakes one for data -- it - fn sow_decoys
simply never derives that name. From outside there is nothing to separate the two, which is the point. Returns how many were written. Names that happen to collide with an existing file are skipped rather than overwritten. - fn audit
Check every record the roster knows about, and count what else is here. This is the tamper report the user sees after unlocking. It can say a record was edited, and it can say a record the roster expected is gone. It cannot say anything - fn audit
about a folder somebody emptied entirely, including the roster, and does not try to. - fn is_hoard_shaped
Whether a filename has the shape this module writes. Used only to count decoys, never to decide what to open: a name that looks right is still never read unless it is one the key derives. - fn fill_random
Fill `buf` from the operating system, treating a refusal as an error rather than falling back to anything. An empty buffer is a no-op: `getrandom` is within its rights to refuse a zero-length request, and asking for nothing is not a - fn fill_random
failure. - mod tests
- fn the_padding_size_is_refused_rather_than_wrapping
Sizing the padded buffer cannot wrap on a 32-bit target. `bucket_for` is fed a record length times eight, and this project ships i686 and armv7 builds where that wraps above about 512 MiB. No test can allocate that much, so the arithmetic - fn the_padding_size_is_refused_rather_than_wrapping
is checked here directly. - fn hoard
- fn other_hoard
- fn a_record_round_trips
- fn a_record_that_was_never_written_is_absent_not_an_error
- fn the_filename_gives_nothing_away
- fn a_different_key_derives_a_different_name_for_the_same_record
- fn another_key_cannot_read_what_this_one_wrote
- fn contents_do_not_appear_in_the_file
- fn short_and_long_records_are_padded_to_the_same_few_sizes
- fn the_file_size_does_not_move_when_the_encoding_does
A file's size must depend on the data's length and nothing else. The encoding is drawn fresh on every write. If the bucket were chosen from the *encoded* length, a record rewritten repeatedly would move between buckets, and the smallest - fn the_file_size_does_not_move_when_the_encoding_does
bucket ever seen would pin the true length far more tightly than one bucket was meant to allow. Padding is supposed to hide length; that would have handed it back. - fn no_encoding_expands_past_the_allowance
- fn an_edited_record_is_refused_rather_than_returned
- fn two_records_cannot_be_swapped
- fn audit_reports_an_edited_record
- fn audit_reports_a_deleted_record
- fn a_clean_folder_audits_clean
- fn decoys_are_indistinguishable_from_records_by_shape
- fn decoys_do_not_disturb_the_records_beside_them
- fn removing_a_record_takes_it_out_of_the_roster
- fn a_record_can_be_overwritten_without_growing_the_roster
- fn the_encoding_changes_from_one_write_to_the_next
Every record is written through a different encoding, over time. The choice is drawn fresh on each write, so the same record written many times should not keep landing on the same one. A scheme that says it picks at random and does not is - fn the_encoding_changes_from_one_write_to_the_next
worse than one that never claimed to. - fn a_records_plaintext_is_never_on_disk_even_before_the_cipher
- fn a_woven_name_is_still_twenty_four_characters
- fn a_name_is_the_same_every_time_it_is_derived
- fn decoys_are_still_the_same_shape_as_woven_records
- fn a_record_survives_whatever_outer_encoding_was_drawn
The outer encoding varies, and the record still comes back. The whole "before or after the cipher" ask: an outer length-preserving weave is applied after sealing, drawn fresh each time, and the record round trips whichever one was drawn. - fn the_outer_marker_is_the_first_two_bytes_and_varies
- fn nothing_on_disk_is_a_long_run_of_one_byte
- fn base64url_matches_the_standard_alphabet
- fn buckets_round_up_and_never_shrink
- fn a_truncated_file_is_reported_not_returned
- fn an_empty_record_round_trips
- fn a_record_larger_than_every_bucket_round_trips
crates/veilvoice-crypto/src/hybrid.rs
- (module)
Post-quantum hybrid key encapsulation: X25519 + ML-KEM-768. # Why hybrid ML-KEM (FIPS 203, formerly Kyber) is believed secure against a quantum adversary, but it is young, and lattice schemes have had implementation breaks. X25519 is - (module)
battle-tested but falls to a cryptographically relevant quantum computer. Running both and mixing the two shared secrets means an attacker must break *both*: the construction is at least as strong as the stronger of the two, and it - (module)
degrades gracefully if either one is broken. This is the same reasoning behind the hybrids now deployed in TLS. It matters here specifically because of *harvest-now-decrypt-later*: a recording captured today can be stored until quantum - (module)
hardware exists. A tool whose whole purpose is protecting who is speaking has to assume the adversary is patient. # The combiner The two shared secrets are mixed with HKDF-SHA256 rather than concatenated or XORed. The input keying material - (module)
is the X25519 shared secret followed by the ML-KEM one. The salt is the transcript of the exchange: the ephemeral X25519 public key, the ML-KEM ciphertext and the recipient's X25519 public key. The info string names the construction and - (module)
its version. Binding the transcript is what stops an attacker who can substitute one half of the exchange from steering the result, and is what keeps the combiner robust if one KEM's ciphertexts turn out to be malleable. The recipient's - (module)
ML-KEM encapsulation key is not in the salt, and does not need to be. FIPS 203 derives the ML-KEM shared secret from the message and a hash of the encapsulation key, so that key is bound through the secret itself. X25519 makes no such - (module)
promise, which is why its public key is bound here by hand. This is the shape of the X-Wing combiner, the ML-KEM secret, the X25519 secret, the X25519 ciphertext and public key under a label, with HKDF in place of one SHA3-256 call. - (module)
Changing it now would change every key and every container already made, and would not change what an attacker can do. # In plain words This is for encrypting a recording to somebody else's key rather than to a passphrase, and it uses two - (module)
different systems at once. One is the kind in use everywhere today. The other is designed to resist a quantum computer, which does not yet exist in a useful form but which somebody recording traffic now would be counting on later. Both - (module)
have to be broken to read the file. Using two means that if the newer one turns out to have a flaw, you are no worse off than with the old one alone, and if the old one falls to a quantum computer, the newer one still holds. - const HKDF_INFO
Domain separation string, so keys derived here can never collide with keys derived by any other part of the system. - const X25519_PUB_LEN
Encoded length of the X25519 public key. - const MLKEM_EK_LEN
Encoded length of the ML-KEM-768 encapsulation (public) key. - const MLKEM_CT_LEN
Encoded length of an ML-KEM-768 ciphertext. - const MLKEM_DK_LEN
Encoded length of the ML-KEM-768 decapsulation (private) key. - const X25519_SECRET_LEN
Encoded length of the X25519 private scalar. - const SECRET_KEY_LEN
Total encoded length of a [`SecretKey`]. - const PUBLIC_KEY_LEN
Total encoded length of a [`PublicKey`]. - const ENCAPSULATION_LEN
Total encoded length of an [`Encapsulation`]. - type MlKemDk
- type MlKemEk
- struct PublicKey
A recipient's public key: an X25519 point plus an ML-KEM-768 encapsulation key. Safe to publish. - struct SecretKey
A recipient's private key. Zeroized on drop by the underlying types. - struct Encapsulation
The public values a sender transmits so the recipient can recover the shared secret. Safe to store next to the ciphertext. - impl PublicKey
- fn to_bytes
Serialise to `PUBLIC_KEY_LEN` bytes. - fn from_bytes
Parse from exactly `PUBLIC_KEY_LEN` bytes. - impl Encapsulation
- fn to_bytes
Serialise to `ENCAPSULATION_LEN` bytes. - fn from_bytes
Parse from exactly `ENCAPSULATION_LEN` bytes. - impl SecretKey
- fn generate
Generate a fresh key pair from the OS CSPRNG. - fn to_bytes
Serialise to `SECRET_KEY_LEN` bytes. The result is returned inside a [`Secret`], not a plain `Vec`: this is the private key, and it must not sit in ordinary heap memory waiting to be swapped out. Callers should hand it straight to - fn to_bytes
[`crate::container::seal_with_password`] rather than write it in the clear. - fn from_bytes
Parse from exactly `SECRET_KEY_LEN` bytes. - fn public_key
The matching public key. - fn decapsulate
Recover the shared secret from a sender's [`Encapsulation`]. - impl PublicKey
- fn encapsulate
Produce a shared secret for this recipient, plus the public values they need in order to recover it. - fn combine
Mix both shared secrets, with the exchange's transcript as the salt. What is and is not in the transcript, and why, is argued in the module note under "The combiner". - struct OsRng
Bridges the OS CSPRNG to the `rand_core` traits the KEM crates expect. `getrandom` is already the entropy source everywhere else in VeilVoice, so routing these through it keeps the whole crate on one source rather than pulling in a second - struct OsRng
RNG stack. - impl rand_core::RngCore for OsRng
- fn next_u32
- fn next_u64
- fn fill_bytes
# Why this one panics `RngCore::fill_bytes` has no error return: the trait's contract is that it either fills the buffer or does not come back. The alternatives are worse than a panic. Leaving `dest` as it was, or zeroing it, hands - fn fill_bytes
predictable bytes to whatever is drawing a key from them, silently, and a key derived from a buffer of zeros is not a key. `try_fill_bytes` below is the fallible form, and every caller in this crate that can report a failure uses - fn fill_bytes
`getrandom` directly rather than coming through here. This exists for the KEM crates, which take an `RngCore` and nothing else. - fn try_fill_bytes
- impl rand_core::CryptoRng for OsRng {}
- mod tests
- fn the_randomness_adapter_actually_fills_what_it_is_given
The adapter that hands the operating system's randomness to the KEM crates actually hands over randomness. **Round thirty-three.** Mutation testing replaced `next_u32` and `next_u64` with functions returning a constant, and replaced - fn the_randomness_adapter_actually_fills_what_it_is_given
`try_fill_bytes` with one that returns `Ok(())` without touching the buffer. All three survived the suite, which is to say nothing in this crate checked that its only randomness adapter emits anything. A key derived from a buffer these - fn the_randomness_adapter_actually_fills_what_it_is_given
left alone would be a key an attacker already knows, and every test here would still have passed. Not a statistical test: this asks whether bytes arrive at all, which is the failure a mutant (or a stubbed build) produces. `getrandom` is - fn the_randomness_adapter_actually_fills_what_it_is_given
the system's own source and is not this crate's to assess. - fn encapsulate_then_decapsulate_agrees
- fn a_different_recipient_cannot_recover_it
- fn each_encapsulation_is_fresh
- fn tampering_with_the_x25519_half_changes_the_secret
The hybrid must fail if the classical half is tampered with, because otherwise it would be no stronger than ML-KEM alone. - fn tampering_with_the_ml_kem_half_changes_the_secret
...and equally if the post-quantum half is tampered with. - fn public_keys_round_trip_through_bytes
- fn encapsulations_round_trip_through_bytes
- fn malformed_encodings_are_rejected
- fn secret_keys_round_trip_through_bytes
- fn encoded_secret_key_is_protected_and_opaque
The encoded private key must come back in protected storage, not a bare `Vec` that could be swapped to disk. - fn public_key_can_be_recovered_from_the_secret_key
- fn declared_encoding_lengths_match_the_implementation
crates/veilvoice-crypto/src/kdf.rs
- (module)
Password-based key derivation with Argon2id. Argon2id is the memory-hard KDF recommended by RFC 9106 and the OWASP password-storage guidance; the `id` variant resists both GPU/ASIC parallelism and the side-channel exposure of pure Argon2i. - (module)
Parameters travel *with* the ciphertext rather than being compiled in, so a file encrypted today still opens after the defaults are raised, and a user on a small machine can lower the memory cost without forking the format. # Cost - (module)
parameters arrive from a file, so they are hostile input That flexibility has a sharp edge, and two shipped defects came from it. `m_cost` and `p_cost` are read verbatim from a `.veil` header -- and from the app-lock file, **which is - (module)
parsed before anyone has authenticated**. * `argon2` 0.5.3 evaluates `m_cost < p_cost * 8` *before* it checks whether `p_cost` is within range, so a large `p_cost` overflows the multiplication. With overflow checks on -- every debug build, - (module)
and any project consuming this crate as a library -- that is a panic on attacker-controlled input (F-2). * `m_cost` is allocated before anything else happens, so a header claiming `u32::MAX` asks for **4 TiB**. The allocation fails, and a - (module)
failed allocation aborts the process. Merely *opening* a hostile container killed the program (F-3). Both are bounded in [`KdfParams::checked`], in arithmetic that cannot overflow. **Never bypass that funnel.** It is the single place every - (module)
derivation passes through, and it exists because the alternative -- checks scattered across the call sites -- is how one of them gets missed. A residual is stated rather than fixed: a container may still declare a legitimate-but-expensive - (module)
cost, so an attacker can make opening *their* file slow. That is inherent to shipping the cost with the file, which is what lets old files open after defaults rise. Slow is not crashing, and the user chose to open that file. # Domain - (module)
separation The app-lock password and the recording passphrase are different secrets and are kept different: they are domain-separated in the derivation, so unlocking the application does not unseal recordings and one cannot be derived from - (module)
the other. # In plain words This turns a passphrase into a key. A passphrase somebody can remember is far too short and too predictable to use directly, so it is put through a process designed to be slow and to need a large amount of - (module)
memory. That does not slow you down noticeably once, but it makes guessing millions of passphrases enormously expensive for anybody trying. The settings are stored with each file, so an old recording still opens after the defaults are made - (module)
stronger. - struct KdfParams
Argon2id cost parameters. - impl Default for KdfParams
- fn default
RFC 9106's "first recommended" profile: 2 GiB is the second option, but 256 MiB with three passes is the sweet spot for an interactive desktop unlock: strong against offline cracking while still opening a file in well under a second on - fn default
ordinary hardware. - impl KdfParams
- fn weak_for_tests
A deliberately cheap profile for tests and low-memory devices. Do not use this to protect real data. - const MAX_P_COST
Argon2's own documented ceiling on parallelism: 2^24 - 1. - const MAX_M_COST
The largest memory cost this build will attempt, in KiB, which is 4 GiB. A ceiling is necessary because `m_cost` arrives from the file. Argon2 allocates that much memory before it does anything else, so a header claiming `u32::MAX` asks - const MAX_M_COST
for four *terabytes*: the allocation fails, and a failed allocation in Rust aborts the process. Merely *attempting to open* a hostile `.veil` would kill the program, and for the app lock it is worse, because that file is read before anyone - const MAX_M_COST
has authenticated, so anything that can write it can stop VeilVoice from starting at all. 4 GiB is chosen to sit well above every parameter set anyone would deliberately pick: RFC 9106's *first* recommended profile is 2 GiB and this - const MAX_M_COST
crate's default is 256 MiB. A file declaring more than this is refused with [`Error::KdfParams`] rather than obeyed. The honest residual: a file whose declared cost is legitimate but larger than *this machine's* memory still cannot be - const MAX_M_COST
opened, and that failure comes from the allocator rather than from here. A cap cannot fix a small machine; it can stop an absurd number from being taken seriously. - const MAX_T_COST
The largest number of passes this build will attempt. **F-82.** `m_cost` had a ceiling and `t_cost` had only a test for zero, so a header could declare `u32::MAX` passes: four billion of them, over however much memory it also asked for. - const MAX_T_COST
Nothing overflows and nothing allocates, so every check above passed and the derivation simply did not finish. Found by the coverage-guided campaign, which produced a header declaring `m_cost` 65535, `t_cost` 4521984 and `p_cost` 1280. - const MAX_T_COST
Measured on the machine that found it, in a release build: **about 74 hours**, and that input is not the worst one, only the one the fuzzer happened to reach. `u32::MAX` passes at the same memory is roughly eight years. It matters in two - const MAX_T_COST
places and the second is worse. A `.veil` file is something somebody sent you, and merely attempting to open it would hang the program. The app-lock file carries the same three numbers and is read **before anyone has authenticated**, so - const MAX_T_COST
anything able to write it could stop VeilVoice from starting, for ever, with no error and nothing to see. That is the argument [`MAX_M_COST`](Self::MAX_M_COST) already makes about memory; nobody had made it about time. 16 is chosen the way - const MAX_T_COST
the memory ceiling was, and then tighter, because time has no allocator to fail on its behalf. RFC 9106's two recommended profiles use one pass and three, libsodium's most expensive preset uses four, and this crate's default is three; 16 - const MAX_T_COST
is four times the highest of those. Measured in a release build: 16 passes at [`MAX_M_COST`](Self::MAX_M_COST) is 75 seconds, and at [`UNATTENDED_MAX_M_COST`](Self::UNATTENDED_MAX_M_COST) it is 18. So the most expensive header this build - const MAX_T_COST
will accept is a wait somebody can sit through, rather than one they will never see the end of. This is one ceiling and it is enforced in [`checked`](Self::checked), the single funnel every derivation passes through, so it holds for the - const MAX_T_COST
container, for the app lock, and for anything built against this crate. The honest residual is the same one the memory ceiling states: a cap cannot make a hostile file cheap, it can stop an absurd number from being taken seriously. - const UNATTENDED_MAX_M_COST
A ceiling for a caller with nobody watching. [`MAX_M_COST`](Self::MAX_M_COST) exists to stop an *absurd* value; it is deliberately generous, so a container may still declare a legitimate but expensive cost and make itself slow to open. - const UNATTENDED_MAX_M_COST
That is fine when a person chose to open that file and can decide to stop waiting. It is not fine for a service processing whatever arrives, which is why [`KdfParams::within`] exists and this is the value to pass it: 1 GiB is four times - const UNATTENDED_MAX_M_COST
this crate's default and still opens in a few seconds, while refusing a header that asks for four gigabytes of someone else's memory. This is a *policy*, not a security boundary. The honest framing is that it bounds the cost of being - const UNATTENDED_MAX_M_COST
handed a hostile file, not that it makes one safe. - fn within
Check the costs against a caller-chosen memory ceiling as well as the built-in one. Opening a container whose declared cost is legitimate but large is slow by design, and that is the price of shipping the cost with the file so old files - fn within
keep opening. A caller running without a human present, such as a batch job, a service or anything processing files it did not choose, can use this to decline instead of spending the memory. Pass - fn within
[`UNATTENDED_MAX_M_COST`](Self::UNATTENDED_MAX_M_COST) unless there is a reason for something else. There is no time ceiling here and there does not need to be: unlike memory, time is bounded for every caller by - fn within
[`MAX_T_COST`](Self::MAX_T_COST), which is tight enough that the worst header this build will accept is a wait rather than a hang. A ceiling here as well would sit on the *attended* path too, since that is - fn within
[`super::container::open_with_password`] with a larger number, and would refuse a container somebody had deliberately chosen to open. - fn checked
Check the costs are ones Argon2 can accept, **before** handing them to it. This is not belt-and-braces, it is a fix. `argon2` 0.5.3 validates in the wrong order: `Params::new` evaluates `m_cost < p_cost * 8` before it checks `p_cost > - fn checked
MAX_P_COST`, so a `p_cost` above `u32::MAX / 8` overflows the multiplication. With overflow checks on, which is every debug build and any consumer of this crate as a library, that is a **panic on attacker-controlled input**, since `p_cost` - fn checked
is read verbatim from a `.veil` header or an app-lock file. Found by the campaign in `tests/parser_fuzz.rs`. VeilVoice's own release profile disables overflow checks, where the multiplication wraps and the `MAX_P_COST` test then rejects it - fn checked
anyway, but "our release profile happens to make the panic unreachable" is not a property to rely on, and it is not true for anyone building against these crates. So the bound is enforced here, in the one place every derivation passes - fn checked
through, in arithmetic that cannot overflow. # Why mutating the parallelism ceiling changes nothing Worth saying, because a reader who tries it will find it out and wonder. `p_cost > MAX_P_COST` can never be the *only* test that fires: the - fn checked
lane relation below requires `m_cost >= p_cost * 8`, and `m_cost` is itself capped at [`MAX_M_COST`](Self::MAX_M_COST), so nothing above `MAX_M_COST / 8` can get past this function whatever this line says. It stays because it is the bound - fn checked
`argon2` itself documents and because the argument above is about the order the checks run in: a later change that moved the lane relation would leave this as the thing standing between a header and the overflow, and a guard that is only - fn checked
redundant today is not a guard to delete. - fn build
Reject values Argon2 cannot accept, so a corrupt header fails loudly rather than panicking deep inside the KDF. - const SALT_LEN
Length of the salt stored in an encrypted container. - const KEY_LEN
Length of a derived symmetric key. - fn derive_key
Derive a 32-byte key from `password` and `salt`. The result lands directly in page-locked, zeroizing storage; it is never held in an ordinary `Vec` along the way. - fn random_salt
Draw a fresh random salt from the OS CSPRNG. - mod tests
- const P
- fn weak
- fn every_ceiling_accepts_its_own_value_and_refuses_one_past_it
Every ceiling in this module, from both sides of it. **Round thirty-three.** Mutation testing turned `>` into `>=` and into `==` in the cost tests and `||` into `&&` in the parallelism test, and all four mutants survived: the suite tested - fn every_ceiling_accepts_its_own_value_and_refuses_one_past_it
values that are obviously wrong and values that are obviously right, and never the one at the edge. A ceiling nobody tests at the edge is a ceiling that can move by one without anybody noticing, and these ceilings are what stop a header - fn every_ceiling_accepts_its_own_value_and_refuses_one_past_it
somebody sends you from choosing how much memory this program allocates. - fn the_unattended_ceiling_is_inclusive
The unattended ceiling lets through exactly what it names. `within` is what the desktop application opens a container with when nobody has asked for it, so the value it is given is the largest wait this build will sit through unattended. - fn the_unattended_ceiling_is_inclusive
Off by one here is either a container refused that should open, or a wait somebody did not choose. - fn a_salt_of_exactly_eight_bytes_is_accepted_and_seven_is_not
The shortest salt this accepts is the one it documents. Argon2 requires eight bytes. `derive_key` refuses anything shorter, and a mutant that made the test `<=` would refuse a salt of exactly eight, which is legal and which nothing in the - fn a_salt_of_exactly_eight_bytes_is_accepted_and_seven_is_not
suite was using. - fn derivation_is_deterministic
- fn different_password_or_salt_diverges
- fn cost_parameters_change_the_key
- fn a_number_of_passes_that_would_not_finish_is_refused
**F-82.** The exact parameters the coverage-guided campaign found, and the worst case it did not reach. These pass every other check: nothing overflows, nothing allocates beyond the memory ceiling, and `m_cost >= p_cost * 8` holds. The - fn a_number_of_passes_that_would_not_finish_is_refused
only thing wrong with them is that the derivation does not finish. Measured in a release build before the ceiling existed: about 74 hours for the first set, and roughly eight years for `u32::MAX`. The numbers are written out rather than - fn a_number_of_passes_that_would_not_finish_is_refused
referred to, because that is what makes this a regression test for the input rather than a restatement of the rule. - fn the_time_ceiling_sits_above_every_cost_this_project_uses
Every parameter set this project actually writes stays acceptable, and the ceiling sits well clear of them. A ceiling that refused the default would be a much louder bug than the one it was added for, and "we chose a number" is not - fn the_time_ceiling_sits_above_every_cost_this_project_uses
evidence that the number is above the ones in use. - fn key_material_is_page_locked_storage
- fn impossible_parameters_are_rejected_not_panicked
- fn an_absurd_parallelism_is_rejected_rather_than_overflowing
Regression for the panic the parser campaign found: `argon2` 0.5.3 computes `p_cost * 8` before checking `p_cost`'s ceiling, so a `p_cost` above `u32::MAX / 8` overflows. `p_cost` comes straight out of a `.veil` header or an app-lock file, - fn an_absurd_parallelism_is_rejected_rather_than_overflowing
so this is attacker-controlled. - fn an_absurd_memory_cost_is_rejected_rather_than_allocated
The other half of the same finding: `m_cost` is the number of KiB Argon2 allocates up front, so `u32::MAX` asks for 4 TiB. The allocation fails, and a failed allocation aborts the process, so simply *trying to open* a hostile file would - fn an_absurd_memory_cost_is_rejected_rather_than_allocated
kill the program. - const _
Checked at compile time: the ceiling must never be tightened below the largest profile RFC 9106 actually recommends, or the cap would start refusing legitimate files rather than absurd ones. - fn sane_parameters_still_pass_the_check
- fn short_salt_is_rejected
- fn random_salts_differ
crates/veilvoice-crypto/src/lib.rs
- (module)
# veilvoice-crypto Key derivation, post-quantum-hybrid key agreement, authenticated encryption and amnesic secret storage for VeilVoice. ## What this crate is for [`veilvoice_core`](../veilvoice_core/index.html) makes a voice - (module)
unrecognisable; it does not hide the *words*, and it is not meant to. When a recording needs to stay secret as well, at rest on disk or in transit to someone else, that is this crate's job. - [`kdf`], Argon2id, for turning a password into - (module)
a key. - [`hybrid`], X25519 + ML-KEM-768, so a recording captured today is not readable by a quantum adversary tomorrow. - [`aead`], XChaCha20-Poly1305, with random nonces and authenticated associated data. - [`container`], the `.veil` - (module)
file format that ties the three together. - [`amnesia`], page-locked, zeroizing, constant-time-comparable secrets. - [`shred`], secure erasure, and an honest account of what that is worth on flash storage. - [`privatefile`], writing a file - (module)
that is owner-only from the moment it exists, rather than world-readable until a second syscall tightens it. - [`lock`], the application lock: an Argon2id verifier with a rate limit, which protects against casual access and says so rather - (module)
than pretending to be tamper-proof. ## Threat model, stated plainly This crate protects data **at rest and in transit** against an attacker who later obtains the file, including one who stores it until quantum hardware exists. It does - (module)
**not** protect against an attacker who is already running code as you, or who can read this process's memory: page-locking keeps keys out of the swap file, not out of a debugger. Hibernation writes RAM to disk wholesale and defeats - (module)
locking entirely. ## Example ``` use veilvoice_crypto::{container, kdf}; # fn main() -> Result<(), veilvoice_crypto::Error> { // Cheap parameters so the doctest is fast; real callers use the default. let params = - (module)
kdf::KdfParams::weak_for_tests(); let sealed = container::seal_with_password(b"pass phrase", b"audio bytes", params)?; assert_eq!(container::open_with_password(b"pass phrase", &sealed)?, b"audio bytes"); - (module)
assert!(container::open_with_password(b"wrong", &sealed).is_err()); # Ok(()) # } ``` VeilVoice contains **no `unsafe` code at all**, including the page-locking in [`amnesia`], which goes through a safe wrapper. # In plain words This is the - (module)
locking. Two separate things use it. Recordings are sealed on your disk so that somebody who takes the disk cannot listen to them, and the app can be put behind a password so somebody who picks up your unlocked computer cannot open it. - (module)
Those two passwords are deliberately different. Opening the program should not be the same act as unsealing everything it has ever written. The maths is chosen to be slow to guess and to still be safe if somebody one day builds a quantum - (module)
computer. Nothing is sent anywhere; the sealing happens on your machine and the key is made from your password each time. - mod aead
- mod amnesia
- mod container
- mod decoy
- mod hoard
- mod hybrid
- mod kdf
- mod lock
- mod privatefile
- mod shred
- mod studio
- mod tape
- mod vault
- mod weave
- const VERSION
Crate version string, surfaced in the About panel. - enum Error
Everything that can go wrong in this crate. Decryption failures are deliberately coarse: [`Error::Decrypt`] does not say *why* authentication failed, because distinguishing a wrong password from a corrupt tag would hand an attacker an - enum Error
oracle. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
crates/veilvoice-crypto/src/lock.rs
- (module)
The application lock: an Argon2id password verifier with a rate limit. # What this is worth, stated before anything else **This is not a security boundary against someone holding the disk.** It cannot be. A local application has nowhere to - (module)
hide a secret from the machine it runs on: whoever can read this file can also delete it, and deleting it removes the lock. That is not a defect in the implementation, it is what a local lock *is*. What it does buy is real and worth - (module)
having: someone who picks up your unlocked computer, such as a flatmate, a colleague or a border officer with your session open, cannot open VeilVoice, see which files you have processed, or start a live scramble. That is the threat this - (module)
defends against, and [`SCOPE`] says so in the words the user sees. If the threat is an attacker with the disk, the answer is full-volume encryption (LUKS, BitLocker, FileVault) plus [`crate::container`] for the recordings themselves. - (module)
Neither of those is replaced by this module. # Why a verifier and not a key The lock stores `Argon2id(domain ‖ password, salt)`, split by HKDF into a verifier and a tag key, and compares the verifier in constant time. It derives no key - (module)
that encrypts anything, and that is still true of this module after roadmap item 86. What changed is one level up. The desktop application can now be told to seal every recording it writes *with the app-lock passphrase*, and it does that - (module)
through [`crate::container`] in the ordinary way, with a fresh salt per file. So the recordings do not depend on this file: delete the lock and they still open, given the passphrase. Nothing here holds a key to them and nothing here can. - (module)
The property that is given up by switching that on is not cryptographic, it is human, and it belongs in the interface rather than in a comment: one passphrase then opens the application and the archive together. The default is still two - (module)
separate secrets, and `docs/USER_GUIDE.md` section 5.4 states the trade in the words a user reads. The stored verifier is a password hash sitting on disk in the clear, and must be treated like one. Argon2id at the default cost (256 MiB, - (module)
t=3, p=4) makes an offline attack expensive per guess, which matters for a decent passphrase and does not save a bad one. # The tag, and the one tamper claim this file can honestly make Every record carries a 16-byte authentication tag - (module)
over all the bytes before it, keyed by a value that exists only while a correct passphrase is in memory. The tag key is the second half of one Argon2id run, split from the verifier by HKDF, so publishing the verifier, which the file does - (module)
by sitting on disk, says nothing about the tag key. That buys exactly one thing, and it is worth naming precisely. Somebody who edits this file without knowing the passphrase cannot leave the edit looking authentic. Resetting the failure - (module)
counter to zero, winding the last-failure timestamp back to escape a wait, or dropping the Argon2id cost so that a guess is cheap. All three are edits, and all three are caught at the next successful unlock, because that is the moment the - (module)
tag key exists. It buys nothing at all against the two attacks people expect it to stop. Deleting the file still removes the lock. Replacing the file wholesale with a lock the attacker created still lets the attacker in, because their own - (module)
record is authentic under *their* passphrase. [`crate::vault`] answers the first with a second copy and answers the second not at all. The tamper flag, once raised, is stored and is cleared only by an unlock that also proves the - (module)
passphrase. So the report survives a restart, and the person who caused it cannot dismiss it. # Rate limiting Wrong attempts are counted and the count is **persisted**, so killing the process does not hand an attacker a fresh budget. After - (module)
three free attempts the wait doubles: 5 s, 10 s, 20 s … capped at fifteen minutes. The counter is stored in the same unauthenticated file as the verifier, and the wait is measured against the system clock. Someone who can edit the file or - (module)
move the clock defeats both. Again: casual access, not the disk. # Separate from the recording password The password that unlocks the app and the password that encrypts recordings are two different passwords, on purpose, so that unlocking - (module)
the app is not the same act as unsealing everything it has ever written. To make that structural rather than merely conventional, the verifier is derived over a domain- separated input, so the same passphrase used in both places still - (module)
produces unrelated values. # In plain words The lock on the application window. It asks for a passphrase before VeilVoice will open, and it slows down after repeated wrong answers so that guessing is not worth trying. **It is not - (module)
protection against somebody who has your disk.** It stops the person who picks up your unlocked laptop, and that is genuinely worth having, but anybody who can read the files directly is not stopped by a program deciding whether to show - (module)
you a window. Encrypting your recordings is what protects them; this protects the session. - const SCOPE
What the app lock protects against, and what it does not, in the words a front-end should show the user. Single-sourced so the CLI and the GUI cannot drift into two different promises, and asserted by the tests so it cannot quietly become - const SCOPE
a boast. - const MAGIC
Magic bytes at the start of a lock file. - const FORMAT_VERSION
Format version this build writes. - const LOCK_LEN
Exact size of a version 2 lock file, in bytes. - const LOCK_LEN_V1
Exact size of a version 1 lock file, which this build still reads. - const DOMAIN
Domain separator, so the app-lock secret can never coincide with a key derived from the same passphrase anywhere else in this crate. - const INFO_VERIFIER
HKDF label for the half of the derivation that is written to disk. - const INFO_TAG
HKDF label for the half that is not, and that authenticates the record. - const INFO_STORE
HKDF label for the obfuscated store key. A third branch of the same Argon2id run, independent of the other two: publishing the verifier, which the file does by existing, says nothing about this. This is the branch that gives the lock - const INFO_STORE
something to hold. Before it, the lock verified a passphrase and nothing else depended on it; now [`crate::hoard`] names and opens every record in the program folder with this key, so the passphrase is what makes those files readable - const INFO_STORE
rather than merely what makes the window open. - const TAG_LEN
Length of the record tag. Sixteen bytes of Poly1305, as everywhere else. - const BODY_LEN
Length of the tagged part of a record, which is also the offset of the nonce that follows it. - const FREE_ATTEMPTS
Failed attempts allowed before the wait starts. - const BASE_DELAY_SECS
The first enforced wait, in seconds. It doubles from here. - const MAX_DELAY_SECS
The longest the wait ever gets. Beyond a quarter of an hour the attacker has long since moved to attacking the file directly, and the honest user is the only one still being punished. - fn delay_secs
How long to refuse the next attempt after `failures` consecutive failures. Public because it is the whole of the rate-limit policy, and a policy nobody can read or test is not a policy. - fn unix_now
Seconds since the Unix epoch, negative before it. - struct AppLock
A password verifier plus its attempt history. This is the on-disk state. Most callers want [`LockStore`], which ties it to a file and persists every attempt. - impl AppLock
- fn create
Create a lock for `password`. - fn store_key
Derive the key that names and opens the obfuscated program folder. Separate from [`Self::verify`] rather than returned by it, because most unlocks do not need it and the ones that do want it at a different moment. It costs a full Argon2id - fn store_key
run, so callers hold the result for as long as the session is unlocked rather than deriving it per record. The password is **not** checked here: a wrong one yields a key that derives names nothing is stored under, which reads as an empty - fn store_key
folder rather than an error. Call [`Self::verify`] first, and act on what it says. - fn verify
Check `password`, recording the outcome. Returns [`Error::AppLockCooldown`] while the rate limit is in force, without touching the KDF. An attacker should not be able to spend our CPU either. - fn verify_at
Check a passphrase against this lock as of `now`. Taking the time as an argument rather than reading the clock is what lets the cooldown be tested at all: a test that had to wait out the delay would not be run. - fn same_secret_as
Whether two records hold the same stored password. Compares the salt as well as the verifier, because two locks made from the same passphrase have different salts and therefore different verifiers: the question being asked is "are these - fn same_secret_as
the same lock", not "would the same passphrase open both". Constant time, although the values being compared are both already on disk. It costs nothing and keeps one habit for this kind of material rather than two. - fn tampered
Whether a record has been found edited by somebody without the passphrase, at any point since this was last cleared. Sticky on purpose. A report that a restart clears is a report an attacker clears. - fn acknowledge
Clear the tamper report, after proving the passphrase. Takes the password rather than trusting an earlier unlock, so that nothing can dismiss the report except the person who can open the lock. - fn tag_matches
Whether the file's tamper tag is the one this key computes. Compared in constant time, and a tag that cannot be computed is treated as matching: see the note in the body for why that is the safe direction. - fn tag
The authentication tag over everything in the record before it. Poly1305 over an empty message with the record as associated data: a keyed tag built from the AEAD already in this crate, rather than a second MAC construction to review. - fn cooldown
Seconds still to wait before another attempt is accepted. - fn cooldown_at
How long is left of the delay after the last failed attempt, if any. `None` means try now. - fn failures
Consecutive failed attempts recorded so far. - fn params
The Argon2id cost this lock was created with. - fn body
The bytes the tag covers. Not the whole record. The failed-attempt counter and its timestamp are deliberately outside, and the reason is worth stating rather than leaving to be discovered: they are written at the one moment the tag key - fn body
does not exist. A wrong passphrase has to be counted, and counting it means a write, and the write cannot be authenticated by a key that only a right passphrase produces. Putting them inside would mean either re-tagging with a stale tag -- - fn body
so every honest typo would be reported as tampering -- or not counting failures at all. So the rate limit is exactly as defeatable by an editor as it was before, and [`SCOPE`] does not claim otherwise. What the tag does cover is the part - fn body
an attacker actually wants: the verifier, the Argon2id cost, and the tamper flag itself. - fn retag
Draw a fresh nonce and re-tag the record under `password`. Called when the passphrase is in hand: at creation, at a change, and after a successful unlock. A version 1 record becomes version 2 here, which is the whole of the upgrade path. - fn to_bytes
Serialise exactly as it appears on disk. ```text offset size field covered by the tag 0 8 magic "VEILLOK1" yes 8 1 format version (2) yes 9 3 reserved, must be zero yes 12 4 Argon2id m_cost (KiB, little-endian) yes 16 4 Argon2id t_cost yes - fn to_bytes
20 4 Argon2id p_cost yes 24 16 salt yes 40 32 verifier yes 72 1 tamper report: 1 raised, 0 acknowledged yes 73 24 tag nonce, fresh on every re-tag no 97 16 tag over bytes 0..73 no 113 4 consecutive failed attempts no 117 8 Unix seconds of - fn to_bytes
the most recent failure no ``` A record that has never been tagged -- one read from a version 1 file and not yet unlocked -- is written back as version 1, so that a failed attempt against an old lock still records itself. - fn to_bytes
[`AppLock::retag`] is what moves it forward, and it needs the passphrase to do so. - fn parse
Parse a lock file, version 1 or version 2. Nothing is authenticated here. Deciding whether the tag is right needs the passphrase, which parsing does not have, so parsing answers only "is this a lock file" and leaves the rest to - fn parse
[`AppLock::verify`]. - fn derive_pair
Derive the verifier and the tag key for `password`. One Argon2id run, split in two by HKDF. Two runs would double the cost of every unlock for no gain: HKDF outputs under distinct labels are independent, so publishing the verifier -- which - fn derive_pair
the file does, by existing -- reveals nothing about the tag key. The domain separator is prepended before the Argon2id run so that neither half can collide with a container key derived from the same passphrase and salt. The joined buffer - fn derive_pair
is wiped as soon as it has been consumed. - fn derive_keys
As [`derive_pair`], and the store key with it. One Argon2id run produces all three. Callers that need the store key must use this rather than deriving it separately: a second run would double the cost of every unlock for no gain, and the - fn derive_keys
whole point of the HKDF split is that one expensive step can feed as many independent labels as are wanted. - struct LockStore
An [`AppLock`] bound to a file, which is persisted after every attempt. Persisting on failure is the point: a rate limit that a process restart clears is not a rate limit. - enum Backing
Where a [`LockStore`] keeps its record. Two, because the two are asked for by different callers. The default location is a [`crate::vault::Vault`]: two copies under unguessable names, one of them administrator-owned where the platform - enum Backing
allows it. An explicit path is one plain file, which is what `veilvoice lock --path` is for and what a script pointing at a temporary directory expects. - impl Backing
- fn primary
The path this lock is really kept at, whether it is a plain file or the real one among a vault's decoys. - impl LockStore
- fn open
Load the lock at `path`, or `Ok(None)` if no lock is configured there. A file that exists but does not parse is an error, not an absent lock: silently treating a corrupt lock as "unlocked" would turn one bad byte into an open door. - fn create
Create a lock at `path`, refusing to overwrite one already there. The refusal is done by the *creation* rather than by a prior `path.exists()` test. Checking and then writing is a race, and the two ways it loses both matter here: another - fn create
process can win between the two steps, and a symbolic link planted at the lock path would have been followed, so `fs::write` would have overwritten whatever it pointed at. `create_new` asks the kernel to fail if anything is already there, - fn create
which is one atomic answer to both. - fn unlock
Check `password` and persist the outcome. A failure to write the updated attempt count does not change the verdict because the attempt really did succeed or fail, so the write is best-effort here. The consequence of losing it is a rate - fn unlock
limit that resets, which is already true of anyone who can delete the file. - fn tampered
Whether the stored record has been found edited by somebody without the passphrase. See [`AppLock::tampered`]. - fn acknowledge
Clear the tamper report, after proving the passphrase, and persist that. One key derivation, not three. The first version called `unlock` and then `AppLock::acknowledge`, which verifies again, and each of those is a full Argon2id run at - fn acknowledge
256 MiB. Three of them is the better part of a minute on a slow machine to dismiss a message, and a control nobody will wait for is a control nobody uses. `unlock` has already proved the passphrase by the time the flag is cleared. - fn report_tamper
Raise the tamper report from outside, and persist it if the passphrase allows. [`crate::vault`] calls this when it finds the two copies of a lock disagreeing, which is evidence this module cannot see on its own. The flag is held in memory - fn report_tamper
either way; persisting it needs an unlock, so a report raised now becomes durable at the next successful one. - fn change_password
Replace the password, after proving the current one. - fn remove
Remove the lock, after proving the password. Proving it is a courtesy to the honest user, not a control: the file can simply be deleted by anyone who can reach it, which [`SCOPE`] says. - fn cooldown
Seconds still to wait before another attempt is accepted. - fn store_key
Derive the key that names and opens the obfuscated program folder. Delegates to [`AppLock::store_key`], and carries the same warning: the password is not checked here, so call [`Self::unlock`] first and act on what it says. - fn failures
Consecutive failed attempts recorded so far. - fn path
Where this lock is stored. The first of the two copies, when it is vault-backed. - fn save
Write the record, and say whether every copy of it is now current. `Ok(false)` only ever comes from a vault whose administrator-owned spare could not be written. A single file is either written or an error. - fn every_copy_current
Whether the last write reached every copy. False when the administrator-owned spare could not be updated, which leaves it holding an older record. Callers show this; a spare carrying the previous password is a way back in for anybody who - fn every_copy_current
knew it. - fn open_default
Open the lock at the default location, wherever this platform keeps it. Returns `Ok(None)` when no lock is configured, and `Err` when the environment does not say where a configuration directory is -- a caller that cannot find one should - fn open_default
say so rather than scatter a lock file into the working directory. The second element is true when the two copies did not agree: one had gone and was rebuilt from the other, or both were there and held different stored passwords. Neither - fn open_default
happens on its own, so the caller should treat it as a tamper report and show it; [`LockStore::report_tamper`] is how to make it stick. - const LEGACY_NAME
The name the lock had before the vault: one file, under the obvious name. - fn open_in
Open a vault-backed lock under `base`, adopting a pre-vault file if one is there. Split out of [`open_default`] so it can be tested against a directory a test owns, which is the half that was missing when F-141 shipped. - fn read_legacy
Read a pre-vault lock file, if one is there. A file that exists but does not parse is reported rather than ignored: it is somebody's lock, and treating it as absent is how a lock silently becomes no lock. - fn create_default
Create a lock at the default location, refusing to replace one already there. - fn create_in
Create a vault-backed lock under `base`. Split out of [`create_default`] for the same reason as [`open_in`]: so a test can drive creation and opening against one directory it owns, and catch the two disagreeing about where a lock lives. - fn write_private
Write the lock file so it is owner-only from the moment it exists. The previous version used `fs::write`, which creates with the process umask, usually world-readable, and chmod'd afterwards, so the stored password verifier was readable by - fn write_private
every other local user for the window between the two calls. That window reopened on **every save**, and a save happens after every failed unlock attempt. `exclusive` additionally requires that nothing is already at the path, which is how - fn write_private
a lock is created without a check-then-write race and without following a symbolic link planted there. See [`crate::privatefile`] for the whole argument. - fn config_path
Where the lock file lives, given a platform and an environment. Separated from [`default_path`] so every branch can be tested from any machine. The Windows answer is the one that mattered here and the one that could not previously be - fn config_path
checked outside Windows, which is a large part of why **F-143** survived. # F-143: an empty variable is not a directory `var_os` returns `Some("")` for a variable that is set to nothing, and `PathBuf::from("")` joined with anything is a - fn config_path
*relative* path. An empty `HOME` produced `.config/veilvoice/applock.bin`; an empty `APPDATA` produced `veilvoice\applock.bin`. Either scatters the lock into whatever directory the program was started in, and then silently fails to find it - fn config_path
on the next launch from anywhere else -- the same disappearing lock as F-141, from an unrelated cause. `XDG_CONFIG_HOME` was filtered for exactly this. The other two were not. An environment with an empty `APPDATA` is not exotic: services, - fn config_path
stripped sandboxes and some installer contexts all produce one. - const PORTABLE_DIR
The name of the folder that makes a copy of VeilVoice keep its state beside itself. Fixed, and not configurable, because the thing it turns on has to be visible: somebody looking at a folder on a memory stick can see whether the settings - const PORTABLE_DIR
are in it. - fn portable_dir
Where a portable copy keeps its state, when it is one. # Why this is opted into rather than detected "Beside the program if that is writable" would move an ordinary installation's settings the day somebody unpacked it somewhere writable, - fn portable_dir
and the symptom would be an empty vault: everything still on the disk, and the program looking in the other place. So it is not guessed. A directory named [`PORTABLE_DIR`] beside the executable turns it on, and removing that directory - fn portable_dir
turns it off. Both are things a person does deliberately and can see they have done. The directory has to exist already. Creating it here would make every copy portable on its first run, which is the guess this avoids. - fn choose_base
Which of the two locations a copy is using, given what is beside it and what the platform says. Split out so both branches are testable from any machine, which is the same reason [`config_path`] is split out, and for the same reason: the - fn choose_base
branch that mattered in **F-143** could not be checked from the machine that wrote it. The portable folder wins where it exists. That is the whole point of it, and it is the answer even when the platform also offers a configuration - fn choose_base
directory: somebody who put that folder beside the program asked for the state to be there. - fn default_dir
The configuration directory the vault keeps its files in, if the environment says where one is. - fn platform_dir
The platform's own configuration directory, whether or not it is the one in use. [`default_dir`] answers "where is the state", which is the portable folder when there is one. This answers "where would it be if there were not", which is the - fn platform_dir
other half of the question somebody installing a portable copy is being asked: what to carry the settings *to*. - fn is_portable
Whether this copy is keeping its state beside itself. Reported rather than inferred by a caller comparing paths: the About tab says which of the two arrangements is in use, and working that out by looking at a path would be a second copy - fn is_portable
of the rule. - fn default_path
Where the lock file lives, if there is anywhere for it. Two answers, in this order. A folder named [`PORTABLE_DIR`] beside the program, when one is there: a copy on a memory stick then keeps its settings, its vaults and its lock on the - fn default_path
stick, and stays a copy on a stick. Otherwise this platform's configuration directory. The configuration directory is resolved from environment variables rather than a directories crate: it is twenty lines, it adds no dependency to a - fn default_path
security crate, and it returns `None` instead of guessing when the environment does not say. A caller that gets `None` should tell the user it cannot find a config directory rather than scattering a lock file into the working directory. - fn default_path
This is the path a caller naming one gets, and the anchor a dozen other settings files are derived from -- policy, capture, sentry. It is no longer where the lock itself is kept by default: [`open_default`] goes through [`crate::vault`], - fn default_path
whose files sit in the same directory under names derived from its index. - mod tests
- fn weak
- fn a_folder_beside_the_program_wins_over_the_platform_directory
- fn a_portable_folder_is_an_answer_even_where_the_platform_has_none
- fn nothing_beside_the_program_is_created_by_asking
- fn the_name_that_turns_it_on_is_the_one_that_is_looked_for
- fn the_right_password_opens_it_and_a_wrong_one_does_not
- fn a_success_clears_the_failure_history
- fn a_lock_file_is_accepted_at_exactly_its_length_and_not_one_byte_under
A lock file of exactly the right length parses; one byte short does not. `parse` refuses `bytes.len() < want`, and mutation testing turned that into `<=`, which rejects the exact length every real lock file has. The suite never handed it a - fn a_lock_file_is_accepted_at_exactly_its_length_and_not_one_byte_under
buffer of exactly `want`: it tested rubbish, which is too short, and real files, which come through `to_bytes` and so were never measured against the boundary from below. - fn nine_bytes_is_enough_to_reach_the_version_and_eight_is_not
The header boundary from both sides: nine bytes reaches the version. - fn a_legacy_lock_that_cannot_be_read_is_not_reported_as_absent
A legacy lock file that cannot be read is an error, not an absent lock. Telling somebody upgrading that they have no lock is how a lock silently becomes no lock. - fn the_platform_directory_is_a_real_path
Where the lock would live without a portable folder, which is a different question from where it does live. - fn a_save_recreates_its_directory_if_it_is_removed_underneath
A save puts its directory back when somebody has deleted it. Every other path has already created it, so this is the only case that reaches the creation in `save`. - fn a_path_that_is_not_a_readable_file_is_an_error_rather_than_no_lock
Only `NotFound` means there is no lock. Reporting a file that exists but cannot be read as absent turns a permissions problem into an open door. - fn a_report_raised_from_outside_is_visible_and_survives_an_unlock
The vault can raise the tamper report, and unlocking does not clear it. - fn the_store_reports_its_wait_and_whether_every_copy_is_current
The wait and the every-copy-current answer, in states where each matters. - fn the_default_lock_path_is_an_actual_path
The default path names a real file in a real directory. Which path it is belongs to the machine, so only its shape is asserted. - fn the_wait_is_free_then_doubles_then_caps
The rate limit is the whole defence against a script guessing all night, so its shape is asserted rather than assumed. - fn two_locks_agreeing_on_one_half_are_not_the_same_lock
Same lock means the salt **and** the verifier match. One half matching is the case somebody would construct; two records agreeing about everything or nothing cannot tell `&` from `|`. - fn a_tamper_report_is_not_cleared_by_the_wrong_passphrase
Clearing the tamper report costs the passphrase. A report anybody can dismiss is a report an attacker dismisses. - fn the_wait_is_reported_and_counts_down
The remaining wait shrinks with the clock, and a clock that went backwards is not credit against it. - fn the_clock_reads_the_present_rather_than_a_constant
The clock is the real one. Every other test passes its own time, so nothing else would notice a constant. - fn a_rate_limited_attempt_is_refused_without_consulting_the_password
- fn a_clock_that_moves_backwards_does_not_shorten_the_wait
Rolling the clock backwards must not be a way to shorten the wait. - fn it_round_trips_through_its_file_format
- fn the_failure_count_survives_a_reload
A failed attempt must survive a restart, or the rate limit is theatre. - fn malformed_lock_files_are_rejected_cleanly
- fn the_lock_file_is_owner_only_from_the_moment_it_exists
Regression: the verifier used to be written with the process umask and only chmod'd afterwards, so it was world-readable for a window on every single save, and a save happens after every failed attempt. - fn a_symlink_at_the_lock_path_is_not_followed
Creating must fail atomically rather than by testing `exists()` first, so a symbolic link at the lock path is refused instead of followed. - fn the_verifier_is_domain_separated_from_container_keys
The verifier must not be a key anything else could derive from the same passphrase and salt. Domain separation is what guarantees that. - fn the_stored_verifier_and_the_tag_key_are_independent
The half of the derivation that is written to disk must say nothing about the half that authenticates the record, or the tag is worth nothing to anybody holding the file. - fn an_empty_config_variable_is_never_a_relative_path
**F-141.** A lock set through the interface has to be there next time. The desktop application created locks with `LockStore::create`, which writes the single pre-vault file, and loaded them with `open_default`, which reads the vault. - fn an_empty_config_variable_is_never_a_relative_path
Nothing tested the two against each other, so nothing noticed they were using different files: the lock appeared to be set, was gone on the next launch, and the second attempt failed with `AppLockStore` because `applock.bin` was already - fn an_empty_config_variable_is_never_a_relative_path
there. **F-143.** An empty environment variable must read as absent. Every branch, from any machine: the Windows answer is the one that mattered and the one that could not previously be checked off Windows. - fn a_missing_config_variable_is_also_absent
- fn each_platform_puts_the_lock_where_that_platform_keeps_configuration
- fn a_relative_config_directory_is_refused_on_the_real_platform
- fn a_lock_that_was_created_can_be_opened_again
- fn creation_writes_the_vault_and_not_the_pre_vault_file
The sharp half of F-141: *where* creation writes. The round trip above passes even with the bug present, now that `open_in` adopts a pre-vault file -- adoption would quietly paper over exactly the mistake that caused this. So this pins the - fn creation_writes_the_vault_and_not_the_pre_vault_file
thing that was actually wrong: creation goes to the vault, and does not leave the single file behind for the next creation to trip over. - fn setting_a_lock_twice_is_refused_rather_than_failing_on_the_file
- fn a_pre_vault_lock_file_is_adopted_on_open
A lock written by a version that predates the vault is adopted. Every user who set one through the window in 0.1.17 has one of these, under a passphrase they chose and expect to still work. - fn a_pre_vault_file_blocks_a_new_lock_rather_than_being_overwritten
- fn an_empty_directory_has_no_lock_and_that_is_not_an_error
- fn all_three_branches_of_one_run_are_independent
- fn the_store_key_does_not_follow_from_the_verifier_on_disk
- fn the_store_key_is_the_same_every_time_for_one_passphrase
- fn a_different_passphrase_derives_a_different_store_key
- fn the_stored_verifier_is_not_the_password_or_a_bare_hash_of_it
- fn the_store_persists_attempts_across_reopening
- fn no_lock_file_means_no_lock_but_a_broken_one_is_an_error
- fn creating_over_an_existing_lock_is_refused
- fn changing_and_removing_need_the_current_password
- fn swapping_the_stored_verifier_is_reported_at_the_next_unlock
The claim the tag exists to support, stated as a test so it cannot quietly stop being true: an edit made without the passphrase is caught at the next unlock. - fn weakening_the_argon_cost_stops_the_lock_opening_at_all
Weakening the stored cost is the other edit worth making, and it is answered before the tag is even consulted: the cost is an input to the derivation, so a changed cost produces a different verifier and the owner's own passphrase stops - fn weakening_the_argon_cost_stops_the_lock_opening_at_all
matching. Refusal, not a warning, which is the stronger of the two answers. - fn clearing_the_tamper_flag_by_hand_puts_it_straight_back
- fn a_report_outlives_a_restart_and_needs_the_passphrase_to_clear
The report must not be dismissible by anything except the passphrase, and it must survive the process dying. - fn a_wrong_password_is_never_reported_as_tampering
An honest typo is not tampering, and a test says so because getting this wrong would train the user to ignore the warning. - fn a_version_one_lock_still_opens_and_is_upgraded_in_place
A lock written by an older build has no tag. It must keep working, and it must gain one, and it must not be accused of anything on the way. - fn two_writes_never_share_a_tag_nonce
The nonce must move on every write, because Poly1305 under a repeated nonce hands an observer of two records enough to forge a third. - fn a_lock_file_cannot_demand_more_memory_than_an_unattended_caller_allows
F-91. The one file this program reads before anybody has authenticated must not be able to ask for more memory than the machine has. Nobody chose to open it, so nobody can choose to stop waiting for it. - fn acknowledging_a_report_derives_the_key_once
F-88. Acknowledging a report must cost one key derivation, not three. Counted in the source rather than timed, because a timing test on a deliberately slow function is a flaky test. - fn a_change_that_did_not_reach_the_spare_is_not_reported_as_done
F-86. A password change that could not reach the spare has left the previous password in a file this process cannot rewrite, so it is not a finished change and must not be reported as one. - fn the_scope_note_states_the_limit_rather_than_a_guarantee
The user-facing claim must keep stating the limit. If someone edits this into a promise, this test is what stops it shipping. - fn the_default_path_is_under_a_config_directory_when_the_environment_says
crates/veilvoice-crypto/src/privatefile.rs
- (module)
Writing a file that only its owner can read. Returns [`std::io::Result`] rather than this crate's [`Error`](crate::Error), which is `Copy` and therefore cannot carry the underlying reason. A caller reporting "could not write the key" is - (module)
far more useful when it can say why. # Why this is not `std::fs::write` plus a `chmod` `std::fs::write` creates the file with the process umask, which on almost every Unix system means `0644` -- world readable. Tightening it afterwards - (module)
with `set_permissions` leaves a window, however short, in which any other local user can open the file and read all of it. For a file that exists *because* its contents are sensitive, that window has no reason to exist: `OpenOptions::mode` - (module)
applies the permission at the moment of creation, before any byte is written. The audit found this pattern in three places -- the app-lock verifier, the encrypted private key written by `veilvoice keygen`, and the plaintext a recording is - (module)
decrypted into. The verifier one was the worst, because it is rewritten after *every* failed unlock attempt, so the window reopened on each try. This module is the single answer to all of them. # What this does not do It is a Unix - (module)
permission, not a security boundary against root, against someone holding the disk, or against a backup client running as you. It narrows one specific, avoidable exposure: another unprivileged user on the same machine. On Windows there is - (module)
no `mode`. A file created under the user profile inherits an ACL that already excludes other unprivileged users, and there is no portable tightening to apply beyond that -- so on Windows this is an ordinary write, and says so rather than - (module)
implying a protection it did not obtain. # In plain words Writes a file that only you can read. The important part is the order. The permissions are set as the file is created, not afterwards, because a file that exists for even a moment - (module)
with the wrong permissions is a file somebody else's program may have read in that moment. When it cannot manage that, it says exactly why rather than just failing, since "could not write the key" is not something anybody can act on. - fn write_owner_only
Create `path` containing `bytes`, readable only by the current user. An existing file is truncated and rewritten. Use [`write_owner_only_new`] when the file must not already exist. - fn write_owner_only_new
As [`write_owner_only`], but fail if anything is already at `path`. This is the way to create a file without a check-then-write race. Testing `path.exists()` first and then writing loses twice: another process can win between the two - fn write_owner_only_new
steps, and a symbolic link planted at the path would be followed, so the write would land on whatever it points at. `create_new` asks the kernel for one atomic answer to both. - fn replace_owner_only
Replace `path` with `bytes` in one step, or leave what was there. The write goes to a temporary file in the same directory and is renamed over the destination, which the operating system performs as a single operation. A process that dies - fn replace_owner_only
part-way through leaves the old file, not half of a new one. This matters where a half-written file is not merely useless but *wrong*. A truncated app-lock copy reads as a copy that has been interfered with, and a false report of - fn replace_owner_only
interference is worse than none: it teaches the person reading it to dismiss the true one. The temporary file is created owner-only, and the rename carries that permission to the destination, so there is no moment at which the contents are - fn replace_owner_only
readable by anybody else. - fn tighten
Make an existing file readable only by its owner. For files this program did not create. `veilvoice import` and `veilvoice video` hand the writing to `ffmpeg`, which creates the output under its own umask, and `import`'s output is the - fn tighten
*original* audio pulled out of a container: the untouched voiceprint, which is the single most revealing thing this program ever puts on a disk. It was being left world-readable. # What this cannot do There is a window between `ffmpeg` - fn tighten
creating the file and this tightening it, and during that window the file is whatever the umask made it. That is inherent to delegating the write to another program, and it is not closed by pretending otherwise. Narrowing the exposure from - fn tighten
"for ever" to "for the length of one transcode" is worth having, and the honest description of it belongs here rather than in a claim that it is airtight. A no-op on platforms without Unix permissions, where the file's protection comes - fn tighten
from the directory it is in. - fn write_inner
Create or replace a file that only its owner can read. `exclusive` refuses to replace an existing file. The Unix mode is set in the open itself rather than afterwards, so there is no window in which the file exists and is readable by - fn write_inner
anybody else. - mod tests
- fn tightening_closes_a_file_somebody_else_left_open
A file another program created is tightened afterwards. - fn a_replacement_lands_whole_or_not_at_all
- fn a_replacement_is_owner_only_including_while_it_is_temporary
- fn a_replacement_with_no_directory_beside_it_still_writes
- fn the_contents_are_what_was_asked_for
- fn the_file_is_owner_only_immediately
The point of the module: owner-only from the moment the file exists, not after a second syscall. - fn an_existing_loose_file_is_tightened
A file left world-readable by an older build is tightened on the next write, since `mode` alone would not touch it. - fn the_exclusive_form_refuses_an_existing_file
- fn the_exclusive_form_does_not_follow_a_symlink
A symbolic link at the target path must not be followed: creating exclusively is how that is refused rather than obeyed.
crates/veilvoice-crypto/src/shred.rs
- (module)
Secure erasure, the self-destruct. # Read this before relying on it Overwriting a file does not reliably destroy it on modern storage, and any tool that tells you otherwise is selling something. This module does the best that software can - (module)
do from userspace, reports honestly what that is worth on your storage, and points at the thing that actually works. **On a spinning disk**, overwriting is genuinely effective. The write goes to the same physical sectors, and the belief - (module)
that a scanning-microscope recovery of overwritten magnetic media is practical does not survive contact with the literature. Gutmann's 1996 paper, whose 35-pass pattern is still cited, says so himself in its own epilogue about modern - (module)
drives. **On an SSD, or any flash media**, it is not reliable and cannot be made so. Wear levelling means the controller writes your "overwrite" to *different* physical cells and marks the old ones free. The original data still exists in - (module)
flash, out of reach of every write you can issue. The same applies to SD cards, USB sticks, eMMC and NVMe. It also applies through copy-on-write filesystems (Btrfs, ZFS, APFS), snapshots, journals and any backup that has already run. **The - (module)
answer that does work is full-disk encryption.** If the volume is encrypted, destroying the key destroys everything on it at once, wherever the controller chose to put the blocks. LUKS, BitLocker and FileVault all do this. Use it, and - (module)
treat this module as a second line rather than a first. # Why not 35 passes Because passes stopped being the interesting variable decades ago. Against a drive that honours writes, one pass is enough; against one that does not, no number of - (module)
passes reaches the retained cells. The default here is three, random then complement then random, which satisfies the common three-pass expectation without pretending that thirty-five would be stronger. Time is better spent enabling disk - (module)
encryption than on passes 4 through 35. # In plain words This is meant to destroy a file, and the first thing it does is tell you how much that is worth. On the drives most computers now have, overwriting a file does not reliably remove - (module)
it. The drive puts the new data somewhere else and leaves the original sitting in a place no ordinary program can reach, until it is cleaned up later, which may be never. So this does what it can and refuses to promise more. If you need - (module)
something to be genuinely unrecoverable, encrypt it from the start and never write it unencrypted anywhere. - const CHUNK
Bytes written per chunk. Large enough to be fast, small enough that a huge file does not need a huge buffer. - enum Passes
How thoroughly to overwrite before unlinking. - impl Passes
- fn count
How many overwriting passes this setting means, with a custom count held between 1 and 32. Zero passes would be a shred that shreds nothing. - struct ShredReport
What actually happened, so the caller can tell the user the truth. - fn shred_file
Overwrite a file's contents, then delete it. The file is opened for writing in place, never truncated and never copied, so the bytes on disk are the ones being overwritten, as far as the operating system and the drive allow. - fn caveats
The honest limits, phrased for a user rather than a security engineer. - mod tests
- fn sample
- fn the_file_is_gone_afterwards
- fn the_contents_are_overwritten_before_deletion
The point of the exercise: the plaintext must not still be in those bytes. Read the file back before it is unlinked to prove the overwrite actually landed. - fn the_length_is_reported_and_every_byte_is_covered
- fn an_empty_file_is_handled
- fn pass_counts_are_clamped_not_trusted
- fn the_report_always_states_its_limits
The report must never claim a clean kill. Someone acting on this needs to know about flash retention whether or not they thought to ask. - fn a_missing_file_is_an_error_not_a_silent_success
- fn a_symlink_is_refused_and_its_target_is_untouched
Regression for the finding that this followed symbolic links. Erasing `link -> victim` used to fill `victim` with random bytes and unlink only `link`, then report a clean erasure of a file that was still there. The target must be untouched - fn a_symlink_is_refused_and_its_target_is_untouched
and the link must survive. - fn a_symlink_is_refused_and_its_target_is_untouched
The same on Windows, where a symlink needs either Developer Mode or elevation to create, so the test skips rather than fails when it cannot make one. - fn the_write_buffer_is_never_empty_at_any_file_length
Regression for the 32-bit truncation that made the loop non-terminating: the buffer length must be derived in `u64` and can never be zero, for any file length a `u64` can express. - fn buffer_len_for
- fn a_directory_is_refused
crates/veilvoice-crypto/src/studio.rs
- (module)
The studio vault: a key that exists only when both locks have been opened. # The one thing this adds Everything else in this crate is protected by one secret. The app lock guards the window; a sealed recording is opened by its own - (module)
passphrase. Each is a single point: whoever has that one secret has the thing it guards. A [`StudioKey`] is derived from **both**, and from neither alone. A laptop stolen with VeilVoice already unlocked opens nothing here, because the - (module)
at-rest passphrase was never entered. An at-rest passphrase learned by any means opens nothing here either, because it is not the app lock. Both, at the same time, on the same machine, or the vault stays shut. # How the two are combined - (module)
Not concatenated, and not one encrypting the other. Both secrets go into HKDF-SHA256 as input keying material, under a salt that names this vault and its version, and the output is the vault key: ```text ikm = app_lock_key || at_rest_key - (module)
salt = "veilvoice/studio-vault/v1" key = HKDF-SHA256(ikm, salt, info) ``` HKDF-Extract mixes the whole of the input, so an attacker holding one half and guessing the other faces the full cost of the half they are guessing. Concatenating - (module)
the two *ciphertexts* instead, or encrypting once with each key in turn, would let each layer be attacked separately, which is the mistake this shape exists to avoid. The length of each half is bound into the info string. Without that, - (module)
`("ab", "c")` and `("a", "bc")` would produce the same input keying material and therefore the same vault key, which is a collision an attacker chooses rather than finds. # What it is worth, and what it is not It raises the cost of a - (module)
stolen machine and of a leaked passphrase, and it turns one compromise into two. Both of those are real. It does **not** defeat somebody who is watching this process while both secrets are entered: at that moment the derived key exists in - (module)
memory, and this crate has never claimed to beat an attacker who is already inside the process. It is page-locked and zeroized like every other secret here, which narrows the window and does not close it. The vault is a second lock on the - (module)
door, not a guard in the room. # In plain words The recordings the studio makes are locked with a key made out of *two* of your passwords at once. Somebody who learns one of them still cannot open them, and neither can somebody who walks - (module)
off with the computer while the app is open. What it cannot do is protect you from something already running inside VeilVoice at the moment you type both. - const KEY_LEN
Bytes in a studio vault key. - const SALT
Names this construction and its version in the HKDF salt. Versioned so that changing how the two halves are combined produces a different key rather than silently reinterpreting an existing vault. - const INFO
The HKDF info label. - struct StudioKey
A key that exists only while both locks are open. No `Debug`, no `Clone`, and no way to read the bytes out except [`StudioKey::expose`], which the sealing code needs. Wiped when dropped, because the [`Secret`] inside it is. - impl StudioKey
- fn derive
Derive the vault key from both secrets. `app_lock` is the key material the app lock produced when the window was unlocked; `at_rest` is the key material the recording passphrase produced. Both are required, and an empty one is refused - fn derive
rather than treated as "no second factor": a vault that quietly degraded to one secret when the other was missing would be the exact failure this type exists to prevent, and it would do it silently. - fn expose
Borrow the key bytes, for sealing and opening the vault. - fn is_locked
Whether the operating system agreed to keep this key out of swap. Reported rather than assumed, exactly as [`crate::amnesia`] and [`crate::tape`] report it: locking is best effort, and a caller telling somebody their vault key is - fn is_locked
unswappable should be saying what actually happened. - struct Entry
One recording in the vault. Metadata only: what it is, when it was made and how large. The audio is never in here, so listing a vault does not decrypt any of it. - impl std::fmt::Debug for Entry
Deliberately opaque: an entry names a recording somebody made, and a name like "meeting with the lawyer" reaching a log is the sort of leak this project is otherwise careful about. - fn fmt
- struct Studio
A directory of recordings, sealed under a [`StudioKey`]. # What is on disk, and what it gives away One file per recording, named by an identifier that says nothing, plus one index file. The index holds every name and date and is itself - struct Studio
sealed under the same key, so a vault sitting on a disk shows how many recordings there are and roughly how large each is, and nothing else. Those two facts are not hidden inside a vault, and the documentation says so rather than implying - struct Studio
otherwise. What hides them is the folder the vault is in: [`make_decoy_in`] fills it with vaults of exactly this size holding nothing, and [`find_or_make`] finds the real one by opening it rather than by its name, so the count and the - struct Studio
sizes stop identifying anything. The program folder's own storage takes the other route, padding, which [`crate::hoard`] is for and is a different trade. - const INDEX
The index file's name. Fixed rather than derived: a vault whose index cannot be found is a vault nothing can open, and the directory already discloses that it is a vault by existing. - impl Studio
- fn open
Open the vault in `dir`, creating the directory if it is not there. - fn dir
Where the vault lives. - fn secret_key
The vault key as a [`Secret`], for the AEAD. A fresh copy each time, taken into locked memory and wiped when it goes out of scope, so the working copy never outlives the call that needed it. `Secret::new` wipes the intermediate `Vec` as it - fn secret_key
takes ownership. - fn list
Every recording in the vault, oldest first. An index that will not open is an error rather than an empty list. A vault that quietly reports "no recordings" when the truth is "the key is wrong, or this has been tampered with" would be the - fn list
worst possible answer: it reads as reassurance. - fn store
Seal `wav` into the vault under `name`, returning its entry. - fn load
Open one recording into locked memory. Returns a [`Secret`], not a `Vec`: this is the audio in the clear, and the whole vault exists so that it is never anywhere unprotected. A caller playing it back reads from here and does not write it - fn load
to a temporary file, because a temporary file is the thing the vault was avoiding. - fn rename
Change what a recording is called. Only the index is rewritten. The audio is sealed under the recording's identifier rather than its name, so renaming does not re-encrypt anything and cannot lose the recording if it is interrupted: either - fn rename
the new index lands or the old one stays. A name that is not in the vault is an error rather than a silent no-operation. Somebody renaming a recording that is not there has a wrong identifier, and telling them so is more use than appearing - fn rename
to succeed. - fn remove
Remove one recording and its index entry. - fn write_index
Seal the index and replace the file on disk with it. Replaced rather than rewritten in place, so a crash halfway through leaves the old index rather than half of the new one. - fn seal
Seal with a fresh nonce, binding `aad` so a file cannot be moved to another identity inside the same vault and still open. - fn unseal
Open what [`Studio::seal`] produced, with the same associated data. The nonce is the first bytes of the file, so a file shorter than one is truncated rather than something to attempt. - fn unseal_secret
The same, decrypting straight into locked memory. A recording is the whole point of this vault and it is large. Going through [`Self::unseal`] would put every byte of it into an ordinary heap `Vec` first, where the kernel is free to page - fn unseal_secret
it out, and only then copy it into a [`Secret`]. That window is not brief for a file of any size, and it is exactly the window this vault exists to close, so the index goes through `unseal` and the audio goes through here. - const ID_LEN
How long an identifier is, in characters. Named because two things read it: the generator below, and the arithmetic that works out how large a decoy will be. A second literal in either place would be a fact written twice. - fn new_id
A random, opaque identifier: [`ID_LEN`] lower-case letters and digits that say nothing about what they name. One byte is drawn per character and five bits of each are used, so the identifier carries five bits per character: a hundred of - fn new_id
them at the length set above. That is far more than a vault will ever hold and it is not a compromise for space: the alphabet has 32 letters in it, so five bits per character is exactly what one character holds, and taking more would need - fn new_id
a base conversion for no benefit. - const ALPHABET
- fn safe_id
Whether `id` is one this vault could have produced. Checked before it reaches a path. An identifier read back out of an index is data, and an index is a file somebody could have edited: without this, an id of `../../.bashrc` would send a - fn safe_id
read or a delete somewhere else entirely. - fn render_index
The index, as lines. Tab-separated because a name may contain almost anything except a tab or a newline, and both are rejected on the way in. - fn parse_index
Read the index back: one entry per line, four tab-separated fields. Every field is read from inside the sealed region, so this parses text that has already been authenticated. It still refuses a line it cannot read rather than filling in a - fn parse_index
default, because a record that says the wrong thing is worse than one that is missing. - struct Shape
What a decoy vault looks like from outside, so it looks like the real one. Taken from a real vault rather than invented, because the whole value of a decoy is that the two cannot be told apart by looking. A decoy built to a guessed shape - struct Shape
is a decoy that stands out. - impl Shape
- fn of
Measure a real vault, to build decoys that match it. - fn bytes_on_disk
What one vault of this shape occupies, in bytes, as files on a disk. # Why this is here and not where it is asked for The number depends on the sealed file layout: a nonce and a tag on every file, and the index beside them. That layout is - fn bytes_on_disk
this module's, so the arithmetic is this module's too. An interface that worked it out for itself would be a second copy of the format, and the two would part company the first time a field was added here. It is exact rather than - fn bytes_on_disk
approximate, and a test builds decoys and adds up the real files to prove it stays exact. - fn bare_index
The shortest index a decoy of this shape can be written with: every entry present and every name empty. - fn index_len
The index length a decoy of this shape will actually be written with. The measured length, unless that is shorter than a decoy of this many recordings can be, in which case the shortest one is what gets written. Both this and - fn index_len
[`make_decoy`] read it, so the size a panel promises and the size the disk receives cannot part company. - fn digits
How many decimal digits `n` is written with. The index stores the byte count as text, so its width is part of the size on disk. Written out rather than reached for through a formatted `String`, because this is asked once per decoy per - fn digits
redraw of a panel. - fn vault_dirs
Every directory under `parent` that is shaped like a vault. Shaped like one means it holds an index. That is the only thing that can be seen from outside, and it is deliberately the only thing looked at: a real vault and a decoy are the - fn vault_dirs
same shape here, and which of them opens is a question only a key can answer. Sorted by name so the order does not depend on how the filesystem happens to hand directories back, which would otherwise make a test flaky and, worse, make the - fn vault_dirs
order a decoy is tried in vary between machines. - fn find_or_make
Open the one vault under `parent` that `key` unlocks, making it on a first run. # Why the vault is found rather than named A decoy is only worth making if it cannot be told from the real thing, and a real vault at a fixed, known name is - fn find_or_make
told from a decoy by reading the name. So every vault under `parent`, real and decoy alike, is a directory with an opaque identifier for a name, and the only thing that distinguishes them is that exactly one of them opens. This tries each - fn find_or_make
in turn. Trying is cheap: the expensive part of unlocking is deriving the key, which happens once before this is called, and each attempt after that is opening one small sealed index. # What happens when nothing opens The error from the - fn find_or_make
last attempt is returned, and **no vault is made**. Making a fresh one there would be the worst answer available: somebody who mistyped a passphrase would be shown an empty vault and would reasonably conclude their recordings were gone. A - fn find_or_make
vault is made only when `parent` holds none at all, which is a first run. It is made with an index written immediately, so that a vault holding nothing is the same shape on the disk as a decoy holding nothing, from the moment it exists. - fn migrate_flat
Move a vault written straight into `parent` down into a directory of its own. # The layout this converts from Before decoys existed there was one vault and it sat directly in `parent`, because there was nothing for it to be confused with. - fn migrate_flat
There is now, and a vault sitting where decoys are siblings would be the one directory that is not a directory, which gives it away completely. # Interrupted half way The index moves **last**, so `parent` still holding an index means the - fn migrate_flat
move did not finish, and this runs again on the next open. It moves into the directory already made rather than a new one when there is exactly one, so running again finishes the job instead of splitting the vault in two. - fn make_decoy_in
Make one decoy under `parent`, named the way a real vault is named. Returns where it went. The name is drawn from the same generator that names recordings and vaults, so a decoy is not distinguishable from the real vault by its name any - fn make_decoy_in
more than by its size. - fn make_decoy
Fill `dir` with a vault that never held anything. # What a decoy is, and what it deliberately is not It is **not** a vault with weak contents, or a vault whose passphrase is written down somewhere, or a vault holding harmless recordings. - fn make_decoy
Any of those is a vault that rewards cracking, and a decoy that rewards cracking teaches an attacker that cracking works. It is a vault whose contents never existed. The files are random bytes sealed under a key generated here and dropped - fn make_decoy
before this function returns, so nobody holds it: not the person who made the decoy, not this code, not anybody who takes the disk. Opened by brute force, it yields bytes that parse as nothing, because there is nothing under them to find. - fn make_decoy
# What it buys, stated plainly It raises the cost of a search. Somebody who takes a disk and finds nine vaults must attack all nine to learn which one matters, and eight of them cannot be finished at any price. It does **not** make the - fn make_decoy
real vault unfindable to somebody who watches you open it, reads this process's memory, or has any other way to see which directory you actually use. Decoys are cover against a search of the disk, not against being observed, and anybody - fn make_decoy
relying on them should know which of those they are facing. - fn pad_index
Grow the names until the index is exactly the length a real one was. # Why the names are padded rather than left empty The index is sealed, so nobody can read a name out of it or see how the length is divided between them. What anybody can - fn pad_index
see is the size of the file, and that size is the length of the text plus a fixed overhead. A real vault's recordings are called something and a decoy's are called nothing, so without this every decoy in a folder is the one with the - fn pad_index
smallest index. The share is even because the division is invisible: only the total is on the disk. The characters are random rather than repeated for the same reason the audio is, which is that a file whose size does not match its entropy - fn pad_index
is itself a tell if the sealing is ever broken. `want` shorter than the bare minimum is left alone rather than forced. It means the real index was smaller than a decoy of the same count can be, and the honest answer to that is a decoy a - fn pad_index
few bytes larger, not a corrupt one. - mod tests
- fn secret
- fn vault_with_a_take
A vault in a temporary directory, with a take already in it. - fn renaming_changes_the_name_and_nothing_else
- fn a_newline_in_a_name_cannot_forge_a_second_entry
- fn renaming_something_that_is_not_there_is_refused
- fn the_same_pair_always_gives_the_same_key
- fn neither_secret_alone_produces_the_vault_key
- fn changing_either_half_by_one_bit_changes_the_key
- fn where_the_split_falls_is_part_of_the_key
- fn swapping_the_two_halves_is_a_different_vault
- fn the_key_is_key_shaped_rather_than_a_copy_of_its_inputs
- fn a_studio
- fn a_recording_stored_comes_back_byte_for_byte
- fn the_other_key_opens_nothing_in_this_vault
- fn nothing_readable_is_left_on_disk
- fn a_recording_cannot_be_moved_to_another_identity_and_still_open
- fn an_identifier_out_of_an_index_cannot_walk_out_of_the_vault
- fn removing_one_leaves_the_others_openable
- fn an_unreadable_index_is_an_error_rather_than_an_empty_vault
- fn a_name_with_a_tab_or_newline_cannot_forge_a_second_entry
- fn each_stored_recording_gets_an_identifier_of_its_own
- fn a_decoy_looks_like_the_real_thing_from_outside
- fn a_decoy_holds_nothing_and_nobody_can_open_it
- fn a_first_run_makes_one_vault_and_the_next_run_finds_it
- fn a_new_vault_has_an_index_from_the_moment_it_exists
- fn the_wrong_pair_finds_nothing_and_makes_nothing
- fn the_real_vault_is_found_among_its_decoys
- fn a_decoy_is_the_same_shape_on_the_disk_as_the_vault_it_copies
- fn a_vault_written_the_old_way_moves_down_into_a_directory_of_its_own
- fn a_move_interrupted_half_way_finishes_rather_than_splitting_the_vault
- fn a_decoy_takes_exactly_the_room_its_shape_says_it_will
- fn a_decoy_is_not_full_of_zeroes
- fn decoys_differ_from_each_other
- fn the_shape_of_an_empty_vault_does_not_divide_by_zero
- fn a_recording_is_decrypted_straight_into_locked_memory
A recording never passes through an ordinary heap buffer on its way out. `Studio::load` used to call `unseal`, which calls `aead::open`, which hands back a plain `Vec<u8>`. The whole recording was decrypted into pageable memory the kernel - fn a_recording_is_decrypted_straight_into_locked_memory
may write to swap, and only then copied into a `Secret` and the vector wiped. Every test here passed: the bytes were right and the wrong key still opened nothing. The defect was in where the right bytes had been, which no round trip can - fn a_recording_is_decrypted_straight_into_locked_memory
see. So this reads the source. It is a blunt check and it is the only kind that can express "and it never went anywhere else on the way". - fn the_key_reports_its_locking_rather_than_claiming_it
crates/veilvoice-crypto/src/tape.rs
- (module)
A recording held in locked, zeroizing memory while it is still being made. # The problem this exists for [`Secret`] holds a passphrase or a key: a few dozen bytes, known in full before the allocation is made. A recording is neither. It - (module)
arrives a few milliseconds at a time, for as long as somebody keeps talking, and nobody knows at the start how long that will be. Accumulating it in a `Vec<u8>` would undo the thing this crate is careful about everywhere else. A `Vec` is - (module)
not locked, so the operating system may write it to the page file; it is not zeroized, so its contents outlive it in freed memory; and it reallocates as it grows, which leaves the *previous* buffer, still holding the recording so far, - (module)
somewhere on the heap with nothing wiping it. Every doubling leaves another copy behind. A `Tape` is the growable equivalent of a [`Secret`]: append-only, made of page-locked chunks that are never reallocated or moved, and zeroized in full - (module)
when it goes out of scope. # Why chunks, rather than one buffer that grows Growing means reallocating, and reallocating a secret means copying it to a new address and leaving the old bytes unwiped in memory the allocator is now free to - (module)
hand to anybody. The whole point is that no copy is ever left behind, so nothing here is ever resized. A full chunk is kept exactly where it is and a new one is added beside it. Chunks are [`CHUNK`] bytes, which is a deliberate compromise - (module)
rather than a round number picked for looks. Locking is charged against a per-process budget (`RLIMIT_MEMLOCK` on Linux, often a few megabytes and sometimes far less), and a chunk is the unit in which that budget is spent. Small chunks - (module)
spend it in fine increments, so a tape that outgrows the budget locks as much as the budget allowed rather than losing a large request wholesale. # What this buys, and what it does not It buys the same thing [`Secret`] buys, over a buffer - (module)
that grows: the recording is kept out of the page file where the operating system permits it, and it is wiped rather than abandoned. It does not defeat somebody who can already read this process's memory, and locking does not survive - (module)
hibernation, which writes RAM to disk wholesale. Locking can also simply fail: the budget above is small and unprivileged processes cannot raise it. That is reported rather than hidden. [`Tape::fully_locked`] is false the moment one chunk - (module)
could not be locked, and [`Tape::locked_chunks`] says how many were, so a caller can tell the user what was actually obtained instead of implying a guarantee. Zeroization, as in [`crate::amnesia`], always happens. # In plain words - (module)
Somewhere to keep a recording while it is being made, which asks the operating system not to write it out to disk and wipes it when it is finished with. It is built out of fixed-size pieces so that it never has to move what it is already - (module)
holding. Moving it would leave a copy of your recording behind in memory, which is exactly what this is for avoiding. - const CHUNK
Bytes per chunk. See the module documentation: this is the unit in which the operating system's lock budget is spent, so it is small enough that a tape which outgrows that budget still locks most of what it holds, and large enough that a - const CHUNK
long recording does not accumulate an absurd number of allocations. At 48 kHz 16-bit mono, one chunk is about two thirds of a second. - struct Tape
An append-only buffer of locked, zeroizing chunks. Deliberately has no `Debug`, no `Clone` and no way to hand out an owned copy of its contents: every route out of it writes into storage the caller has already made safe. See - struct Tape
[`Tape::copy_into`]. - impl Default for Tape
- fn default
- impl Tape
- fn new
An empty tape. No allocation happens until the first byte is appended. - fn push
Append `bytes`. Fills the current chunk before adding another, so a tape of `n` bytes holds `n.div_ceil(CHUNK)` chunks and no more: the count is a function of the length alone, never of how the appends were split up. A caller pushing one - fn push
sample at a time and a caller pushing a whole buffer end up with byte-for-byte the same tape. - fn len
Total bytes held. - fn is_empty
Whether nothing has been appended. - fn locked_chunks
How many chunks the operating system agreed to lock out of swap. - fn chunk_count
How many chunks the tape holds. - fn fully_locked
Whether every chunk is locked. An empty tape is trivially fully locked: there is nothing unlocked in it. Callers reporting this to a user should check [`Tape::is_empty`] first if "nothing to protect" and "protected" should read differently. - fn copy_into
Copy the whole tape into `out`, which must be exactly [`Tape::len`]. Takes a destination rather than returning a `Vec` on purpose. Returning one would put the entire recording into unlocked, unzeroized memory and undo this type in a single - fn copy_into
line, and it would do it at the one moment the recording is complete and therefore at its most worth protecting. The caller allocates a [`Secret`] and passes its [`expose_mut`](Secret::expose_mut) here instead. Returns - fn copy_into
[`Error::TapeLength`] if `out` is the wrong size, rather than copying what fits: a partial recording written into a buffer sized for a whole one is a truncated file that nothing would flag. - fn wipe
Wipe and release everything held, leaving an empty tape. Each chunk is a [`Secret`], so dropping it wipes it and unlocks its pages. This exists so a caller can do that at a chosen moment rather than waiting for the tape itself to go out of - fn wipe
scope, which matters when the tape lives inside a long-running session. - mod tests
- fn a_push_that_crosses_a_chunk_boundary_lands_where_it_should
A push longer than one chunk fills the room actually left in it. - fn fully_locked_agrees_with_the_chunk_counts
Stated as a relation between the counts, because whether the operating system grants a lock is a property of the machine. - fn an_empty_tape_holds_nothing_and_allocates_nothing
- fn what_goes_in_comes_out_byte_for_byte
- fn a_tape_spanning_many_chunks_reassembles_in_order
- fn how_the_appends_were_split_up_does_not_change_the_tape
- fn a_chunk_boundary_lands_exactly_where_the_arithmetic_says
- fn pushing_nothing_changes_nothing
- fn a_destination_of_the_wrong_size_is_refused_rather_than_part_filled
- fn wiping_leaves_an_empty_tape_that_still_works
- fn the_lock_count_never_claims_more_than_the_tape_holds
- fn an_empty_tape_reports_no_unlocked_chunks
crates/veilvoice-crypto/src/vault.rs
- (module)
Where the app lock is kept: two copies, unpredictable names, and a restore. # What is real here and what is only awkward This module does three things to the app-lock file, and they are not worth the same amount. Saying which is which is - (module)
the point of this section. **The second copy is real.** A lock kept in one file is removed by deleting that file. A lock kept in two files in two different directories is not, unless the person deleting knows about both. When the first - (module)
copy is gone or unreadable, [`Vault::load`] restores it from the second and reports the event, so the lock comes back and the owner is told it went. **The administrator-only copy is real, where the platform provides it.** On Unix, when - (module)
VeilVoice is run with enough privilege to write under `/etc`, the second copy is written there and is thereafter not writable by an ordinary user. Removing the lock then needs `sudo`, which is a genuine step up from needing a file manager. - (module)
VeilVoice never asks for that privilege and never elevates itself: it uses what it already has and otherwise carries on. On Windows the equivalent needs an access-control list this crate does not link the API to set, so the second copy - (module)
there is a second copy and nothing more, and this module says so rather than implying a protection it did not obtain. **The unpredictable name and the masked contents are neither.** They are obscurity. The name is derived from a value in - (module)
an index file that sits at a fixed, obvious path, because something has to, or nothing could ever find the lock again. Anybody who reads this source, or simply reads the index, recomputes both names in a second. What they buy is narrow and - (module)
real enough to keep: a scan for the string `VEILLOK1` across a disk finds nothing, a backup rule written against `applock.bin` misses, and advice of the form "just delete this file" does not survive being passed on. None of that stops an - (module)
attacker who is paying attention, and none of it is counted as security anywhere in the documentation. # What none of it does It does not stop somebody holding the disk. It does not stop somebody who knows the passphrase. It does not make - (module)
the failed-attempt counter trustworthy: see [`crate::lock::AppLock::to_bytes`] for why that one cannot be authenticated at all. If the threat is the disk, the answer is still full-volume encryption. # In plain words The lock is kept twice, - (module)
in two places, under names that are not guessable from the outside, and scrambled so it does not look like a password file. If one copy goes missing, the other puts it back and you are told. The scrambling and the odd names are speed - (module)
bumps, not locks. They stop careless deletion and casual searching. They do not stop somebody who has decided to get in and has your disk. Where VeilVoice is already running with administrator rights, the spare copy is put somewhere an - (module)
ordinary user cannot touch, and that part is not a speed bump. - const SITE_LEN
Length of the per-installation value the file names are derived from. - const NAME_BYTES
Bytes of hex in a derived file name. Ten bytes is eighty bits, which is far past any chance of collision and short enough to read out over a phone. - const INDEX_NAME
Fixed name of the index. Deliberately not hidden: pretending the entry point is secret would be the dishonest half of this idea. - const LABEL_PRIMARY
Domain separators, so the two names and the mask cannot coincide. - const LABEL_SHADOW
- const LABEL_MASK
- enum Found
What [`Vault::load`] found when it went looking. - struct Vault
The two files a lock lives in, and the index that names them. - impl Vault
- fn at
Resolve the vault under `base`, creating the index if there is none. `base` is the per-user configuration directory: the parent of what [`lock::default_path`] returns. `admin` is a directory only an administrator can write, or `None` when - fn at
this platform or this process has no such directory to offer. - fn primary
The file the lock is read from and written to. - fn shadow
The second copy. - fn index
The index that names both. - fn load
Read the lock, restoring one copy from the other if it has to. Returns the record and what it took to get it. A copy that parses is preferred over one that does not, and when both parse the primary wins -- it is the one the running program - fn load
writes, so it is the one carrying the current attempt count. - fn store
Write both copies, and say whether the spare is now current. `Ok(false)` means the first copy was written and the spare could not be. That is the administrator-owned arrangement working as designed and it is still not something to swallow, - fn store
because a spare left behind is a spare holding an *older* record. After a password change that older record is the previous password, and deleting the first copy would restore it. So the answer is returned rather than discarded, and - fn store
[`lock::LockStore`] refuses to call a password change finished until the spare has caught up. - fn clear
Remove both copies, and the index with them. - fn write_one
Mask `bytes` for this site and write them at `path`, creating the directory if it is not there. Replaced rather than truncated and rewritten, for the reason in the body. - fn read_masked
Read one file back and unmask it, or `None` if it is not a lock. A decoy fails to parse here, which is how the real file is found among them without anything on disk saying which it is. - fn name_for
The file name derived from `site` under `label`. - fn mask
Exclusive-or `bytes` with a keystream derived from `site`. This is a mask, not encryption, and the difference is not a technicality: the value it is derived from is in a file next to the one it masks. It exists so that a lock file does not - fn mask
announce itself to a string search, and for nothing else. It is its own inverse, which is why one function serves both the write and the read. - fn admin_dir
A directory only an administrator can write to, if this process can make one there. The test is the attempt. Asking the operating system "am I an administrator" needs a platform API in each case; creating the directory answers the only - fn admin_dir
question that matters -- can this process put a file somewhere an ordinary user cannot rewrite -- and answers it the same way everywhere. No privilege is requested and none is escalated: an unelevated run simply gets `None` and keeps both - fn admin_dir
copies in the user's own directory. Returns `None` on Windows even when the directory can be created, because `%ProgramData%` is writable by ordinary users by default and tightening it needs an access-control list this crate does not set. - fn admin_dir
A second copy there would be a second copy, which [`Vault`] already provides, not a privileged one, and calling it privileged would be the overclaim. - mod tests
- fn weak
- fn vault
- fn clearing_a_vault_that_was_never_written_succeeds
Removing nothing is not a failure, and removing it twice is not either. - fn a_store_recreates_its_directory_if_it_is_removed_underneath
A store puts its directory back when somebody has deleted it. `Vault::at` creates it when it writes a new index, so the creation in `write_one` only matters between one write and the next. - fn an_index_that_cannot_be_read_is_refused_rather_than_replaced
An unreadable index is refused, not replaced. The index names both copies of the lock: drawing a fresh one writes the lock under a name nothing can compute again. - fn the_two_copies_are_not_the_same_file
- fn the_names_are_stable_across_runs_and_differ_between_installs
- fn nothing_on_disk_says_it_is_a_lock_file
- fn a_masked_file_reads_back_as_what_was_written
- fn deleting_one_copy_does_not_remove_the_lock
- fn a_shredded_copy_is_rebuilt_rather_than_believed
- fn no_lock_reads_as_no_lock_rather_than_as_an_error
- fn clearing_leaves_nothing_behind
- fn the_mask_changes_the_bytes_and_undoes_itself
The mask is its own inverse, and the test says so directly rather than only through a round trip, because a mask that quietly became a no-op would still pass a round trip. - fn a_damaged_index_refuses_rather_than_writing_a_new_one
F-85. A read that fails for any reason other than "there is no index" must not be answered by writing a new one. The failure that mattered was not corruption, it was permission: one refused read and the lock would have been orphaned under - fn a_damaged_index_refuses_rather_than_writing_a_new_one
a name nothing could compute again. - fn a_spare_that_cannot_be_written_is_not_reported_as_deleted
F-87. A spare that could never be written is not a spare that was taken away, and reporting it as one raises the same false alarm at every launch until nobody reads any of them. - fn two_copies_with_different_passwords_are_reported
F-86. Two copies that hold different passwords mean one of them is not the lock that was set, and the older one is the way back in for whoever knew the previous password. - fn storing_says_whether_the_spare_was_written
F-86, the reporting half: `store` says whether the spare caught up, so a caller can refuse to call a password change finished when it did not. - fn site_of
The site is private, and this test needs it to forge a spare. Reading it back from the index is what any other process would have to do. - fn the_index_is_owner_only_from_the_moment_it_exists
crates/veilvoice-crypto/src/weave.rs
- (module)
Thirty-one reversible encodings, chosen at random, applied around the encryption -- before it, after it, or both. # What this buys, and it is not what it looks like Say the disappointing part first, because the alternative is letting a - (module)
reader assume it. **This adds no cryptographic strength.** Every record here is sealed with ChaCha20-Poly1305, whose output is already indistinguishable from random to anybody without the key. Encoding the plaintext before encrypting it - (module)
does not make that ciphertext harder to break, and anybody who tells you a layer of base91 under an AEAD is "double encryption" is wrong. If the only thing standing between an attacker and your data were the encoding, the answer would be: - (module)
that is not security, it is a puzzle. So the honest list of what it does buy, all of it smaller than the previous paragraph is big: - **Plaintext that leaks by a route other than the AEAD is not readable.** A core dump, a swap file, a page - (module)
that was written before the seal, a future bug in this crate's own framing -- any of those hands somebody the plaintext buffer. `frame_ms = 4.25` in that buffer is a sentence. The same record base91-ed under a move-to-front transform is - (module)
not, and cannot be grepped for. - **After a key compromise there is one more step.** Small, and worth naming as small: somebody with the passphrase reads the encoding marker in the first byte and undoes it. It costs them a minute, not a - (module)
month. - **A partially-recovered record does not read as text.** Truncated or damaged plaintext that decodes to nothing is better than truncated plaintext that decodes to half your settings. That is the whole claim. It is defence in depth - (module)
against exposure that does not go through the cipher, not a second cipher. # It is nowhere near the live path, and adds no lag These run only when VeilVoice writes one of its own small files -- settings, measurements, the integrity record. - (module)
They never touch a sample of audio. The DSP and capture crates do not name this module, and cannot, because it is not in their dependency graph. So "does the encoding slow down the live scramble" has a structural answer rather than a - (module)
benchmarked one: the code that would slow it down is not reachable from it. Even where they do run, the input is kilobytes and the transforms are a single linear pass, so the cost is lost in the Argon2id run the same unlock already pays - (module)
for. # Names are different, and the difference matters A record's filename is 18 bytes of HMAC, base64url-encoded to exactly 24 characters. Weaving those bytes first is fine -- but **only with a codec that preserves length**. If a name - (module)
could be hex-encoded it would come out 48 characters instead of 24, and the length of the filename would announce which encoding was used. Worse, decoys are random bytes with a random weave while records have a key-derived one, so a length - (module)
difference would separate the two at a glance and undo the entire point of the decoys. So names use [`LENGTH_PRESERVING`] only, and the choice is derived from the key rather than drawn at random, because a name has to be computable again - (module)
next time. Contents may use anything, because contents are padded to fixed buckets afterwards. One honest consequence of that padding: an expanding codec can push a record into a larger bucket than a compact one would, so writing the same - (module)
data twice can produce two different file sizes. That reveals nothing about the data -- only that the encoding changed -- and it is the reason bucket sizes are coarse. # In plain words Before VeilVoice encrypts one of its own files, it - (module)
scrambles the contents into one of twenty-seven odd formats picked at random, and does something similar to the filename. It is not what keeps the file secret. The encryption does that. This means that if the unencrypted contents ever - (module)
escape some other way -- a crash dump, a swap file -- what escapes does not read as anything. - enum Weave
Every encoding, by name. The identity is deliberately in the set. A scheme that never leaves data alone is a scheme in which "unencoded" is itself a signal. - const LENGTH_PRESERVING
Every encoding that leaves the byte count alone. The only ones a filename may use. See the module note for why. - const ALL
Every encoding, for contents. - impl Weave
- fn id
The marker stored with a record so the encoding can be undone. `Rotate` carries its shift in the low byte of a two-byte marker; every other encoding is one byte and a zero. - fn from_id
Recover an encoding from its marker. - fn preserves_length
Whether this leaves the byte count untouched. - fn random_length_preserving
Pick one at random from the length-preserving set. This is what the *outer* layer uses -- the encoding applied to a record after it is sealed. It must not change the length, because the sealed blob is already bucket-padded and an outer - fn random_length_preserving
encoding that grew it would make a file's size depend on the draw, handing back the length the padding exists to hide. - fn random
Pick one at random, from `ALL`. - fn for_name
Pick one deterministically from key material, from `LENGTH_PRESERVING`. Deterministic because a filename has to be computable again on the next launch, and length preserving because the name's length must not announce the choice. - fn apply
Encode. - fn undo
Decode. Any input this encoding could not have produced is refused. - fn encode
Encode with a randomly chosen encoding, returning it so it can be undone. # Why there are two layers and not one The chosen encoding is applied over a fixed scramble rather than over the plaintext, and that is not decoration. Two of the - fn encode
twenty-seven pass some bytes through untouched. Run-length copies any run that does not repeat, so `frame_ms = 4.25` survives it almost intact; and `Rotate(0)`, if it were ever chosen, is the identity wearing a hat. A scheme that picks - fn encode
uniformly at random is only as good as its worst outcome, so roughly one record in twenty-seven would have been left plainly readable in any buffer that escaped -- which is the single thing this layer exists to prevent. Putting an - fn encode
unconditional [`Weave::Substitute`] underneath fixes it for every choice at once, including the identity. It is a fixed public permutation and adds no secrecy whatsoever; what it adds is that **no choice in the set can leave recognisable - fn encode
text**, which is a property of the scheme rather than of a lucky draw. - fn decode
Undo [`encode`]. - fn nibble
One hex digit as a number, in either case. Anything else is a header this cannot read, which is the only verdict a decoder should reach about input it does not recognise. - const HEX
- const HEX_UPPER
- const B32
- const B32HEX
- const ZB32
- const CROCKFORD
- const B45
- const A85
- const Z85A
- const UU
- const XX
- const XX_ALPHABET
- const Z85_ALPHABET
- const SBOX
A fixed permutation of every byte value, and its inverse. Generated once from a multiplicative step over the odd residues, so it is a genuine bijection rather than a table somebody typed and hoped about. - const UNSBOX
- fn base32_encode
Five bytes to eight characters, over whichever 32-character alphabet was chosen. A short final chunk emits only the characters its bits reach, so nothing is padded and the length of the output still says only what the length of the input - fn base32_encode
says. - fn base32_decode
Undo [`base32_encode`] over the same alphabet. A character outside the alphabet is refused rather than skipped: silently ignoring input is how a decoder accepts two different texts as the same bytes. - fn base45_encode
Two bytes to three characters, over the 45-character alphabet the QR standard uses. An odd final byte becomes two characters. - fn base45_decode
Undo [`base45_encode`]. Refuses a value that does not fit the two bytes it is supposed to represent, which is the case a length check alone would miss. - fn base85_encode
Four bytes to five characters. `flavour` picks between Ascii85 and Z85, and `offset` is where Ascii85's alphabet starts in ASCII, which is `!`. - fn base85_decode
Undo [`base85_encode`] with the same flavour and offset. The accumulator multiplies and adds with checked arithmetic, so a group whose digits do not fit four bytes is refused rather than wrapping into a different four. - const B91
- fn base91_encode
Thirteen or fourteen bits at a time, over 91 characters, which is the densest of these that stays printable ASCII. - fn base91_decode
Undo [`base91_encode`]. A trailing partial group is completed from what bits are there, and anything outside the alphabet is refused. - fn sixbit_encode
Three bytes to four characters over a 64-character alphabet, in the shape uuencode and xxencode use. `flavour` chooses between the two. - fn sixbit_decode
Undo [`sixbit_encode`] with the same flavour. - mod tests
- fn corpus
Every input worth trying, including the ones that break naive codecs. - fn a_chosen_rotation_is_never_the_identity
All three choosers force the rotation odd, because Rotate(0) is the identity and a no-op is not an obfuscation layer. - fn preserves_length_is_not_simply_true
The claim each encoding makes about its length, against what it does. Asymmetric on purpose: preserving length is a promise about every input and one counterexample refutes it, while not preserving it only means some input changes. - fn a_truncated_escape_is_refused
An escape with nothing after it is refused rather than read past. Quoted-printable and percent take two hex digits after the marker; yEnc takes one byte, so what is truncated for them is complete for it. - fn the_base91_decoder_is_pinned_on_input_of_its_own
The decoder, on input built from its alphabet rather than from the encoder. A round trip is blind to any change the pair still agrees on, and `undo` is fed bytes read back from disk, not only what `apply` wrote. `}A` is digit 88 then 0, - fn the_base91_decoder_is_pinned_on_input_of_its_own
which is the value either side of its bit-width test. - fn every_encoding_emits_the_same_bytes_at_every_length
- fn lengths
Inputs of every length from nothing to sixty-four bytes. A repeating pattern rather than random bytes, so the corpus is the same on every machine and every run, and one that repeats at an odd period so run-length and word-swap have - fn lengths
something to do at most lengths. - const PATTERN
- const LENGTH_DIGESTS
- fn length_digest
FNV-1a over every encoded output, in order. Not a cryptographic hash and not used as one: this detects a change, and the thing it is detecting is this project's own encoder moving. Written out rather than pulled in so the test has no - fn length_digest
dependency of its own, and fixed rather than randomly seeded so the committed value means something. - fn emit_length_digests
- fn every_encoding_emits_exactly_these_bytes
- fn every_encoding_round_trips_every_input
- fn there_are_at_least_twenty_of_them
- fn every_marker_is_distinct_and_recoverable
- fn an_unknown_marker_is_refused_rather_than_guessed
- fn a_name_encoding_never_changes_the_length
- fn the_name_choice_is_stable_for_one_seed
- fn different_seeds_reach_different_encodings
- fn random_reaches_every_encoding_eventually
- fn the_substitution_is_a_real_bijection
- fn no_choice_leaves_recognisable_text
The one thing this layer actually buys, checked for **every** choice. The first version of this tested `Weave::apply` directly and found two encodings that leave plaintext readable: run-length copies any run that does not repeat, so - fn no_choice_leaves_recognisable_text
`frame_ms = 4.25` came through it almost intact, and `Rotate(0)` was the identity by accident. A scheme that picks uniformly at random is only as good as its worst outcome, so one record in twenty-seven would have been left greppable in - fn no_choice_leaves_recognisable_text
any buffer that escaped. That is why `encode` puts a fixed substitution underneath the choice, and why this test goes through `encode` rather than `apply`. - fn morse_looks_like_morse_and_comes_back
- fn morse_refuses_anything_that_is_not_dots_and_dashes
- fn the_new_length_preserving_ones_keep_the_length
- fn random_length_preserving_never_leaves_the_safe_set
- fn random_length_preserving_reaches_the_whole_safe_set
- fn the_layered_pair_round_trips
- fn the_list_holds_no_second_identity
- fn decoding_rubbish_fails_rather_than_returning_something
crates/veilvoice-guard/src/blame.rs
- (module)
Best-effort attribution: which program changed a file. # Read this before relying on it **Most of the time this cannot answer, and says so.** Neither Windows nor Linux records who wrote to a file unless auditing has been switched on for - (module)
that path in advance, and on a normal desktop it has not been. An unprivileged program cannot switch it on either. So the honest design is a function that returns [`Blame::Unknown`] with a reason, and tells the user what would have to be - (module)
configured for a real answer. A plausible guess would be worse than nothing here: naming the wrong program to somebody worried about surveillance is not a small error. # Where an answer can come from - **Linux** -- the kernel audit - (module)
subsystem. With a watch in place (`auditctl -w <path> -p wa -k veilvoice`), `ausearch` reports the syscall, the pid and the executable name for every write. Reading those records usually needs root as well. - **Windows** -- object access - (module)
auditing. With a SACL on the file and the "Audit File System" policy enabled, Security event 4663 records the process that opened it for write. `wevtutil` can query that log, though reading the Security log needs elevation. Both are - (module)
checked for, neither is configured by this crate, and the absence of either is reported rather than hidden. Setting them up is a deliberate act by an administrator, and pretending otherwise would misrepresent what a clean report means. # - (module)
In plain words Tries to say which program changed a file, and is careful about how sure it is. It is a guess built from what was running at the time. It can be wrong, and it says so with every answer. Naming the wrong program confidently - (module)
is worse than naming none, because somebody acts on it. - fn no_window
- const CREATE_NO_WINDOW
- fn system_tool
Resolve a system tool to an absolute path, rather than letting the operating system search for it. `Command::new("wevtutil")` does **not** mean "the Windows tool". Rust's `Command` resolves a bare name through the platform's search order, - fn system_tool
and on Windows that order includes the **current working directory** before most of `PATH`. Running `veilvoice guard check` inside a directory that happens to contain a file called `wevtutil.exe` therefore ran that file, as the user, with - fn system_tool
no prompt. A downloads folder is enough. The same applies to `reg.exe` in `veilvoice-watch`, and to `ausearch` on a Unix box with a writable directory early in `PATH`. Naming the absolute path removes the search entirely. Returning `None` - fn system_tool
when the tool is not where it should be is the right answer too: this module's whole design is to say "I cannot tell you" rather than to guess, and running *something else called `wevtutil`* is the worst possible guess. - enum Blame
What, if anything, is known about who changed a file. - impl Blame
- fn describe
A single line for a terminal, an alert or a log. - fn is_known
Whether an actual name was found. - fn unconfigured
Build the "nobody configured auditing" answer for this platform. - fn who_touched
Try to name the program that last wrote to `path`. Returns [`Blame::Unknown`] far more often than not. That is the correct answer on an unaudited system, and is reported as such rather than guessed at. - mod linux
- const AUSEARCH_DIRS
Where a distribution puts `ausearch`. Searched in order, and nowhere else. See [`super::system_tool`] for why `PATH` is not consulted. - fn who_touched
Ask `ausearch` what touched the path, if the audit tools are present at all and this process is allowed to read the records. - fn field
Pull `key="value"` or `key=value` out of an interpreted audit line. - mod tests
- fn fields_are_pulled_out_of_an_interpreted_record
- mod windows
- fn wevtutil
Query the Security log for object-access events naming this path. Event 4663 is "an attempt was made to access an object", and carries the process name. It is only written if a SACL is on the object *and* the audit policy is on, which is - fn wevtutil
off by default; and reading the Security log needs elevation. All three are ordinary reasons to get nothing. `wevtutil.exe` lives in the system directory and nowhere else. Resolved absolutely rather than searched. See - fn wevtutil
[`super::system_tool`]. - fn who_touched
- mod tests
- fn every_subprocess_is_spawned_without_a_console_window
Every subprocess in this file must be spawned through `no_window`. This reads the file's own source rather than exercising the behaviour, because "no console window appeared" cannot be observed from a test -- which is precisely why the - fn every_subprocess_is_spawned_without_a_console_window
defect reached a release. A `Command::new` added later without the wrapper fails here rather than on a desktop. - fn an_unaudited_file_yields_an_honest_unknown
On an ordinary machine there is no auditing, so this must return a reason rather than a guess -- and must never panic doing it. - fn a_missing_file_does_not_panic
- fn descriptions_read_sensibly
crates/veilvoice-guard/src/failsafe/act.rs
- (module)
Actually closing a program, kept apart from deciding to. # Why this is its own file Everything in [`crate`] is arithmetic over a list: it decides, and it can be tested without a machine. This is the only part that reaches out and ends - (module)
somebody's program, and separating the two means the decision can be reasoned about on its own and this can be short enough to read in one go. # The check is made twice, on purpose [`crate::failsafe::Guard::look`] already worked out - (module)
whether a program may be closed, and [`close`] checks again before it acts. That is not redundancy for its own sake: the two are reached through different paths, a future caller could compute `closeable` wrongly or not at all, and the cost - (module)
of being wrong here is ending somebody's desktop session. A guard that refuses at the last step as well as the first is a guard that cannot be talked past by a mistake upstream. # In plain words This is the part that closes a program that - (module)
picked up your real microphone. It checks one last time that the program is safe to close, meaning never VeilVoice itself and never anything belonging to the operating system, and it says what it did either way. - fn file_name
A program's own name, without the path it was found at. - fn still_named
Whether this process id still belongs to this program. Windows asks `tasklist`, filtered by the id, and looks for the name in what comes back. This has to exist even though `taskkill` is also given the name as a filter, because - fn still_named
**`taskkill` exits 0 whether or not it killed anything.** Measured: with a filter that matches nothing it prints "INFO: No tasks running with the specified criteria" and returns success, exactly as it does after a real termination. - fn still_named
Trusting the exit code meant reporting "closed Discord" while Discord carried on running, which is the worst thing a safety catch can say. - const CREATE_NO_WINDOW
- fn run_state
The one-letter run state in a line of `/proc/<pid>/stat`. The second field is the program's own name in brackets, and a program is free to have brackets and spaces in its name, so the split is on the **last** `)` rather than on whitespace. - fn run_state
Splitting on the first one reads the state of a process called `foo)bar` out of the middle of its own name. - fn still_named
Whether this process id still belongs to this program **and is running**. See F-74. Asked immediately before the kill, which narrows the window rather than removing it: that is the best a caller outside the kernel can do, and saying so is - fn still_named
better than pretending the problem is gone. # F-76: a program that has died is still listed until somebody collects it On Unix a process that has exited stays in the table, holding its id and its name, until its parent asks for its exit - fn still_named
status. Measured on Linux: after `kill -TERM`, `/proc/<pid>/comm` still reads `sleep`, `ps -p <pid> -o comm=` still prints `sleep` and still exits 0, and the only field that has changed is the run state, which is now `Z`. So a check that - fn still_named
asks only for the name answers "yes, still there" about a program that is already dead. That is F-74's false report in the other direction: [`close`] would wait out its whole retry loop and then tell somebody to go and close a program by - fn still_named
hand that had closed nearly three seconds earlier. The state is read first, and a collected-but-not-yet-reaped process counts as gone, because it is. - fn close
Close a program, having decided it should be closed. Refuses anything on [`crate::failsafe::PROTECTED`] whatever the caller believed, and refuses when there is no process to act on: closing "whatever is called Discord" is a different and - fn close
much worse operation than closing process 4812. - const CREATE_NO_WINDOW
- mod tests
- fn a_protected_program_is_refused_here_as_well
The last line of defence, and it holds whatever the caller believed. - fn without_a_process_id_nothing_is_closed
No process, no action. Closing "whatever is called Discord" is a different and much worse operation than closing one process. - fn nothing_is_ever_closed_by_name
Nothing here closes anything by name, which would take out every process sharing it. - fn wearing_its_own_name
Wait until a just-started child is wearing its own name. **F-96.** `Command::spawn` returns before the program it started is visible under its own name. Measured on Linux, once in about four thousand spawns: `/bin/sleep` is started and - fn wearing_its_own_name
`/proc/<pid>/comm`, read on the next line, still says the name of the program that started it. The kernel releases the parent when the child's address space goes, which happens inside `begin_new_exec` and before the line that sets the new - fn wearing_its_own_name
name, so there is a short window where the id is live and belongs to the program that is starting while still answering with somebody else's name. [`still_named`] refuses during that window, which is the safe direction and is what this - fn wearing_its_own_name
program wants: a doubtful answer closes nothing. The tests below are the ones that have to care, because they start a child and act on it in the next few microseconds, which no scan ever does. Without this wait they fail rarely and - fn wearing_its_own_name
misleadingly: one CI run reported "sleep is no longer process 7030" about a `sleep` that was still running when the job cleaned up after it. Waiting on the function under test is deliberate. The precondition these tests need is exactly - fn wearing_its_own_name
"this id now belongs to this program", which is what it answers, and a child that never arrives fails here with that said plainly rather than inside an assertion about something else. - fn a_process_whose_name_does_not_match_is_left_alone
A name that does not match the process is refused, which is F-74's whole purpose: the id alone is not enough to know what will be closed. - fn a_process_is_never_closed_by_number_alone
**F-74.** A process id is not a durable handle to a program. Between the scan that found something and the line that closes it, the program can exit and the operating system can hand its id to a new process. Closing by number alone would - fn a_process_is_never_closed_by_number_alone
then terminate whatever inherited it, and report having closed the one it meant. On Windows the name travels with the kill as a filter, so the check and the act are one operation. Elsewhere the name is checked immediately before, which - fn a_process_is_never_closed_by_number_alone
narrows the window rather than removing it. - fn a_program_that_has_died_but_not_been_collected_counts_as_gone
**F-76.** A program that has died is reported as gone, not as running. On Unix a process that has exited keeps its id and its name in the table until its parent collects its exit status. This test is that parent, and it deliberately does - fn a_program_that_has_died_but_not_been_collected_counts_as_gone
not collect: after the signal the child is dead, `/proc/<pid>/comm` still reads `sleep`, and `ps` still prints `sleep` and still exits 0. Before the fix, [`close`] read that as "still running", waited out its whole retry loop, and then - fn a_program_that_has_died_but_not_been_collected_counts_as_gone
told the reader to go and close by hand a program that had been dead for nearly three seconds. Measured: the two tests below this one failed on Linux for exactly that reason while passing on Windows, where a terminated process leaves the - fn a_program_that_has_died_but_not_been_collected_counts_as_gone
table at once. - fn a_process_this_test_started_is_actually_closed
A real process, closed for real. Started here so nothing else is at risk, and it is a program that does nothing but wait.
crates/veilvoice-guard/src/failsafe/mod.rs
- (module)
Failsafe: nothing leaves this machine in your own voice by accident. # The accident this exists to stop You set a calling program to VeilVoice's virtual cable, you start live mode, and everything is veiled. Then you plug in a headset. - (module)
Windows offers the new microphone, the calling program takes it, and from that moment your **real voice** is going out, with the veiled window still open in front of you, still showing meters moving, still looking exactly as it did a - (module)
second ago. Nobody notices that. It is not a mistake somebody makes through carelessness; it is a mistake the operating system makes on their behalf. Failsafe is on by default because the cost of it being off is that the one thing this - (module)
whole project exists to prevent happens silently. # What it can do, and what it cannot It **watches** which applications hold a microphone, and it knows which device VeilVoice is veiling. If another program picks up a *real* microphone - (module)
while you are veiling, that is the accident, and Failsafe: 1. says so, loudly, because a silent guard is not a guard; and 2. closes that program, if [`Posture::CloseIt`] is set, which it is by default, because a warning you have not read - (module)
yet does not stop your voice going out. It **cannot stop the operating system handing a microphone to another program in the first place.** Doing that needs exclusive-mode capture of every input device, or a driver, and neither is - (module)
something this project ships. See [`CANNOT_PREVENT`], which is the wording a front end must show. What Failsafe does is notice within a second or so and act. That is a real difference from nothing, and it is a real difference from - (module)
prevention, and both halves have to be said. # Killing another program is a serious thing So it is bounded rather than general: * **VeilVoice never closes itself**, which would end the veiling it exists to protect. * **It never closes a - (module)
system process.** [`PROTECTED`] is a list, checked by name, and anything on it is reported and left alone. * **It only closes what is actually holding a microphone**, from the watch feed, never a name from a guess. * **Every close is - (module)
recorded**, because a program that vanishes with nothing to explain it is indistinguishable from a crash. # In plain words Failsafe is on by default and it is the safety catch. The danger it guards against is this: you are talking through - (module)
VeilVoice with your voice disguised, you plug in headphones or a headset, and your computer quietly switches the call over to the *real* microphone. Your actual voice goes out and nothing on screen looks any different. Failsafe watches for - (module)
exactly that. If another program picks up a real microphone while you are being veiled, it tells you straight away and closes that program so your voice stops going out. What it cannot do is stop your computer from handing the microphone - (module)
over in the first place, because that needs a level of access this program does not have, and it says so rather than letting you believe otherwise. It notices, and it acts, within about a second. - mod act
- enum Posture
How hard Failsafe acts when it finds something. - impl Posture
- fn label
A short name for a picker. - fn note
What this choice costs and buys. - fn is_on
Whether anything is being watched at all. - const ALL
Every posture, in the order a picker should offer them. - fn key
The identifier written to the settings file. - fn from_key
Read a posture back. An unrecognised value becomes the **default**, not `Off`. A settings file this build cannot read must never be the reason the safety catch is not on: of the two ways to be wrong, the one that keeps watching cannot let - fn from_key
somebody's voice out. - const PROTECTED
Processes Failsafe will never close, whatever they are holding. Closing any of these ends the session, the desktop, or the machine. A guard that can take the whole computer down to stop a microphone is a worse problem than the one it was - const PROTECTED
solving. Matched on the executable's own name, lower-case, so a program somewhere unusual is still protected. - fn is_protected
Whether this program is one Failsafe refuses to close. - enum Finding
What Failsafe found. - impl Finding
- fn is_alarming
Whether this needs the reader's attention now. - fn phrasing
The sentence to show. - struct Action
One thing Failsafe did, for the record. - struct Holder
What the watch feed says about one application holding a device. A plain copy rather than a dependency on `veilvoice-watch`: this crate is arithmetic over a list, and keeping it that way means its tests need no machine, no microphone and - struct Holder
no platform. - struct Guard
The state Failsafe needs to decide anything. `Default` gives the default **posture**, which is on -- so a guard that nobody has configured still watches. - impl Guard
- fn new
A guard in its default posture, watching nothing yet. - fn is_ours
Whether a device name is the one being veiled. - fn look
Decide, given who holds a microphone right now. `problems` is whatever went wrong looking. A non-empty `problems` with an empty `holders` is [`Finding::CannotTell`], never [`Finding::Clear`]. - fn record
Record what was done about a finding. - fn log
Everything Failsafe has done, oldest first. - fn is_veilvoice
Whether this is one of VeilVoice's own programs. - const CANNOT_PREVENT
What Failsafe cannot do, in the words a front end must show. - const NEVER_CLOSES
What Failsafe refuses to close, and why. - mod tests
- fn holder
- fn veiling_guard
- fn a_program_taking_a_real_microphone_while_veiling_is_the_alarm
**The accident this crate exists for.** Another program takes a real microphone while veiling is running, and it is caught. - fn a_program_on_our_own_cable_is_not_the_accident
A program on our own cable is the arrangement working, not the accident. Reporting it would make the alarm fire constantly and be ignored. - fn veilvoice_holding_the_microphone_is_not_reported_against_itself
VeilVoice holding the real microphone is what veiling *is*. - fn a_platform_that_cannot_see_is_never_reported_as_clear
**The most dangerous conflation in the crate.** A platform that cannot see must never read as a quiet machine. - fn not_veiling_is_its_own_answer_rather_than_all_clear
"Nothing is wrong" and "nothing is being protected" are different, and showing the first while live mode is stopped is a lie of reassurance. - fn system_processes_and_veilvoice_itself_are_never_closeable
A guard that can close the desktop is worse than the problem. - fn a_protected_program_still_warns_and_says_what_to_do
The protected program is reported, and told what to do instead, rather than silently skipped. - fn the_watch_only_posture_warns_but_does_not_close
Watching without closing still warns, and says the voice keeps going. - fn off_watches_nothing_and_says_so_plainly
Off is off, and the interface says what that costs. - fn the_default_is_on_and_an_unreadable_setting_stays_on
**On by default**, and a settings file this build cannot read must not be the reason the safety catch is off. - fn the_limits_say_it_notices_rather_than_prevents
The limit is stated outright. This is the sentence that stops somebody believing they are protected in the gap. - fn nothing_here_claims_to_have_prevented_anything
Nothing this crate says claims to have prevented anything. - fn every_action_is_written_down
Every action is recorded. A program that vanishes with nothing to explain it is indistinguishable from a crash. - fn no_program_is_known_here_by_name_except_the_protected_ones
Failsafe knows no program by name except the ones it refuses to close. The tests above use real application names as fixtures, because a test reads better with one, and reading this file it is easy to come away thinking a particular chat - fn no_program_is_known_here_by_name_except_the_protected_ones
program is wired in. It is not, and this is what keeps it that way: the decision is made about whatever holder the caller found, and the only names written down anywhere in the working half of this file are the operating system's own. - fn an_unheard_of_program_is_treated_like_any_other
A program nobody has ever heard of is handled like any other.
crates/veilvoice-guard/src/lib.rs
- (module)
# veilvoice-guard Tamper **detection** for VeilVoice's own files: a manifest of what they should be, a check of what they are, and a best-effort answer to "what changed them". ## What this is, and the word it deliberately does not use It - (module)
is not tamper-*proof*. Nothing that runs as an ordinary program on your computer can be. A guard running as you can be killed by anything else running as you; a guard running as root can be stopped by root. The only honest verb here is - (module)
**detect**, and where detection can be defeated that is said rather than glossed. This is the same limit the app lock has, for the same reason, and the project answers it the same way: state it plainly, in the place the user reads. See - (module)
[`SCOPE`]. ## What it actually does - [`Manifest::of`] records each file's size and SHA-256. - [`Manifest::check`] compares that against what is on disk now and reports what was **modified**, **removed** or **added**. - [`blame`] tries to - (module)
name the process responsible for a change. It usually cannot, and says so instead of guessing. ## The manifest is only as trustworthy as where it is kept Written plainly, a manifest detects accidental corruption, an interrupted update, a - (module)
file swapped by something careless -- and an attacker who did not think to rewrite it. It does not detect one who did, because they can recompute it as easily as this crate can. To raise that bar, seal the manifest with - (module)
[`veilvoice_crypto::container::seal_with_password`] and keep the passphrase out of the manifest's own directory. Then rewriting it undetectably requires the passphrase as well as write access. That is a real improvement and still not - (module)
proof: an attacker who is present *while* you type the passphrase has everything. [`Manifest::seal`] and [`Manifest::open_sealed`] do this. ## What a privileged helper would add, and why there is not one here A root service using - (module)
`fanotify` (Linux) or a SACL plus Security event 4663 (Windows) could attribute every write reliably, and `fanotify` with `FAN_OPEN_PERM` could even block one. That is genuinely stronger than anything in this crate. It is also an - (module)
installer, a privileged daemon and a much larger attack surface bolted onto a project that currently needs no privileges at all -- and it still could not stop a root-level attacker, only watch one. So the unprivileged half ships first, on - (module)
its own merits, and `ROADMAP.md` records what the privileged half would need to be worth adding. # In plain words This notices when the program's own files have been changed. It writes down what every file should look like, and later tells - (module)
you if any of them no longer does -- and which one, and when. It is a smoke alarm, not a lock. Anything that can change those files can change the list too. What it catches is a change nobody was hiding. - mod blame
- mod manifest
- mod failsafe
- mod sentry
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What tamper detection is worth, in the words a front-end should show. Single-sourced and asserted by the tests, exactly as the app lock's note is, so it cannot quietly turn into a promise. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl From<veilvoice_crypto::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn the_scope_note_states_the_limit_rather_than_a_guarantee
The claim must keep stating the limit. If someone edits this into a promise, this is what stops it shipping -- the same guard the app lock's scope note has.
crates/veilvoice-guard/src/manifest.rs
- (module)
The integrity manifest: what the files were, and what they are now. # Format Deliberately a text format, one record per line: ```text VEILGUARD1 <sha256 hex> <size> <path> ... ``` Text rather than a packed binary layout because the point - (module)
of the file is to be checkable. Someone who suspects tampering can read it with `cat` and compare a digest by hand with `sha256sum`, without this crate and without trusting it. A binary format would have been marginally smaller and would - (module)
have made the honest response to "prove it" be "run my tool again". Paths are stored with forward slashes so a manifest written on Windows still reads on Linux, and are rejected if they contain a newline -- otherwise a filename could forge - (module)
a record. # In plain words The written record of what VeilVoice's files were, so a later check can tell whether they still are. It is plain text on purpose: you can read it, diff it and keep a copy somewhere else. A record you cannot - (module)
inspect is one you have to take on trust, which rather defeats the point of having it. - const MAGIC
Magic first line. The digit is a format version. - struct Entry
One recorded file. - enum Change
How a file differs from its record. - impl Change
- fn path
The path this change concerns. - fn describe
A single line for a terminal or a log. - struct Report
The result of checking a manifest against the disk. - impl Report
- fn is_clean
Whether anything at all differs. - struct Manifest
A record of a set of files. - fn normalise
Normalise a path for storage: forward slashes, no leading `./`. - fn unrecordable
Why this path cannot go in a manifest, if it cannot. **F-83.** The record is a line-oriented text file that gets printed to a terminal, and those two facts decide what a path may contain. A line break would end the record early and let one - fn unrecordable
entry forge a second. A carriage return returns the cursor to the start of the line, so a crafted path overwrites what the report has already printed and the report says something other than what is recorded. An escape character does more - fn unrecordable
again: colour, cursor movement, clearing the screen. The product of this module is a report somebody reads to decide whether their files have been altered, so a report that can be made to lie is the whole thing failing. The refusal covers - fn unrecordable
the C0 and C1 control ranges rather than the two characters that were found, because listing the ones somebody thought of is how the next one gets in. Refusing rather than stripping is deliberate: a path this format cannot represent - fn unrecordable
faithfully is one it must not claim to hold. Such a filename is legal on Unix and vanishingly rare, and being told so is better than a record that quietly describes a different file. - fn digest_of
- impl Manifest
- fn of
Record every readable file in `paths`. A path that cannot be read is skipped rather than failing the whole manifest: recording nine of ten files is more useful than recording none, and the tenth shows up as `Removed` at check time, which - fn of
is the honest description of a file this build cannot see. - fn len
How many files are recorded. - fn is_empty
Whether nothing is recorded. - fn paths
The recorded paths, in order. - fn check
Compare the record against what is on disk now. `extra` is the set of paths currently in the watched location, so a file that has *appeared* can be reported. Pass an empty slice to check only what was recorded. - fn to_text
Serialise to the text format described at the top of this module. - fn parse
Parse the text format. - fn save
Write the manifest to `path` in the clear. - fn load
Read a manifest written by [`Manifest::save`]. - fn seal
Seal the manifest under a passphrase. This is what makes the record worth more than a courtesy: rewriting it undetectably then needs the passphrase as well as write access. Keep that passphrase somewhere other than beside the manifest, or - fn seal
the exercise is circular. It is still not proof. An attacker present while you type the passphrase has everything, which is the same caveat every password in this project carries. - fn open_sealed
Open a manifest sealed by [`Manifest::seal`]. # Why the unattended cost ceiling, and not the generous one F-92. This used the same ceiling as any other container, which is four gigabytes of Argon2 memory, and that ceiling is right for a - fn open_sealed
`.veil` a person was sent and chose to open: it is slow, they can decide to stop waiting, and refusing a legitimate-but-expensive file would be worse. Nobody chooses to open this one. It sits at a known path beside the app lock, and since - fn open_sealed
the desktop application started checking it at every unlock, it is read automatically whenever somebody logs in. Anybody who can write that directory can leave a sealed manifest declaring four gigabytes, and on a modest machine that is not - fn open_sealed
a wait, it is an allocation failure, and this workspace aborts on one. The window would die immediately after a correct passphrase. This is the same defect F-91 fixed on the app-lock file, in the second place it applies. Fixing one and not - fn open_sealed
the other is the exclusion list naming the files somebody happened to think of, which is the failure this project has already recorded twice. - fn files_in
Every file directly inside `dir`, for use as `check`'s `extra` argument. Not recursive on purpose: the caller decides what is being watched, and a silent recursive walk of a directory somebody pointed this at is a good way to hash a home - fn files_in
folder by accident. - mod tests
- fn a_path_that_could_rewrite_the_report_is_refused_on_the_way_in
**F-83.** A record somebody hands you cannot contain a character that rewrites the report. `Manifest::of` refused a path with a line break in it and `parse` accepted one, so VeilVoice would not write a record it was happy to read from - fn a_path_that_could_rewrite_the_report_is_refused_on_the_way_in
somebody else. `veilvoice guard check` reads whichever file is at the path it is given. The carriage return is the one the coverage-guided campaign found. The rest are here because listing the characters somebody thought of is how the next - fn a_path_that_could_rewrite_the_report_is_refused_on_the_way_in
one gets in. - fn an_ordinary_path_is_still_recorded
And an ordinary path, including the awkward but legitimate ones, still parses. A refusal that catches real filenames is a worse bug than the one it fixes. - fn what_cannot_be_written_cannot_be_read
The two ends agree: what `of` will not write, `parse` will not read. That asymmetry *was* the finding, so it is the thing to hold rather than the individual characters. - fn write
- fn an_untouched_set_of_files_is_clean
- fn a_modified_file_is_reported_with_both_digests
- fn a_same_length_substitution_is_still_caught
A file of the same *length* with different contents is the case a size-only check would miss, which is why the digest is the check. - fn a_removed_file_is_reported
- fn a_new_file_in_the_watched_set_is_reported
- fn the_text_format_round_trips_exactly
- fn a_path_containing_spaces_survives
Paths with spaces are ordinary on Windows, and splitting naively would truncate them. - fn a_malformed_manifest_is_rejected_rather_than_half_read
- fn a_manifest_written_with_backslashes_keys_the_same_way
A hand-written manifest using backslashes must key the same way one this crate wrote does, or every recorded file also reports as newly added and the report becomes noise. - fn saving_and_loading_round_trips
- fn a_sealed_manifest_needs_its_passphrase
The sealed form is what makes the record more than a courtesy: without the passphrase it cannot be rewritten to match a tampered file. - fn a_sealed_manifest_cannot_demand_more_memory_than_the_machine_has
F-92. The sealed record is read automatically at every unlock, so the cost it declares is not a cost anybody chose to pay. - fn an_unreadable_path_is_skipped_when_recording
- fn recording_nothing_is_allowed_and_checks_clean
crates/veilvoice-guard/src/sentry/canary.rs
- (module)
Decoy files that should never change, and a record of what they were. # The idea, in one paragraph Put a file somewhere nothing legitimately writes, remember exactly what it contained, and look at it later. If it differs, something walked - (module)
that directory and wrote to everything it found. That is a much cleaner signal than counting file changes, because there is no innocent explanation for a file nobody uses having been rewritten. # And the hole in it, which is large A canary - (module)
fires only if whatever is running **reaches it**. Something that encrypts `.docx` under one folder will never look at a canary planted anywhere else, and this crate has no way to tell "nothing happened" from "it happened somewhere I was - (module)
not". A quiet canary is not an all-clear, and no wording in a front end may present it as one. Plant several, in the directories that actually matter, and accept that the answer is still one-sided: a trip is evidence, silence is not. # The - (module)
name is a deliberate trade, and the default takes the honest side [`DEFAULT_NAME`] says what the file is. Anything that reads filenames before deciding what to encrypt can therefore skip it, and the signal is lost. A decoy called - (module)
`quarterly-report.docx` would survive that. The default is the recognisable name anyway, for two reasons. Indiscriminate encryption of everything under a directory is the overwhelmingly common case, and it is not fooled either way. And a - (module)
file the user does not recognise is a file the user eventually deletes, which reads here as a trip, produces an alarm that was nobody's fault, and teaches them to ignore the next one. A warning system whose alarms are usually wrong is - (module)
worse than no warning system. [`Nest::plant`] takes a name, so anybody who wants the quieter trade can have it. It is a choice, made in the open, not a default that decides for them. # A deletion and an encryption look the same Much - (module)
ransomware writes a new encrypted file beside the original and deletes the original, so the canary comes back as [`State::Removed`], which is also what a user tidying a folder produces. [`State`] reports which of the two happened to the - (module)
file, never which of the two happened in the world. # Format One record per line, text, for the same reason the tamper manifest is text: the point of the file is to be checkable without this crate. ```text VEILSENTRY-NEST1 <sha256 hex> - (module)
<size> <planted unix seconds> <path> ``` # In plain words Files VeilVoice puts in a folder and never touches again. Nothing should ever read or change them. If one does change, something has walked through that folder writing to everything - (module)
in it, which is what ransomware does, and you have found out early. It does not stop anything. It is a tripwire, and its whole value is being noticed quickly. - const MAGIC
Magic first line. The digit is a format version. - const DEFAULT_NAME
The default filename for a canary. Recognisable on purpose. See the module documentation for the trade this makes and why the default takes this side of it. - const PROSE_CEILING
Below this, a canary that was prose has stopped being prose. Encrypted output sits within a hair of 8.0 bits per byte; English prose is under 5. The threshold is well clear of both, so it says "this is no longer text" rather than "this is - const PROSE_CEILING
encrypted", which this crate cannot know and does not claim. A `.zip` written over the canary would read the same way. - struct Canary
One planted decoy, and what it was when it was planted. - enum State
What a canary looks like now. - impl State
- fn is_trip
Whether this is anything other than [`State::Intact`]. - fn stopped_being_text
Whether the contents stopped looking like the text that was planted. False for every state except [`State::Modified`], and false there for a rewrite that is still text. **Not** a claim that the file was encrypted; see [`PROSE_CEILING`]. - fn describe
A single line for a terminal or a log. - struct Sighting
One canary and what became of it. - struct Nest
The set of planted canaries. - fn normalise
Normalise a path for storage: forward slashes, as the tamper manifest does, so a nest written on Windows still reads on Linux. - fn digest_of
- fn now_seconds
- fn contents
The text a canary is filled with. Derived from the filename alone, so it is deterministic: the same name always produces the same bytes and therefore the same digest, which is what makes this testable without a clock or a random source. It - fn contents
is prose, and that is load-bearing. The entropy of what is there later is only informative because the entropy of what was put there is known to be low. Filling a canary with random bytes would have destroyed the one measurement it - fn contents
supports. - const FILLER
Plain sentences, in the project's own register, repeated to make a body. - impl Nest
- fn new
An empty nest. - fn len
How many canaries are planted. - fn is_empty
Whether nothing is planted. - fn canaries
The planted canaries, in a stable order. - fn plant
Write a canary into `dir` and record it. `name` defaults to [`DEFAULT_NAME`]. Returns the path written. **Refuses to overwrite an existing file.** A canary is a file this crate creates; writing over one that was already there would destroy - fn plant
somebody's data in the name of protecting it, and the failure would be silent because the whole point is that nothing reads these afterwards. - fn pull_up
Stop watching a canary, and delete it. The order matters: the record goes first. If the delete fails, the file is left behind but no longer watched, which is an untidy folder. Doing it the other way round and failing leaves a record of a - fn pull_up
file that is gone, which is a permanent false alarm. - fn check
Look at every canary. Changes nothing. - fn trips
Only the canaries that are not intact. - fn to_text
Serialise to the text format described at the top of this module. - fn parse
Parse the text format. - fn save
Write the nest to `path`. - fn load
Read a nest written by [`Nest::save`]. - fn state_of
- mod tests
- fn nest_in
- fn a_planted_canary_is_intact
- fn checking_changes_nothing
Checking must not be what changes the thing being checked. - fn a_rewritten_canary_is_a_trip
- fn a_canary_full_of_incompressible_bytes_reads_as_no_longer_text
The one measurement entropy supports: a canary that was prose and is now incompressible. - fn a_deleted_canary_is_reported_as_gone_without_saying_why
- fn planting_never_writes_over_an_existing_file
A file already there is somebody's data. Refusing is the only safe answer, because nothing reads a canary afterwards and the loss would never be noticed. - fn a_name_that_is_a_path_is_refused
- fn pulling_up_removes_both_the_record_and_the_file
- fn pulling_up_a_canary_that_is_already_gone_still_clears_the_record
A canary somebody already deleted must still be removable from the record, or the alarm can never be cleared. - fn several_canaries_are_each_watched
- fn a_canary_names_itself_so_copies_do_not_satisfy_each_other
Two canaries in different folders must not contain identical bytes, or a record could be satisfied by copying one over the other. - fn contents_are_deterministic
The same name always produces the same bytes: the digest is testable without a clock or a random source. - fn the_planted_body_is_low_entropy_text_of_a_usable_size
Prose, and enough of it for the entropy figure to mean anything. - fn the_planted_body_states_its_own_limits
The body must say what it cannot do, in the file itself, because that is the copy somebody reads when they find it and wonder what it is. - fn a_nest_survives_a_round_trip_through_text
- fn a_nest_survives_a_round_trip_through_a_file
- fn a_windows_path_written_by_hand_reads_back_normalised
- fn a_malformed_nest_is_refused_rather_than_half_read
- fn blank_lines_are_tolerated
- fn a_state_that_could_not_be_read_says_so
An unreadable canary is not an absent one, and the difference is worth reporting: a permissions change is itself a thing that happened. - fn intact_is_the_only_state_that_is_not_a_trip
crates/veilvoice-guard/src/sentry/mod.rs
- (module)
# sentry An early warning that something is going through your files: decoy files that should never change, and a measure of how fast a directory tree is changing. ## The word this crate does not use It does not **prevent** anything. By - (module)
the time a canary has been encrypted, whatever encrypted it has already been running for some number of seconds and has already reached some number of real files. What this buys is the difference between finding out now and finding out - (module)
when you next open a document, which is a real difference, and it is the whole of what is on offer. See [`SCOPE`]. Stopping a process mid-run needs an interposition point in the kernel: `fanotify` with `FAN_OPEN_PERM` on Linux, a - (module)
filesystem minifilter on Windows. The Windows one requires a code-signing identity issued to a verified legal entity, which this project does not have and will not get, because it is published under a pseudonym on purpose. `ROADMAP.md` - (module)
records that as a decision rather than an omission. ## The two signals, and what each is worth **[`canary`], decoy files that should never change.** A file nothing legitimately touches is a clean signal: if its contents differ from what - (module)
was planted, something walked the directory and wrote to everything it found. Very few false positives, and one enormous hole: it only fires if the attacker *reaches* it. Something that encrypts only `.docx` under one folder will never see - (module)
a canary planted anywhere else, and this crate cannot tell you that it did not fire because nothing happened. **[`rate`], how much of a tree changed and how fast.** No blind spot, and far weaker evidence: a restore from backup, importing a - (module)
camera card, a compiler, or a synchronisation client catching up all look exactly like a mass rewrite, because they are one. [`rate::Churn`] therefore reports numbers and a [`rate::Concern`] level against a threshold **you** set. It never - (module)
reports "ransomware", because it does not know that and neither does anything else that only counts files. Used together they are worth more than either: a canary hit *and* a high churn rate at the same moment is a much stronger statement - (module)
than either alone. That is a judgement for the front end to present, not for this crate to make on the user's behalf. ## What it cannot tell you: who Nothing here names a process. Attribution needs system auditing that is normally switched - (module)
off, and it lives in `veilvoice-guard` rather than here. `crate::who_touched` takes a path and usually, honestly, answers that it does not know. This crate deliberately does not depend on that one: a project that wants canaries should not - (module)
have to take a cryptography stack with them. ## Entropy is a hint and never evidence [`entropy`] measures Shannon entropy in bits per byte, and encrypted data sits near 8.0. So does a JPEG, a `.zip`, a video, and this project's own `.veil` - (module)
container. High entropy is only meaningful for a file whose contents this crate *planted* and therefore knows should have been prose. It is used for exactly that and nothing else. # In plain words This watches for the pattern ransomware - (module)
makes. It leaves a few files lying around that nothing has any reason to touch, and it counts how fast files in a folder are being rewritten. Something quietly encrypting your documents trips both. It is a warning, not a defence. It - (module)
notices; it does not stop anything, and it can be fooled by anything patient enough to go slowly. - mod canary
- mod rate
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What this crate is worth, in the words a front end should show. Single-sourced and asserted by the tests, exactly as the app lock's and the tamper detector's notes are, so it cannot quietly turn into a promise. - fn entropy
Shannon entropy of `bytes`, in bits per byte, from 0.0 to 8.0. Empty input is 0.0 by definition here: there is no distribution to measure, and returning `NaN` would propagate into every comparison downstream. **Read the crate documentation - fn entropy
before using this to judge a file.** Near-8.0 means "incompressible", which is true of encrypted data and equally true of every JPEG, archive and video on the machine. It carries information only about a file whose original contents are - fn entropy
known. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn the_scope_note_states_the_limits_rather_than_a_guarantee
The claim must keep stating the limits. If somebody edits this into a promise, this is what stops it shipping. - fn entropy_of_nothing_is_zero_rather_than_nan
- fn entropy_of_one_repeated_byte_is_zero
- fn entropy_of_a_uniform_byte_range_is_eight
Every byte value once is the maximum: 8 bits per byte. - fn entropy_of_two_equally_likely_values_is_one
Two equally likely values is exactly one bit. - fn prose_is_well_below_encrypted_data
English prose sits far below the ceiling. This is the whole basis for noticing that a planted canary stopped being prose. - fn entropy_stays_within_its_documented_range
Never outside the range the documentation promises, for any input. - fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-guard/src/sentry/rate.rs
- (module)
How much of a directory tree changed, and how fast. # What this measures, and what it does not It counts files. Take a [`Snapshot`] of a tree, take another later, and [`compare`] reports how many were added, removed or altered in between, - (module)
and over what interval. That is the whole of the measurement. It is **not** a ransomware detector, and [`Churn`] deliberately has no `is_ransomware` on it. Restoring a backup, importing a camera card, a synchronisation client catching up - (module)
after a week offline, extracting an archive, and a compiler writing a target directory all produce exactly the shape this measures, because they are all mass rewrites. Anything claiming to tell those apart by counting files is claiming - (module)
something it cannot do. So the output is numbers plus a [`Concern`] level against a [`Threshold`] **the user sets**, and the front end's job is to say "this many files changed in this long, was that you?", which is a question, and the - (module)
person at the keyboard can answer instantly and this crate never can. # Modification times can be set by whatever did the modifying A file counts as changed when its length or its modification time differs. Both are attacker-controllable: - (module)
anything that can rewrite a file can restore its old timestamp, and on most filesystems its length is whatever it chose to write. This measure therefore has a floor, not a guarantee, and something careful will pass under it. - (module)
[`crate::sentry::canary`] does not have that weakness, because it compares contents against a recorded digest. It has a different weakness instead. That is why there are two signals here rather than one. # Walking a tree costs real time, - (module)
so the walk is bounded [`Limits`] caps how many files and how deep, and a snapshot that hit a cap is marked [`Snapshot::truncated`]. A truncated snapshot compared against another truncated snapshot is still useful, because both walked in - (module)
the same order, but the count is of what was looked at, not of what is there, and every report carries that flag so a front end cannot present it as complete. Symbolic links are recorded and never followed. Following them turns a tree walk - (module)
into a graph walk with cycles in it, and lets a link inside the watched directory make this crate report on files outside it. # Format A snapshot has to survive between two runs of the program, or the only comparison possible is one taken - (module)
inside a single session, which measures the minute you were looking and nothing else. So it is written to disk, in text, one record per line, for the same reason the tamper manifest is text: the point of the file is to be checkable without - (module)
this crate. ```text VEILSENTRY-SNAP1 root /home/somebody/Documents taken 1700000000 truncated false unreadable /home/somebody/Documents/private: permission denied file 4096 1699999000 /home/somebody/Documents/notes.txt ``` A modification - (module)
time the platform did not report is written as `-`, which is not the same as zero: zero is a real instant in 1970 and files claiming it do exist. # In plain words Watches how much of a folder is changing and how quickly. A few files - (module)
changing is somebody working. Hundreds changing in a minute is something else, and worth being told about. It measures how much and how fast, and nothing more. It does not know which program is responsible and does not guess, because a - (module)
monitor that names the wrong program is worse than one that names none. - const MAGIC
Magic first line of a saved snapshot. The digit is a format version. - struct Facts
What is recorded about one file. Deliberately cheap: no file is opened. - struct Limits
How far a walk is allowed to go. Defaults are a compromise: large enough for a documents folder, small enough that walking one does not become the reason the interface stopped painting. - impl Default for Limits
- fn default
- struct Snapshot
What a tree looked like at one moment. - struct Churn
The difference between two snapshots. - impl Churn
- fn touched
Added plus removed plus modified: everything that is not unchanged. - fn per_minute
Files touched per minute. `None` when the two snapshots have the same timestamp. A rate over a zero-length window is not a large number, it is not a number, and returning infinity would sail past every threshold in this module. - fn share
The proportion of the earlier snapshot that was touched, from 0.0 to 1.0. `None` when the earlier snapshot was empty. A rate alone says nothing about scale: twenty files a minute is nothing in a source tree and most of a folder holding - fn share
thirty photographs. - fn describe
One line for a terminal or a log. States the counts, never a cause. - struct Threshold
When to raise the level, set by the user rather than guessed at here. Both conditions must be met for [`Concern::High`]: a rate on its own has no sense of scale, and a share on its own has no sense of time. Twenty files a minute is nothing - struct Threshold
in a source tree; half a folder rewritten over a fortnight is a fortnight of ordinary work. - impl Default for Threshold
- fn default
- enum Concern
How much of the threshold a [`Churn`] met. Three levels rather than a boolean, and none of them is a verdict. The highest is called [`Concern::High`] and not `Ransomware` on purpose: what has been established is that a lot of files changed - enum Concern
quickly, which is a question for the user and not an answer. - impl Concern
- fn describe
A line for a front end, phrased as the question it actually is. - fn concern
Judge a [`Churn`] against a [`Threshold`]. A churn whose window is zero cannot meet the rate condition at all, because it has no rate. It can still meet the share condition, so the answer is at most [`Concern::Elevated`], which is the - fn concern
honest ceiling for a measurement missing half its inputs. - fn normalise
Normalise a path for storage: forward slashes, as everywhere else here. - fn seconds
- fn now_seconds
- impl Snapshot
- fn take
Walk `root` and record what is there. Opens nothing: only directory entries and their metadata are read, which is what keeps this cheap enough to run on a timer. Unreadable directories are recorded in [`Snapshot::unreadable`] rather than - fn take
failing the walk, because a snapshot of nine folders out of ten is more useful than none, and the tenth is itself reported. - fn len
How many files were recorded. - fn is_empty
Whether nothing was recorded. - fn files
The recorded paths and facts, in a stable order. - fn with_taken
Replace the recorded time. For tests, which cannot wait a minute. - fn to_text
Serialise to the text format described at the top of this module. - fn parse
Parse the text format. A line whose keyword is not recognised is an error rather than something to skip. A snapshot is a baseline: quietly dropping half of one produces a comparison against a tree that never existed, and every missing file - fn parse
reads as a deletion. - fn save
Write the snapshot to `path`. - fn load
Read a snapshot written by [`Snapshot::save`]. - fn baseline_name
A stable filename for the saved snapshot of `root`. Sixteen hex characters of the SHA-256 of the normalised path, plus `.txt`. Two different roots colliding is then implausible rather than merely unlikely -- and a collision would be one - fn baseline_name
baseline silently overwriting another, which shows up later as a whole tree appearing to have been replaced. Deriving the name rather than sanitising the path also keeps the watched directory's name out of the state directory's listing, - fn baseline_name
which is a small courtesy on a shared machine. - fn compare
Compare two snapshots of the same tree. The order of the arguments is not checked against their timestamps, and the window is a saturating subtraction, so passing them the wrong way round gives a window of zero rather than a nonsense rate. - fn compare
A clock that went backwards between the two produces the same thing, and for the same reason: no rate is better than an invented one. - mod tests
- fn write
- fn churn
- fn an_untouched_tree_shows_no_churn
- fn additions_removals_and_rewrites_are_counted_separately
- fn subdirectories_are_walked_and_the_depth_limit_holds
- fn the_file_cap_truncates_and_says_so
- fn a_zero_window_has_no_rate_rather_than_an_infinite_one
- fn the_rate_is_per_minute
- fn the_share_is_of_what_was_there_before
- fn an_empty_before_has_no_share
- fn high_needs_both_speed_and_breadth
Both conditions, or it is not the top level. - fn no_concern_level_names_a_cause
The top level is a question put to the user, not a verdict about the world. If this wording ever becomes an accusation, the build fails. - fn the_churn_line_states_counts_and_no_conclusion
A churn line reports counts and never a cause. - fn a_backwards_window_is_zero_rather_than_enormous
Swapped arguments, or a clock that stepped backwards, must not invent a rate out of a negative window. - fn a_snapshot_of_a_missing_root_reports_it_rather_than_failing
A directory that cannot be read is recorded, not skipped in silence: a folder that became unreadable is itself something that happened. - fn a_touched_timestamp_alone_counts_as_a_change
- fn a_snapshot_survives_a_round_trip_through_text
- fn a_snapshot_survives_a_round_trip_through_a_file
- fn an_unknown_modification_time_round_trips_as_unknown
A time the platform did not report is `-`, and comes back as `None` rather than as the first instant of 1970. - fn a_truncated_snapshot_says_so_after_a_round_trip
- fn unreadable_directories_survive_the_round_trip
- fn a_malformed_snapshot_is_refused_rather_than_half_read
A baseline that half-parsed would compare against a tree that never existed, and every file it dropped would read as a deletion. - fn a_windows_path_written_by_hand_reads_back_normalised
- fn a_baseline_name_is_stable_and_distinct_per_root
- fn a_baseline_name_ignores_the_separator_style
The same directory written with either separator is the same baseline, or a Windows user gets a fresh one every time the path is spelled the other way and never sees a comparison at all. - fn a_baseline_name_does_not_leak_the_path
The watched directory's own name must not appear in the filename: on a shared machine the listing of the state directory would otherwise say what somebody is watching. - fn the_defaults_are_the_documented_ones
crates/veilvoice-gui/src/app.rs
- (module)
The VeilVoice desktop application: seven tabs, one window, no menus. One window, seven tabs, no menus and no settings file to hunt for. This file owns the window: the tab strip, the state behind it, and the rules about what the user is - (module)
allowed to do before they have answered the questions that matter. The tabs themselves live partly here and partly in siblings -- [`crate::security`] draws the lock tab and the unlock screen, [`crate::prefs`] draws settings. # The tabs, - (module)
and why these One row, in the order [`Tab::ALL`] lists them, which is the order they are drawn in and the order this table is in. | Tab | What it is | |---|---| | **anonymise file** | Process a recording on disk. The default path. | | - (module)
**group** | One recording with several people in it, each given their own voice. | | **studio** | Scramble a microphone in real time, and keep what was said if it was asked for. | | **browser** | What is in the recording vault, without - (module)
opening any of it. | | **monitor** | Which applications currently hold the microphone and camera. | | **lock** | The app lock, and a plain statement of what it is worth. | | **verify** | Check a download against the signed list of hashes. - (module)
| | **settings** | Colour scheme, animation, and where those choices are kept. | | **install** | Whether this copy is portable or installed, and the optional companions. | | **about** | Versions, licence, and the honest scope. | **Roadmap - (module)
item 130** took one row out of this table rather than adding one. Live scramble was a tab, and everything it did the Studio also did, through the same session, with the devices the other tab happened to be set to. Two screens for one act, - (module)
and two starters for one microphone. There is no "advanced" tab and no hidden pane. Everything the program can do is reachable in one click from the strip, because a privacy tool whose important controls are buried is a privacy tool whose - (module)
important controls do not get used. # Nothing slow runs on the UI thread [`VeilVoiceApp::start_job`] spawns a worker and hands back an [`std::sync::mpsc`] receiver; [`VeilVoiceApp::poll_job`] drains it with `try_recv` once per frame. The - (module)
window keeps painting while a job runs. That split is not tidiness. A long recording takes real time to process, and sealing it runs Argon2id at 256 MiB, which is **deliberately** slow -- that is the whole point of a memory-hard KDF. Doing - (module)
either on the UI thread means a frozen window and an operating system offering to kill the application, in the middle of the operation the user cares most about completing. `poll_job` handles all three channel outcomes, including - (module)
`Disconnected` -- a worker that panicked. The user is told the thread stopped rather than watching a progress state that will never finish. # The at-rest choice is enforced here, not merely offered Recordings are encrypted at rest by - (module)
default (locked decision 4.10), and a job **cannot start** until the user has answered the modal that appears if they try to turn that off. The rule is asserted by a test in this file rather than left as a property of the layout code, - (module)
because "the button was disabled" is a claim about pixels and "the job refuses to start" is a claim about behaviour. The worker encodes the WAV **in memory** and seals it before anything is written, so a recording that is going to be - (module)
encrypted never touches the disk in the clear -- not even briefly, not even in a temporary file that would be deleted afterwards. Deleting a file does not remove its contents from a flash device; not writing it does. # Nothing that talks - (module)
to the operating system runs on this thread The device monitor is the one that got this wrong and shipped. It was polled straight from `update`, and asking Windows which applications hold the microphone cost about a hundred and ninety - (module)
subprocesses -- so the window froze for seconds at a time, every two seconds. Both halves are fixed: `veilvoice-watch` now costs two subprocesses, and [`crate::watchfeed`] keeps even that on a thread of its own. The rule this file keeps, - (module)
and the reason the defect is worth a paragraph: **`update` may read state and paint it, and may start work, and may never wait for any.** A job, a lock operation, a monitor scan and an install all go to a worker and come back through a - (module)
channel. # The monitor indicator [`VeilVoiceApp::watch_indicator`] shows, in the header, whether anything is holding the microphone or camera right now, and clicking it goes to the monitor tab. It is polled on a timer rather than watched - (module)
continuously, because the underlying platform code enumerates processes and doing that every frame would cost more than the rest of the window put together. What it reports is bounded by what the platform allows, and - (module)
`veilvoice_watch::support()` states that bound rather than letting an empty list imply an empty machine. The indicator must never present "we could not see" as "nothing is there". # A policy tightens the controls, and the tightening is not - (module)
the drawing code [`crate::policy::InForce`] holds whatever `veilvoice policy` fixed on this machine. Fixed controls are drawn disabled with the reason underneath, but that is a courtesy: the values a job actually uses come from - (module)
[`VeilVoiceApp::posture`], which applies the policy every time it is asked. A policy that held only while a checkbox was drawn would not be a policy, and this file already keeps that rule for the at-rest choice. # Where the honest limits - (module)
are stated The about tab carries the scope text, and the lock tab carries `veilvoice_crypto::lock::SCOPE`. Neither is decoration: tests fail the build if that wording is softened, because a user who over-trusts the app lock is left worse - (module)
off than one who never had it. If you are editing text in this file and a test starts failing, it is that rule, and it is working. # In plain words The window itself: the tabs along the top, what each one shows, and the state they all - (module)
share. One window with tabs, no menus, and no settings file to go hunting for. Everything VeilVoice can do is reachable from something visible. The one rule this file follows without exception is that painting the window never waits for - (module)
anything. Reading a recording or running the engine takes seconds; if that happened here the window would stop responding, so it is started on another thread and the answer is collected later. - enum Tab
The things VeilVoice does. - impl Tab
- fn key
The name this tab answers to on the command line. Lower case and stable. These are what `--tab` accepts and what `tools/shots/gui.ps1` names each picture after, so changing one renames a screenshot and breaks a link in the README. They are - fn key
not the labels on screen, which are written for a reader and may be reworded. - const ALL
Every tab, in the order the window shows them. - fn from_key
The tab with this name, if it is one. - enum JobDone
Result of a background file job. - struct VeilVoiceApp
Application state. - fn preferred_output
Pick the output to start on: a virtual cable if the machine has one, because routing there is what lets other applications hear the veiled voice at all; otherwise the system default. A free function over a device list rather than a step - fn preferred_output
inside `Default`, so the choice can be tested against every arrangement of devices without touching the machine's audio stack. That matters more than it looks: building the app enumerates devices through `cpal`, and several tests doing - fn preferred_output
that at once on a headless runner is a good way to find out what WASAPI does when there are no endpoints and COM is being initialised from four threads at once. The answer was an access violation. - fn preferred_input
Pick the input to start on: the system default, else whatever is first. - impl VeilVoiceApp
- fn count_frames
Frames per second, to stderr, when `VEILVOICE_FRAME_LOG` is set. # Why a counter and not a frame time The About tab already shows how long a frame took, which answers "is drawing slow". It does not answer the question people actually have, - fn count_frames
which is "why is this using processor time when I am not touching it". An idle window should draw *no* frames. One drawing sixty a second is costing a laptop its battery whether each frame is fast or not, and a frame time cannot tell those - fn count_frames
apart: the fast-drawing runaway looks healthiest of all. Off unless asked for, printed once a second, and to stderr rather than into the window, because the person reading it is diagnosing rather than using. - fn frame_readout
Roadmap item 148. The frame rate in the header, when it has been asked for. Two numbers and no more: the rate as drawn, and how many frames arrived late in the last second. The second one turns yellow rather than appearing, because a - fn frame_readout
readout that changes shape is harder to read at a glance than one that changes colour, and somebody who turned this on is watching it. Nothing is formatted unless the readout is on, and nothing here is measured: `Pace` did that once at the - fn frame_readout
top of the frame. - fn frame_rate_detail
The sentence behind the readout, and the one the About tab prints. Says what the window is aiming at, what it measured the display to be, and what it is drawing with, because a rate well under the target on software rendering is a - fn frame_rate_detail
different conversation from the same rate on a GPU. - fn without_devices
The application with no devices enumerated. `Default` calls this after asking the system what it has. Tests that are not about device selection use it directly, so the suite touches the platform's audio stack exactly once instead of once - fn without_devices
per test. - impl Default for VeilVoiceApp
- fn default
- impl VeilVoiceApp
- fn integrity_panel
Build the app, applying theme and fonts to `ctx`. This is where the lock file is read, rather than in `Default`: tests and anything else constructing the app must not touch the real one. Which tab to open on, if one was named on the - fn integrity_panel
command line. `veilvoice-gui --tab verify`. It exists so the screenshot tool can put the window on a tab without clicking: driving the interface with synthetic mouse events needs the window in the foreground, Windows refuses to give a - fn integrity_panel
background process the foreground, and the refusal is reported by a return value that nothing was reading. Every capture then silently showed whichever tab was already open. A deep link into a tab is a reasonable thing for an application - fn integrity_panel
to have on its own account, which is why this is a real argument rather than a hidden one. What the integrity record found, drawn under the lock controls. **Roadmap item 75.** An associated function rather than a method so it borrows the - fn integrity_panel
state it reads and nothing else: `self.security.tab` already holds a mutable borrow of the same struct on the line above. It says which of the two records was consulted, because a sealed record and a plain one are worth different amounts - fn integrity_panel
and a reader who is not told which they have will assume the better one. - fn tab_from_arguments
The tab `--tab=` asks for, so a capture can open one screen directly. Read here rather than through the argument parser the command-line program uses, because the window takes no other arguments and pulling that dependency in for one flag - fn tab_from_arguments
would be the larger cost. - fn new
Build the application, ready for its first frame. - fn apply_policy
Bring the controls into line with the policy, once, at startup. Not the enforcement -- [`VeilVoiceApp::posture`] is. This is so the interface *opens* showing the values a job would use, rather than showing something looser that silently - fn apply_policy
changes when the job runs. - fn posture
The settings as they will actually be used, after the policy. Everything that runs a job reads this rather than the fields directly. The policy can only tighten it, so the worst this can do is process a recording more thoroughly than the - fn posture
sliders show -- which is the right direction for the one mistake that is unrecoverable. - fn config
The de-identification settings the panels currently describe. - impl VeilVoiceApp
- fn fit_to_the_screen
Open at a size this screen can actually show, once, on the first frame. The size the window is *created* with has to be chosen before there is a window, and therefore before anything knows how big the screen is. So it is created at the - fn fit_to_the_screen
preferred size and corrected here, on the first frame, when egui can say what the monitor is. Once only. Re-fitting on every frame would undo a resize the moment somebody made one, which is a window that fights its user; and because a - fn fit_to_the_screen
resize causes a frame, it would also be a loop. Skipped entirely when `--size` was given: that is somebody, or the screenshot harness, saying exactly what they want. - fn header_button
A small-text button drawn to the size of the control beside it. **Finding F-178.** The header's right-hand controls sit in one centred row, and the theme picker is the tallest thing in it. A button around small text works its own height - fn header_button
out from the padding in the style and lands a pixel short of the picker, which is enough for two boxes side by side to read as not quite lining up, and nothing anywhere said the two should match. So the height is passed in from the - fn header_button
picker's own rectangle rather than written down: whatever the picker turns out to be, the button is that. **Finding F-196.** The width was left alone, on the reasoning that a button as wide as the picker would be a different complaint. It - fn header_button
was the same complaint: a 46-point button against a 132-point dropdown, sharing a middle and agreeing on nothing else, is what "not aligned" was describing. It is drawn at [`crate::layout::LOCK_WIDTH`] now, which is the picker's width and - fn header_button
the unlock button's width too. - impl eframe::App for VeilVoiceApp
- fn on_exit
- fn ui
- impl VeilVoiceApp
- fn poll_job
Take the result of a finished job without ever waiting for one. Called once a frame from the drawing thread, so it uses `try_recv`: blocking here would hand the window's responsiveness to however long the job takes. - fn settings
Draw the settings panel, with a policy floor shown as a floor rather than as a value somebody can move. - fn file_tab
Draw the File tab: pick a recording, veil it, write it somewhere else. - fn start_job
Hand the work to a thread, so the window keeps drawing while it runs. - fn guest_list
The Recording Studio: the voice first, then the take. # Roadmap item 130: live scramble is not a separate tab any more It was, and the split was in the wrong place. The Studio has always recorded through the same `LiveSession` the live tab - fn guest_list
ran, with the same engine and the same routing, so the two screens were one act performed in two rooms: pick the devices over there, come here, press record. The devices the Studio recorded with were the ones the other tab happened to be - fn guest_list
set to, and nothing on this screen said so. Worse, each tab started a session of its own. Veiling on one and recording on the other opened the same microphone twice, which on the platforms that allow it at all gives the second stream a - fn guest_list
copy of the input nobody asked for. There is now exactly one starter, in `studio::Studio::start_session`, and this tab is the only thing that calls it. So: the voice half is here, at the top, and it works with the vault shut because - fn guest_list
veiling a call has never needed a vault and requiring one would be a worse program. The take half is under it and needs both passphrases, as it always has. The lists and the settings widgets stay the window's rather than the Studio's, - fn guest_list
because the window is what enumerates the devices, once, at startup, and because the file tab shows the same engine settings. The room: a name and a microphone each, and what that costs. **Roadmap item 147.** Drawn by the window rather - fn guest_list
than by the Studio for the reason the device pickers are: the device list belongs to the window. What the Studio owns is the list of guests, because the session it starts is built from it. - fn studio_tab
Draw the Recording Studio: record into the vault, veiled on the way in. - const WHAT_A_METER_IS_WORTH
- fn start_preview
Veil to this machine's own output, and say where it is going. The chosen output is deliberately ignored by the session this starts: a preview that went to the virtual cable would be heard by whatever is listening on it, which is the one - fn start_preview
place somebody checking their setup does not want it to go. - fn check_failsafe
Ask the safety catch what it makes of what is holding a microphone. Called once a frame, straight after the watch feed is drained, because that is where the information arrives. The decision is arithmetic over a list -- see - fn check_failsafe
`veilvoice_guard::failsafe` -- so doing it every frame costs nothing and means the answer is never a frame out of date. **Closing a program is done here and nowhere else**, and only when the guard has said it may be. - fn watch_indicator
Re-scan on a timer rather than every frame. The always-visible indicator. - fn watch_tab
Draw the Watch tab: what is recording the screen, and what is allowed to. - fn report_a_fault
Say so if the last run ended badly, and offer the file. A report written to disk that nobody is told about is a report nobody reads. The crash log exists because this application had no way at all to explain a failure -- no console, and an - fn report_a_fault
abort on panic -- and leaving its output for the user to stumble across would only half fix that. Shown in the about tab rather than as a modal on launch: the previous run failing is worth knowing and is not worth a dialog in front of - fn report_a_fault
somebody who has just successfully opened the application. A way to report a fault, whether or not anything has crashed. What was here was the crash notice itself, and it was only here. The panel above every tab has taken that over, - fn report_a_fault
because About is where somebody goes to read version numbers rather than where they land after a crash. What stays is the standing offer: the version numbers a report needs are directly above this line, and now so is somewhere to send it. - fn about_tab
Draw the About tab: versions, where files live, and the companion list. - fn paths_section
Where this copy is keeping things, on this machine. Read out of the same functions the rest of the application calls, so the panel cannot say one thing while the program does another. See [`crate::paths`] for what is listed and for the one - fn paths_section
path that is deliberately not. - fn device_picker
A dropdown of devices that keeps working when the chosen one disappears. - fn field
One labelled read-only value, in the shape the About tab uses throughout. - mod header_layout_tests
- fn the_lock_button_is_the_theme_pickers_height
**Finding F-178.** The lock button is the theme picker's height. Measured from the widgets rather than from a photograph: the capture scripts photograph a window with no app lock set, and the lock button is only drawn when there is one, so - fn the_lock_button_is_the_theme_pickers_height
no screenshot this repository produces contains the control in question. The theme is installed first. Without it the default spacing makes every control the same height and the test would pass while measuring nothing about this - fn the_lock_button_is_the_theme_pickers_height
application. - fn a_button_left_to_size_itself_does_not_match_the_picker
And the shape this corrects, so the assertion above is known to be able to fail: a button that works its own height out from padding is a pixel shorter than the picker beside it. - mod tests
- fn the_drawing_thread_never_waits_on_anything
**Roadmap item 79.** Nothing that waits happens on the thread that draws. A window stutters for one of two reasons: it is asked to draw too rarely, or it is doing something slow between frames. The second is the one that cannot be tuned - fn the_drawing_thread_never_waits_on_anything
away, and it is invisible in a screenshot: the window simply stops for as long as the call takes. So the draw path is read for the calls that wait. Everything this application does that can block already runs on its own thread and reports - fn the_drawing_thread_never_waits_on_anything
back through a channel: the file dialogs after seven of them froze the window, the update check, the verifier, the group render, the key derivation. This keeps that true rather than assuming it. Comments are stripped first, for the reason - fn the_drawing_thread_never_waits_on_anything
the lock screen's guard gives: the first version of a test like this flags its own explanation. - fn the_window_does_not_wake_itself_to_check_on_the_monitor
An untouched window draws nothing. # The measurement this is here to keep With the animations off and nobody touching it, the window drew **2.1 frames a second on every one of the nine tabs, for ever**, and cost 7 to 9 per cent of a core - fn the_window_does_not_wake_itself_to_check_on_the_monitor
doing it. After this it draws none, and costs 0.2 per cent. Measured on the same machine, twenty seconds a tab, with `VEILVOICE_FRAME_LOG=1` counting the frames and `/proc` counting the time. The cause was a pair of reasonable-looking - fn the_window_does_not_wake_itself_to_check_on_the_monitor
decisions meeting. The microphone monitor sent an update on every poll whether or not anything had changed, and this file woke the window twice a second to ask the channel whether anything had arrived. Each half is the sort of thing that - fn the_window_does_not_wake_itself_to_check_on_the_monitor
reads fine in review. Together they are a program that never sleeps. The rule now is that the thread with the news asks for the repaint, because it is the only thing that knows there is any. This checks the window is not asking on a timer - fn the_window_does_not_wake_itself_to_check_on_the_monitor
instead. - fn the_user_guide_documents_every_tab
The user guide describes the application that exists. It said "Five tabs" and documented five, and there are nine. The four it left out were **group**, **verify**, **settings** and **install** -- among them the verify tab, which is the one - fn the_user_guide_documents_every_tab
this project tells people to use before running a download it has just told them not to trust. This is the fourth finding of one shape in this repository: a document describing the program, with nothing comparing the two. F-71 was two - fn the_user_guide_documents_every_tab
hand-typed copies of a number, F-101 a page linking files that were never published, F-110 an example the parser refused. The remedy is always the same one, and this is it for the guide. Each tab gets a heading of its own, named for the - fn the_user_guide_documents_every_tab
key the tab answers to, so `veilvoice-gui --tab verify` and the section explaining that tab cannot come apart. A tab added without a section fails the build here. - fn the_user_guide_does_not_count_the_tabs_by_hand
A count of the tabs, written out in the guide, is a second copy of a fact and drifts from the first. It already did: "Five tabs", nine tabs. - const WORDS
The number words, in order, so a written count can be compared with the list it is a copy of. This used to be a list of *wrong* counts to forbid, which is the same mistake one level up: the list had to be edited every time the number - const WORDS
changed, and roadmap item 130 took a tab away and made "ten" both the truth and one of the forbidden words. Reading the number and comparing it needs no maintenance at all. - fn the_help_text_lists_the_tabs_that_exist
The tab names `veilvoice-gui --help` lists have to be the tab names that exist. Written after the first version of that help text named `watch`, `security` and no `install`, when the keys are `monitor`, `lock` and `install`. Three wrong - fn the_help_text_lists_the_tabs_that_exist
names in the one place somebody reads to find out what the right ones are, and the manual page is generated from that text, so the error would have shipped inside the package as well. - fn a_running_job_does_not_count_as_using_the_window
Roadmap item 92. A job running is not the window being used. The tempting version of an idle timer treats "something is happening" as "somebody is here", and it is exactly backwards for this program: somebody who starts a long render and - fn a_running_job_does_not_count_as_using_the_window
walks away has walked away, and the recording being produced is the thing worth locking away. - fn an_unanswered_vault_question_blocks_the_job_rather_than_redirecting_it
Roadmap item 83. An unanswered hidden-volume question must stop the job, not quietly redirect it back beside the source file. A user who believes their recording went into a vault and finds it next to the original is the failure the whole - fn an_unanswered_vault_question_blocks_the_job_rather_than_redirecting_it
question exists to prevent. - fn the_integrity_record_runs_itself_at_launch_and_again_at_unlock
Roadmap item 75. The record has to be taken without anybody knowing to ask, and it has to wait for the passphrase when there is one to wait for. - fn every_tab_has_a_stable_unique_name
Every tab has a name, they are unique, and they round trip. The screenshot tool names each picture after one of these, so a change here renames a file the README links to. - fn every_tab_has_a_tour_card_and_every_card_has_a_tab
Every tab is introduced, and the tour introduces nothing that is gone. The tour reads as a list of tabs, and a list of tabs written by hand beside a list of tabs written by hand is two lists that drift. This is what stops a tab being added - fn every_tab_has_a_tour_card_and_every_card_has_a_tab
with no sentence to explain it, which is the failure that matters: an unexplained tab looks the same as an explained one until somebody opens it. - fn device
Device selection is tested against synthetic lists, never the machine. See `preferred_output` for why that is not merely tidier. - fn the_bar_and_the_number_agree_about_the_level
The bar and the number beside it have to be saying the same thing. They did not: the bar was filled linearly and the number was decibels, so at ordinary speech the number said -12 and the bar showed a quarter. Both now come from - fn the_bar_and_the_number_agree_about_the_level
`veilvoice_audio::meter`, and this is the assertion that keeps them there. - fn defaults_are_the_safe_ones
- fn a_job_cannot_start_before_the_at_rest_choice_is_made
The default is only worth anything if the button honours it: with encryption on and nothing to encrypt with, a job must not start. - fn config_reflects_the_controls
- fn every_reachable_reseed_setting_is_valid
The slider's whole range must produce a configuration the engine accepts, or a user could drag it into an error. - fn a_virtual_cable_is_preferred_over_the_system_default
A virtual cable must win, because routing there is the whole point of live mode. Every arrangement, none of them involving real hardware. - fn the_default_input_is_preferred_then_the_first
- fn a_policy_constrains_the_settings_a_job_actually_uses
A policy that fixes the engine settings must reach the *job*, not just the widgets. The fields are deliberately left loose here: if `config` read them directly, this would fail. - fn a_policy_never_loosens_what_a_job_would_do
Whatever the sliders say, a policy may only ever make the result more thoroughly processed. Checked across the slider's whole range. - fn a_required_encryption_is_pinned_rather_than_only_disabled
The at-rest requirement is pinned in the state, not merely drawn disabled -- and pinning it must also close the dialogue that turns it off, which is reachable from more than one frame's worth of state. - fn a_required_lock_is_announced_and_not_imposed
A required lock is announced and never imposed: VeilVoice cannot set a lock, because that needs a passphrase only the user has. - fn without_a_policy_nothing_is_fixed
With no policy -- the ordinary case -- nothing is pinned and nothing is raised. - fn building_the_app_with_real_device_enumeration_does_not_panic
The one test that talks to the machine's audio stack. Kept single, and last: enumerating devices from several test threads at once on a headless runner is what produced an access violation in CI.
crates/veilvoice-gui/src/autolock.rs
- (module)
Locking the window again after a period of no use. **Roadmap item 92.** On at half an hour, and the delay is the user's to choose: anything from five minutes to forty eight hours from a list, a number typed in if none of those fit, and the - (module)
ends of that range movable by anybody who wants a shorter or longer one. # Why it is on by default, having been off It was off, on the argument that a lock engaging part way through a recording is a lock that gets removed, and that - (module)
VeilVoice cannot know whether a given user is the one who walks away or the one who leaves a job running -- so it should ask rather than assume. The asking is the part that was wrong. A default nobody is shown is not a question, it is an - (module)
answer, and the answer it gave was "no protection" to everybody who never opened the settings tab. The people most helped by an autolock are the least likely to go looking for one. So it is on, and the first-run setup shows it rather than - (module)
leaving it to be discovered: half an hour, with the choice and the off switch right there. The original concern is answered by the delay rather than by the default -- thirty minutes of an untouched window is not somebody part way through - (module)
anything -- and by the fact that this has never counted a running job as use, which is deliberate and explained below. # What counts as use Any keystroke, click, scroll or pointer movement over the window, which is what egui reports as - (module)
input. Deliberately **not** the passage of a job: somebody who starts a long render and leaves the room has left the room, and the recording they are producing is the thing worth locking away. The clock is egui's own frame time rather than - (module)
the system clock, so moving the machine's clock does not bring the lock forward or push it back. It also means the countdown only advances while the window is being drawn, which is the honest limit of this: a window nobody is drawing is a - (module)
window nobody is looking at, and it locks the moment it is looked at again. # In plain words Locks the window again if you have not touched it for half an hour. On to begin with, and setup shows you the switch. You pick how long, from five - (module)
minutes up to two days, or type your own, and you can turn it off. Starting a long job does not count as using it: if you walk away while something is rendering, that is exactly when you would want it locked. - const DEFAULT_SECS
The delay a fresh installation uses: half an hour. Long enough that it does not interrupt somebody working, short enough to matter if they walk out. It is the setup screen's suggestion as well as the code's default, so the number a user is - const DEFAULT_SECS
shown is the number they get. - const FLOOR_SECS
The shortest delay the list offers, in seconds. - const CEILING_SECS
The longest, in seconds. Forty eight hours. - const CHOICES
The delays offered without typing anything, in seconds. Chosen to be the ones people actually mean: a coffee, a lunch, an afternoon, overnight, a weekend away. Anything else is typed. - struct Autolock
How the autolock is configured. - impl Default for Autolock
- fn default
- impl Autolock
- fn sane
Bring every field into a state that can be offered and obeyed. A settings file is editable, so every number that reaches here has been through somebody's text editor as far as this code knows. Rather than refuse, which would leave a user - fn sane
with an autolock they cannot fix from the interface, each value is brought back into range and the result is always something the interface can show. - fn after
The delay as a `Duration`. - fn expired
Whether `idle` has reached the delay. False when the autolock is off, whatever `idle` says, so a caller cannot lock a window the user asked to leave unlocked by getting the condition the wrong way round. - fn describe
The delay in the words a person uses for it. - fn describe_secs
A number of seconds as a phrase. Whole units only, because every value this offers is a whole number of minutes or hours and "1 hour 0 minutes" reads like a machine talking. - fn parse
Read a delay somebody typed. Accepts a bare number of minutes, or a number with a unit: `90`, `90m`, `90 min`, `2h`, `2 hours`, `1d`. Returns `None` rather than guessing when it cannot tell, so the interface can say it did not understand - fn parse
instead of silently applying a number the user did not mean. - mod tests
- fn it_is_on_at_half_an_hour_out_of_the_box
- fn switching_it_off_switches_it_off
- fn the_default_delay_is_one_of_the_offered_choices
- fn it_locks_once_the_delay_has_passed_and_not_before
- fn every_offered_choice_is_inside_the_range_it_is_offered_from
- fn a_hand_edited_settings_file_is_brought_back_into_range
The settings file is editable, so every number here has been through a text editor as far as this code knows. - fn a_range_the_user_widened_is_kept
The user may move the ends of the range, so a delay outside the default one is not an error. - fn typed_delays_are_read_the_way_they_are_written
- fn nonsense_is_refused_rather_than_guessed_at
Refusing beats guessing: a number nobody meant, silently applied, is a window that locks at a time its owner cannot explain. - fn an_absurd_number_does_not_wrap_into_a_tiny_delay
A huge typed number must not wrap into a small one and lock the window immediately, which is the opposite of what was asked for. - fn a_delay_is_described_the_way_a_person_would_say_it
- fn every_choice_has_a_readable_name
crates/veilvoice-gui/src/avnotice.rs
- (module)
Noticing when antivirus software has closed VeilVoice, and saying so kindly. # The problem this is for A newly released application that few people have run yet has, in the language of Windows antivirus, "low reputation". A low-reputation - (module)
program that reads the microphone and writes encrypted files is exactly the shape a heuristic scanner is tuned to be suspicious of, and some of them will close it -- a false positive, but indistinguishable from a crash to the person - (module)
sitting in front of it. VeilVoice is offline, reproducible and signed, so a user *can* establish that it is what it says it is. But that is cold comfort if the window just vanished and they have no idea why. # What this does, and what it - (module)
does not It does **not** try to evade anything, hide from anything, or stop an antivirus doing its job. That would be both wrong and futile. It does the opposite: it helps the user understand what happened so they can make their own - (module)
decision. On a clean exit VeilVoice removes a small marker file. So on the next launch, a marker still present means the previous run ended without getting to its own shutdown -- and if VeilVoice had crashed, it would have written a crash - (module)
report on the way down (see [`crate::crashlog`]). A marker present with no crash report is the signature of the process being terminated from outside. When that has happened *and* a known antivirus product is on the machine, the next - (module)
launch shows a plain notice: which product was found, that a low-reputation app is sometimes stopped by mistake, that they would normally have seen an alert from that product, and that adding an exclusion is worth doing **only** if they - (module)
are actually seeing the problem. Nothing is changed on the system and nothing is suppressed; it is one paragraph of context. # Why the decision is a pure function Whether to show the notice depends on three facts -- was the last exit - (module)
unclean, was there a crash report, is an antivirus present -- and getting that logic wrong means either crying wolf or staying silent when it would have helped. [`diagnose`] takes those three as arguments so every branch is tested from any - (module)
machine, and the platform probing that gathers them is kept separate and thin. - struct Product
An antivirus product recognised on this machine. - struct Notice
The notice to put in front of the user, once. - impl Notice
- fn message
The message, assembled from the products found. Plain, specific, and non-alarming: it names what was found, says the likely cause, and puts the decision back in the user's hands rather than telling them to disable their protection. - fn diagnose
Decide whether to show the notice. `Some` only when the last exit was unclean, VeilVoice did **not** leave a crash report (so it did not fall over on its own), and at least one antivirus product is present. Any other combination is `None`: - fn diagnose
a clean exit is nothing to explain, a crash has its own report, and with no antivirus present there is nobody to name and the notice would be a guess. - fn marker_path
The marker whose presence on startup means the last run did not exit cleanly. - struct Session
A session's clean-shutdown marker. Write it when the window opens, remove it when the window closes normally. If the process is killed, the file is left behind, which is exactly the signal [`diagnose`] reads. - impl Session
- fn begin
Begin a session: note whether the last one ended cleanly, then claim the marker for this one. - fn prior_was_unclean
Whether the previous session ended without a clean shutdown. - fn end
End the session cleanly, removing the marker. Consumes `self` so it cannot be called twice, and so the ordinary Drop does not also fire. A session that is never ended -- because the process was killed -- leaves the marker, which is the - fn end
point. - fn detect
Look for antivirus products on this machine. Best effort and deliberately conservative: it names a product only from evidence it is really there. Off Windows it returns nothing, because the low-reputation false positive this exists for is - fn detect
overwhelmingly a Windows phenomenon, and naming an antivirus on a machine that has none would be the crying-wolf this is built to avoid. - const WINDOWS_PRODUCTS
Known products, and a path whose presence is good evidence of them. Directories rather than running processes: a directory check needs no process enumeration, no privileges and no unsafe call, and an installed antivirus is what matters - const WINDOWS_PRODUCTS
here rather than whether it happens to be running this second. - fn detect_windows
- mod tests
- fn product
- fn a_clean_exit_says_nothing
- fn a_crash_of_our_own_says_nothing_here
- fn an_unclean_exit_with_no_antivirus_says_nothing
- fn an_unclean_exit_with_antivirus_and_no_crash_is_the_one_case
- fn the_message_names_the_product_and_does_not_tell_them_to_disable_it
- fn several_products_are_all_named
- fn the_marker_sits_beside_the_lock
crates/veilvoice-gui/src/crashlog.rs
- (module)
Make a failure that produces no output produce some. # The problem this exists for `veilvoice-gui` is built with `windows_subsystem = "windows"`, so it has no console, and the workspace builds with `panic = "abort"`, so a panic does not - (module)
unwind into anything that could report it. Put together, **every way this application can fail on Windows produces exactly nothing**: no message, no dialog, no log. The window appears or it does not. That is not hypothetical. A release - (module)
shipped and the report was "it flashes a command prompt, loads in an unusable state, and crashes" -- which is all a user *can* report, because the program tells them nothing. The console flash turned out to be subprocesses (see `no_window` - (module)
in [`crate::reduced_motion`]), and the crash could not be diagnosed at all from what was observable. # What this does about it Two failures are caught and written to a file beside the preferences: * **A panic**, through a hook. The hook - (module)
runs before `abort` even under `panic = "abort"`, so there is a window in which to write. * **A startup failure from `eframe`**, which is a returned `Err` rather than a panic and is otherwise printed to a stderr nobody can see. The second - (module)
is the one worth expecting. This application renders through **glow**, which is OpenGL, and creating a GL context depends on the graphics driver. In a virtual machine, over a remote desktop session, or on a laptop whose hybrid graphics - (module)
hand the process the wrong adapter, that call fails -- and the honest answer to "why did nothing happen?" needs to survive the process exiting. # What it deliberately does not do **It does not report anything anywhere.** The file is - (module)
written next to the preferences, on the user's own disk, and stays there until they delete it or the application clears it. A privacy tool that phones home about its own crashes would be exactly the thing this project spends its - (module)
documentation refusing to be, and there is no network code in the dependency graph to do it with even if that changed. **It records no user content.** A panic message, a source location, the version and a timestamp. Not the file being - (module)
processed, not a path the user chose, not a passphrase -- nothing that is theirs. # In plain words Makes sure that if VeilVoice falls over, it leaves something behind saying so. A windowed program on Windows has nowhere to print to. - (module)
Without this, a failure at startup produces a window that never appears and no message anywhere, and the only thing anybody can report is "it crashed", which is exactly the report that arrived once. So the reason is written to a file, and - (module)
the file is shown to you next time the application opens. It stays on your machine and is never sent anywhere. - fn default_path
The file a failure is written to, beside the preferences. - fn stamp
Seconds since the Unix epoch, or 0 if the clock is unreadable. No date formatting and no dependency for it. A support conversation needs to know *which* run failed and roughly when, and an epoch second answers that; pulling in a calendar - fn stamp
library to render it would be a poor trade in a project whose argument is that its dependency graph can be read. - fn write
Write one failure report. Never panics, whatever happens. Called from a panic hook, so it has to be the most defensive code in the crate: a panic in here during a panic is an abort with even less to show for it than before. Every error is - fn write
swallowed deliberately -- there is nowhere left to report a failure to report a failure. - fn advice
The paragraph that tries to be useful about *this* failure. # Why this is not one paragraph It was. Every report ended with the same note about OpenGL contexts, which is the right guess for a window that never appeared and the wrong one - fn advice
for most of the ways this program can actually fail. A report that confidently names a cause it did not check is worse than one that names none: it sends the reader to look at their graphics driver while the real message, two lines above, - fn advice
says a keyboard library is missing. That happened here rather than in theory. Upgrading the window toolkit added a runtime dependency on `libxkbcommon-x11`, which is opened by name at startup and therefore invisible to every packaging tool - fn advice
that works out dependencies from what a binary links against. On a machine without it the application built, installed and packaged perfectly and then aborted before drawing anything, and the report blamed the GPU. So the note is chosen - fn advice
from the panic message. The library case names the library and the package that carries it on each family of Linux; anything unrecognised keeps the graphics note, which remains the best guess when there is nothing else to go on. - fn missing_library
The name of the shared library a panic message says could not be loaded. Matched on the shape of the message rather than on one library's name, so the next toolkit upgrade that adds one is covered without an edit here. `xkbcommon-dl` - fn missing_library
writes "Library libfoo.so could not be loaded."; the same shape covers the other dynamic loaders in this dependency graph. - fn install
Install the panic hook. Call once, as early in `main` as possible. Chained rather than replacing: the default hook prints to stderr, which is useful when there *is* a console (a debug build, or the binary run from a terminal), and this - fn install
adds the file for when there is not. - fn record_startup_failure
Record a startup failure that `eframe` returned rather than panicked. - fn previous
Read a previous report, if one is there, so the interface can mention it. - fn clear
Forget a previous report. - mod tests
- fn no_panic_in_this_program_formats_a_path_into_its_message
No panic VeilVoice writes itself puts a path in its message. The crash panel invites somebody to paste this report into a public issue tracker, and says VeilVoice puts no file names in it. The panic message is the one part not written in - fn no_panic_in_this_program_formats_a_path_into_its_message
advance, so that claim has to be checked against the source rather than asserted. A dependency's panic could still carry a path, which this cannot see and does not pretend to. That is exactly why the panel names the error message as the - fn no_panic_in_this_program_formats_a_path_into_its_message
variable part and puts the whole text on screen instead of promising on a library's behalf. - fn scratch
- fn a_report_names_the_version_and_the_detail
- fn a_report_says_it_was_sent_nowhere
- fn a_report_points_at_the_command_line_tool
- fn a_missing_library_is_named_instead_of_the_graphics_card
- fn the_loader_message_shape_is_matched_not_one_library_name
- fn an_enormous_panic_message_is_bounded
- fn writing_into_a_directory_that_does_not_exist_creates_it
- fn an_unwritable_destination_is_survived_rather_than_panicked_on
crates/veilvoice-gui/src/crashreport.rs
- (module)
Offering the report from the last crash, on the run after it. # What was wrong with where this used to be A report has been written to disk since v0.1.10, and the interface mentioned it: one line on the About tab saying the previous run - (module)
ended unexpectedly and where the file is. About is the last tab somebody who has just had a crash will open. It is where you go to read version numbers. The person this notice is for restarted the application to get back to what they were - (module)
doing, landed on the tab they were using, and saw nothing. So the report existed, was accurate, was written for a person to read, and was never read by one. It is now shown above whichever tab you land on, once, on the first run after a - (module)
crash. That is the panel the application already uses for anything it needs somebody to see whatever they are looking at. # Why it is offered rather than sent Nothing is transmitted, and nothing here could transmit it: this project - (module)
contains no network client and CI fails the build if one enters the dependency graph. A crash reporter that uploads is the ordinary shape of this feature and it is the wrong shape for this program, because a report from a tool people use - (module)
to protect themselves is a report about a person who was being careful. So the report is shown, in full, in the window. The whole text, not a summary of it, because "would you like to send this" is only a real question if you can read what - (module)
"this" is. Then two buttons: copy it, and open the issue tracker in a browser. What happens after that is the person's decision and their clipboard. # And it says what is in it, without overpromising The report carries the version, the - (module)
operating system and processor, when it happened, the panic message and the source location. VeilVoice puts in no file names, no settings, no passphrase and nothing about the audio. The panel used to say flatly that the report *contains* - (module)
no file names. That is a promise about the panic message, and the panic message is the one part nobody writes in advance. No panic in VeilVoice's own code formats a path -- there is a test for that, in `crashlog`, and it reads the source - (module)
rather than trusting the claim -- but a decoder or a toolkit it depends on could put one there, and this program cannot promise on their behalf. So the panel says what VeilVoice puts in, names the error message as the part that is not - (module)
written in advance, and offers the whole text to read. A promise that cannot be kept is worse than an accurate description with a button next to it. - const NEW_ISSUE
Where a report goes, if the person wants to file one. A new issue rather than the issue list: somebody arriving from a crash has a report in their clipboard and wants a box to put it in, not a search. - const ISSUES
The issue tracker itself, linked from About whether or not anything crashed. - const COPIED_FOR
How long the copy button says it copied, in seconds. - struct Offer
What the panel is showing, kept across frames. - impl Offer
- fn look
Read the previous report, if there is one. Cheap after the first call. - fn waiting
Whether there is anything to show. - fn panel
Draw the offer. Returns true when it has been dealt with and the panel should go away. Dismissing deletes the file. That is deliberate and it is the reason the whole text is on screen first: a notice that keeps coming back is a notice - fn panel
people learn to close without reading, and the report is of no use to anybody once its owner has decided not to file it. - mod tests
- fn source
The source of this file, up to its tests, so a test can check the code rather than matching its own string literals. - fn code
The same, with comments removed. The scan below looks for the machinery of sending something. The comments at the top of this file explain at length why there is none, and to do that they have to use the words: the first run of that test - fn code
failed on the sentence "a crash reporter that uploads". A check that forbids describing the thing it forbids is a check that punishes writing the explanation down. - fn nothing_is_offered_when_nothing_crashed
- fn the_disk_is_read_once_and_not_every_frame
- fn the_offer_names_the_project_and_not_a_third_party
- fn nothing_here_transmits_anything
- fn the_panel_says_what_the_report_holds_before_offering_it
crates/veilvoice-gui/src/decoys.rs
- (module)
Decoy vaults: how many there is room for, and the panel that offers them. # What a decoy is worth, said plainly A decoy is a directory that is the same shape as the real vault, holding encrypted nonsense under a key that was thrown away - (module)
the moment it was written. Somebody who takes the disk sees several vaults and cannot tell which one holds anything: the names say nothing, the sizes are the same, and cracking one yields bytes that parse as nothing, which looks exactly - (module)
like a wrong passphrase. What that buys is the cost of searching. It does **not** hide the real vault from somebody watching the screen while it is opened, from somebody who has already got into the running program, or from a backup taken - (module)
before the decoys were made. The panel says all three where the button is, because a defence somebody misjudges is worse than one they do not have. # Why the count is measured A number chosen here would be a guess dressed as advice: nine - (module)
decoys is careless on a nearly full laptop and timid on a four-terabyte disk. So the count comes from the room actually free where the vault lives, and where the system will not say how much that is, the panel says so instead of quietly - (module)
inventing a figure. - const SHARE
Never more than this share of what is free. One twentieth. Decoys are not the only thing that wants the disk, and a feature that filled it would cost somebody their next recording to protect the ones they had already made. - const MOST
Never more than this many, however much room there is. Past here the work of searching stops rising in any way that matters: an attacker willing to attack thirty-two vaults is willing to attack a hundred, and the folder becomes something - const MOST
its owner cannot look at and understand. - struct Advice
What to offer, and on what evidence. - fn advise
Work out what to offer from the vault's shape and whatever the disk says. Pure, so the arithmetic can be tested without a disk and without a window. - fn panel
The panel, under the listing in the Browser. Returns how many decoys were asked for, or `None` when nothing was asked. Drawing and doing are separate because making a decoy writes files and the caller owns the vault this is measuring. - fn counted_vaults
"one vault" or "four vaults", so the panel does not say "1 vaults". - mod tests
crates/veilvoice-gui/src/decoys/tests.rs
- (module)
What the panel offers, checked without a disk or a window. - fn shape
A vault of `n` recordings of `each` bytes, with an index to match. - fn the_count_comes_from_the_free_space_and_not_from_a_number_picked_here
- fn a_disk_that_will_not_say_is_not_reported_as_a_measurement
- fn a_full_disk_offers_nothing_rather_than_offering_one_anyway
- fn an_enormous_disk_stops_at_the_stated_ceiling
- fn an_empty_vault_still_has_a_size_and_does_not_divide_by_zero
- fn the_size_offered_is_the_size_the_vault_format_says
- fn the_count_is_said_in_words_that_read_properly
crates/veilvoice-gui/src/dialog.rs
- (module)
Asking for a file without stopping the window. # The defect this exists to fix Every file picker in this application was opened with `rfd`'s **blocking** API, straight from the frame that handled the click: ```ignore if ui.button("choose - (module)
file…").clicked() { if let Some(path) = rfd::FileDialog::new().pick_file() { … } } ``` `pick_file` does not return until the person has chosen a file or cancelled. It is called from inside `update`, which is the render loop, so for as long - (module)
as that dialog is open **VeilVoice draws nothing at all**: the window does not repaint, animations stop, the meters freeze, and dragging it leaves a trail of stale pixels. Somebody browsing for a recording for thirty seconds has a frozen - (module)
application for thirty seconds. It is also the answer to "it lags when I select things", which is a real report and an accurate one. There were seven of these. # How this is avoided, and the one platform where it cannot be The dialog runs - (module)
on a thread of its own and the answer comes back down a channel, which [`Pending::poll`] reads without waiting. `update` starts the ask and returns immediately; the window keeps painting the whole time. **macOS is the exception, and it is - (module)
not a shortcut.** `NSOpenPanel` must be driven from the main thread; opening one anywhere else does not work, and on some versions it does not fail politely either. So on macOS the ask is made inline, exactly as before. That platform keeps - (module)
the old behaviour because the alternative is a picker that does not appear, and a frozen window is better than no dialog. # In plain words When you click "choose a file", the box that opens used to freeze the rest of VeilVoice until you - (module)
picked something. Now it opens beside the application and everything carries on running while you browse. On Apple computers it still waits, because macOS insists that file pickers are opened by the main part of a program and there is no - (module)
way around that which actually works. - enum Ask
What is being asked for. - impl Ask
- fn open
An open dialog with no filter. - fn open_filtered
An open dialog restricted to these extensions. - fn save
A save dialog offering this name. - fn save_filtered
A save dialog offering this name, restricted to these extensions. - fn show
Show it, here, now. Blocks until answered. - fn can_show
Whether a platform file panel can be opened from here at all. # Two places where it cannot, and what each one did **A test binary.** There is no window for a panel to belong to and, on a build machine, usually no desktop either. Opening - fn can_show
one there was not a harmless no-operation: on macOS `rfd` panics outright rather than returning nothing, and on Windows the dialog thread outlived the test harness and took the process down with an access violation after every test had - fn can_show
passed. Both were failing builds on those two platforms and neither said anything about VeilVoice. **Anywhere but the main thread on macOS.** `NSOpenPanel` must be driven from the main thread; asking from another one is not slow or - fn can_show
unreliable, it is impossible, and `rfd` says so by panicking. A panic here would take down an application in the middle of somebody's recording, so the ask is refused and reads as a cancel instead. The main thread is recognised by its - fn can_show
name, which is what Rust calls it and is the only way to ask without `unsafe`. A thread deliberately named `main` would fool it, and that is a thing this codebase does not do. - struct Pending
A file dialog that is open, or has just been answered. Held by whichever panel asked. `None` inside means nothing is being asked. - impl Pending
- fn new
Nothing is being asked. - fn is_open
Whether a dialog is open right now. A caller should disable the button that started it: two pickers open at once is two answers arriving for one question, and the second would overwrite the first with no way to tell which was which. - fn start
Start asking. Does nothing if a dialog is already open. - fn poll
The answer, if one has arrived. Never waits. Returns `Some(None)` when the dialog was cancelled, which is a real answer and different from "still open". - fn taken
The answer, if one arrived and was a path. - mod tests
- fn a_fresh_pending_is_not_open_and_has_no_answer
- fn a_thread_that_never_answers_is_treated_as_a_cancel
A dropped sender is a cancel, not a dialog that stays open for ever. Otherwise the button that started it is disabled with no way back. - fn cancelling_is_reported_as_an_answer_rather_than_as_silence
Cancelling is an answer, and a different one from "still waiting". - fn a_chosen_path_comes_back_once_and_closes_the_dialog
- fn no_platform_panel_is_opened_from_a_test_binary
Nothing may reach the platform panel from a test binary. This is the guard itself: `can_show` is what `start` asks, and a change that made it answer yes here would put a native dialog back into every build machine on macOS and Windows, - fn no_platform_panel_is_opened_from_a_test_binary
which is how those two platforms were failing. - fn an_ask_that_cannot_be_shown_is_open_and_then_cancelled
An ask that cannot be shown still reads as open, and then as cancelled. Reporting it as never started would leave the caller believing the button had done nothing at all. - fn starting_a_second_dialog_while_one_is_open_does_nothing
Two asks at once would be two answers to one question, and the second would quietly overwrite the first. - fn the_asks_carry_their_filters_and_names
The builders carry what they were given, so a caller's filter is not silently dropped on the way to the dialog. - fn nothing_outside_this_module_opens_a_dialog_on_the_render_thread
**The point of the module.** No panel may call the blocking picker directly any more; every ask goes through here, which is the only place that knows about threads and about macOS. - mod house_style
- fn no_dashes_in_anything_the_interface_says
**No dashes in anything the application shows.** Not the em dash, and not the doubled hyphen this project used in its place. A dash is almost always a colon, a semicolon, a full stop or a pair of brackets wearing a disguise, and the - fn no_dashes_in_anything_the_interface_says
sentence reads better once it has been made to choose. Checked across the whole crate rather than in one file, because the strings a reader sees are spread through every panel. Comments are exempt: this is about the interface, and a sweep - fn no_dashes_in_anything_the_interface_says
of the rest of the repository is a separate decision that has not been taken.
crates/veilvoice-gui/src/firstrun.rs
- (module)
The first run: the four things worth deciding before anything else. # What this replaced Two checkboxes about animation. Everything that actually matters -- the app lock, the passphrase recordings are encrypted with, whether the window - (module)
locks itself -- was left to be discovered on a tab most people never opened. That is a defensible choice for a preference and a bad one for a protection. A default nobody is shown is not a question, it is an answer, and for a privacy tool - (module)
the answer it was quietly giving was "none of it". # What it asks, and what it will not do Four cards, each skippable, each stating what it buys before asking for anything: 1. **Appearance.** The two animation choices, kept from the old - (module)
panel. 2. **The app lock.** A passphrase for the window, and -- since 0.1.18 -- the key that names and encrypts VeilVoice's own files. The card says both, and says the sentence that has to be said out loud: forget it and those files are - (module)
gone. 3. **The recording passphrase.** What veiled recordings are encrypted with. Separate from the app lock by default, with the option to use one passphrase for both and a plain statement of what that trades. 4. **Locking itself.** On at - (module)
half an hour, with the delay and the off switch right there. **Nothing here is a gate.** Every card has a way past it, and skipping all four leaves VeilVoice exactly as it was before this module existed. A setup flow that will not let - (module)
somebody reach the program is a setup flow they resent; this one is a set of offers made at the moment they make sense. The tour runs after it, so a person meets the decisions first and the tabs second, which is the order they matter in. # - (module)
In plain words The first time you open VeilVoice it offers you a password for the app, a password for your recordings, and a timer that locks the window when you walk away. You can skip any of them and set them later. - enum Step
Which card is showing. - impl Step
- fn next
The step after this one, or `None` at the end of the tour. - fn position
One-based position, for "step 2 of 4". - const COUNT
- struct FirstRun
What the setup is holding while it runs. - enum Outcome
What the panel wants the application to do after drawing. - impl FirstRun
- fn panel
Draw the current card. Takes the settings and the security state because it changes both, and returns whether it is done rather than deciding that itself: the caller owns what happens next, which is the tour. - fn should_skip
Whether a card has nothing left to ask. The app lock and the recording passphrase can both be set already -- from the command line, from a previous run, or from a copied configuration. Asking somebody to set a thing they have set is how a - fn should_skip
setup flow teaches people to click through it without reading. - fn appearance
The appearance step: pick a palette and see it applied immediately. - fn app_lock
The app-lock step: set a passphrase for VeilVoice itself, or decline it. - fn recording
The recording step: the at-rest passphrase, and what it is separate from. - fn machine
What this machine says about itself, and the one choice that follows. **Roadmap item 135.** Every number here is read from the machine at the moment the card is drawn. None of it is a default written into this program: a setup screen that - fn machine
asserts how much room there is, or that the graphics will be fine, is guessing on somebody else's hardware and sounding certain about it. Where the machine will not say, the card says that instead. "This system would not tell us" is a real - fn machine
answer and is a different one from a number. - fn autolock
The auto-lock step: how long idle before the window locks itself. - fn card
A bordered card, so each step reads as one thing rather than a page of text. - fn device_counts
How many recording and playback devices this machine has. Counted rather than listed on the setup card: the names are long, the list belongs in Settings where it can be chosen from, and the question at first run is "is there one at all", - fn device_counts
which a number answers. A platform that will not enumerate reports zero of each, which the card reads the same way as a machine with no sound card. That is the right reading here: from the person's side, "we cannot see a microphone" and - fn device_counts
"there is no microphone" have the same consequence. # Not called by any test, and that is deliberate This asks the platform for its real devices, and a test that did so was **F-165**: the desktop crate's test binary already enumerates - fn device_counts
once, on purpose, in `app`, and a second enumerator running beside it killed the process on Windows. One enumeration, in one place, is what this crate does. What can be checked without a device is that the card calls this rather than - fn device_counts
carrying a number, and a test reads the card's source for exactly that. - fn field
A password field with its label, laid out like the rest of the application. - fn buttons
The row that moves on. Returns whether it was pressed. - mod tests
- fn the_steps_run_in_order_and_then_stop
- fn every_step_knows_where_it_is
- fn a_question_already_answered_is_not_asked_again
- fn appearance_and_autolock_are_always_shown
- fn the_machine_card_measures_rather_than_asserts
**Roadmap item 135's whole point.** The card has to read the machine rather than carry numbers written here. A constant would be a claim about somebody else's hardware, stated with the confidence of a measurement. - fn nothing_here_is_a_gate
crates/veilvoice-gui/src/graphics.rs
- (module)
What the window is drawn with, asked for explicitly and then reported. # Why this is not left to a default Drawing went through whatever `eframe::NativeOptions::default()` happened to choose. That was the right backend by luck rather than - (module)
by decision: the defaults are a property of the version of `eframe` in `Cargo.lock`, and an upgrade can move them without a single line of this project changing. A release that silently stopped using the GPU would look exactly like a - (module)
release that got slower for no reason. So the four choices that decide how a frame reaches the screen are named here, with the reasoning beside each one, and a test asserts the values rather than trusting them to stay put. # Hardware - (module)
acceleration is preferred, not required `Preferred` asks the platform for a GPU context and accepts a software one if it cannot give you a GPU. `Required` refuses to start without hardware. Required is the wrong choice here and it is worth - (module)
saying why, because it sounds like the stronger setting. A virtual machine, a remote desktop session, a server with no graphics card and a laptop that has handed the wrong adapter to a hybrid-graphics driver all refuse a hardware context. - (module)
On `Required` every one of those becomes a program that does not open at all. A privacy tool that will not run is not more private, and somebody anonymising a recording over SSH with X forwarding has a real reason to be doing it there. - (module)
Software rendering is slower and it works. # What is actually reported `describe` reads the context that was really created, not the request. It uses `glow`'s parsed version, which is a safe call: this crate forbids unsafe code and nothing - (module)
here is worth making an exception for. The vendor string it returns comes from the driver, so somebody reporting a slow window can say which driver produced it, and 3.3 on Mesa and 4.6 on a discrete card are different conversations. - const BACKEND
The rendering backend. OpenGL through `glow`. The alternative in `eframe` is `wgpu`, which reaches Vulkan, Metal and Direct3D and is the better long-term answer; it also pulls in a substantially larger dependency graph, and this project - const BACKEND
counts what it depends on. OpenGL is present on every system in the target list, including the three BSDs. - const VSYNC
Whether frames wait for the display. On. Without it the window tears when it is dragged, which is the exact complaint this work exists to fix, and an unbounded frame rate burns a core to draw pictures nobody sees. - const MULTISAMPLING
Multisampling, off. Everything drawn here is rectangles and monospace text on axis-aligned pixel boundaries. MSAA costs a full multiple of the fill rate and would have nothing to smooth. - fn describe
One line for the About tab, and for a bug report. Given no context it says so rather than guessing: a window that never got a GL context has a different problem from a slow one, and the two should not read the same. - fn asked_for
What was asked of the platform, in the words the About tab uses. **Roadmap item 137.** Two settings and no third: asking, and not asking. The middle option, `Required`, is the one that sounds strongest and is wrong here for the reason in - fn asked_for
the module note, so it is not offered anywhere and this cannot express it. - fn options
The options the window is created with. Takes the viewport rather than building it, because where the window opens is `window`'s business and this is only about how it is painted. `acceleration` is the person's setting, and it can only - fn options
turn the request off. It is never `Required`: see the module note, where the whole point is that a machine which cannot give a GPU context must still open. - mod tests
- fn the_gpu_is_asked_for_and_not_demanded
- fn switching_it_off_asks_for_software_rather_than_demanding_hardware
- fn what_was_asked_for_is_said_differently_each_way
- fn frames_wait_for_the_display
- fn the_backend_is_named_rather_than_inherited
- fn the_viewport_passes_through_untouched
- fn no_context_is_reported_as_no_context
- fn the_backend_line_names_what_a_reader_can_check
crates/veilvoice-gui/src/group.rs
- (module)
Group mode: several people in one recording, each with a name and a colour. The engine has handled several speakers since `veilvoice-conversation` existed. This is the part of it a person can see: who is in the recording, what each of them - (module)
is called, what colour each is drawn in, and which destination voice each becomes. # The toggle does not persist, and the tick that makes it persist is separate Group mode changes what a recording is *treated as*. A mode that survives a - (module)
restart is a mode somebody eventually forgets is on, and for this tool that means a single-speaker recording rendered against a plan that does not describe it, which silences everything the plan does not claim. So the toggle is per-run and - (module)
off by default, and there is a second, explicit tick for "always start in group mode" which is the only thing written to disk. Two controls where one would do, deliberately. They answer two different questions: *is this recording a group - (module)
recording* and *are most of my recordings group recordings*. # Colour is assigned, never guessed from the voice A speaker's colour is a function of their **slot**, exactly as their destination voice is, and for the same reason: anything - (module)
chosen by measuring the input would make an output property a function of the input speaker, which is the linkage this whole project exists to destroy. Slot 0 and slot 1 are the furthest-apart pair in the table because two people is the - (module)
common case; every slot after that is the colour whose nearest neighbour among the ones already used is furthest away. That order was computed rather than judged. See `veilvoice_video::palette`. A colour can be **overridden** per speaker, - (module)
from any colour in any of the nine palettes the website offers. An override is a person's choice about their own recording, made after the fact, and carries none of the linkage problem above. # Colour is never the only signal The name is - (module)
drawn beside every circle, in the list, and in the subtitles. Somebody who cannot separate two of these colours has the name, everywhere. A panel that distinguished speakers by colour alone would be one about eight per cent of men could - (module)
not use. # In plain words The panel for a recording with several people in it. Each person gets a name and a colour, so you can see at a glance who is who. The colours are chosen to be as different from each other as possible, and you can - (module)
pick your own from any of the palettes the website offers. Group mode is off when the application opens unless you have said otherwise, and turning it on lasts only for that run. A mode that quietly survived a restart is a mode somebody - (module)
eventually forgets is on. - struct Person
One person, as the panel holds them. - impl Person
- fn at
A person with the default name for their slot. - struct Outputs
What comes out of a group render. All three by default. The request was "video + audio + combined unless the user restricts it", and a default that produces less than was asked for is a default that gets discovered after the recording has - struct Outputs
been deleted. - impl Default for Outputs
- fn default
- impl Outputs
- fn any
Whether anything at all would be written. - fn names
The ticked ones, by name, for a project file. - fn from_names
Read back from a project file. An unrecognised name is ignored rather than refused: the file's own parser has already refused anything structurally strange, and an output this build cannot write is a thing it simply does not write. - fn hex_of
An egui colour as `#rrggbb`. - fn colour_of
`#rrggbb` as an egui colour, or `None` for anything that is not one. - struct Group
The group-mode panel's state. - impl Default for Group
- fn default
- impl Group
- fn start_from
The panel as it should open, given the saved preference. The *only* path by which group mode is on before anybody has touched anything. - fn len
How many people are in the recording. - fn is_empty
Whether there is nobody in it. - fn colour
The colour a slot is drawn in: the override, or the one it is given. - fn add
Add a person, if there is room for one. Ten is the engine's limit, not this panel's: there are ten destination voices, and an eleventh speaker would have to share one with somebody. Two people sharing a voice is a real collision, so the - fn add
limit is stated rather than wrapped around. - fn limit
How many people this panel can hold in its current mode. - fn remove
Remove one person, keeping at least two. One speaker is not a group, and a panel that let you get there would leave group mode on with nothing for it to do. - fn to_plan
Build a plan from the panel: names in slot order, no turns. Turns come from a plan file or from one microphone per person. This makes the half the panel knows about, and says what is missing rather than inventing it: a plan with no turns - fn to_plan
claims no audio, and every second of a recording rendered against it would be silenced. - fn tab
The whole panel. `settings` is here for one tick: "always start in group mode" is the only thing on this panel that outlives the run, and it belongs beside the toggle it modifies rather than on a settings page three clicks away. The two - fn tab
controls only make sense read together. - fn collect_dialogs
Collect whatever the open pickers have answered. Called once per frame, before anything is drawn, so a file chosen while the reader was browsing is in place by the time the panel that shows it is painted -- the same reason F-61 moved the - fn collect_dialogs
dropped-file read to the top of `update`. - fn body
Everything inside the scroller. - fn profile_controls
The named ways of working, and the project this panel came from. A profile is a *starting point*, not a lock: picking one sets the controls below and then leaves them alone. Anything else would mean a preset quietly overriding a choice - fn profile_controls
somebody made after picking it, and they would find that out in the output. - fn apply_profile
Set the controls this profile names, and change nothing else. - fn mode_controls
The two controls that decide whether group mode is on. - fn voice_mode_controls
A voice each, or one voice between everybody. The second is more private and is not the default, which is the honest way round: it removes a real trace -- *which* speaker somebody was -- and it costs the ability to follow the recording by - fn voice_mode_controls
ear. Most people want the first, and the ones who want the second want it for a reason they already know. - fn strip
The picture: a circle per person, in their colour, with their name. This is the part the request was actually about. A mode you can only tell is on by reading a checkbox is a mode that gets left on. - fn people_list
One row per person: colour, name, and a way to remove them. - fn palette_picker
Every colour in every palette, as swatches. The whole set rather than a colour wheel: these are the colours the rest of this application and the website are drawn in, so a speaker picked from them looks like part of the same thing. Grouped - fn palette_picker
by palette and named, because "the third blue" is not a thing anybody can ask for. - fn swatch
One clickable colour. - fn output_controls
What a render writes. - fn to_workspace
This panel as a saveable project. - fn from_workspace
Put a saved project back. Anything this build does not recognise -- a profile from a newer version, a palette that has been renamed -- is **reported and left alone** rather than quietly replaced. Loading a project and silently getting - fn from_workspace
different settings is the failure worth avoiding here: the whole point of the file is that it puts things back where they were. - fn is_busy
Whether a render is running, so the window keeps repainting. - fn drain
Take the worker's answer if it has one. Called once a frame; never waits. - fn files_and_theme
The recording, the plan, the title and the palette. - fn render_controls
The button, and what came of pressing it. - fn start
Start a render on a thread of its own. `update()` may read, paint and start work; it may never wait for any. A render reads a whole recording and runs the engine over it, which is seconds at best and the length of the file at worst. - fn file_name
The last component of a path, for showing beside a button. - struct Job
Everything one render needs, taken from the panel at the moment the button was pressed. Named fields rather than eight positional arguments: four of them are interchangeable at the type level, and F-67 was a value that never reached this - struct Job
function at all. A struct makes both mistakes visible at the call site. - fn render_now
Do the render. Runs on a worker thread and touches no interface state. The names come from the panel and the turns come from the plan, and the two have to agree about how many people there are. A plan naming three speakers rendered against - fn render_now
a panel holding two would put one person's audio in somebody else's voice, which is the one mistake here that cannot be heard in the result -- so it is refused rather than reconciled. - fn render_video
**Roadmap item 139.** Draw the frames, then have `ffmpeg` make the video of them. The pictures go into a directory beside the output and are **left there** rather than cleaned up. Two reasons, and the second is the one that decided it: a - fn render_video
render that failed at the encode should not throw away the hours of drawing that preceded it, and somebody who wants a different encoding of the same conversation should not have to draw it again. Without `ffmpeg` the frames and the list - fn render_video
are still written and the command is returned in the error, which is the same bargain the rest of this program makes with tools it does not ship: prepare everything, name the one step that is not ours, and let the person run it. - fn write_private
Replace the last extension, keeping any `.veiled` before it. Write a file only this account can read, naming it if that fails. The same fix as the command line's, in the other half of the program. All four of these used the ordinary - fn write_private
`fs::write`, which is 0644, while the file tab wrote its result 0600 even where somebody had turned encryption off on purpose. The group output is the one holding the names and the words as typed, so the more revealing file had the weaker - fn write_private
permission, and that was not a decision: one path called the hardened write, the other the default. - fn with_extension
- fn assigned_colour
The colour a slot is given, as an egui colour. One table, shared with the video crate rather than copied here, so a circle in this panel and the same speaker's circle in a rendered page cannot drift apart. `unwrap_or` rather than `expect`: - fn assigned_colour
a malformed entry would be a bug in that table, and a panel is the wrong place to find out about it. - mod tests
- fn group_mode_is_off_unless_the_saved_preference_says_otherwise
- fn the_default_panel_never_opens_in_group_mode
The whole reason the mode is not persisted: it must not survive a restart on its own. This is the test that would fail if somebody added `enabled` to `Prefs`. - fn a_slot_gets_the_colour_its_slot_is_given_until_one_is_chosen
- fn what_the_recording_gives_away_is_shown_beside_the_choice_that_sets_it
The score sits beside the control that decides it. It answers "what does this recording give away about who was speaking", and the two things that decide the answer are the voice mode and the number of people. Somewhere else in the window - fn what_the_recording_gives_away_is_shown_beside_the_choice_that_sets_it
it would be a number without the two controls that move it. - fn the_first_two_colours_are_the_furthest_apart_pair
Two speakers is the common case, and slots 0 and 1 are the furthest apart pair in the table. If that ever stops being true the panel is drawing two people in two colours somebody cannot tell apart. - fn a_ninth_speaker_is_refused_and_the_refusal_names_the_way_out
The limit is the *measured* one -- how many voices can be told apart -- not the ten the table holds. Written as a bounded loop rather than `while len < MAX_VOICES`, which is what it was: `add` now refuses at nine, so that loop never - fn a_ninth_speaker_is_refused_and_the_refusal_names_the_way_out
terminated and the test suite hung rather than failed. A test that cannot fail cannot pass either. - fn one_voice_for_everybody_carries_more_people
And switching to one voice lifts it, which is the point of the refusal naming that mode. - fn switching_back_to_a_voice_each_is_refused_when_there_are_too_many
Switching *back* with too many people is refused rather than silently dropping somebody or handing two people one voice. - fn removing_stops_at_two
One speaker is not a group. Removing past two would leave the mode on with nothing for it to do. - fn a_plan_carries_the_names_in_slot_order
- fn a_name_with_a_line_break_is_refused_by_the_plan_rather_than_written
A name with a line break in it could forge a record in the plan file. The conversation crate refuses it; this checks the panel surfaces that rather than producing a plan with a hole in it. - fn the_panel_draws_both_bars_for_every_speaker_while_rendering
**Roadmap item 133.** Two bars per speaker, drawn while the render runs. Read out of the source rather than driven, for the reason every guard in this crate is: drawing a panel needs a window, and a test that opened one is what F-162, - fn the_panel_draws_both_bars_for_every_speaker_while_rendering
F-163 and F-165 all were. What is worth keeping is that there are two of them, that they are the shared meter rather than a second bar that would drift from it, and that the limit is printed with them. - fn everything_a_group_render_writes_is_readable_only_by_this_account
Nothing a group render writes is readable by another account. The same check as the command line's, on the other half of the program, because the same defect was in both and a fix to one would not have found the other. - fn a_render_needs_a_recording_a_plan_and_an_output
A render cannot start without both files and something to write. This is the state the button is disabled in, asserted rather than trusted to the interface. - fn a_render_that_stops_without_finishing_is_reported
A worker that dies without answering must not leave the panel saying "rendering" forever. - fn the_page_palette_starts_at_tokyo_night_every_time
The palette is Tokyo Night unless changed, and is not persisted -- the same shape as the mode toggle above it. - fn rendering_against_a_plan_with_a_different_count_is_refused
A plan and a panel that disagree about how many people there are is refused, because the result would be somebody's audio in the wrong voice and nobody could hear it. - fn the_limit_follows_the_settings_rather_than_the_defaults
F-67. The panel's answers have to come from the application's settings, not from the defaults, because the controls that change them are on another tab and nothing here would say otherwise. Checked by moving the configuration to something - fn the_limit_follows_the_settings_rather_than_the_defaults
that changes the answer: a coarser frame puts the destination pitches on a wider grid, registers collapse onto each other, and fewer voices stay clearly apart. If the panel were still reading the defaults, the limit would not move. - fn a_panel_round_trips_through_a_project_file
The whole point of a project file: what you had is what you get back. - fn an_automatic_colour_is_saved_as_automatic
A colour chosen by hand survives; one left automatic stays automatic rather than being frozen into whatever it happened to look like. - fn an_unknown_profile_is_reported_rather_than_silently_replaced
A profile this build does not have is reported and the settings left alone. Silently opening under different settings is the failure worth avoiding: the point of the file is that it puts things back. - fn an_unknown_palette_is_reported_rather_than_silently_replaced
And an unknown palette likewise. - fn a_project_with_too_many_people_for_the_mode_is_reported_not_trimmed
A project saved with more people than the current mode can carry is loaded and *reported*, not trimmed. Dropping somebody out of a group to make a preset fit is a thing nobody would notice until the render. - fn a_profile_sets_what_it_names_and_nothing_else
Picking a profile sets what it names and leaves everything else. A preset that overrode a later choice would be found out in the output. - fn outputs_survive_being_named_and_read_back
- fn everything_we_can_write_ourselves_is_on_by_default_and_the_video_is_not
Everything this program can write on its own is on by default. **The video is not**, and it is the only one. The other three are files VeilVoice writes itself; the video needs `ffmpeg`, which it does not ship and will not install without - fn everything_we_can_write_ourselves_is_on_by_default_and_the_video_is_not
being asked, and a default that silently depends on a tool the machine may not have is one that fails on somebody else's computer rather than on the machine it was chosen on. - fn every_output_survives_a_project_file
A project file remembers the video choice like the other three. `names` and `from_names` are two halves of one format, and a field added to one and not the other is a setting that silently resets every time a project is opened.
crates/veilvoice-gui/src/integrity.rs
- (module)
The integrity record, taken and checked by the window rather than by hand. # What this adds to `veilvoice-guard` Nothing, cryptographically. Every hash, every comparison and every honest limit is [`veilvoice_guard`]'s, and - (module)
[`veilvoice_guard::SCOPE`] is what the interface prints. What this module adds is that it happens at all: the command-line `veilvoice guard init` has always been there and has always been a thing somebody had to know to run. # When it runs - (module)
At the first launch that finds no record, one is taken. At every launch after that, the record is checked. Both happen on a worker thread, because reading and hashing the installed files is disk work and the drawing thread does none. # - (module)
Sealing, and the passphrase problem underneath it A record written in the clear beside the files it describes is rewritten by anybody who can change those files. Sealing it under a passphrase raises that to needing the passphrase as well, - (module)
which is a real improvement and is what [`veilvoice_guard::Manifest::seal`] is for. The awkward part is which passphrase, and when. A record cannot be sealed by a program that has no secret, and at the moment a window opens it has none. - (module)
So: * With an app lock set, the record is sealed under the **app-lock passphrase**, and is taken and checked at the moment of unlocking, which is the one moment that passphrase exists. That is the arrangement worth having. * With no app - (module)
lock, the record is written **in the clear** and the interface says so, in those words. It still catches accidental corruption, a failed update and a careless overwrite. It does not catch somebody who thought to rewrite it, and pretending - (module)
otherwise by sealing it under a key stored beside it would be a decoration, not a protection. # In plain words VeilVoice writes down what its own files look like the first time it runs, and checks them every time after that. If you have - (module)
set an app lock, that record is locked with the same passphrase, so changing the files *and* the record needs your passphrase too. If you have not, the record is readable, and it will still spot a file that changed by accident but not one - (module)
changed by somebody covering their tracks. - enum State
What the record has to say, as far as this window knows. - struct Integrity
The integrity record as the window drives it. - impl Default for Integrity
- fn default
- impl Integrity
- fn state
What the last completed check found. - fn is_busy
Whether a check is running, so the window keeps repainting while it is. - fn changed
Whether the record found a difference worth showing the user. - fn start
Take or check the record, off the drawing thread. `password` is the app-lock passphrase when there is one. It is consumed by the worker and dropped there rather than being held by this struct, so a passphrase does not sit in the window's - fn start
state for the life of the session. Calling this while a check is already running does nothing. A second walk of the same files would only race the first to the same answer. - fn poll
Collect a finished check. Returns true when the state changed, which is the window's cue to repaint. - fn record_path
Where the record is kept, beside the app lock and under the same rules. The same path `veilvoice guard` uses, so the window and the command line read one record rather than two. - fn sealed_path
The sealed record sits beside the plain one under the container suffix. - fn targets
The files worth watching: the running program, and nothing assumed. Deliberately short. A manifest over a directory somebody else installs into reports every legitimate update as a change, and a report that cries wolf on every update is - fn targets
one nobody reads. The binary is the file that matters and it is the file this can name without guessing. - fn run
The whole of the work, on the worker thread. - fn write_private
- mod tests
- fn a_fresh_record_is_idle_and_not_busy
- fn polling_with_nothing_running_reports_no_change
- fn a_disconnected_worker_is_reported_rather_than_waited_for
- fn nothing_here_blocks_the_drawing_thread
The window must never wait on the disk. This is the same guard the drawing thread carries, applied to the one module that reads files. - fn the_sealed_record_is_preferred_over_a_plain_one
A sealed record must win over a plain one wherever both are present, or dropping a plain file beside the sealed one downgrades the check. - fn the_record_sits_beside_the_app_lock
- fn the_sealed_name_is_not_the_plain_one
- fn the_watched_files_are_ones_that_actually_exist
The watched set has to name a real file rather than a guess about where somebody installed this.
crates/veilvoice-gui/src/layout.rs
- (module)
Centring a row of widgets, which egui does not do by nesting. # The defect this exists to fix The unlock screen drew its mark, its name and the word "locked" inside [`egui::Ui::vertical_centered`], and then drew the password field, the - (module)
unlock button and the status line underneath in a plain row. The heading sat in the middle of the window and the controls sat against the left edge. The same shape turns up wherever a row is drawn only after setup rather than at launch, - (module)
because those rows tend to be written later and separately from the ones they end up beside. The obvious repair does not work, and it is worth writing down why, because it looks like it should. Wrapping the row in `vertical_centered` puts - (module)
it in a `Layout::top_down(Align::Center)`, which centres each child *narrower than the available width*. `Ui::horizontal` is never narrower: it allocates ```text let initial_size = vec2( self.available_size_before_wrap().x, // the whole - (module)
width self.spacing().interact_size.y, ); ``` so the row's box is already full width, there is nothing left to centre it within, and its contents start at that box's left edge. A centred layout inside a centred layout changes nothing. - (module)
`Layout::left_to_right` carries `main_align: Align::Center` and does not help for the same reason. # Measured last frame, drawn this frame The row is drawn once, with a space in front of it worked out from how wide the same row turned out - (module)
to be on the previous frame, remembered in egui's own temporary memory. **The closure is called once, and that is the whole design constraint.** The tidier-looking approach is [`egui::UiBuilder::sizing_pass`]: lay the row out invisibly, - (module)
measure it, then lay it out again for real. That needs the closure twice, and these closures are not pure. The unlock row spawns a key derivation when its button reports a click, and a sizing pass runs the body rather than skipping it, so - (module)
measuring that way would risk spawning the work twice from one press. A row of widgets is not worth a double unlock, so the width comes from the last frame instead. The cost is that the first frame a row appears on is drawn left-aligned, - (module)
for as long as it takes to ask for another frame, which is done here immediately. Nobody sees a single frame at sixty of them a second; and if it were ever visible, being briefly left-aligned is what the defect looked like permanently. - fn centred_row
Draw a row of widgets centred in the width available. A drop-in replacement for [`egui::Ui::horizontal`] wherever a row belongs under centred headings. The returned response covers the row itself, not the padding in front of it. - fn column
A fixed-width column inside a row, so what follows it starts at one x. # Why this is not `ui.label(format!("{label:<16}"))` Padding a label with trailing spaces is the obvious way to make a column and it aligns nothing outside a terminal. - fn column
The interface font is proportional, so a space is not the width of a letter and eight letters plus two spaces is not the width of ten; and egui gives trailing whitespace no reliable width at all. Rows padded that way sat at slightly - fn column
different places on different screens, and everything lined up beneath them inherited the drift. This has now been the cause of two findings in two different files, which is why it is here rather than written out a third time. # Why - fn column
`set_min_width` is not optional [`egui::Ui::allocate_ui_with_layout`] asks for a width and then gives back only what the contents actually used, so a short label would take a short column and the next widget would move left again, which is - fn column
the whole problem. `set_min_width` is what turns the request into a column. - const LOCK_WIDTH
The width of every control that locks or unlocks the application. **Finding F-196.** The header draws the theme picker and, beside it, the button that locks the window; the lock screen draws the password field and, beside it, the button - const LOCK_WIDTH
that unlocks it. Each button was written where it was needed and sized by whatever its own text happened to measure, so the two halves of one idea, lock and unlock, were 46 and 98 points wide and neither matched the control it sat next to. - const LOCK_WIDTH
One number, used by the picker and by both buttons. It is the picker's width because the picker is the one of the three that has a list of theme names to fit; a button is happy at any width and a dropdown is not. - fn button_height
The height this style gives a button: its own text, plus its own padding. Needed because a row has to agree on a height *before* the taller of its controls has been drawn. The password field is asked for this height, so the field grows to - fn button_height
the button rather than the button shrinking to the field: both stay a comfortable target that way, and a button squeezed to a text field's height reads as a link rather than as something to press. The test below draws a real button and - fn button_height
fails if this stops agreeing with it, so it is checked against egui rather than assumed to match. - fn lock_button
A button that locks or unlocks: one width, and the height of its neighbour. The height is passed in rather than worked out, because what it has to match differs by where it is drawn: the theme picker in the header, the password field on - fn lock_button
the lock screen. The width does not differ, which is the point of [`LOCK_WIDTH`]. - mod tests
- fn input
Lay the same content out twice through one `Ui`, so the second call sees what the first remembered. Returns where the row starts each time. A context whose window really is `width` across. `Ui::set_width` does not do this: it sets a - fn input
minimum, and the panel goes on offering the whole screen, which in a default test context is nearly ten thousand points. Centring inside that is centring inside the wrong number, so the screen itself is sized here. - fn twice
- fn a_centred_row_is_centred_and_a_plain_row_is_not
The row lands in the middle once its width is known. Both halves matter. Asserting only that the centred row is centred would pass just as happily if egui had been centring rows all along, and the helper would be dead weight nobody could - fn a_centred_row_is_centred_and_a_plain_row_is_not
tell was dead. So the same content is drawn as a plain row too, and the two must differ. - fn a_row_wider_than_the_window_is_not_pushed_off_both_edges
A row too wide for the window starts at the left, not off both edges. - fn the_row_body_runs_exactly_once_per_frame
The body runs once per frame, however many times the row is measured. This is the constraint the whole design exists for: the unlock row spawns a key derivation when its button reports a click, and a helper that ran its closure twice could - fn the_row_body_runs_exactly_once_per_frame
spawn it twice from one press. - fn the_height_a_button_is_predicted_to_be_is_the_height_it_is
[`button_height`] is what the toolkit actually makes a button. It is arithmetic over the style rather than a number read out of egui, which is fine while the two agree and silently wrong the day they stop. So a real button is drawn and - fn the_height_a_button_is_predicted_to_be_is_the_height_it_is
measured. - fn the_lock_and_unlock_buttons_are_one_size
**Finding F-196.** The two buttons are one size, and each is the size of what it stands beside. Both rows are laid out here, in one frame, with this application's own theme installed: without the theme every control is the same height by - fn the_lock_and_unlock_buttons_are_one_size
default and the test would pass while measuring nothing. - fn buttons_left_to_size_themselves_match_nothing
And the shape all of that corrects, so none of it can pass by accident: left to themselves, a button beside a password field is neither the field's height nor the other button's width.
crates/veilvoice-gui/src/lib.rs
- (module)
# veilvoice-gui The VeilVoice desktop application: an egui/eframe front-end, monospace throughout: anonymise a file, scramble a microphone live, watch what is listening, manage the app lock, choose how the app looks, and an about panel - (module)
that states the honest scope. The binary lives in `main.rs`; this library exists so the UI logic can be unit tested without opening a window. That split is worth stating plainly, because it is the reason this crate has tests at all: a - (module)
binary crate cannot be unit tested, so everything with logic in it -- the app lock's state machine, preference loading, palette resolution, the reduced-motion decision -- lives here where a test can reach it without a display server. - (module)
`main.rs` holds only what genuinely needs a window. # The modules | Module | What it owns | |---|---| | [`security`] | The unlock screen, the lock tab, and the at-rest controls | | [`prefs`] | Preferences, and recovering from a corrupt - (module)
preferences file | | [`policy`] | Settings somebody has fixed, and the reason beside each one | | [`settings`] | The settings tab | | [`setup`] | Installing this copy, and the optional companions | | [`theme`] | The palette, shared with - (module)
the command-line front end | | [`soundbar`] | The animated level meter | | [`reduced_motion`] | Whether to animate at all | | [`watchfeed`] | The device monitor, on a thread that is not this one | # Two rules this crate keeps **The user - (module)
interface never softens a scope note.** Where a control has a bound -- the app lock is a verifier and not disk encryption, tamper detection detects rather than prevents -- the interface says so next to the control, and tests fail the build - (module)
if that text changes. Documentation nobody opens does not protect anybody. **Animation is a preference that is honoured, not a decoration.** [`reduced_motion`] resolves the platform's own setting alongside the user's explicit choice, and - (module)
the whole interface reads that answer rather than each widget deciding for itself. # In plain words This is the window. Tabs down the top for the things the program does: disguise a file, scramble a microphone as you talk, handle a - (module)
recording with several people, watch for anything using your microphone, put the app behind a password, check a download, and change how it looks. Nine colour schemes, and your own if you write one. It does the slow work on another thread, - (module)
so the window keeps answering while it is busy. - mod app
- fn tabs
The name of every tab the window shows, in the order it shows them. Exported so that `veilvoice-gui --tabs` can print them and the screenshot scripts can read them, rather than each carrying a copy of a list that goes stale the first time - fn tabs
a tab is added. When it went stale the failure was silent: the run succeeded and the new tab simply had no picture. - fn jetbrains_mono_path
Where JetBrains Mono is on this machine, if it is anywhere. Re-exported so `--typeface` can answer without a window and without the binary reaching into a module the rest of it does not use. - mod autolock
- mod avnotice
- mod crashlog
- mod crashreport
- mod decoys
- mod dialog
- mod firstrun
- mod graphics
- mod group
- mod integrity
- mod layout
- mod monitor
- mod notify
- mod pace
- mod palettes
- mod paths
- mod policy
- mod prefs
- mod reduced_motion
- mod security
- mod settings
- mod setup
- mod soundbar
- mod storage
- mod studio
- mod theme
- mod tour
- mod updates
- mod vault_store
- mod verify
- mod watchfeed
- mod window
- const VERSION
Crate version string, surfaced in the About panel. - fn headless_frame
Draw one frame with no window, and discard what a real backend would have uploaded. Every test in this crate that renders headlessly goes through here. `egui` 0.36 asserts that a frame's texture deltas were handled: dropping a `FullOutput` - fn headless_frame
that still carries one panics, which is exactly right for a backend that forgot to upload a font atlas and exactly wrong for a test that only wants the shapes back. Clearing it in one place beats repeating the same two lines at fifteen - fn headless_frame
call sites and forgetting it at the sixteenth.
crates/veilvoice-gui/src/main.rs
- (module)
Entry point for the desktop application: open a window, hand it to [`veilvoice_gui::VeilVoiceApp`], and get out of the way. Everything of substance is in the library beside this file. That split is the point: a binary crate cannot be unit - (module)
tested, so the whole user interface lives in `lib.rs` and its modules where tests can reach it, and this file holds only what genuinely needs a window to exist. Three decisions are made here and nowhere else. **No console window on - (module)
Windows, in release only.** A release build sets `windows_subsystem = "windows"`, so double-clicking the application does not flash up a terminal behind it. A debug build deliberately keeps the console, because that is where panics and - (module)
`eprintln!` go and losing them while developing costs far more than the flash of a window is worth. **The icon is raw RGBA, not a PNG.** `assets/generate.py` writes `icon-32.rgba` beside the PNG it generates from the same pixels, so the - (module)
application can set its own title-bar icon without linking an image decoder. A decoder is a parser, a parser is an attack surface, and this one would exist solely to draw a 32x32 square. The length is checked before use, and a mismatch - (module)
means the window simply opens without an icon rather than panicking at startup. **The window has a minimum size, and it is about width.** Every tab is inside one scroll area, so anything taller than the window can be reached by scrolling - (module)
to it and a short window loses nothing. Width is different: the layout is monospace and column-based, and below roughly 720 across, columns start overlapping rather than reflowing. So the floor is enforced here rather than left to produce - (module)
an unreadable window on somebody else's machine. It opens at 1100 by 720, which is large enough to read without resizing and still fits a 1366 by 768 laptop with its taskbar. Anything bigger opens partly off the bottom of a common screen, - (module)
which looks like a broken application rather than a generous one. # In plain words Opens the window and hands over to the rest of the application. Almost nothing happens here. Everything of substance lives beside it in code that can be - (module)
tested without a screen, and this file holds only the few things that genuinely need a window to exist: its size, its icon, and making sure a failure to open leaves a message behind. - const ICON_RGBA
The window icon, as raw 32x32 RGBA produced by `assets/generate.py`. Raw rather than PNG so the application needs no image decoder just to draw its own title bar. - const ICON_SIZE
- const USAGE
What `--help` prints, on the platforms where printing works. Kept beside the argument parsing rather than in a file somewhere, because a second description of an interface drifts from the first: `tools/release/ manpage.py` turns this text - const USAGE
into the installed manual page at package build time, so the page and the program cannot disagree. Declared only where it is read. A Windows release build has no console to print to, so the reader below is Unix only, and a constant nothing - const USAGE
reads is dead code: the Windows job failed on it under `-D warnings` while every other platform passed. The test that checks this text against the tab names reads the file rather than the constant, so it still runs everywhere. - fn answered_without_a_window
Answer `--help` and `--version` before a window is opened. Unix only, and the restriction is the honest part rather than an oversight. A release build on Windows declares `windows_subsystem = "windows"` and has no console attached, so - fn answered_without_a_window
`println!` there writes to nothing: the program would appear to do nothing at all, which is worse than the window it currently opens. Where a console is guaranteed, this answers; where it is not, behaviour is unchanged. It exists because - fn answered_without_a_window
`veilvoice-gui --help` used to try to open a window and, on a machine with no display, failed with a winit error naming `WAYLAND_DISPLAY`. That is the reply to a reasonable question, and it is also what `lintian` was pointing at with - fn answered_without_a_window
`no-manual-page`: a binary with no help text has no page to derive. - fn answered_without_a_window
- fn main
crates/veilvoice-gui/src/monitor.rs
- (module)
The live monitor: what is going in, and what is coming out, wherever you are. # Why this is not just the meters on the Studio tab The Studio has drawn an input and an output meter for some time, and they are the right meters. What they - (module)
were not was *visible*: they are inside one panel, and the moment somebody switched to Group to set up an interview, or to Settings, or to Monitor, the only picture of what their microphone was doing went off screen while the audio carried - (module)
on. That is the wrong way round for this feature in particular. Live scramble is the mode where the thing being protected is happening *now*, in real time, and where the two questions a person actually has are "is it hearing me" and "is - (module)
anything coming out". A meter you have to navigate to in order to answer them is a meter that answers them late. So the monitor rides the window. It is on by default, it shows on every tab, and it shows exactly two things plus their state: - (module)
the level going in, and the level coming out. # Two places it can sit, and one way to switch it off [`Style::Toolbar`] docks it to the bottom of the window, where it takes a strip of height and never covers anything. [`Style::Overlay`] - (module)
floats it over the panel, bottom right, for somebody who would rather keep the full height for the panel and accept that it sits on top of a corner of it. [`Style::Off`] is offered because a strip somebody does not want is a strip they - (module)
will resent, and the Studio still has the full meters either way. The overlay is deliberately **not** click-through and **not** draggable: a floating thing that moves is a floating thing somebody loses behind the window edge, and this one - (module)
has a close button that sets the preference instead. # What it does not claim It shows levels. A level is not proof that the voice is being changed: a working meter and a bypassed engine look identical, and saying so is the difference - (module)
between a monitor and a reassurance. What tells you the engine is running is that the output is a voice that is not yours, which is what the preview in the Studio is for. # In plain words A small strip along the bottom of the window - (module)
showing how loud your voice is going in and how loud the veiled voice is coming out, while live scramble is running. It follows you around the application, because the moment you want it is the moment you are doing something else and are - (module)
not sure the microphone is still working. It cannot tell you that the disguise is working. It can tell you that sound is arriving and sound is leaving, which is the thing that usually goes wrong. - enum Style
Where the monitor sits, or whether it is shown at all. - impl Style
- fn label
A short name, for a picker. - fn note
What this choice costs and buys, in the words a front end should show. - const ALL
Every style, in the order a picker should offer them. - fn key
The identifier written to the settings file. - fn from_key
Read a style back. An unrecognised value is the default rather than an error, and the default *shows* the monitor. Of the two ways to be wrong about a settings file this build cannot read, hiding the only picture of a live microphone is - fn from_key
the worse one. - struct Levels
The smoothed levels the monitor and the Studio both draw. One copy, updated once a frame from the session, because two copies is two bars that disagree by a frame and one of them is always the one somebody is looking at. - const HOLD
How long a held peak stays up before it falls back to the current level. - impl Levels
- fn update
Take a new reading. - fn clear
Back to nothing, for when a session stops. - fn bar
A compact bar, for the strip. The full-height one lives on the Studio tab. Same scale as `veilvoice_audio::meter`, so this bar, the Studio's bar and the one `veilvoice live` draws in a terminal are the same bar at three sizes. A monitor - fn bar
that used a scale of its own would be a fourth opinion about the same number. - enum Action
What the reader did with the monitor this frame. - fn row
Draw the row itself. Shared by both styles so they cannot drift apart. `preview` changes the word and the colour, and it is not cosmetic. A preview goes to this machine's own output and a live session goes to whatever is listening on the - fn row
cable, and somebody who has those two the wrong way round is either talking to a call in their own voice or talking to nobody. The strip is the thing on screen, so the strip has to say which. - fn show
Draw the monitor for this frame. Call once per frame from the shell, after the tab strip and before the panel, whether or not a session is running: this returns immediately when there is nothing to show, so the caller has one line rather - fn show
than a condition it can get wrong in one place and not the other. - fn meter
One level meter: a bar on the decibel scale, and the number beside it. This was a **linear** bar with a decibel number printed next to it, which is a meter arguing with itself: the number said -12 dB and the bar showed a quarter. Ordinary - fn meter
speech at a sensible recording level peaks near -12 dBFS, so the bar read as near-silence and the only way to fill it was to clip. The scale now comes from `veilvoice_audio::meter`, which is where the peaks come from, so this bar and the - fn meter
terminal's are the same bar. `hold` is the highest level of the last moment or so, drawn as a mark: a transient is over before an eye finishes moving, and a bar showing only *now* cannot show one. # Why it lives here It was in `app.rs` and - fn meter
private there, so when the Studio needed to meter a take as it recorded there were two choices: reach into a private module, or draw a second bar that would slowly stop looking like the first. It belongs here, beside [`Levels`], which is - fn meter
the thing that smooths what it draws. Below -40 dBFS the colour goes muted rather than green, so a quiet room does not read as a working microphone. - mod tests
- fn the_monitor_can_be_kept_above_other_windows
**Roadmap item 150.** The always-on-top window is offered and is not the default. Not the default because a window that puts itself above everything is a thing somebody should ask for. Offered because the other two live inside the - fn the_monitor_can_be_kept_above_other_windows
VeilVoice window, and somebody on a call has the call in front of that window. - fn a_style_reads_back_as_itself
Every style survives a round trip through the settings file. - fn an_unknown_setting_still_shows_something
An unreadable setting shows the monitor rather than hiding it. The same rule the notification style follows, for the same reason: of the two ways to be wrong about a file this build cannot parse, the one that hides the only picture of a - fn an_unknown_setting_still_shows_something
live microphone is the worse one. - fn every_style_explains_itself
Every style says what it is and what it costs. - fn clipping_is_sticky_because_it_is_over_in_a_millisecond
Clipping stays reported once it has happened. - fn the_hold_never_sits_below_the_bar_it_marks
The held peak is at least the current level, always.
crates/veilvoice-gui/src/notify.rs
- (module)
How the application tells you something, and the three ways to be told. # Three modes, and none of them is the obviously right one [`Style::Overlay`] draws a rounded, translucent card in the corner of VeilVoice's own window. It is the - (module)
quiet option: it does not steal focus, it does not interrupt what you are typing, and it fades on its own. [`Style::Alert`] is the loud one. It stops the panel it is on until it is dismissed, so it cannot be missed and it cannot be missed - (module)
*quietly* -- which is the point when the thing being reported is that something started recording your screen. [`Style::Off`] shows nothing. It is offered because a monitor that interrupts somebody every thirty seconds is a monitor they - (module)
switch off at the operating system, and then it is not watching for anything at all. Better a reader who chose silence knowingly than one who disabled the whole feature to get it. There is no default that suits everybody, so the default is - (module)
the middle one and the choice is a preference rather than a guess. # The contrast is computed, never assumed A translucent card is a colour laid over whatever is behind it, so the text on it is legible only if the *composited* result has - (module)
enough contrast. Two things follow, and both were got wrong in the first version of this file: * The background to measure against is the blend, not the card's own tint. [`blend`] does that arithmetic, and [`Card::readable_text`] measures - (module)
the result with the same WCAG ratio [`crate::palettes`] already uses on user palettes. * If no candidate reaches the threshold, the card is drawn **opaque** rather than shipped illegible. Translucency is a nicety; being able to read a - (module)
warning is not. # What this does not do It does not raise a system notification, put anything in a tray, or reach outside VeilVoice's own window. Those need per-platform APIs and, on two of the three, a registered application identity -- - (module)
and this project is published under a pseudonym on purpose. A notification that only appears while the window is open is a real limit, and [`SCOPE`] says so rather than letting somebody rely on being told while VeilVoice is closed. # In - (module)
plain words When VeilVoice has something to tell you, it can do it three ways: a small rounded box in the corner of its own window that fades away by itself, a message that stops what you are doing until you dismiss it, or nothing at all. - (module)
The quiet box is see-through, so the colours behind it change how readable the writing is. Rather than guessing, VeilVoice measures the actual contrast of the result and picks the text colour that comes out clearest -- and if none of them - (module)
is clear enough, it makes the box solid instead. A warning you cannot read is not a warning. One honest limit: these only appear while the VeilVoice window is open. It does not put messages into your desktop's own notification area. - const LEAST_CONTRAST
The smallest contrast ratio a notification's text may have. WCAG 2.1's threshold for body text. Not 3.0, which is the large-text allowance: a notification is read once, quickly, often out of the corner of an eye, and it is the one piece of - const LEAST_CONTRAST
text in the application most likely to be read badly. - const CARD_ALPHA
How much of the card's own colour shows over what is behind it. Not a free parameter. Below about this the card stops reading as a surface and the text appears to float on the panel; far above it there is no point calling it translucent. - enum Style
How the application shows a notification. - impl Style
- fn label
A short name, for a picker. - fn note
What this choice costs and buys, in the words a front end should show. - const ALL
Every style, in the order a picker should offer them. - fn key
The identifier written to the settings file. - fn from_key
Read a style back. An unrecognised value is the default rather than an error: a settings file from a newer version should not stop the application, and of the two ways to be wrong, showing a notification is the one that cannot hide a - fn from_key
warning. - enum Level
How serious a notification is. - struct Notice
One thing to tell the reader. - impl Notice
- fn note
A plain note. - fn warn
Something worth acting on. - fn blend
Lay `over` on top of `under` at `alpha`, giving the colour actually seen. The whole reason the contrast here is computed rather than assumed. A card drawn at 85% opacity over a dark panel is neither of those two colours, and measuring - fn blend
against either one gives an answer that is wrong in a direction nobody notices until they are reading a warning they cannot read. - struct Card
A card's measured colours: what it is drawn in, and what its text is drawn in, chosen so the result is legible. - impl Card
- fn for_level
Work out how to draw a card of this level on this panel. Tries the translucent card first, and only if nothing on it reaches [`LEAST_CONTRAST`] does it fall back to an opaque one. Translucency is a nicety; reading the warning is not. - fn readable_text
The palette colour that reads best on `fill`, and its ratio. Measured across the palette's own text colours rather than assuming black or white. A user palette can be anything, and picking the contrasting extreme would put a colour on - fn readable_text
screen that is in no theme. - fn show
Draw a notice, in whichever way was chosen. Returns true when the reader dismissed it. [`Style::Off`] returns true immediately: nothing was shown, so nothing is waiting to be acknowledged, and leaving it queued would build a backlog nobody - fn show
can ever clear. - fn overlay
The quiet one: a rounded translucent card. - fn alert
The loud one: it stops the panel until acknowledged. - const SCOPE
What a reader has to be told about these notifications. - mod tests
- fn every_style_is_named_explained_and_round_trips
- fn an_unknown_setting_falls_back_to_showing_something
An unreadable settings file must not stop the application, and of the two ways to be wrong the one that still shows a warning is chosen. - fn blending_lands_between_the_two_colours
The blend is the colour actually on screen, and it is between the two. - fn a_card_is_always_legible_even_if_it_has_to_stop_being_translucent
**The point of the module.** Whatever the palette, the text on a card is legible -- and where translucency cannot manage it, translucency is what gets given up. - fn the_text_colour_is_the_best_measured_candidate
The text colour is chosen by measurement, not by assuming black or white -- a user palette can be anything, and an assumed extreme puts a colour on screen that is in no theme. - fn switching_notifications_off_does_not_build_a_queue
Nothing queued behind a style that shows nothing. A notice that is never displayed and never dismissed is a backlog nobody can clear. - fn the_scope_note_says_these_do_not_leave_the_window
The limit is stated, because somebody will otherwise rely on being told while the window is closed. - fn a_notice_carries_its_level
crates/veilvoice-gui/src/pace.rs
- (module)
How often the window draws while something in it is moving, and what that actually came to. # The number this replaces The animations ran at twenty frames a second by design. The mark in the header carried its own constant, the busy path - (module)
asked for a frame every fifty milliseconds, and the veiling path every sixteen. Each was a reasonable number on its own, and together they meant that on any display somebody had bought in the last five years the window moved at a fraction - (module)
of the display's rate, with the busy path visibly juddering against the mark beside it. Sixteen milliseconds is the interesting one. It is the number everybody writes for sixty a second, and it is wrong for a window that waits for the - (module)
display: a display at sixty draws every 16.67 ms, so a request for a frame "no later than sixteen milliseconds from now" wakes the loop just after the frame it could have joined and the drawing lands on the one after. That is thirty a - (module)
second, asked for as sixty, and it is where "forty frames a second on a good machine" came from. # What this does instead While something is moving, the window asks for the next frame **now**, and lets vsync decide when that is. Under - (module)
vsync a frame cannot be drawn faster than the display shows it, so this costs one frame per display refresh and not one more, and the rate is the display's own, whatever it is. Somebody who wants fewer frames than that, on a battery or a - (module)
machine that struggles, sets a target in Settings and the window asks for a frame every `1/target` seconds instead, which is the old behaviour with the number chosen rather than hard-coded. Idle still draws nothing; this only decides the - (module)
spacing of frames that were going to be drawn anyway. # The display's rate is measured, not asked for Neither `egui` nor `eframe` says what the display's refresh rate is. It can be measured: a run of frames requested back to back under - (module)
vsync settles at the display's rate, and the median interval over the last thirty-two frames is a number a single slow frame cannot move. That median, rounded and clamped to 30..=1000, is what the About tab reports as the display and what - (module)
"match the display" means in Settings. # Dropped frames A frame that arrives more than one and a half times the expected interval after the one before it is counted as dropped. The count is shown beside the frame rate, and when more than a - (module)
handful drop inside one second the window says so in the header, with whether it is on software rendering, because that is the first thing to check and the About tab is not where somebody looks while it is happening. # Realtime `frame` - (module)
runs once per drawn frame on the thread that draws. It allocates nothing, locks nothing and prints nothing: the interval history is a fixed ring, the median is taken over a copy of it on the stack, and the target that other modules read is - (module)
an atomic. The guard from roadmap item 126 reads this file. - const DISPLAY_FLOOR
The lowest rate a display is believed to have. Anything measured under this is the window being starved, not a display. - const DISPLAY_CEILING
The highest rate the window will run at, display or setting. This was 240, which was wrong twice. It was a cap on what the measurement could *report*, so a 360 Hz display was described as a 240 Hz one in the About tab, and it was a cap on - const DISPLAY_CEILING
what "match the display" could ask for, so that display was driven at 240. Both are now well clear of any panel sold: 500 Hz exists, 1000 Hz is the number the research displays quote, and a ceiling that has to be raised again in two years - const DISPLAY_CEILING
is a ceiling in the wrong place. It stays a ceiling rather than becoming no limit because the measurement is a median of observed intervals, and a window starved to a handful of microsecond frames should be reported as fast, not as - const DISPLAY_CEILING
impossible. - const ASSUMED
The rate assumed until the display has been measured. - const TARGETS
The targets Settings offers, besides "match the display". The rates panels are actually sold at, plus the two at the top for displays that are ahead of that list. "Match the display" remains the default and covers every one of these - const TARGETS
without being asked, so this is for somebody who wants to pin it lower to save power, or higher than their panel to see what the window can do. - const WINDOW
How many intervals the median is taken over. - const DROPPED_AT
A frame this much later than expected is a dropped one. - const NOTICE_AT
More drops than this inside one second is worth saying out loud. - static INTERVAL_MICROS
The interval every animation in the window paces itself by, in microseconds. Zero means "ask for the next frame now and let vsync pace it". An atomic rather than a field passed down, because the mark is drawn from Settings' preview as well - static INTERVAL_MICROS
as from the header and neither of those has the application in hand. Written once per frame by `Pace::frame`, read by anything that moves. - static ANIMATING
Set by [`next_frame`], read and cleared once per frame by [`Pace::frame`]. This is how the measurement knows whether the frame it is looking at was asked for by an animation or by somebody moving the mouse, without every animation in the - static ANIMATING
window having to report itself. The mark in the header asks for its own frames and is drawn from two places, neither of which has the application in hand; this way it counts like everything else. - fn next_frame
Ask for the next frame the way the current target wants it asked for. The one call an animation makes. With the target on the display, this is `request_repaint`, and vsync spaces the frames; with a lower target it is - fn next_frame
`request_repaint_after` the target's interval. - enum Target
What a person chose in Settings. - impl Target
- fn from_setting
From the preference as stored: zero is the display. - fn to_setting
The preference to store. - fn label
The words Settings shows for it. - struct Pace
The measurement, kept across frames. - impl Default for Pace
- fn default
- impl Pace
- fn new
A fresh measurement with this target. - fn set_target
Change the target, keeping what has been measured. - fn target
The target as chosen. - fn target_hz
The rate the window is aiming at right now, in frames a second. - fn display_hz
The display's rate as measured, if it has been. - fn fps
Frames a second over the last whole second of drawing. - fn dropped_total
Every frame counted as dropped since the window opened. - fn dropped_last_second
Frames dropped in the last whole second. - fn is_dropping
Whether frames are being dropped steadily enough to be worth saying. Two consecutive seconds rather than one. Opening a window costs a hitch: fonts are rasterised, the first textures are uploaded, and the first second of almost any launch - fn is_dropping
drops frames. Telling somebody their machine is struggling because of that would be crying wolf on every start. - fn dropping_seconds
How many consecutive seconds have been dropping frames. - fn frame
Record that a frame is being drawn at `time`, egui's clock in seconds. Called once per frame from the draw path, before anything else reads the numbers. Whether this frame was asked for by an animation is taken from the flag [`next_frame`] - fn frame
set at the end of the frame before, and only those frames are used to measure the display: the interval between two frames somebody caused by moving the mouse says nothing about how fast the screen is. - fn median_hz
The display's rate from the median interval, clamped to sense. - fn publish
Tell the animations what to ask for. - fn interval
The interval animations currently pace by, for tests and the About tab. `None` is "vsync decides". - mod tests
- fn run
Frames an animation asked for, at `hz`. - fn idle_frame
One frame nothing asked for: somebody moved the mouse. - fn the_display_is_measured_from_the_frames_it_paced
- fn one_slow_frame_does_not_move_the_measurement
- fn a_fixed_target_paces_by_its_own_interval_and_measures_nothing
- fn the_setting_round_trips_and_is_clamped
- fn idle_gaps_are_not_dropped_frames
- fn a_second_of_late_frames_is_worth_saying
- fn one_bad_second_at_launch_is_not_an_alarm
- fn the_published_interval_follows_the_target
crates/veilvoice-gui/src/palettes.rs
- (module)
User-defined colour schemes, and the contrast check that keeps them usable. A reader can drop a small text file into a `palettes/` directory beside the preferences file and have it appear in the theme picker alongside the nine built-in - (module)
schemes. The format is the same `key = value` shape [`crate::prefs`] already uses, for the same reason: it is trivial to write by hand, trivial to parse without a dependency, and has no syntax in which something surprising can hide. # A - (module)
palette file is untrusted input It arrives from the filesystem, it may have been written by hand at two in the morning, and it may have been copied from a web page by somebody who has never seen a hex colour. So it is parsed like anything - (module)
else this project reads: every field validated, every failure named, and **refused rather than patched up**. Refusing matters more here than it first appears. The obvious lenient design is to fill in whatever is missing from the default - (module)
theme -- and that produces a palette which is *mostly* the user's, with a few colours from somewhere else, and no indication which. The user sees an application that looks subtly wrong and has nothing to go on. An error naming the missing - (module)
token is worth more than a window that opens. # Contrast is computed, not trusted The request that prompted this asked for "whatever colour with correct contrast to read". Correct contrast is not an aesthetic judgement, it is arithmetic: - (module)
WCAG 2.1 defines relative luminance and a contrast ratio, and a ratio below 4.5 means body text a substantial number of people cannot read. So a palette whose foreground fails against its own background is **refused with the measured ratio - (module)
in the message**, rather than accepted and left to produce an application nobody can use. It is the one validation here that is about the user rather than about the parser, and it is the reason this module exists at all rather than the - (module)
fields being read straight into a struct. The thresholds are stated where they are enforced, and they are the standard's, not invented here: | Pair | Minimum | Why | |---|---|---| | `fg` on `bg` | 4.5 | Body text, WCAG AA | | `fg` on - (module)
`bg_soft` | 4.5 | The same text on a raised surface | | `muted` on `bg` | 3.0 | Secondary text, AA large-text threshold | | `accent` on `bg` | 3.0 | Links and controls, AA non-text contrast | | `err` on `bg` | 3.0 | A warning nobody can - (module)
read is worse than none | `muted` is deliberately held to 3.0 rather than 4.5. It is used for secondary text that is meant to recede, every built-in theme would fail at 4.5, and pretending otherwise would mean shipping a rule the project's - (module)
own themes break. # In plain words Lets you write your own colour scheme and have VeilVoice use it. Drop a small text file in a folder and it appears in the list. Every colour has to be there, and the text has to be readable against the - (module)
background: a scheme whose text fails that check is refused rather than applied, with the measured numbers so you know how far off it is and which way to move. - const REQUIRED
Every token a palette file has to define. Listed rather than derived so that a missing one is named in the error. The order is the order they are reported in. - const MAX_PALETTES
The most palette files that will be read from the directory. A bound on work driven by the contents of a directory, in the same spirit as the bounds in the search index and the release verifier. Nobody has forty colour schemes; a directory - const MAX_PALETTES
containing forty thousand files is a mistake or a prank, and either way the application should still start. - const MAX_BYTES
The largest palette file that will be read. - fn luminance
Relative luminance, as defined by WCAG 2.1. The channel transfer is the standard's, including the 0.03928 knee: it is not the same as a plain gamma of 2.2, and using one would shift every ratio slightly -- enough to pass a palette the - fn luminance
standard fails. - fn channel
- fn contrast
The WCAG contrast ratio between two colours, from 1.0 to 21.0. Symmetric: the order of the arguments does not matter, which is why the lighter of the two is worked out here rather than assumed by the caller. - const PAIRS
The contrast pairs a palette has to satisfy, with the reason for each. - fn contrast_problems
Check a palette's contrast, returning one message per failing pair. Returns the measured ratio in each message. "Your colours are too similar" is not actionable; "fg on bg is 2.1:1, and body text needs 4.5:1" tells somebody exactly how far - fn contrast_problems
off they are and which way to move. - struct Parsed
One parsed colour scheme, before it is accepted. - fn parse_hex
- fn parse
Parse a palette file's text. Returns the scheme, or every problem found. Every problem, not the first: somebody fixing a hand-written file should learn about all four mistakes in one go rather than rerunning after each. - fn build
- fn default_dir
Where palettes live, beside the preferences file. - fn load
Read every palette in `dir`, returning the usable ones and every complaint. Both halves are returned rather than one or the other. A palette that failed must not silently vanish -- the user wrote a file, put it in the right place, and is - fn load
entitled to be told why it is not in the picker. The settings tab shows these messages verbatim. # On leaking [`Theme`] holds `&'static str` for its id and name so that reading the active theme in a paint loop is one relaxed atomic load - fn load
and a slice index, with no lock and no allocation -- see the module docs on `theme.rs`. Custom palettes therefore leak those two strings. That is a deliberate, bounded, load-once leak: at most [`MAX_PALETTES`] entries, read during startup, - fn load
never in a loop, and each a few dozen bytes. The alternative -- reference counting or a lock around the palette table -- would put synchronisation into the hottest read in the application to avoid leaking about a kilobyte, once. - mod tests
- const GOOD
- fn a_well_formed_palette_is_accepted
- fn the_contrast_ratio_matches_the_standard
- fn contrast_is_symmetric
- fn every_built_in_theme_passes_its_own_contrast_check
- fn an_unreadable_palette_is_refused_with_the_measured_ratio
- fn a_missing_token_is_named_rather_than_filled_in
- fn every_problem_is_reported_not_just_the_first
- fn a_bad_colour_is_refused_rather_than_guessed
- fn a_palette_cannot_impersonate_a_built_in_theme
- fn an_id_that_would_not_survive_the_preferences_file_is_refused
- struct Scratch
A scratch directory that cleans up after itself. No `tempfile` dependency for four tests: this project's argument is that its supply chain is small enough to read, and a crate pulled in to make a directory would be a poor trade. - impl Scratch
- fn new
- fn write
- impl Drop for Scratch
- fn drop
- fn a_palette_file_on_disk_becomes_a_theme
- fn a_rejected_palette_is_reported_rather_than_dropped_in_silence
- fn one_bad_palette_does_not_hide_a_good_one
- fn palettes_load_in_a_stable_order
- fn something_that_is_not_a_regular_file_is_refused_not_read
- fn a_file_over_the_size_bound_is_refused
- fn a_missing_directory_is_not_an_error
crates/veilvoice-gui/src/paths.rs
- (module)
Exactly where this copy of VeilVoice is keeping things. # Why the About tab prints these Every one of these locations is derived on the machine it runs on, from the environment the platform provides, and every one of them is different on - (module)
Windows, macOS and Linux. Somebody backing up a vault, moving a settings file, or wondering why a palette they wrote is not in the picker has one question, and until now the honest answer was "read the source". A guess is worse than - (module)
nothing here. A person told the wrong directory deletes the wrong directory. # Derived, not listed Each entry calls the function the rest of the application calls, so this panel cannot say one thing while the program does another. The list - (module)
is not a second description of where things live; it is the same one, read out. A test reads the crate's own source for every `default_path` and `default_dir` and fails naming any this panel does not show, so a location added tomorrow - (module)
cannot quietly stop being reported. # The one path deliberately not shown The app lock's own files. [`veilvoice_crypto::vault`] gives them names derived from an index rather than a fixed name, which is obscurity and is described as - (module)
obscurity there: what it buys is that a search of a disk for a known filename misses, and that a backup rule written against one does too. Printing the names in a window would hand both back to anybody standing behind the person reading - (module)
it. The directory is shown, which is the part that answers "what do I back up". # In plain words The About tab lists the folders VeilVoice is actually using on this computer, so you can find your settings, your vaults and your palettes - (module)
without guessing. - struct Where
One place VeilVoice keeps something, and what it keeps there. - fn arrangement
Which of the two arrangements this copy is using, in one sentence. Above the list rather than in it, because it is not a path: it is the fact that decides every path below it. Somebody carrying a copy between two computers wants this line - fn arrangement
before they want any of the others. Read from [`veilvoice_crypto::lock::is_portable`] rather than worked out by comparing paths here, so there is one rule for what portable means and this is not a second spelling of it. - fn all
Every location, in the order the About tab shows them. Ordered from the outside in: the program, then the folder everything else hangs off, then what is in it. Somebody reading down the list sees the shape of the installation rather than - fn all
an alphabetical pile. - fn carry_over
Copy a portable copy's state into the platform's own configuration directory, without overwriting anything already there. # The question this answers, and why it is a question Somebody installing a portable copy has state in two possible - fn carry_over
places and one of two intentions, and neither can be guessed. A person moving off a memory stick onto their own machine wants it carried over. A person installing on a shared or borrowed machine wants it left exactly where it is, which is - fn carry_over
the case where guessing wrong copies somebody's vault onto a computer they do not own. # Nothing is overwritten and nothing is removed A name that already exists at the destination is left alone and reported. Overwriting would destroy the - fn carry_over
settings and vaults of whoever already uses that account, and the portable copy is not removed either: this is a copy, so both work afterwards and the person decides what to do with the stick. Directories are copied whole, which is what a - fn carry_over
vault is. - fn copy_new_only
Everything in `from` that is not already in `into`, and a line about each. Split from [`carry_over`] so the rule that matters can be tested with two temporary directories: nothing at the destination is ever replaced, and the report says - fn copy_new_only
which names were left and why. - fn copy_into
One file or one whole directory, recursively. Written out rather than reached for through a crate: it is fifteen lines, and a dependency added to copy a folder is a dependency in a graph this project invites people to read. - mod tests
crates/veilvoice-gui/src/paths/tests.rs
- (module)
That the panel reports every location, and reports them truthfully. - fn every_entry_is_labelled_and_explained
- fn no_label_is_used_twice
- fn a_path_that_is_known_is_absolute
- fn everything_kept_between_runs_sits_under_the_settings_folder
- fn every_place_this_crate_keeps_something_is_reported
**The point of the module.** A location added to this crate tomorrow must appear here, or the About tab goes quietly out of date and nothing notices. The list is read out of the crate's own source rather than written down beside it, which - fn every_place_this_crate_keeps_something_is_reported
is the same rule the rest of this repository follows: a fact in two places is derived in one of them or checked. - fn the_arrangement_names_the_folder_that_switches_it
- fn carrying_over_never_replaces_anything_at_the_destination
- fn carrying_nothing_over_says_so_rather_than_looking_like_success
- fn what_was_left_behind_is_still_there_afterwards
crates/veilvoice-gui/src/policy.rs
- (module)
The policy in force, and what the interface does about it. A thin layer over [`veilvoice_policy`]: read the plain file once at startup, hand the answers to the controls it fixes, and draw the reason beside each one. # A control that is - (module)
disabled without a reason is a bug report Every requirement carries its own sentence ([`veilvoice_policy::Requirement::describe`]), and this module draws it under the control it has taken away. That is the whole user-facing point: somebody - (module)
who cannot turn encryption off should be able to see, without asking anybody, that it was fixed deliberately and by what. # Enforcement is not the drawing code Disabling a checkbox is a claim about pixels. The values a job actually uses - (module)
come from [`crate::VeilVoiceApp`]'s constrained posture, so a policy holds even if a control is drawn wrongly, and the tests assert the behaviour rather than the layout, the same rule the at-rest dialogue follows. # Reading it costs - (module)
nothing, and proves nothing [`InForce::load`] never asks for a passphrase and never blocks. It can therefore say only that a policy is in force, not that it is the one somebody sealed; `veilvoice policy verify` is where that question is - (module)
asked. The reason it is safe to apply an unverified policy is the one-way property [`veilvoice_policy`] is built around, and [`InForce::panel`] states it rather than leaving the reader to infer it. # In plain words Reads the rules that say - (module)
which settings must stay on, and makes the window obey them. The controls show the value that will actually be used, rather than one that silently changes when you press the button. A slider showing something a job will not honour is worse - (module)
than a slider you cannot move. - struct InForce
The policy this machine is running under, if any. - fn default_dir
Where the policy files live, beside everything else VeilVoice keeps. The same directory the command line uses. Resolved from the app lock's path rather than worked out again, so the two front ends cannot end up looking in different places. - impl InForce
- fn none
No policy. What tests and `Default` use, so neither touches the disk. - fn from_policy
A policy supplied directly, for tests. - fn load
Read the plain policy from the usual place. Never asks for a passphrase. - fn is_active
Whether anything is fixed. - fn requires
Whether a particular requirement is in force. - fn minimum_intensity
The intensity floor, or 0.0 when none is set. - fn constrain
Apply the policy to a posture. Only ever tightens. - fn note
Draw the reason a control is fixed, under that control. Does nothing when the requirement is not in force, so a call site can be unconditional and there is no `if` for somebody to get backwards. - fn panel
The summary panel, for the about tab. - mod tests
- fn requiring
- fn no_policy_constrains_nothing
- fn an_empty_policy_is_not_active
An empty policy file is a policy file, and it fixes nothing. - fn a_policy_tightens_the_posture_it_is_given
- fn loading_reads_and_changes_nothing
Loading must never change the machine, and must never panic on a machine that has no configuration directory at all. - fn every_panel_state_renders_without_a_window
Every panel state renders with no window, including the one nobody wants: a policy file that will not parse. - fn an_unreadable_policy_is_reported_rather_than_treated_as_absent
A policy file that will not parse must say so rather than reading as "no policy" -- the difference is between "nothing was asked for" and "something was asked for and is not being applied".
crates/veilvoice-gui/src/prefs.rs
- (module)
What the user has chosen about how the app looks and moves. # Nothing here is secret, and nothing here is required Preferences are a convenience. Every field has a working default, a missing file is not an error, and a corrupt one is not - (module)
either -- it falls back to the defaults and says so in the settings panel rather than refusing to start. An app that will not open because its preferences file has a stray byte in it has turned a cosmetic setting into an outage. This is - (module)
deliberately *not* written through [`veilvoice_crypto::privatefile`]. That module exists for files whose contents are sensitive, and using it here would blur a distinction worth keeping: your choice of colour scheme is not a secret, and - (module)
treating it like one would make the real protections look like decoration. # The format One `key = value` per line, ASCII, with `#` comments -- readable and editable with any text editor, for the same reason the integrity manifest is a - (module)
text format. No parser dependency, and no way for a malformed file to do anything more interesting than be ignored. # Animations On by default, and switchable off in two places: the settings panel, and the `VEILVOICE_NO_ANIMATION` - (module)
environment variable, which wins over the file so that a machine which struggles with them can be fixed without opening the UI that is struggling. The system's own "reduce motion" setting is honoured above both. Someone who has told their - (module)
operating system they do not want movement has already answered this question, and a privacy tool asking again -- and defaulting to yes -- would be ignoring them. # In plain words What you have chosen about how VeilVoice looks and behaves, - (module)
kept in a small text file. Nothing in it is secret and nothing in it is required: delete the file and the application opens with its defaults. You can read it and edit it by hand. A setting this version does not recognise falls back to the - (module)
default rather than stopping the program, and where the safe direction matters, the default is the one that keeps a protection on. - struct Prefs
Everything the user can choose about presentation. - impl Default for Prefs
- fn default
- fn default_path
Where preferences live: beside the app lock, in this platform's config directory. `None` when the environment does not say where that is, in which case the app runs on defaults and simply does not persist them. - impl Prefs
- fn load
Read preferences from `path`. Never fails. A missing file gives the defaults; an unreadable or unparseable one gives the defaults with [`recovered_from_corrupt_file`](Self::recovered_from_corrupt_file) set, so the UI can be honest about it. - fn parse
Parse the `key = value` format. Unknown keys are ignored, so a file written by a newer build still works in an older one. - fn to_text
Serialise to the text format. - fn save
Write preferences to `path`, creating the directory if needed. Returns the reason on failure so the settings panel can show it. A failure here must never be fatal: the choice still applies for this session, it simply will not be remembered. - fn parse_bool
- struct Motion
Whether movement is allowed, and how much. Resolved once per frame from three inputs, in order of authority: 1. **The operating system's reduce-motion setting.** Someone who has told their system they do not want movement has answered this - struct Motion
already. 2. **`VEILVOICE_NO_ANIMATION`**, so a machine that struggles with animation can be fixed without opening the interface that is struggling. 3. **The preference**, which is on by default. - impl Motion
- fn resolve
Resolve for this frame. - fn secs
A duration scaled by whether motion is allowed. Returns zero when it is not, so a caller can pass this straight to an easing function and get an instant result rather than having to branch at every call site. That matters: a branch nobody - fn secs
wrote is how a stray animation survives the toggle. - mod tests
- fn the_defaults_are_the_documented_ones
- fn it_round_trips_through_its_text_format
- fn a_hand_edited_frame_rate_is_clamped_and_zero_is_left_alone
- fn every_boolean_spelling_people_actually_type_is_accepted
- fn hostile_and_broken_files_fall_back_to_the_defaults
A settings file must never be able to stop the app starting. - fn a_wholly_unreadable_file_is_reported_rather_than_hidden
A file whose every line was rejected should say so, so the settings panel can explain why the defaults are in force. - fn unknown_keys_are_ignored_not_fatal
A newer build's keys must not break an older one. - fn a_missing_file_is_not_an_error
- fn saving_and_loading_round_trips_through_a_real_file
- fn the_system_setting_wins_over_the_preference
The system's reduce-motion setting outranks the preference. Someone who asked their OS for less movement has already answered. - fn the_icon_can_be_stilled_without_stilling_everything_else
- fn the_settings_file_sits_beside_the_app_lock
crates/veilvoice-gui/src/reduced_motion.rs
- (module)
Whether the operating system has been asked to reduce motion. # Why this exists rather than an egui call egui does not surface the platform's accessibility preference, so it has to be read here. The website gets this for free -- CSS has - (module)
`prefers-reduced-motion` and the browser answers it -- and it would be odd for the desktop app to be the one front-end that ignores the setting. # Read once, at startup Every platform answers this through a subprocess, and a subprocess per - (module)
frame would be indefensible in a paint loop. It is read once when the app starts and cached for the session. Someone who changes the setting while VeilVoice is open sees it on the next launch, which is the same behaviour most applications - (module)
have. # Absolute paths, always `Command::new("defaults")` is a *search*, and on Windows that search includes the current working directory -- which is precisely the defect (F-13) this project fixed in `veilvoice-watch` and - (module)
`veilvoice-guard`. Every tool here is named by absolute path, and an unfound tool answers "I do not know" rather than falling back to a search. # When it cannot tell [`Query::Unknown`] means the platform was not asked or did not answer, - (module)
and the caller treats that as "no reduction requested" -- because defaulting to *off* would silently disable animation for everybody on a platform this cannot read, which is a worse failure than missing the preference for the few who set - (module)
it. The settings panel only claims the system asked for reduced motion when it actually saw it say so. # In plain words Asks the operating system whether you have said you would rather things did not animate. Some people get motion - (module)
sickness from moving interfaces, and every system has a setting for it. Honouring it is not decoration: an application that animates regardless is one those people cannot comfortably use. When the answer cannot be determined, animation - (module)
stays on, and the setting can be overridden by hand either way. - fn no_window
- const CREATE_NO_WINDOW
- enum Query
What the platform said. - impl Query
- fn reduces
Whether to treat this as a request to reduce motion. `Unknown` is *not* a reduction: see the module note. - fn tool
Resolve a tool to an absolute path. Never searches `PATH`. - fn query
Ask the operating system. Called once, at startup. - fn windows_query
Windows: "Show animations in Windows" lives in the `UserPreferencesMask` under `HKCU\Control Panel\Desktop`. It is a little-endian bit field, and **bit 1 of byte 0** is `CLIENTAREAANIMATION` -- set when animation is wanted, clear when the - fn windows_query
user has switched it off. - fn parse_user_preferences_mask
Pull the mask out of `reg query` output and read the animation bit. - fn macos_query
macOS: the Accessibility "Reduce motion" switch. - fn unix_query
Linux and the BSDs: GNOME's `enable-animations`, which the other major desktops have largely adopted as the common key. - mod tests
- fn every_subprocess_is_spawned_without_a_console_window
Every subprocess in this file must be spawned through `no_window`. This reads the file's own source rather than exercising the behaviour, because "no console window appeared" cannot be observed from a test -- which is precisely why the - fn every_subprocess_is_spawned_without_a_console_window
defect reached a release. A `Command::new` added later without the wrapper fails here rather than on a desktop. - fn the_platform_can_be_asked_without_incident
Whatever this machine says, it must say it without panicking and without hanging. - fn not_knowing_is_not_a_request_to_reduce
"I do not know" must not switch animation off for everybody on a platform this cannot read. - fn a_missing_tool_yields_unknown_rather_than_a_search
- fn the_windows_mask_is_read_from_the_right_bit
- fn the_real_windows_setting_parses
The real registry value, on the machine running the test, must parse.
crates/veilvoice-gui/src/security.rs
- (module)
The application lock, and the at-rest encryption of what VeilVoice writes. # Two passwords, and why There are two, deliberately: - the **app lock**, which decides whether VeilVoice will open at all, and - the **recording passphrase**, - (module)
which encrypts the files it produces. Collapsing them into one would mean that unlocking the app also unseals every recording it has ever written, which is the opposite of what a lock is for. [`veilvoice_crypto::lock`] additionally - (module)
domain-separates its verifier, so even a user who types the same string in both places does not end up with two copies of one value. # What the lock is worth Not much against an attacker with the disk, and the UI says so in - (module)
[`veilvoice_crypto::lock::SCOPE`], shown on the unlock screen itself rather than buried in an about page. It stops the person who picks up your unlocked laptop. It does not stop someone who takes the drive. # A limitation of typing a - (module)
password into a window A text field owns a `String`, so a passphrase exists as ordinary heap bytes while it is being typed. That window cannot be removed, because something has to receive the keystrokes, but it can be kept short, and it - (module)
is: - the typing buffer is wiped the moment the passphrase is confirmed; - the confirmed passphrase is held only as a [`veilvoice_crypto::Secret`], page-locked and zeroized on drop, for the rest of the session; - locking the app, or - (module)
changing the passphrase, wipes both. It used to be kept as a plain `String` for the whole session, which was a much larger window for no benefit. None of this defends against someone who can read this process's memory. If they can, they - (module)
have already won, and `docs/WHITEPAPER.md` §7 says so rather than implying otherwise. What it does is stop a passphrase lingering in a heap allocation long after it was needed, where a core dump or a swapped page could pick it up. # In - (module)
plain words The lock on the window, and the encryption of the files VeilVoice writes. There are two passphrases and they do different jobs. One opens the application. The other encrypts a recording, and it is asked for separately because - (module)
they protect different things and losing one should not mean losing the other. The panel says what the lock is worth and what it is not: it stops somebody who picks up your unlocked computer, and it does not stop somebody who has the disk. - (module)
Encrypting the recording is what protects the recording. - fn into_secret
Move a typed passphrase out of its `String` and into page-locked storage, wiping the buffer it came from. No `unsafe`, so the intermediate `Vec` is a genuine second copy for a moment; `Secret::new` wipes it before returning. Writing - fn into_secret
through `String::as_bytes_mut` would avoid the copy and is not worth an `unsafe` block in a crate that has none. - enum Sealing
How the recording that comes out of a job is protected. - enum Op
What a background lock operation was trying to do. - type OpResult
A finished lock operation: the store as it now stands, and how it went. - struct Security
Everything about locking the app and sealing its output. - impl Default for Security
- fn default
The safe state, and deliberately free of I/O so tests and `VeilVoiceApp::default()` never touch the real lock file. The running app calls [`Security::load`]. - impl Drop for Security
- fn drop
- impl Security
- fn load
Read the lock file for this machine and start locked if one is set. - fn take_unlock_passphrase
Take the passphrase that just opened the lock, once. Returns `Some` on exactly the frame after a successful unlock and `None` on every other. It exists so [`crate::integrity`] can open a record sealed under the app-lock passphrase without - fn take_unlock_passphrase
this module keeping that passphrase for the life of the session. The caller must wipe what it gets; the worker that receives it does. - fn take_unlock_store_key
Collect the obfuscated store's key from the unlock that just happened. One caller, one frame, like the passphrase. See [`crate::vault_store::VaultStore`] for what it opens and what that is worth. - fn prefer_app_lock_sealing
Start in [`Sealing::AppLock`], because the user asked for that last time. Applied at startup, before the window is drawn and so before anything can be unlocked, which matters: the passphrase is captured as the lock opens and only when this - fn prefer_app_lock_sealing
mode is already chosen. - fn seals_with_app_lock
Whether the app-lock sealing mode is currently chosen, so the window can have the choice remembered. - fn tampered
Whether the lock reported having been interfered with. Stays true until an unlock acknowledges it, which needs the passphrase, so nobody can dismiss the banner except the person who can open the app. - fn is_locked
Whether the unlock screen should be shown instead of the app. - fn set_lock_from_setup
Whether a lock is configured at all. Set an app lock from the first-run setup. The same worker and the same `Op::Set` the security tab uses, so there is one path that creates a lock rather than two that can disagree -- which is exactly how - fn set_lock_from_setup
F-141 happened. - fn has_recording_passphrase
Whether a recording passphrase is held for this session. - fn set_recording_passphrase
Take a recording passphrase from the first-run setup. Moved into page-locked storage immediately, like the one the security tab takes: a `String` typed into a text field is ordinary heap memory, and the point of `Secret` is that it does - fn set_recording_passphrase
not stay that way. - fn has_lock
Whether an app lock is configured on this machine. - fn lock_now
Lock the app now, wiping the session passphrase with it. This is the deliberate one: somebody pressed Lock. The screen says nothing about how it got there, because the person reading it already knows. - fn lock_after_idle
Lock because nobody has touched the window for a while. Identical to [`Self::lock_now`] except that the lock screen says so. Coming back to a locked window you did not lock is the moment to wonder whether somebody else has been at the - fn lock_after_idle
machine, and answering that costs nothing and saves a bad minute. - fn lock_inner
Lock, remembering whether the person did it or the idle timer did. The distinction is carried because the lock screen says which, and "locked after twenty minutes idle" answers a question that "locked" leaves open. - fn wipe_secrets
Wipe every plaintext secret this struct is holding. - fn ready_to_write
Whether a job may start: either encryption is off, or there is something to encrypt with. - fn blocked_reason
Why a job cannot start yet, for the button's tooltip. - fn plan
How the next job should protect its output. Returns the material by value so the worker thread owns it; the copy held here stays for the next file. - fn spawn
Run a lock operation on a thread, so the window keeps drawing. Argon2id at 256 MiB takes long enough to be felt. Doing it on the drawing thread would freeze the window for the duration, which reads as a crash. - fn poll
Collect a finished lock operation. Returns true if anything changed. - fn wipe_form
Clear what was typed, so a passphrase does not sit in a field after use. - fn busy
Whether an operation is in flight, so the panel can refuse a second one. - fn is_busy
Whether a lock operation is running, so the window keeps repainting and the spinner actually spins. - fn unlock_screen
The full-window unlock screen. Nothing else is drawn while this is up. - fn interference_banner
The standing report that the lock file was interfered with. **Roadmap item 76.** It is drawn here rather than on the lock screen, and the distinction matters. The lock screen is read by whoever is holding the machine, and telling them - fn interference_banner
their edit was noticed tells them to try something else. This side of the lock is read only by somebody who has already produced the passphrase. It will not go away on its own. Clearing it runs [`veilvoice_crypto::LockStore::acknowledge`], - fn interference_banner
which asks for the passphrase again, so the only person who can dismiss the report is the one who could have opened the lock anyway. - fn tab
The security tab: manage the lock, and see what it is worth. - fn load_mandate
Read the baseline from disk and apply it to the checkbox. Called once at startup by the running app. A file that will not parse leaves the strict default in place and records why, because the safe direction and the silent direction are not - fn load_mandate
the same thing. - fn mandate_requires_app_lock
Whether the baseline insists on the app lock. - fn mandate_requires_encryption
Whether the baseline insists on encryption at rest. - fn mandate_history
The change log, for the panel that shows it. - fn record
Record a change to the baseline, and write it down. A no-op when the value is already that, so re-drawing a frame cannot fill the history with entries nobody made. Failing to write is reported rather than swallowed: a relaxation the user - fn record
believes is recorded, and is not, is the failure this whole module exists to avoid. - fn recording_controls
The at-rest controls that sit inside the file tab. - fn mandate_history_panel
The log of every time a requirement was turned off or back on. The same history the CLI prints from `veilvoice mandate history`, shown here so that a relaxation made in this window is not a change with no visible record. Collapsed by - fn mandate_history_panel
default, because on a machine nobody has relaxed anything on it says only "none", and that is the common case. - fn mandate_history_rows
One coloured line per change, newest concern last: green for a requirement put back, yellow for one turned off. Split from the header so a test can read the rows without opening a collapsed section. - fn disable_dialogue
The dialogue shown when the user turns at-rest encryption off. Returns true while it is open, so the caller can disable the rest of the window rather than let a click land behind it. - const DISABLE_WARNING
What the user is told before recordings stop being encrypted. Kept as data so the test suite can assert it still says the uncomfortable part, exactly as the CLI's equivalent does. - enum Plan
What a finished job should do with its bytes. - impl Plan
- fn write
Seal `wav` if the plan says to, and write it. Returns where it landed. Runs on the job thread, never the UI thread: Argon2id is meant to be slow. `wav` is the in-memory encoding, so an encrypted recording never exists on disk in the clear. - fn write
`params` is the caller's, rather than being read from [`kdf::KdfParams::default`] in here. The app passes the default; the tests pass a cheap profile, because a unit test that allocates 256 MiB and runs three passes of Argon2 is not - fn write
testing the thing it claims to, it is testing the runner's memory, and on a CI machine running several such tests at once it stops being a test at all. - impl std::fmt::Debug for Plan
Deliberately opaque about the passphrase, so a plan cannot reach a log line through `{:?}`, the same rule [`veilvoice_crypto::Secret`] follows. - fn fmt
- fn run_op
Run one lock operation, off the UI thread. - fn reopen
Re-open the lock store from disk, or `None` when there is nothing to open. - const PASSWORD_LABEL_WIDTH
The width every passphrase label is given, so every field starts level. Wide enough for "passphrase", which is the longest of them. - const PASSWORD_FIELD_WIDTH
How wide every passphrase field is drawn, on this tab and on the lock screen. One number, because two fields that are nearly the same width read as a mistake rather than as two sizes. - fn button_column
One labelled passphrase field, with the field in the same place every time. # Why the label gets a column of its own These labels used to be padded with trailing spaces to line the fields up: `"current"`, `"new"`, `"repeat"`, `"password"`, - fn button_column
against a bare `"passphrase"`. That aligns nothing outside a terminal. The interface font is proportional, so a space is not the width of a letter and eight letters plus two spaces is not the width of ten letters; and egui gives trailing - fn button_column
whitespace no reliable width at all. The visible result was that fields sat at slightly different places on different screens, and the screens that differed most were the ones drawn only after setup, because those carry the labels that - fn button_column
needed the most padding. Buttons underneath inherited the same drift. Giving the label a fixed column puts every field, and everything lined up beneath it, at one x on every screen. Returns the field itself, so a caller, or a test, can ask - fn button_column
where it landed. Draw `contents` in the same column the passphrase fields occupy. The buttons under a passphrase field act on that field, and they were starting at the panel's left edge while the fields they belong to started one - fn button_column
label-width in. The eye reads a left edge as a grouping, so the buttons looked like they belonged to the section rather than to the fields directly above them, and the further down the tab you went the more obviously the two columns - fn button_column
disagreed. Indented by the same [`PASSWORD_LABEL_WIDTH`] the labels reserve, so there is one column rather than two. Taking the width from that constant rather than repeating the number is what keeps them in step: changing the label column - fn button_column
moves the buttons with it. - fn password_row
One passphrase field with its label, at the shared width. - fn unlock_row
The password row on the lock screen: the label, the field and the button. **Finding F-196.** Split out of `unlock_screen` so that a test can draw exactly what ships rather than a replica of it. The measurements that justify the sizes below - fn unlock_row
are only worth something if they are measurements of this row. The three sizes are one size. The field was a default `TextEdit`, 19.1 points tall, and the button was the word "unlock" padded with two literal spaces on each side, 27.0 tall - fn unlock_row
and 98.3 wide: nearly eight points of height between them, their middles four points apart, and a width that depended on how wide a space happens to be in the interface font. Padding a label with spaces to size a control is the mistake - fn unlock_row
[`crate::layout::column`] already exists to stop somebody making, in a proportional font, for the second time. So the row agrees on a height first, from [`crate::layout::button_height`], and the field is grown to it rather than the button - fn unlock_row
squashed down to the field. The button is [`crate::layout::LOCK_WIDTH`] wide, which is what the lock button in the header is drawn at: the control that locks the window and the control that unlocks it are the same control to a reader, and - fn unlock_row
were two different sizes. - mod tests
- fn a_lock_button_lines_up_with_the_field_above_it
The buttons under the passphrase fields start where the fields do. Measured rather than eyeballed: this renders a real `password_row` and a real button in the same column and compares where each begins. - fn every_passphrase_field_starts_in_the_same_place
Every passphrase field starts at the same x, whatever its label says. The defect: the labels were padded with trailing spaces to fake a column, which lines nothing up in a proportional font. Screens drawn only after setup carry the labels - fn every_passphrase_field_starts_in_the_same_place
that needed the most padding, so their fields, and the buttons under them, sat at a different place from the ones present at launch. The shortest and the longest label in use are drawn here. If the fields ever part company again this fails - fn every_passphrase_field_starts_in_the_same_place
with both positions. - fn no_passphrase_label_is_padded_with_spaces
No passphrase label is padded with spaces to fake its width. The column is what aligns these now. A label that comes back padded is somebody reaching for the old trick, and it would drift again the first time the font changed. - fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used
**Roadmap item 74.** The locked window explains nothing. It used to explain a great deal: what the lock is and is not worth, where its file lives, and that deleting that file starts over. All true, all addressed to the wrong person. The - fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used
reader of a locked window is either its owner, who does not need any of it at that moment, or somebody who picked the machine up. This reads the source of `unlock_screen` rather than rendering it, because what is being held is that certain - fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used
sentences are not reachable from that function at all. A rendering test would only prove they were absent from one frame. Roadmap item 86. The passphrase is kept only for the mode that needs it. A user who has not asked for app-lock - fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used
sealing must keep the old behaviour exactly: the passphrase is wiped the instant it has been checked. Holding it "just in case" would be a security regression paid for by everybody, to make a feature nobody switched on slightly more - fn the_app_lock_passphrase_is_kept_only_when_it_is_going_to_be_used
convenient. - fn changing_the_password_drops_the_passphrase_that_was_sealing_with_it
F-94. Changing the app-lock password must not leave the old one sealing new recordings. A user who changes their password and keeps working would otherwise produce files that open with a password they have just replaced, and be told - fn changing_the_password_drops_the_passphrase_that_was_sealing_with_it
nothing. - fn locking_the_window_drops_the_sealing_passphrase
Roadmap item 86. Locking the window must put the state back where a fresh launch would leave it, or the lock is a picture of a lock. - fn app_lock_sealing_produces_a_container_that_outlives_the_lock
Roadmap item 86. The plan has to be a password plan, because that is what keeps the recordings openable after the lock is gone. - fn app_lock_sealing_is_not_offered_without_a_lock
The mode is only offered where there is a lock to seal with. - fn the_interference_report_is_behind_the_lock_and_behind_the_passphrase
Roadmap item 76. The report has to be reachable from the tab and only from the tab, and clearing it has to go through the passphrase rather than through a flag the drawing code can set. - fn the_locked_window_tells_a_stranger_nothing
- fn weak
Cheap on purpose: these tests exercise the plan, not Argon2. - fn encryption_at_rest_is_the_default
- fn a_job_is_blocked_until_there_is_something_to_encrypt_with
A job must not be able to start with encryption on and nothing to encrypt with, or the "default" would silently degrade to plaintext. - fn disabling_encryption_needs_the_dialogue_to_be_answered
Unticking the box must not take effect until the warning is answered. - fn the_warning_states_the_actual_consequence
- fn turning_encryption_off_in_the_window_is_written_down_and_survives_a_restart
- fn redrawing_the_frame_does_not_fill_the_history_with_entries_nobody_made
- fn rendered_text
The text a panel renders, gathered by walking egui's output. - fn the_window_shows_the_history_the_command_line_would_print
- fn a_baseline_that_would_not_parse_is_said_in_the_window
- fn a_clean_baseline_shows_no_history_and_no_problem
- fn a_test_built_security_never_writes_to_the_real_configuration
- fn a_plan_with_nothing_set_refuses_rather_than_writing_plaintext
- fn a_password_plan_seals_beside_the_recording_and_leaves_no_plaintext
- fn a_plaintext_plan_writes_the_file_as_asked
- fn a_plaintext_plan_still_writes_owner_only
An unencrypted recording is still readable only by this account. `veilvoice anonymise --encrypt false` had written 0600 since it was written. This, the window's version of the same decision, wrote 0644, so turning encryption off in the - fn a_plaintext_plan_still_writes_owner_only
interface left the recording readable by every other account on the machine and turning it off on the command line did not. - fn the_confirmed_passphrase_leaves_no_plaintext_buffer_behind
The confirmed passphrase must not linger as ordinary heap bytes. It used to be kept as a `String` for the whole session; it is now moved into a page-locked `Secret` the moment it is confirmed, and the typing buffer is wiped. - fn the_plan_carries_page_locked_material_not_a_string
And the plan handed to the worker thread carries the `Secret`, not a copy of the text. - fn locked_security
Locking must take the session passphrase with it, or "locked" would be a screen rather than a state. A real lock in a temporary directory, at test cost. `lock_now` refuses to lock when there is nothing to unlock with, which is the right - fn locked_security
behaviour and means these tests need an actual store. - fn a_deliberate_lock_and_an_idle_one_are_told_apart
- fn the_auto_lock_note_is_not_shown_once_typing_starts
- fn the_lock_screen_shows_the_mark_in_its_badge
- fn the_password_field_and_the_unlock_button_are_one_size
**Finding F-196.** The row that ships, measured: the field and the button are one height, on one middle, and the button is the width every other lock control is drawn at. The theme is installed first. Without it the default style makes a - fn the_password_field_and_the_unlock_button_are_one_size
button and a text field the same height anyway, and this would pass while measuring nothing about this application. - fn nothing_in_the_unlock_row_is_sized_with_spaces
The row is not padded with spaces, which is how it was sized before and is not a width in a proportional font. - fn locking_wipes_the_session_passphrase
- fn locking_does_nothing_when_no_lock_is_configured
crates/veilvoice-gui/src/settings.rs
- (module)
The settings panel: a menu of pages, each a titled group of choices. # Why a menu rather than one long list There are three kinds of setting here and they answer different questions: what the app *looks* like, how it *moves*, and what it - (module)
does with the files it writes. Stacked in one column they read as an undifferentiated wall of tick boxes, and the one that matters most -- at-rest encryption -- ends up looking exactly as important as the colour scheme. A menu with a page - (module)
per group keeps each question next to its own explanation. # Every change applies immediately, and is saved immediately There is no "apply" button and no "unsaved changes" state. Both are ways to lose a choice silently. If saving fails the - (module)
choice still applies for this session and the panel says, in the panel, that it could not be remembered and why -- rather than failing quietly and letting the setting reappear wrong on the next launch. # What is deliberately not in here - (module)
The app lock and the at-rest passphrase have their own tab and stay there. A password field sitting between "animations" and "colour scheme" invites being treated with the same weight, and it is not the same weight. # In plain words The - (module)
settings, arranged as a short menu rather than one long list. There are enough of them now that a single column meant scrolling past things you were not looking for, and finding a setting is most of what anybody does in a settings screen. - (module)
Each page is a titled group with a sentence saying what it covers, and every choice applies as you make it and is remembered. - enum Page
Which page of the settings menu is showing. - impl Page
- const ALL
Every page, in menu order, with its label and one-line summary. - struct Settings
The settings tab's own state. - impl Default for Settings
- fn default
- impl Settings
- fn load
Load preferences from this platform's config directory and apply the chosen theme to `ctx`. Never fails: an unreadable or unparseable file leaves the defaults in force, and the panel says so. - fn motion
How much movement is allowed this frame. Takes `&egui::Context` for symmetry with the rest of the UI even though it does not need it: the platform answer is cached from startup, since reading it costs a subprocess. - fn needs_first_run
Whether the first-run choice has still to be made. - fn persist
Write the settings out, encrypted and obfuscated as they are at rest. - fn save_error
Whether the app should open in group mode, and a way to change it. Exposed as a pair of methods rather than as a public field so the group panel can ask to have a choice remembered without knowing where preferences live or what happens - fn save_error
when the platform will not say. A failed write is not fatal here either: the tick applies for this session and the settings page reports why it was not kept. Why the last write failed, if it did. - fn show_install_tab
Whether the install tab should be offered at all. Two conditions, and the first is not a preference: an installed copy never offers to install itself, because a program that does that is telling its user something untrue about what it is. - fn show_install_tab
The preference only covers the other case -- a portable copy, run on purpose, by somebody who does not want to be asked again. - fn hide_install_tab
Whether the install tab is hidden by preference. - fn set_hide_install_tab
Record whether to hide the install tab on a portable copy. - fn acceleration
Whether the window asks the platform for a hardware-drawn context. **Roadmap item 137.** Read once, before the window is made, because it decides how the window is made. Changing it therefore takes effect at the next launch, and the panel - fn acceleration
says so rather than appearing to do nothing. - fn set_acceleration
Record whether to ask for acceleration. - fn frame_target
How often the window draws while something in it is moving. - fn set_frame_target
Record a new frame-rate target. - fn show_frame_rate
Whether the header carries a live frame-rate readout. - fn always_group
Whether the app should open in group mode. - fn autolock
How the autolock is configured, brought into range. - fn set_autolock
Remember an autolock setting. - fn seal_with_app_lock
Whether every recording is sealed with the app-lock passphrase. - fn destination
The remembered encrypted destination, rebuilt from the settings file. - fn set_destination
Remember a destination, including the answer to the hidden question. - fn failsafe
The Failsafe posture in force. - fn set_failsafe
Record the Failsafe posture. - fn notify_style
How notifications should be shown. - fn set_notify_style
Record how notifications should be shown. - fn live_monitor
Where the live monitor sits, or whether it is shown. - fn set_live_monitor
Record where the live monitor sits. - fn set_always_group
Record whether the app should open in group mode. - fn set_seal_with_app_lock
Remember, or stop remembering, that recordings are sealed with the app-lock passphrase. Roadmap item 86. Same pair-of-methods shape as `always_group` and for the same reason: the security tab asks for a choice to be kept without knowing - fn set_seal_with_app_lock
where preferences live or what happens when the platform will not say. - fn interface_page
Which tabs the window offers. - fn toured_tabs
The tabs the tour has already covered. - fn mark_toured
Record that the tour has covered these tabs, and save. - fn first_run_appearance
The first-run panel: offered once, with animation already on. Shown as a page rather than a modal because it is not urgent and does not gate anything -- the legal notice is the thing that gates, and two blocking dialogues before a user has - fn first_run_appearance
seen the app is one too many. The two appearance choices, for the first-run setup to place. Placed by `crate::firstrun`, which owns the first-run flow, so there is one copy of this wording rather than one per caller. - fn first_run_autolock
The autolock switch and delay, for the first-run setup to place. - fn finish_first_run
Mark the first run answered. - fn tab
The settings tab. - fn custom_palette_help
Explain where custom palettes go, and say what was refused and why. The refusals are the important half. A user who writes a palette file, puts it in the right place and sees nothing happen has no way to tell whether the application never - fn custom_palette_help
looked, or looked and disliked it. Every complaint from the loader is shown verbatim, including the measured contrast ratio, because "muted on bg is 2.4:1 and needs 3.0:1" tells somebody exactly what to change and by roughly how much. - fn theme_picker
The colour scheme, as a compact control for the window header. The same list the appearance page offers and the same effect; what it does not carry is the swatches and the custom-palette help, which need room and belong on the page. Two - fn theme_picker
controls for one setting is worth it here: the header is where the website puts this, and a reader who has used the site looks in the same place. Returns the picker's own rectangle. The controls beside it in the header take their height - fn theme_picker
from it rather than working one out separately, which is what left them a pixel apart (finding F-178). Its width is [`crate::layout::LOCK_WIDTH`], which the lock button and the unlock button are also drawn at, so the picker and the button - fn theme_picker
beside it are one box repeated rather than two boxes that nearly agree (finding F-196). - fn appearance_page
The appearance page: palette, and what each one changes. - fn motion_page
The motion page, including honouring the system's reduced-motion setting. - fn security_page
Roadmap item 92. The autolock, and the range it offers. - fn storage_page
The storage page: where files go, and the portable or installed choice. - fn section
A titled group with a one-line explanation under it. - fn swatches
The active palette, as a row of swatches, so the choice can be seen rather than only read. - mod tests
- fn render
Drive the settings tab once, with no window. - fn every_page_renders_without_a_window
- fn the_first_run_appearance_choices_render
- fn the_menu_lists_every_page_exactly_once
The menu has to cover the pages and the pages have to cover the menu, or a page becomes unreachable. - fn the_first_run_is_offered_once
A first run has not been configured; answering it must stick. - fn animation_is_on_by_default_and_can_be_turned_off
Defaults are what the request asked for: animation on, offered at the start, switchable afterwards. - fn choosing_a_theme_applies_and_records_it
Choosing a theme must apply it and record it. - fn a_failed_save_is_reported_rather_than_swallowed
A save that cannot happen must not be silent, and must not lose the choice for this session either. - fn the_install_tab_is_never_offered_by_an_installed_copy
Reset must not touch anything that is not a presentation choice. An installed copy never offers to install itself, whatever the preference says. The preference is only about the portable case. - fn hiding_the_install_tab_survives_a_reload
The tick is remembered. A preference that has to be set on every launch is not a preference. - fn reset_leaves_the_first_run_answered
- fn a_corrupt_file_leaves_a_usable_panel_that_says_so
Loading must survive a settings file full of nonsense.
crates/veilvoice-gui/src/setup.rs
- (module)
The setup tab: install this copy, undo that, and the optional companions. This is the graphical front end to [`veilvoice_setup`], and it is a *front end* in the strict sense: it holds no installation logic of its own. Every change to the - (module)
machine goes through the same functions `veilvoice install` calls, so the careful part (editing `PATH`, which is the one operation here that can damage a machine) has one implementation and one set of tests. # Portable is the default, and - (module)
this tab says so first The screen opens by telling the user what they already have: a portable copy that needs nothing, or an installed one. Installing is offered as a convenience with an exact list of what it will change, not as a step - (module)
somebody has to complete before the program is usable. A privacy tool that implies it must be installed has trained its user badly. # Nothing is ticked, because there is nothing to tick The companions are not a checklist with defaults. - (module)
Each is a row that states what the software is, who wrote it and under what licence, whether it was found on this machine, and a single button that does one thing to one named program. There is no "install recommended extras", because that - (module)
is the control through which unwanted software historically arrived. Where the answer is a proprietary driver the button opens the vendor's page and nothing else; where it needs root the command is shown and not run. Both of those refusals - (module)
live in [`veilvoice_setup::companions`], not here, so that a second front end cannot be more permissive than this one. # Nothing slow runs on the UI thread Copying binaries, editing the registry and running a package manager all take real - (module)
time, because a package manager can take minutes. Each runs on a worker and reports back through an [`std::sync::mpsc`] channel, exactly as the file job in [`crate::VeilVoiceApp`] does, so the window keeps painting and the progress strip - (module)
keeps moving. The strip honours the reduced-motion decision like everything else: with motion off it is a static bar and a word, not a frozen animation. # In plain words The tab that installs this copy, removes it again, and points at the - (module)
optional extra software. It is only a front end: every decision about where files go and what gets changed lives in one place shared with the command line, so the two cannot disagree about what "installed" means. Nothing here installs - (module)
anything belonging to somebody else without being asked. What each thing is, who makes it and what it is for are shown first, and the exact command is shown before the question. - struct Done
What a worker finished doing: lines to show, and whether it went well. - struct Row
One companion as this tab needs it: the facts, plus what was found. - struct Setup
The setup tab's state. - impl Default for Setup
- fn default
- impl Setup
- fn new
Read the machine's current state. Changes nothing. - fn running_installed
Whether this copy is the installed one. Read once, when the panel was built. That is deliberate: the tab row is drawn every frame and this answer involves the filesystem, so asking per frame would put a `stat` in the paint path -- which is - fn running_installed
exactly the shape of the defect that made the window freeze every couple of seconds. - fn is_busy
True while a worker is running, so the app can keep repainting. - fn poll
Drain the worker channel. Called once per frame. Handles `Disconnected` as well as a message: a worker that panicked must leave the interface saying so rather than spinning for ever. - fn tab
Draw the tab. - fn visibility_note
Why this tab is here, and how to make it not be. Under the header rather than buried in settings, because the question "why is this program asking to install itself" is asked *here*, while looking at the tab. The control that answers it - fn visibility_note
lives in settings -- a tab that could hide itself and nothing else could bring it back would be a one-way door. - fn where_this_copy_lives
Say whether this copy is portable or installed, and where its files are. - fn install_controls
The install and uninstall buttons, and what each will do before it is pressed. - fn carry_question
Ask whether an install should take the settings with it. A question rather than a default, because the two right answers point in opposite directions and nothing here can tell which one applies. Somebody moving off a memory stick onto - fn carry_question
their own machine wants their settings and vaults carried over. Somebody installing on a shared or borrowed machine wants them left exactly where they are, and guessing wrong there copies their vault onto a computer that is not theirs. - fn start
Start a worker, and remember what it is doing so the strip can say. - fn progress
The progress strip: a travelling highlight, or a plain bar when motion is off. - fn companion_rows
One row per companion: whether it is here, and how to get it if not. - fn detect_all
Probe for every companion that applies to this platform. - fn can_install
Exactly what an install changes, in the order it changes it. Written out beside the button rather than in a manual. An installer that says "install?" and nothing else is asking somebody to consent to something they have not been told. - fn can_install
Whether the install button is live. A function rather than four conditions in a closure, so the rule that matters can be asserted: **a portable copy cannot be installed until the question about its settings has been answered**. An install - fn can_install
that ran on the first click would have taken one of the two answers by default, and the wrong one either copies a vault onto somebody else's machine or leaves a person wondering where their recordings went. - fn install_changes
Everything installing alters, listed so the button is never a surprise. - fn field
One labelled read-only value, in the shape this tab uses throughout. - mod tests
- fn a_portable_copy_is_not_installed_until_the_question_is_answered
The tab reads the machine on construction and must not change it. - fn nothing_installs_where_there_is_nowhere_to_install_it
- fn opening_the_tab_changes_nothing
- fn render
Drive the tab once with no window, in both motion states. The progress strip does arithmetic on a rectangle, and a rectangle in a headless context can be zero-width. A panic there would only ever be seen on somebody's desktop, mid-install. - fn motion
- fn the_tab_renders_without_a_window
- fn the_uninstall_confirmation_renders_and_does_nothing_by_itself
The uninstall confirmation is drawn, and drawing it must not act. - fn a_worker_that_disappears_is_reported
A report from a worker that vanished must say so rather than leaving the strip running for ever. - fn every_row_has_been_probed
Every companion shown is one that applies here, with a probe result. - fn the_consent_text_names_what_install_changes
The list of changes is what the user consents to, so it must name the three things `veilvoice_setup::install` actually does, and must not stop mentioning that it asks for no administrator rights. - fn no_button_is_drawn_for_a_command_that_cannot_be_run
A front end must not offer a button for anything the library refuses to run. This is the same rule as `companions::run`, checked from the side that draws the button. - mod companion_tests
- fn looking_again_does_not_probe_on_the_paint_thread
The probe runs off the paint thread. `detect_all` runs a command per companion, and on Windows one of them enumerates sound devices through PowerShell. On the paint thread that is hundreds of milliseconds inside a single frame, which is - fn looking_again_does_not_probe_on_the_paint_thread
the window going white and the pointer becoming a spinner. Pressing "look again" did exactly that. - fn a_missing_ffmpeg_always_says_where_it_can_be_installed
Every message that names a missing ffmpeg says where to get it. Naming a thing somebody cannot act on from where they are standing is the failure this guards: the render said "ffmpeg is not on this machine" and the tab that installs - fn a_missing_ffmpeg_always_says_where_it_can_be_installed
software had never heard of it. - mod typeface_tests
- fn the_capture_script_will_not_photograph_in_the_fallback_face
The capture script refuses to photograph in the wrong face. The window prefers JetBrains Mono and falls back to the built-in monospace. That fallback is right for somebody running the program and wrong for a screenshot: a capture in the - fn the_capture_script_will_not_photograph_in_the_fallback_face
wrong face looks subtly unlike every other picture in the set and nothing about the run says so, so half a set could be published before anybody noticed. - fn the_typeface_flag_is_documented_where_it_is_answered
The flag exists and is documented in the usage text. A flag the capture script depends on and the help does not mention is one somebody removes as unused.
crates/veilvoice-gui/src/soundbar.rs
- (module)
The animated mark: a row of bars that rise and fall. # The same mark as the website `website/index.html` draws this in CSS as `.veil` -- a row of `<span>`s with `@keyframes pulse` taking each between 16% and 82% of the height over 1.9 - (module)
seconds, each with its own delay so the row ripples rather than pumping in unison. The left half is drawn in the accent colour and the right half in the "veiled" secondary, which is the product in one picture and matches the icon. This is - (module)
that, in egui, with the same period, the same height range and the same delays. Two front-ends showing visibly different marks would be worse than one showing none. # Why it is drawn rather than rendered from a GIF An animated image would - (module)
be a committed binary blob, and this project's artwork is generated from source precisely so that nothing in the repository has to be taken on trust. Sixty lines of shape drawing is auditable; a GIF is not. # Cost when it is switched off - (module)
With motion disabled the bars are drawn once, at rest, and **no repaint is requested**. That is the part that matters: an "off" switch that still schedules a frame every 16 ms has turned the animation off visually and left the battery cost - (module)
behind. The caller decides by passing a [`Motion`], and the only way to animate is to ask for it. # Cost when it is switched on, which is the interesting one Motion is on by default, and this is the only thing in the application that moves - (module)
without being asked to. Everything else draws when something happens. So with the default settings, on the file tab, doing nothing, the window was redrawing about sixty times a second for ever, and it was this: measured with - (module)
`Context::repaint_causes`, which named line 117 of this file as the reason for 559 of 566 frames. An animated logo is not worth a permanently busy window, and two of the costs are ones a user actually notices rather than ones a profiler - (module)
does. A laptop lid left open at this screen never lets the processor idle. And a window being dragged is competing, every frame, with a full redraw it did not need, which is what "it lags when I move it" is made of. So the mark now moves - (module)
in the three circumstances where somebody can see it moving, and rests otherwise: - **Not while the window is unfocused.** A background window is still. - **Not while the window is being moved or resized.** Detected from the window's own - (module)
rectangle changing between frames, and resumed a quarter of a second after it stops. This is the drag case specifically. - **Not faster than the window's frame target.** [`crate::pace`] owns that number: the display's own rate by default, - (module)
or whatever was chosen in Settings. This module carried its own constant of twenty a second, which made the mark the slowest-moving thing on any modern display and was the judder people reported (finding F-179). Resting is not the same as - (module)
resetting. Freezing at the midpoint would make every click into another window snap the row flat, so a paused mark holds the shape it had when it paused and picks the cycle up from there. # In plain words The little row of bars in the - (module)
corner that rises and falls. It is the same mark the website uses, drawn rather than loaded as a picture, so it takes the colours of whichever scheme you have chosen. It stops moving if your system is set to reduce motion. That setting is - (module)
a request from somebody who has a reason for making it, and animation that ignores it is animation that makes an application unusable for them. - const PERIOD
Seconds for one full rise and fall. Matches the website's `1.9s`. - const SETTLE
How long the window must hold still before the mark starts moving again. A drag delivers a new window rectangle every few milliseconds, so anything shorter than this restarts the animation between two frames of the same drag and achieves - const SETTLE
nothing. A quarter of a second is below the point where somebody notices the mark was waiting. - const DELAYS
Per-bar phase offsets in seconds, matching the `animation-delay` values in `website/index.html`. Twelve bars, deliberately not in order, so the row ripples instead of sweeping. - const MIN_FRACTION
Height as a fraction of the available box, matching `16%` and `82%`. - const MAX_FRACTION
- fn height_fraction
How far along its cycle a bar is, in 0..=1, eased the way CSS `ease-in-out` eases. - fn window_is_settled
Whether the window is holding still enough for the mark to move. Two questions, both asked of the window rather than of the application: does it have focus, and has its rectangle stopped changing. The focus answer defaults to *yes* when - fn window_is_settled
the platform does not report one. A mark frozen for ever on a system that never says "focused" is a worse failure than one that animates when it did not have to, and there are window managers that do not send focus events at all. The - fn window_is_settled
rectangle answer is how a drag is detected. There is no "the user is dragging me" signal in `egui` or `winit` to ask for; there is the window's own position and size, which changes on every frame of a drag and on no frame of anything else. - fn window_is_settled
Stored in `egui`'s temporary memory, so it lives exactly as long as the context and costs no field on any struct. - fn animation_clock
The clock the bars are drawn against, which is not always the real one. While the mark is moving this is the application clock. While it is resting it is the moment it stopped, held in temporary memory, so the row keeps the shape it had - fn animation_clock
rather than snapping to the midpoint the instant the window loses focus. Clicking away from the window and back should not make the logo jump. - fn badge
Draw the mark at `size`, returning the response so it can carry a tooltip. `time` is the application clock in seconds. When `motion` disallows movement every bar is drawn at its resting height and nothing is scheduled. When motion is - fn badge
allowed but the window is unfocused or being dragged, the bars hold their last shape and nothing is scheduled either. The application mark: the bars inside the rounded square the icon uses. The lock screen drew the bars alone, which is the - fn badge
header's treatment and reads there because the header is full of other VeilVoice furniture. On an otherwise empty locked window it read as a stray animation rather than as the program identifying itself, so this puts them back in the badge - fn badge
the icon puts them in, and a locked window now shows the logo. Drawn rather than decoded, like everything else in this module. The icon on disk is 32x32, and blowing that up to the size wanted here would be visibly soft; the same shape as - fn badge
vectors is crisp at any size and adds no image decoder to the application. - fn draw
Draw the mark at `size`, returning the response so it can carry a tooltip. `time` is the application clock in seconds. When `motion` disallows movement every bar is drawn at its resting height and nothing is scheduled. When motion is - fn draw
allowed but the window is unfocused or being dragged, the bars hold their last shape and nothing is scheduled either. - fn colour_for
The left half in the accent colour, the right in the veiled secondary -- the same split the website and the icon use. - mod tests
- fn moving
- fn frame
A frame of input describing a window: is it focused, where is it, and what time is it. `None` for the rectangle stands for a platform that does not report one. - fn heights_for
The bar heights drawn for one frame of that input. - fn soonest_after
The soonest repaint the context asked for after that frame. - const SOMEWHERE
- fn still
- fn a_bar_stays_inside_the_documented_height_range
- fn the_cycle_repeats_exactly_once_per_period
- fn the_bars_are_out_of_phase_with_each_other
The bars must not all be at the same height, or the row pumps as one block instead of rippling. - fn a_negative_time_is_handled
A negative clock must not produce a negative phase. - fn a_stilled_mark_is_the_same_at_every_moment
The point of the toggle: still means still, and identical at every moment. A "still" mark that still differed frame to frame would mean the switch had not actually turned anything off. - fn a_stilled_mark_requests_no_repaint
And it must not keep the CPU awake once it is off. An "off" switch that still schedules a frame every 33 ms has turned the animation off visually and left the battery cost behind, which on a laptop is most of the reason somebody turned it - fn a_stilled_mark_requests_no_repaint
off. Compared against the moving case rather than against an absolute sentinel, because what egui uses to mean "nothing pending" is its business and not a contract. - fn any_size_can_be_drawn
Drawing must not panic at any size, including degenerate ones a layout can genuinely produce while a window is being resized. - fn an_unfocused_window_does_not_animate
The delays are copied from the website's markup; if that list changes and this one does not, the two marks stop matching. - fn a_window_being_dragged_does_not_animate
- fn the_mark_starts_again_once_the_window_stops
- fn a_paused_mark_keeps_the_shape_it_had
- fn a_platform_that_reports_nothing_still_animates
- fn the_mark_is_paced_by_the_window_and_not_by_a_number_of_its_own
- fn the_delays_match_the_website_markup
- fn render_heights
Render once and read the bar heights back out of the paint list.
crates/veilvoice-gui/src/storage.rs
- (module)
Where veiled recordings are written, and the encrypted volume that may hold them. **Roadmap items 82, 83 and 84.** [`veilvoice_setup::volumes`] finds what is mounted; this decides what to do about it, remembers the answer, and refuses to - (module)
write anywhere the user has not confirmed. # A destination, not a mode With no destination chosen, a veiled recording lands beside the file it came from, which is what VeilVoice has always done. With one chosen, it lands in that directory - (module)
instead, under the same name. That is the whole of the feature: the encryption belongs entirely to Cryptomator or VeraCrypt, and nothing here adds any of its own or claims to. # Why a chosen destination can still be refused Because of - (module)
VeraCrypt's hidden volumes, and the refusal is the point rather than an inconvenience. [`veilvoice_setup::volumes::Hidden`] carries the whole argument; the short version is that writing into the outer volume of a container that has a - (module)
hidden one can destroy the hidden data, nothing can tell the two apart from outside, and so the only safe behaviour is to ask the person who knows and to write nothing until they have answered. A destination whose question is unanswered is - (module)
*not* silently downgraded to writing beside the source. It blocks the job and says why. Falling back quietly would put a veiled recording somewhere unencrypted while its owner believed it was in a vault, which is the exact failure this - (module)
exists to prevent. # Roadmap item 84: detection will fail, and that is planned for Portable installs, custom mount points, a platform neither tool supports. The answer is not a silent fallback: it is a directory the user picks by hand, a - (module)
declaration of what kind of volume it is, and the same confirmation before anything is written. A hand-picked destination is treated exactly like a detected one, including the hidden-volume question. # In plain words Lets you send every - (module)
veiled recording straight into a Cryptomator or VeraCrypt folder, instead of leaving it next to the original. If VeilVoice cannot find your folders it asks you to point at one. Either way it asks, once, whether a VeraCrypt container has a - (module)
hidden volume in it, and will not write anything until you answer, because writing into the wrong half of one of those can destroy what is hidden inside. - struct Destination
The chosen place for veiled output, if there is one. - impl Destination
- fn from_prefs
Rebuild a destination from what was written to the settings file. Anything unrecognised produces no destination rather than a guess. A settings file that has been edited into nonsense must not decide where recordings go. - fn to_prefs
The three strings the settings file keeps. - fn ready
Whether a job may start. True when nothing is chosen, because writing beside the source is VeilVoice's ordinary behaviour and needs no permission. - fn blocked
Why a job may not start, for the button's tooltip. - fn place
Where a recording should be written, given where it would have gone. Keeps the file name and replaces the directory. Returns `default` untouched when nothing is chosen, and also when the destination is not ready: a caller that ignores - fn place
[`Destination::ready`] must not be handed a vault path it was never cleared to use. # F-95, and why `mounts` is an argument rather than a cached flag F-93 fixed the *panel*, which had asked whether the folder existed when the question is - fn place
whether anything is mounted on it. It did not fix this, and this is where the file is actually written. A destination is chosen while the vault is open and answered for. Some time later the vault is locked, which leaves its mount point - fn place
behind as an empty directory, and nothing about the destination changes: the hidden-volume answer is still given, so `ready` is still true. Veiling a recording then wrote it into a bare directory on the ordinary disk while its owner - fn place
believed it had gone into the vault. That is F-93's failure arriving through a different door, which is the pattern this project has now recorded three times: a fix applied to the instance that was found rather than to the class. The mount - fn place
list is therefore passed in and consulted here, at the moment of writing, rather than read once and remembered. - fn still_mounted
Whether the volume is still mounted, judged against `mounts`. **F-93.** The first version of this asked whether the directory existed, which is the wrong question in the one direction that matters: unmounting a volume leaves its mount - fn still_mounted
point behind as an ordinary empty directory. A locked vault therefore looked fine, and VeilVoice would have written a veiled recording onto the unencrypted disk while its owner believed it had gone inside. See - fn still_mounted
[`veilvoice_setup::volumes::covers`]. - fn hidden_key
Stable settings-file spellings for [`Hidden`]. - fn hidden_from_key
The reverse, defaulting to unanswered. Deliberately *not* defaulting to "no hidden volume". An unreadable or hand-edited settings file must not be able to answer a question whose wrong answer destroys data. - struct Storage
Everything the window shows about encrypted storage. - impl Default for Storage
- fn default
- impl Storage
- fn refresh
Look again at what is installed and mounted. Called when the tab is opened rather than every frame: it reads the mount table, which is cheap but is still a file read, and the draw path does none. See the guard test in `app.rs`. - fn found
What was found at the last refresh. - fn found_nothing
Whether anything was found at all, which decides whether roadmap item 84's guided path is the main offer or the fallback. - fn present
Whether the chosen folder was there when this was last refreshed. - fn take_hand_picked
Take a folder the user picked by hand, if the picker has answered. - fn pick_by_hand
Start the folder picker for roadmap item 84's guided path. - fn choose
Choose one of the detected volumes. - fn clear
Go back to writing beside the source file. - fn answer_hidden
Answer the hidden-volume question for the chosen destination. - fn hand_picked_tool
Which tool a hand-picked folder is being declared as. - fn presence
Whether either tool was detected, for the guided text. - fn panel
The encrypted-storage panel, drawn on the security tab. Returns true when the chosen destination changed, which is the window's cue to have it remembered. - mod tests
- fn mounted
The mount list that holds the volume `vault` builds. - fn vault
- fn the_panel_asks_the_disk_nothing
The panel draws every frame, so nothing in it may touch the disk. Deciding whether the vault is mounted means reading the mount table, and the first version of this module did it from the panel: sixty reads a second for an answer that - fn the_panel_asks_the_disk_nothing
changes when somebody unlocks a volume. `app.rs` already refuses this in its own draw path; a new module is where that guard does not look, which is exactly why it is worth repeating here. - fn no_destination_writes_where_veilvoice_always_did
- fn a_chosen_vault_keeps_the_file_name_and_replaces_the_folder
- fn a_vault_locked_since_it_was_chosen_never_receives_a_file
F-95. F-93 fixed the panel; this is where the file is written. A vault chosen and answered for, then locked, leaves its mount point behind as an empty directory and nothing about the destination changes. Writing there puts a veiled - fn a_vault_locked_since_it_was_chosen_never_receives_a_file
recording on the ordinary disk while its owner believes it went inside. - fn an_unanswered_destination_never_yields_a_vault_path
Roadmap item 83's whole point, from the other side: a caller that forgets to check `ready` must not be handed the vault path anyway. - fn the_outer_volume_of_a_hidden_pair_is_refused_here_too
- fn a_cryptomator_vault_needs_no_answer
- fn a_destination_round_trips_through_the_settings_file
- fn a_nonsense_settings_file_leaves_the_question_unanswered
A settings file somebody edited must not be able to answer the one question whose wrong answer destroys data. - fn a_vault_that_is_no_longer_mounted_is_noticed
A vault that has been locked since it was chosen is a directory that is no longer encrypted, or is gone. F-93. A locked vault leaves its mount point behind as an ordinary directory, so existence says yes when the honest answer is no. - fn a_folder_within_a_mounted_vault_counts_as_mounted
A folder chosen inside a mounted vault is inside it.
crates/veilvoice-gui/src/studio.rs
- (module)
The Recording Studio and the Recording Browser. # Two tabs, one vault The Studio records; the Browser is what is in the vault afterwards. They are one module because they are one vault, and a vault opened in two places is two chances to - (module)
get the unlocking wrong. # Roadmap item 130: this is also where veiling as it runs happens Live scramble was a tab of its own, and it did not need to be. The Studio has always recorded through the same [`veilvoice_audio::LiveSession`] that - (module)
tab ran, with the same engine and the same routing, so the two screens were one act performed in two rooms: pick the devices over there, come here, press record. Two sessions was the part that was actually wrong. Veiling on one tab and - (module)
recording on the other opened the same microphone twice, and on the platforms that allow that at all the second stream gets a copy of the input nobody asked for. [`Studio::start_session`] is the only starter now, and a test reads this - (module)
crate's source and fails if a second one appears. The voice half of the tab is drawn by the window rather than here, because the device lists and the engine settings belong to the window. What lives in this module is the session those - (module)
controls drive, and everything about the vault. **The voice half works with the vault shut.** Veiling a call has never needed a recording vault, and making somebody set one up before they could disguise their voice on a call would be a - (module)
worse program than the one that had two tabs. # The vault needs both passphrases, and asks for both here [`veilvoice_crypto::studio::StudioKey`] is derived from the app lock **and** the at-rest passphrase, and from neither alone. That is - (module)
the whole point of it: a laptop stolen while VeilVoice is unlocked opens nothing, because the at-rest passphrase was never typed. So this tab asks for both, every time, rather than reaching into whatever the rest of the application happens - (module)
to be holding. That is a deliberate inconvenience: - The app-lock secret is only kept for the session when [`Sealing::AppLock`](crate::security::Sealing::AppLock) is chosen. Reading it when it happens to be there, and prompting when it is - (module)
not, would make the vault's strength depend on an unrelated setting, and nobody would know which they had. - Prompting for both, always, is the only version of this whose security is the same on every run. Neither passphrase is stored. - (module)
Both typing buffers are wiped the moment the key is derived, and the derived key lives in page-locked memory for as long as the vault is open. Locking the window closes the vault. # What is recorded is what comes out, unless it was asked - (module)
to be otherwise The Studio records through `veilvoice_audio::LiveSession::start_recording`, which is the same path the command line uses, so the samples that reach the recorder are the **veiled** ones. That is the default and it is what - (module)
[`Keep::Veiled`] means. **Roadmap item 131** adds the other two. The microphone can be kept as well, or instead, and the reasoning for allowing it at all is on [`Keep`]: refusing would not stop somebody who needs the real recording, it - (module)
would move them to a phone on the table, which is a plaintext file on a device with none of this. What matters is that it is asked for rather than arrived at. So it is a choice made **before** the button, never remembered between runs, - (module)
reset when the window locks, and stated in the same words the plaintext path uses. The default is the safe one, and every path that has not been asked for the microphone passes `None` where it would go. Each recording is assembled inside a - (module)
`veilvoice_crypto::Secret` and handed straight to the vault to be sealed. Neither is ever a plain file, not even briefly: an unveiled take is a recording of a real voice, and it is sealed exactly as strongly as a veiled one. # In plain - (module)
words Record here, and what you record is kept locked up. Opening the cupboard needs both of your passwords at once, every time, which is what makes it worth having. What gets recorded is the disguised voice. You can ask for your real one - (module)
as well, or instead, and the screen tells you what that means before you start: anybody who can open the cupboard can then hear who was talking. - fn into_secret
Move a typed passphrase into page-locked storage and wipe the buffer. The same shape as [`crate::security`]'s, and for the same reason: a text widget owns a `String`, so the passphrase exists as ordinary heap bytes while it is being typed. - fn into_secret
This shortens that window; nothing can close it. - fn default_dir
Where the vaults live: beside the lock file, in this platform's config directory. `None` when the environment does not say where that is, in which case the Studio says so rather than inventing a location. A folder of vaults rather than a - fn default_dir
vault. The real one and any decoys made beside it are directories in here with opaque names, and which of them is real is a question only the pair of passphrases answers. A real vault at a fixed name would be told from a decoy by reading - fn default_dir
the name, which would make the decoys worthless. - enum Phase
What the Studio is doing. - enum Who
Who is speaking into a session. **Roadmap item 147.** One microphone or several, and the choice is this enum rather than a pair of fields, so "never both" is a thing that cannot be written rather than a thing to remember. - struct Setup
What a live session was started with. Held for as long as one is running, because sinks cannot be attached to a stream that has already started: beginning a take and ending one each restart the session, and it has to come back with the - struct Setup
devices and the engine settings it was running with rather than with whatever the window happens to be set to a minute later. - enum Running
The session the Studio has open, and there is at most one. **Roadmap item 147.** Two fields would be two things to clear, and a room left running beside a single session is two streams on one output with every guest's voice arriving twice. - enum Running
This is one field, so the invariant holds by construction rather than by every path remembering to clear the other. - struct RoomGuest
One guest in a room: the name their take is filed under, and the microphone they speak into. **Roadmap item 147.** A room is a list of these. It is edited while nothing is running and read when a session starts. - impl RoomGuest
- fn called
What to call this guest in slot `slot`, filling in a blank name. A name is what tells four recordings apart afterwards, so an empty one becomes the slot rather than an empty file name. - fn sharing_a_microphone
Which guests are sharing a microphone, and the sentence to say about it. `None` means everybody has their own. Two guests on one device is refused, because one microphone carrying two people is one signal and nothing in this program can - fn sharing_a_microphone
separate it again: veiling it would give both of them the same voice, which is the exact thing a room exists to avoid. Two guests on the default device are the same case. `None` is a device, not an absence, and it took saying so to notice - fn sharing_a_microphone
that a list of guests nobody had picked a microphone for was a room of one microphone opened several times. Pure, and separate from starting, so it can be tested on a machine with no sound card: F-163 and F-165 are why nothing here opens a - fn sharing_a_microphone
device to answer a question that does not need one. - enum Whose
Whose voice one recording of a take is. **Roadmap item 147.** A take used to be one recording, or two when the real voice was kept as well. A room take is the mix plus one or two per guest, which is up to seventeen recordings landing in - enum Whose
one vault under one take name, and the only thing telling them apart is what they are called. - fn take_name
What one recording of a take is called in the vault. Pure, and the **one** place a take name is built. A take can now produce seventeen recordings, stored from three loops, and a suffix added in two of them would leave an entry that is - fn take_name
somebody's real voice looking exactly like the veiled one beside it. Written once here, and a test reads this module to check the suffix appears nowhere else. - struct GuestTake
What is being kept for one guest while a room take runs. - enum Reading
What a running session last reported about itself. **Roadmap item 147.** One microphone reports [`veilvoice_audio::LiveStats`] and a room reports [`veilvoice_audio::RoomStats`], which is a different shape because it has one entry per - enum Reading
guest. The window matches on this rather than being handed a single-microphone reading a room would have to be flattened into, because flattening it is exactly what loses the per-guest bars the roadmap item asked for. - struct Studio
The Studio and the Browser. Every field's default is the shut, empty state, so this derives rather than being written out: a hand-written `Default` here would be a second place to remember a new field, and forgetting one would leave it - struct Studio
carrying whatever the last session put in it. - impl Studio
- fn is_open
Whether the vault is open. - fn is_recording
Whether a take is being kept. The window asks, so that closing it, or locking, does not silently abandon a recording somebody is in the middle of making. A session running with nothing attached to it is not a recording: since roadmap item - fn is_recording
130 the Studio veils whether or not it is keeping anything, and treating those as the same thing would refuse to close a window over a call nobody was recording. - fn is_veiling
Whether the veiled voice is going out. - fn wants_a_room
Whether the form is set to a room. **Roadmap item 147.** - fn want_a_room
Switch the form between one microphone and a room. Switching to a room with nobody in it seeds two guests, because a room of one is one microphone and this tab already has that. Switching away keeps the list: somebody who ticked the box to - fn want_a_room
look at it and untucked it again has not asked for the names they typed to be thrown away. - fn room_guests
Who is in the room, in the order they were added. - fn room_guest_mut
One guest, to be edited by the controls that draw them. - fn add_guest
Add a guest, up to [`veilvoice_audio::MAX_GUESTS`]. The bound is the audio layer's and is checked here as well, so the button stops adding rather than the session refusing afterwards. - fn remove_guest
Take a guest out of the room. - fn guest_levels
The smoothed bars for the room, one pair per guest. Empty when what is running is one microphone, which is what [`Studio::levels`] is for. - fn is_previewing
Whether what is going out is a preview to this machine's own output rather than to the chosen one. - fn levels
The smoothed levels, for the monitor strip and for this tab. - fn trouble
What the audio path last reported about itself, and whether it is new. **Roadmap item 132.** The platform reports a stream error on a callback of its own; before this the only thing done with one was a print to a console the window does - fn trouble
not have. Now the window asks, once a frame, and says so. - fn intruders
The programs that took the microphone while this take has been running. Empty is the ordinary case and means nothing else asked for it. - fn note_microphone_holders
Record which programs are holding the microphone right now. **Roadmap item 132.** Called by the window only while a take is running: outside one this is the Monitor tab's question and the safety catch's, and building a list every frame for - fn note_microphone_holders
a question nobody is asking is what roadmap item 126 is about. VeilVoice itself is holding the microphone whenever this is called, so it is not an intruder in its own recording. It is matched by name rather than by process because Windows - fn note_microphone_holders
reports device use per application and gives no process to compare with. - fn tick
Read the session's counters, once a frame, and move the levels on. Called from the window rather than from this tab, because the monitor strip is drawn on every tab and this tab is drawn on one. Reading the session only while the Studio - fn tick
was on screen would freeze the strip the moment somebody navigated away, which is the exact moment it exists for. - fn catch_a_fault
**Roadmap item 145.** The Studio's own failsafe, and what it is for. Separate from [`veilvoice_guard::failsafe`], which is the application's and is about *other programs* taking the microphone. This one is about this tab: a fault in the - fn catch_a_fault
Studio stops the Studio rather than the recording. # The fault it catches The device a take is being recorded from stops existing: unplugged, switched away by the operating system, taken by something with more authority. Roadmap item 132 - fn catch_a_fault
made that visible, and visible was as far as it went: the take carried on, recording silence, until somebody looked at the screen. A recording that continues after there is nothing to record is worse than one that stops, because it looks - fn catch_a_fault
like it worked. # What it does, and what it deliberately does not It **stores** what was captured and stops. It does not discard, retry, or switch to another device. Not discard, for the reason locking the window does not: everything up to - fn catch_a_fault
the fault is a real recording of something somebody said, and throwing it away because the end of it is missing would be the worst thing this tab could do. Not retry, and not switch: the person chose that microphone. Moving a recording - fn catch_a_fault
onto a different one because the first went away is a program deciding, on its own, to record somebody through a device they did not pick. On a machine where the default input is a laptop's built-in microphone, that is exactly the wrong - fn catch_a_fault
answer. Only a device that has gone. Anything else the platform reports is shown and left alone, because "the mixer said something" is not a reason to end a recording somebody is making. - fn phase
What phase the take half of the tab is in. - fn start_veiling
Start veiling, keeping nothing. `preview` sends it to this machine's own output instead of the chosen one, which is how somebody hears themselves veiled without whatever is listening on the virtual cable hearing it too. - fn start_room
**Roadmap item 147.** Start veiling a room, keeping nothing. One microphone per guest, each veiled into a voice of their own and the results mixed into `output`. The guests are [`Studio::room_guests`] rather than an argument, for the - fn start_room
reason [`Who::Room`] gives. - fn stop_veiling
Stop the audio. A take still running is stored first, never discarded. - fn close
Shut the vault and forget the key. Called when the window locks. A recording in progress is stopped first and **kept**, not discarded: the vault is still open at that moment, and throwing away a recording because the idle timer fired would - fn close
be the worst thing this tab could do. - fn unlock
Derive the key from both entries and open the vault. - fn measure
Read the folder the vaults are in: how much room is free, and how many vaults are already there. Both start a little work, so this is called when something changes rather than while drawing. Neither is an error worth reporting: a folder - fn measure
that will not list and a system that will not say how much is free both mean the panel offers a starting point instead of a measurement, and it says which. - fn make_decoys
Make `count` decoys beside the open vault. Sized from the vault that is open, so they cannot be told from it by size, and named the way it is named, so they cannot be told from it by name. The key each is filled under is made and dropped - fn make_decoys
inside `make_decoy_in`; nothing here ever holds it. - fn start_take
Start recording into the vault's holding area. - fn start_session
Start, or restart, the live session `setup` describes. **The one place in the window a session is started.** Roadmap item 130 moved live scramble here, and one starter is most of what that is worth: two of them meant two opens of the same - fn start_session
microphone, and on the platforms that allow that at all the second stream gets a copy of the input nobody asked for. `keeping` decides whether this session keeps anything. Sinks cannot be attached to a running stream, so starting and - fn start_session
ending a take each restart the session, which costs a short gap in the outgoing voice. That is said on screen rather than hidden: a gap somebody can see explained is better than one they cannot. - fn stop_take
Stop keeping, seal what was captured, and carry on veiling. Veiling continues deliberately. Somebody on a call who has just ended a take has not asked to be heard in their own voice again, and a stop button that unveiled them mid-sentence - fn stop_take
would be the worst control in this window. The session comes back without recorders attached, which costs the short gap [`Self::start_session`] describes. - fn finish_take
Stop recording and seal what was captured into the vault. The audio stops with it. Callers that mean to carry on veiling use [`Self::stop_take`], which restarts it; callers that are shutting everything down use [`Self::stop_veiling`], - fn finish_take
which does not. - fn store_take
Seal one recorder's audio into the vault under `name`. Returns the line to say either way. Split out of [`Self::finish_take`] because a take can now produce two recordings and the sealing is identical for both: what differs is only the - fn store_take
name and, for the person reading the message, which side it came from. - fn play
Play a take, straight out of the vault and out of locked memory. # Nothing is written The obvious way to hear a WAV is to put it somewhere and hand the path to something that plays files. That would leave an unencrypted recording on the - fn play
disk, which is what the vault exists to prevent, and it would leave it there until somebody remembered to shred it. So the samples go from the sealed record, through [`Secret`](veilvoice_crypto::Secret), to the audio device. The take is - fn play
decrypted whole rather than in pieces, because the container is authenticated as one piece and an AEAD that let you open the first second of it would not be authenticating anything. What that buys is the thing that matters, which is no - fn play
plaintext file at any point; what it does not buy is a footprint smaller than the recording, and that is said here rather than implied. - fn export
Turn a take into a page, a video, or both, in `into`. # Leaving the vault is the point, and is said out loud Everything written here is **outside** the vault and is not sealed. That is not a defect: a video nobody can open is not a video. - fn export
It is the one thing somebody doing this needs to have understood, so the tab says it before the button is pressed rather than in a note afterwards. The audio is still veiled, because it was veiled before it was ever stored. What leaves is - fn export
a recording of a voice that is not anybody's. - fn write_page
The player page, its subtitles, and the drawing they sit in. - fn refresh
Re-read the listing from the vault. - impl Studio
- fn tab
The take half of the Recording Studio tab. The voice half, which is the devices, the engine settings, the meters and the buttons that start and stop the veiling, is drawn above this by the window: roadmap item 130 moved live scramble into - fn tab
this tab, and the device lists and the settings widgets it needs are the window's rather than this module's. What is here is everything to do with the vault. `config` is the engine setting the rest of the window is showing, so a take is - fn tab
recorded at the strength on screen rather than at a default this tab chose for itself. - fn browser
The Recording Browser tab. - fn decoy_panel
The decoy panel, under the listing. Here rather than in the Studio tab because it is about the folder the vault is in rather than about making a recording, and this is the tab that already shows what is on the disk. - fn shut_panel
The panel shown while the vault is shut, in both tabs. - fn take_form
The name field for the next take. - fn keep_form
Which side of the engine to keep, asked before anything starts. **Roadmap item 131.** Before the button rather than after it, because the answer cannot be changed once a take has been made: a recording of somebody's real voice is not - fn keep_form
something to discover having made. The safe choice is selected, and choosing either of the others puts what it costs on the screen in the same words the plaintext path uses. There is no tick that quietly remembers this between runs, for - fn keep_form
the reason group mode is not remembered either: a mode somebody forgets is on is a mode that eventually records what they did not mean to record. - fn say
Show the last message, if there is one. - fn apply
Carry out a row action. Separate from drawing because every one of these needs `&mut self` while the loop that produced it is borrowing `self.entries`. - enum Keep
Which side of the engine a take keeps. **Roadmap item 131.** Three, and the order they are written in is the order they are offered: the safe one first, and the one that records the real voice last. # Why the plain voice is offered at all - enum Keep
Because somebody comparing the two needs both, and because an interview whose consent covers the real recording is a real thing people do. Refusing it would not stop that; it would move it to a phone on the table, which is a plaintext - enum Keep
recording on a device with none of this. What matters is that it is asked for rather than arrived at. - impl Keep
- fn wants_veiled
Whether the veiled voice is kept. - fn wants_plain
Whether the real voice is kept. The one question the warning hangs off, so it is asked once here rather than matched on in three places. - fn label
What this is called where it is chosen. - fn cost
What it costs, in the words the plaintext path uses. The wording matters and is deliberately the same shape as the warning on writing an unencrypted file: this is the one thing the Studio does that produces a recording of somebody's real - fn cost
voice, and it says so before it starts rather than after. - enum Render
What a take is to be turned into. Three, because the two useful things are genuinely separate and doing both is the common case: the page is something to look at now, the video is something to send somewhere that will not take an audio - enum Render
file, and somebody who wants the second usually wants to check the first. - impl Render
- fn wants_page
- fn wants_video
- fn wav_shape
The sample rate and frame count a canonical WAV header states. Read from the recording's own header rather than assumed, because the recorder writes the rate the **device agreed to**, which is not always the rate that was asked for. A - fn wav_shape
duration computed from the wrong rate puts every subtitle in the wrong place, and the video would be the wrong length. - fn plan_for
A one-speaker plan spanning a take. A studio take is one person at a microphone, so the plan the renderer wants is a single turn from nothing to the end. Built rather than stored: a plan kept beside each take would be a second description - fn plan_for
of a fact the audio already carries, and the two would disagree the first time a take was trimmed. - enum Act
Something a browser row asked for. - fn counted_interruptions
"one interruption" or "three interruptions". Beside [`counted`] and [`counted_decoys`] for the reason those exist: a message reading "1 interruptions" is a message written by a computer, and this one is read at the moment somebody is - fn counted_interruptions
deciding whether to trust a recording. - fn counted_decoys
"One decoy" or "four decoys", so the interface does not say "1 decoys". - fn counted
"One recording" or "four recordings", so the interface does not say "1 recordings". - fn made_on
A Unix time as a date somebody reads. Deliberately the date and not the time of day. A vault listing sitting open on a screen in an office says enough by naming the recordings; the minute each was made is detail nobody browsing needs and - fn made_on
somebody looking over a shoulder might. - fn pcm16
Sixteen-bit PCM from a WAV, as the waveform drawer wants it. The header is skipped rather than parsed a second time: [`wav_shape`] has already established this is a canonical 44-byte header, and a reader that disagreed with it about where - fn pcm16
the data starts would draw a waveform offset from the audio it is meant to describe. - fn safe_stem
A file name built from what somebody called a recording. A name is whatever was typed, and it reaches a **path** here. Everything that is not a letter, a digit, a dash or an underscore becomes a dash, so a take called `../../etc/passwd` or - fn safe_stem
`a/b` cannot write outside the folder that was chosen. Empty after that, and it is `take`: a file called nothing is not a file. - fn run_ffmpeg
Run `ffmpeg` to put the audio in a video with a black picture. - fn length
A length in seconds, as `m:ss`, for somewhere a person reads. - fn size
A size in bytes, rounded to something a person can compare. - const MIB
- mod tests
crates/veilvoice-gui/src/studio/tests.rs
- (module)
What the Studio has to get right, tested without a window. The drawing needs a running egui context and is checked by the screenshot run. Everything here is the part that would still be wrong if the drawing were perfect: the state machine, - (module)
the wiping, and the two formatters that turn stored numbers into what a person reads. - fn a_new_studio_is_shut_and_stays_shut_until_both_are_given
- fn one_passphrase_alone_never_derives_a_key
- fn taking_a_passphrase_wipes_the_buffer_it_came_from
- fn closing_forgets_the_vault_the_listing_and_both_entries
- fn a_length_reads_as_minutes_and_seconds
- fn a_size_never_reads_as_zero
- fn the_count_is_written_rather_than_printed_with_an_s
- fn a_stored_date_reads_as_the_date_it_was
- fn the_vault_lives_beside_the_lock_rather_than_somewhere_of_its_own
- fn a_name_cannot_write_outside_the_folder_that_was_chosen
- fn a_name_that_is_all_punctuation_still_makes_a_file
- fn a_very_long_name_is_shortened_rather_than_refused
- fn an_ordinary_name_is_left_recognisable
- fn a_wav_header_gives_up_its_rate_and_length
- fn something_that_is_not_a_wav_is_refused_rather_than_guessed
- fn a_plan_for_a_take_is_one_speaker_for_the_whole_of_it
- fn what_each_render_choice_asks_for
- fn sixteen_bit_samples_come_back_in_range
- fn a_wav
A canonical mono 16-bit WAV of `seconds`, with something audible in it. - fn studio_with_a_take
A Studio with an open vault holding one take. - fn a_preview_writes_the_audio_the_page_and_the_captions
- fn a_take_whose_name_is_a_path_is_written_inside_the_chosen_folder
- fn exporting_something_that_is_not_in_the_vault_writes_nothing
- fn without_ffmpeg_the_command_is_printed_rather_than_the_video_promised
- fn asking_where_to_put_it_does_not_block_the_window
- fn a_picker_open_when_the_vault_shuts_cannot_export_afterwards
- fn playing_a_take_that_is_not_there_says_so_and_starts_nothing
- fn locking_the_window_stops_a_take_that_is_playing
- fn starting_one_take_releases_the_one_before_it
- fn the_studio_meters_what_goes_in_as_well_as_what_comes_out
- fn a_new_studio_keeps_the_veiled_voice_and_nothing_else
- fn locking_the_window_puts_the_choice_back_to_the_safe_one
- fn each_side_says_which_of_the_two_it_keeps
- fn anything_that_keeps_the_real_voice_says_so_before_it_starts
- fn every_choice_is_labelled_and_they_are_all_different
- fn the_unveiled_take_is_named_so_it_can_be_told_from_the_other
- fn only_one_function_in_this_module_names_a_take
The suffix is written in one place, so it cannot be dropped from one loop. A take is stored from three loops now: the single microphone's two sides, the mix, and every guest's two sides. Building the name in each of them worked until the - fn only_one_function_in_this_module_names_a_take
third was added, which is exactly when it would have stopped working quietly. - fn every_recorder_that_is_running_is_drained_every_frame
- fn a_device_that_goes_stops_the_studio_and_says_why
**Roadmap item 145.** The Studio's own failsafe stops the Studio, not the take. A device that has gone does not come back and will not produce another sample, so a take left running on it records silence and looks like it worked. What was - fn a_device_that_goes_stops_the_studio_and_says_why
captured up to that point is a real recording of something somebody said, and it is stored rather than discarded, for the same reason locking the window stores one. - fn a_report_that_is_not_a_missing_device_does_not_stop_anything
Anything else the platform says is shown and left alone. "The mixer said something" is not a reason to end a recording somebody is making. Only a device that has stopped existing is, because that is the one report that means no further - fn a_report_that_is_not_a_missing_device_does_not_stop_anything
sample is coming. - fn the_failsafe_stores_rather_than_discarding_or_retrying
The failsafe stores. It does not discard, retry, or change device. Read out of its own source, because the difference between storing and discarding is one line and the test for it would otherwise need a vault, a passphrase and an Argon2 - fn the_failsafe_stores_rather_than_discarding_or_retrying
derivation to observe. What is worth keeping is the decision: `stop_veiling` seals a take on its way through, and dropping the recorders instead would throw one away. - fn a_running_take_says_which_voice_it_is_keeping
**Roadmap item 145.** A take says what it is keeping while it is being made. The choice is made on a form that is gone the moment recording starts, so a take keeping somebody's real voice looked exactly like one that does not for the whole - fn a_running_take_says_which_voice_it_is_keeping
of the recording. The marker asks for this to be shown "at all times", and before the button is not all times. - fn two_guests_on_one_microphone_are_refused_by_name
**Roadmap item 147.** Two guests on one microphone is refused, and says why. One microphone carrying two people is one signal. Veiling it gives both of them the same voice, which is the exact thing a microphone each was for, and nothing - fn two_guests_on_one_microphone_are_refused_by_name
downstream can separate it again. - fn two_guests_on_the_default_microphone_are_the_same_refusal
The default microphone is a device, not an absence. A list of guests nobody had chosen a microphone for is one microphone opened several times, which is the case above wearing a different name. It took saying so to notice it. - fn a_guest_with_no_name_is_called_by_their_slot
A blank name becomes the slot rather than an empty file name. - fn a_room_starts_with_two_and_stops_at_the_audio_layers_limit
The room form seeds two guests, and never more than the audio layer opens. A room of one is one microphone, which is the other half of this tab, so ticking the box for a room and being given a room of one would be a control that did - fn a_room_starts_with_two_and_stops_at_the_audio_layers_limit
nothing. The upper bound is the audio layer's and is checked here as well, so the button stops adding rather than the session refusing after somebody has set eleven of them up. - fn the_studio_cannot_hold_a_room_and_a_single_session_at_once
**Roadmap item 147.** One session or the other, and never both. Two fields would be two things to clear, and a room left running beside a single session is two streams on one output with every guest arriving twice. This reads the source, - fn the_studio_cannot_hold_a_room_and_a_single_session_at_once
because observing it otherwise needs several sound cards: what it checks is that the state is one field rather than two. - fn a_room_gives_every_guest_a_voice_of_their_own
Every guest gets a different voice, from the table a group render uses. Two guests given the same configuration get the same voice, which defeats the point of opening a microphone each: a listener could no longer follow who is speaking, - fn a_room_gives_every_guest_a_voice_of_their_own
which is the whole reason a room is not one microphone.
crates/veilvoice-gui/src/theme.rs
- (module)
Colour schemes for the desktop app. # One palette, three front-ends Every theme here is the same set of twelve tokens the website defines in `website/css/themes.css`, with the same names and the same hex values, and Tokyo Night - (module)
additionally matches the escape codes the CLI emits. The three front-ends are meant to read as one program rather than as three tools that happen to share a name, and the way that is kept true is by copying the numbers rather than by - (module)
approximating them. Tests assert **every token of every theme** against the stylesheet, in **both directions** -- so a colour changed on either side, a theme removed from either side, or a theme added to the website and forgotten here all - (module)
fail the build rather than shipping as two products that no longer look alike. # Why the active theme is an index, not a lock Colours are read on every repaint, from the UI thread, dozens of times a frame. A `Mutex` around the palette - (module)
would be a lock taken hundreds of times a second to read a constant, and a poisoned one would take the window with it. Instead the themes are a `const` array and the selection is a single `AtomicUsize`: reading is one relaxed load, it - (module)
cannot fail, and it cannot be poisoned. The index is clamped on read as well as on write. A value that somehow got out of range would otherwise panic on a slice index, in a paint loop, which is the worst possible place for it -- so the - (module)
read saturates to the default instead. # In plain words The colour schemes the application ships with, and the fonts. They are the same schemes the website offers, defined once so the two cannot drift apart. Choosing one applies it - (module)
straight away and it is remembered for next time. - static TABLE
The theme table, once the user's palettes have been folded in. Empty until [`load_custom`] runs. See [`themes`] for why this is a `OnceLock` rather than anything that can be written twice. - struct Theme
One complete colour scheme. The field names match the CSS custom properties one for one: `bg` is `--bg`, `accent_2` is `--accent-2`, and so on. - fn rgb
- const THEMES
Every theme, in the order the picker shows them. Index 0 is the default and is the one a clamped-out-of-range read lands on. - static ACTIVE
The index of the theme currently in force. - fn active
The theme currently in force. Clamped on read: an index out of range would otherwise panic on a slice index inside the paint loop, which is the worst place for it. - fn themes
Every theme the picker offers: the built-in ones, then any the user added. Returns the `const` array until [`load_custom`] has run, which is what every test and the first frames of startup see. After it has run, the same array with the - fn themes
user's palettes appended -- appended rather than merged, so a built-in theme keeps its index and a preferences file written before a palette was added still selects the same scheme. Reading this is a `OnceLock::get`, which is one atomic - fn themes
load. No mutex: this is on the path of every repaint, and the reason `ACTIVE` is an `AtomicUsize` in the first place applies just as much here. - fn load_custom
Read the user's palettes and add them to the table. Returns any complaints. Called once, during startup, before the first frame. Calling it again is a no-op that returns no problems -- `OnceLock` accepts one value, and silently keeping the - fn load_custom
first is right here: the alternative is a theme table that changes shape while indices into it are live. - fn by_id
Look a theme up by its stable identifier. - fn set_by_id
Switch to `id`, and apply it to `ctx`. Unknown identifiers are ignored, so a preferences file naming a theme this build does not have keeps the default rather than failing to start. - mod palette
Shorthand accessors, so call sites read as `p::fg()` rather than `theme::active().fg`. These were `const` values when there was one theme. They are functions now because the palette is chosen at runtime; the call sites are otherwise - mod palette
unchanged. - fn bg
Window and central-panel background. - fn bg_dark
Deeper background, for inset panels. - fn surface
Raised surface, for widgets. - fn surface_hi
Hovered surface: the raised surface, lifted towards the text colour. Derived rather than tabulated, so a new theme cannot be added with a hover state that does not belong to it. `lerp` towards `fg` works for light schemes as well as dark - fn surface_hi
ones, where simply lightening would not. - fn border
Borders and separators. - fn fg
Primary text. - fn muted
Secondary text. - fn blue
The project's primary colour. - fn cyan
Values and figures. - fn purple
The "veiled" half of the mark. - fn green
Success. - fn yellow
Warning. - fn red
Error. - fn blend
Mix `a` towards `b` by `t` in 0..=1. - const JETBRAINS_MONO_PATHS
Places JetBrains Mono is normally installed. The font is not vendored: it is Apache-2.0 and redistributable, but shipping a binary blob would undercut the claim that everything here is auditable from source. If it is installed the UI uses - const JETBRAINS_MONO_PATHS
it; otherwise egui's own monospace face stands in, which looks close enough that nothing breaks. - fn user_font_paths
- fn jetbrains_mono_path
Where JetBrains Mono is on this machine, if it is anywhere. Separated from [`install_fonts`] so the question can be asked **without opening a window**. `veilvoice-gui --typeface` answers from this, which is what `tools/shots/gui.sh` checks - fn jetbrains_mono_path
before it photographs anything: a capture taken with the built-in face instead looks subtly different from every other one and nothing about the run says so, which is the failure that made this worth having a flag for. - fn install_fonts
Load JetBrains Mono if the system has it. Returns whether it was found. - fn install
Apply the active theme's visuals and a monospace-everywhere type scale. - mod tests
- static ACTIVE_THEME
**F-78.** Four tests here read and write the same process-global `ACTIVE`, and `cargo test` runs them on parallel threads in one process. Nothing kept them apart, so any two could interleave: one switched the theme to `paper` and asked - static ACTIVE_THEME
whether the palette had changed, while another put the index back to zero in between and made the answer no. Measured: one failure in forty runs of this module alone, and it fired for real during a full-workspace run, where there are more - static ACTIVE_THEME
threads competing. A test that fails one run in forty is worse than one that fails every time, because the answer people learn is "run it again". So the tests that touch the global take this in turn. Poisoning is stepped over rather than - static ACTIVE_THEME
propagated: a panic in one test has already failed that test, and turning it into a failure in every other one hides which was the real fault. - fn alone
- fn reset
- fn palette_matches_the_cli_escape_codes
- fn the_website_has_no_theme_the_app_is_missing
The website must not gain a theme the app has never heard of. The test below walks `THEMES` and checks each against the stylesheet, which catches a theme changed or removed on either side -- and misses a theme **added to the website**, - fn the_website_has_no_theme_the_app_is_missing
because nothing was walking the stylesheet looking for entries this crate does not know. That is the shape of the defect recorded against `html.test.js`: a check enumerating from a hardcoded list is only ever as wide as the list, and a - fn the_website_has_no_theme_the_app_is_missing
page added later went unchecked for precisely that reason. So this enumerates from the *stylesheet* instead, and the pair of tests closes the loop in both directions. A reader who picks a theme on the website and then opens the app should - fn the_website_has_no_theme_the_app_is_missing
find it there. Silently falling back to the default is a small thing that reads as the application being broken. - fn every_theme_matches_the_website_stylesheet
The app's themes and the website's are meant to be the same themes, not two sets that happen to share names. Parsed straight out of the stylesheet so a change to either side without the other fails here. - fn the_app_offers_the_same_themes_as_the_website
The website offers exactly these themes, in this order. A theme added to one and not the other is the kind of drift nobody notices. - fn switching_theme_changes_the_palette_and_the_visuals
- fn an_impossible_index_saturates_instead_of_panicking
An out-of-range index must saturate rather than panic. This is read inside the paint loop, which is the worst place for an index panic. - fn theme_identifiers_are_unique_and_stable
- fn font_search_covers_every_supported_platform
- fn styling_applies_without_a_window
- fn missing_font_is_not_fatal
Missing JetBrains Mono must degrade to the built-in face, never panic.
crates/veilvoice-gui/src/tour.rs
- (module)
The short tour on a first run, and after an upgrade. # What it is for The window has nine tabs and nothing said what any of them were. Somebody opening this for the first time met a tab strip and had to guess, and two of the nine, Monitor - (module)
and Lock, are not what their names suggest to a person who has not read the documentation. So: one card per tab, one sentence each, skippable at any point, and gone for good once seen. It is not a walkthrough with arrows pointing at - (module)
controls. It is the paragraph a person would have read in a manual, offered at the moment they would have wanted it, and it takes about twenty seconds. # Why it comes back after an upgrade Only as far as the tabs that are new. A tour that - (module)
replays in full on every upgrade is a tour people learn to skip, and one that never comes back means a tab added in a later release is never introduced to anybody who was already a user. What is stored is the list of tabs that were toured, - (module)
not a "seen" flag and not the version number. A flag cannot answer the question an upgrade asks, and the version can only answer it indirectly: comparing versions tells you *that* something changed, and the tab list tells you *what*, which - (module)
is the thing being shown. It also means a release that adds no tab shows nobody anything, which is the common case and the right behaviour for it. # Portable or installed The last card says which one this copy is, in those words, because - (module)
it is the question behind "where did my settings go" and "why is it not in my menu". It is a statement rather than a prompt: `Install` is a tab, the decision is made there, and a tour is a bad place to ask somebody to commit to anything. - const CARDS
One card: the tab it is about, and what that tab is for. The keys match `Tab::key`, and `app.rs` has a test that every tab has a card and every card has a tab, so a tab added without a sentence fails the build rather than shipping - const CARDS
unexplained. - struct Tour
Where the tour is up to. - fn all_keys
Every tab key the tour knows, for storing once it has run. - impl Tour
- fn start
Start the tour from the beginning, showing every card. - fn start_new_only
Start it showing only the cards whose tabs are not in `known`. Used after an upgrade: somebody who has been using this for months is shown what is new and nothing else. If nothing is new, nothing runs. - fn running
Whether the tour is on screen. - fn stop
Stop it. - fn panel
Draw the current card. Returns true once the tour has finished. `installed` decides the sentence on the last card, and it is a fact about where this binary is rather than a preference. - mod tests
- fn every_card_has_a_sentence_and_no_two_share_a_tab
- fn a_first_run_sees_everything
- fn an_upgrade_shows_only_what_is_new
- fn what_is_stored_is_every_tab_the_tour_covered
- fn an_upgrade_that_adds_no_tabs_shows_nothing
crates/veilvoice-gui/src/updates.rs
- (module)
The manual update check, as the window shows it. [`veilvoice_setup::update`] does the asking and states what the answer is worth. This is the button, the spinner and the result, and the rule that the button is the only thing that ever - (module)
starts it. # It runs on a thread, and the window never waits for it The check runs a subprocess and waits for a network round trip. On a captive portal that is the full ten-second timeout. `update()` may read, paint and *start* work; it - (module)
may never wait for any. That is locked decision 15, and the reason this application was reported as freezing every couple of seconds once already. So the button spawns a thread, the thread sends one message down a channel, and the window - (module)
drains that channel once a frame and moves on. # Nothing here is automatic There is no timer, no check at startup, and no "check again" on a schedule. [`Updates`] holds no clock. The only path into `veilvoice_setup::update::check` is a - (module)
click, and a test asserts the state a freshly built panel is in. # In plain words The update check, as a button you press. VeilVoice never checks on its own and never contacts anything unless you ask. An update check that runs by itself is - (module)
a message to somebody else's server saying that this copy exists and is running now. When you do press it, it compares your version against the newest published one and tells you what it found, including that the answer only tells you what - (module)
a release page says. - struct Updates
The panel's state. - impl Updates
- fn is_busy
Whether a check is running, so the app knows to keep repainting. - fn drain
Take the worker's answer if it has one. Called once a frame; never waits. `Disconnected` is handled as well as a message: a worker that died without sending would otherwise leave the panel saying "checking" forever, which is the failure - fn drain
mode a spinner is worst at showing. - fn start
Start a check. The only path to the network in this application. - fn section
The whole section, as it appears under "about". - fn verdict
The answer itself, in the colour it deserves. - mod tests
- fn a_new_panel_has_asked_nothing
A freshly built panel has asked nothing and is asking nothing. This is the test that fails if a check at startup is ever added. - fn draining_without_a_check_running_does_nothing
Draining with no worker is a no-op, and drains nothing into the answer. - fn a_worker_that_dies_without_answering_is_reported_rather_than_awaited
A worker that dies without sending must not leave the panel saying "checking" forever. - fn an_answer_survives_being_drained_once
An answer is kept until another is asked for, so the result does not vanish on the next frame.
crates/veilvoice-gui/src/vault_store.rs
- (module)
Where the desktop application keeps its own files, and what the app lock buys for them. # The short version, and it is the honest one **With an app lock set**, everything VeilVoice writes about itself lives in [`veilvoice_crypto::hoard`]: - (module)
encrypted, padded to a few fixed sizes, under filenames derived from the lock passphrase, with decoy files sown among them. Somebody who opens the folder without the passphrase cannot tell which file is the settings, which is the integrity - (module)
record, which holds anything, and which is junk. **With no app lock**, none of that is possible and none of it is claimed. There is no passphrase, so there is no key, so there is nothing to derive a name from or encrypt with. The files sit - (module)
in the open under their own names, exactly as they did before this module existed, and the security tab says so in those words. That is the whole bargain, and it is the answer to a fair question about the app lock: what is it actually - (module)
*for*, if it is only a password prompt on a window whose files anybody can read? This is what it is for. Setting a passphrase is what turns the folder from a set of labelled files into a set of indistinguishable ones. # What it still does - (module)
not buy Repeated here rather than left in the crypto crate, because this is the module the application calls and the place somebody looks: - It does not hide that VeilVoice is installed. The folder is named `veilvoice` and the lock file is - (module)
in it under its own name -- it has to be, since it is what checks the passphrase. - It does not stop anybody deleting the folder. - It is no protection at all while the application is open and unlocked. - Anybody who has the passphrase has - (module)
everything. # Moving in, and the risk that comes with it The first unlock after a lock is set migrates the existing plain files in: each is read, written as a hoard record, and the original securely erased. This is the moment to be plain - (module)
about a consequence that is easy to under-state. Once the files are in the hoard, **the passphrase is the only way back to them**. Losing it does not lock you out of a window whose files you could still read by hand; it loses the settings, - (module)
the integrity record and the policies for good. [`veilvoice_crypto::lock`] keeps a second copy of the lock for exactly this reason, and the setup screen says the sentence out loud rather than burying it. - mod records
The logical names of every record the application keeps. Named here rather than spelled at each call site so that the migration, the audit and the readers cannot disagree about what exists. A record not in this list is not migrated and not - mod records
audited. # What is deliberately not here, and why `settings.conf` is **not** an obfuscated record, and this is the one place that decision is explained rather than assumed. The settings file says which theme to use, how big the window was, - mod records
and whether movement is reduced. All three are needed to draw the window -- including the lock screen itself, which is the first thing drawn and the last thing that could wait. A record inside the hoard cannot be read until a passphrase - mod records
has produced a key, and a passphrase cannot be typed until there is a window to type it into. Putting the settings in there would mean every locked VeilVoice opened with the default theme at the default size and then jumped to the user's - mod records
when they unlocked. So the settings stay in the open, and what that costs is stated plainly: somebody reading the folder learns which theme you chose, roughly how big your window is, and which optional features you switched on. They do not - mod records
learn what you have processed, what VeilVoice measured while doing it, what it found when it checked itself, or what any of the other records hold. This is a real limit rather than a temporary one. Closing it properly means splitting - mod records
appearance out from everything else so only the handful of drawing settings sit in the open, and that is worth doing; it is not worth pretending is already done. - const MEASURED
What the application measured about its own running. - const ALL
Every record, with the plain filename each one migrates from. The plain name is what the file was called before there was a lock, and is what it goes back to being if the lock is removed. # Adding to this list is not free A name here is - const ALL
migrated in on the first unlock and the plain file is **shredded**. So a file listed here that anything still reads by its plain path is a file that gets destroyed, silently, on somebody's next unlock. - const ALL
`every_record_is_actually_read_through_the_store` refuses that, and it exists because the first version of this module listed three records and read one. `integrity.manifest` and `last-crash.txt` were on this list and are deliberately not - const ALL
now. Both are still read elsewhere by their plain paths, and the manifest is read by `veilvoice guard` from the *command line*, which has no unlocked session and therefore no key. Moving it in would mean prompting for the app-lock - const ALL
passphrase on every `veilvoice guard` run: a different feature with a different argument, not a detail of this one. - const DECOYS
How many decoys a folder is kept stocked with. Enough that the real records are a minority of what is there, few enough that the folder is not absurd. The exact number is not a security parameter: it blurs the count of real records, it - const DECOYS
does not hide it, and pretending otherwise would be the overstatement this project spends its time avoiding. - struct VaultStore
The application's own storage, locked or not. - impl VaultStore
- fn new
Point at the program folder. Nothing is read or written yet. - fn dir
The program folder, if this platform has one. - fn is_obfuscated
Whether records are currently obfuscated. False before an unlock and false when there is no lock at all, which are different situations with the same answer: in neither of them is anything hidden. - fn unlocked
Take the key from an unlock and open the hoard with it. Migrates any plain files in on the way, and tops the decoys up. Returns what the audit found, so the caller can put a tamper report in front of somebody who has just proved they own - fn unlocked
the machine. - fn locked
Forget the key. Called when the window locks. - fn read
Read a record, from the hoard if it is open and from the plain file if it is not. - fn write
Write a record, obfuscated if there is a key and plain if there is not. - fn plain_path
Where a record sits when nothing is obfuscating it. - mod tests
- fn key
- fn without_a_lock_it_writes_plain_files_under_their_own_names
- fn unlocking_moves_the_plain_files_in_and_removes_them
- fn once_unlocked_no_filename_says_what_it_holds
- fn decoys_outnumber_the_records
- fn a_second_unlock_does_not_keep_adding_decoys
- fn the_wrong_passphrase_finds_nothing_rather_than_erroring
- fn locking_forgets_the_key
- fn an_edited_record_is_reported_by_the_audit
- fn every_record_is_actually_read_through_the_store
**F-142.** A record that is migrated but never read is a file destroyed. The first version of this module listed `integrity.manifest` and `last-crash.txt` beside `measured.dat`. Migration reads each plain file, stores it, and shreds the - fn every_record_is_actually_read_through_the_store
original -- so on the first unlock after setting an app lock, the integrity baseline `veilvoice guard` compares against and the crash log the next launch offers to report would both have been erased, with nothing reading them back. Neither - fn every_record_is_actually_read_through_the_store
is read through this store; both are still read by their plain paths, one of them from a command line that has no key at all. The same shape as F-141 -- written through one path, read through another -- found by auditing for that shape - fn every_record_is_actually_read_through_the_store
rather than by somebody losing a file. So it is pinned: a name in `ALL` has to be read through the store somewhere, or this fails. - fn every_record_has_a_plain_name_to_migrate_from
- struct Measured
What the application measured about its own running, kept between sessions. Small on purpose. These are the numbers the About panel shows and then forgets: how long a frame took, which renderer drew it, how much faster than real time the - struct Measured
engine ran. Keeping them makes the panel able to say "and it was like this last time too", which is the difference between a number and a measurement. It is written through [`VaultStore`], so with an app lock set it is encrypted under a - struct Measured
derived name like everything else there, and with no lock it is a plain file called `measured.dat`. Nothing about it is sent anywhere: VeilVoice has no networking crate in its dependency graph and this is not the exception. - impl Measured
- fn load
Read it back, or the defaults if nothing has been recorded. A record that does not parse reads as absent rather than as an error. These are numbers for a panel; a corrupt one is worth losing silently, and it is emphatically not worth - fn load
blocking a launch over. A record that fails to *authenticate* is a different matter and is reported by the audit, which runs at unlock. - fn save
Write it, obfuscated when there is a key and plain when there is not. - fn record
Fold this session's numbers in. The frame time keeps the *best* seen rather than the last: a frame that took 300 ms because the machine was swapping says nothing about what VeilVoice costs, and the number people want from this panel is - fn record
what it can do rather than what happened once. - mod measured_tests
- fn key
- fn it_round_trips_through_a_plain_folder
- fn it_round_trips_through_an_obfuscated_one
- fn the_numbers_do_not_appear_in_the_file_once_locked
- fn nothing_recorded_reads_as_zeroes_rather_than_failing
- fn a_corrupt_record_reads_as_absent
- fn the_best_frame_time_is_kept_not_the_last
crates/veilvoice-gui/src/verify.rs
- (module)
The verify tab: drop a download on the window and be told what it is. # Why this exists here rather than in the verifier The ask was drag-and-drop verification. There were two ways to have it: link `eframe` into `veilvoice-verify`, or move - (module)
the checking out of that binary so both front ends call the same code. The portable verifier is the one program in this project whose *smallness* is a feature: it is what somebody downloads before they trust anything else here, and a 1.5 - (module)
MB single file is part of why it is checkable at all. Putting a GUI toolkit in it would have cost that for a convenience the desktop application was already the right place for. So the arithmetic moved to `veilvoice_verify::check` and this - (module)
is a second caller, not a second implementation. # Three files, and the tab says so before it is given any Verifying needs the download, the `SHA256SUMS` and the `SHA256SUMS.asc`. Dropping one file on a window and getting a verdict would - (module)
be a lie, and an interface that discovers the other two are missing *after* the drop teaches people that verification is fiddly rather than that it needs three things. All three slots are visible from the start, and a drop fills whichever - (module)
one the file's name says it is. # Roadmap item 97: one press, three answers The tab used to answer one question, and it was not the question somebody actually has. "Is this zip the published one" is a step; "is the program I am about to - (module)
run the published one" is the thing they want to know, and it was being left to the command line. So one press now checks the archive against the signed hash list, then every file extracted out of that archive against the signed contents - (module)
list the release publishes beside it, and then runs the GnuPG on this machine over the same signature and shows what it said. Three answers, one button, and each one drawn separately so a pass on one is never mistaken for a pass on - (module)
another. Two things are deliberately **not** failures. A release that published no contents list -- everything before v0.1.15 -- simply has no such row, rather than a warning about a file that was never meant to be there. And a GnuPG that - (module)
cannot run on this machine is drawn in the quiet colour: it is a fact about the computer and says nothing whatever about the download. # Nothing here downloads anything Not even the key: it is compiled in, and its fingerprint is checked - (module)
against a constant a reader can compare with the README. `veilvoice-verify` is still the tool for fetching a release, because it is the one that can be checked before it is run. # In plain words Drop a download on the window and be told - (module)
whether it is genuine. It checks the signature over the list of hashes first, and only then compares your file against that list. That order matters: a list of hashes that has not been checked is just some numbers somebody sent you. Drop - (module)
the downloaded archive and the hash list and signature sitting beside it are picked up on their own. Nothing is downloaded and nothing leaves the machine. One press checks the zip, then every file you unzipped out of it, and then asks your - (module)
own GnuPG the same question and shows you its answer. - enum Slot
Which of the three files a dropped path is. - fn slot_for
Work out what a dropped file is from its name. By name rather than by content: the signature and the list are both text, the download is anything, and asking the user "which of these is which" after they have dropped three files is worse - fn slot_for
than getting it right for the names this project actually publishes. A wrong guess is visible and one click to correct, because every slot also has its own button. - struct Report
Everything one press of **check** found out. **Roadmap item 97.** The tab used to answer one question -- is this archive the published one -- and it now answers three, because the other two are what somebody actually wants to know and both - struct Report
were being left to the command line. The extra work is done on the same worker thread and reported in the same place, so there is still one button. - struct Contents
What the extracted folder turned out to hold. - struct Gnupg
What this machine's GnuPG said. - struct Verify
The tab's state. - const COPIED_FOR
How long the copy button says "copied", in seconds. Long enough to read, short enough that it is clearly about the press that just happened rather than a state the button is in. - const RELEASES_PAGE
Where every release, and the files published beside it, actually are. The verify tab asks for a hash list and a signature and used to say nothing about where a person gets one. - const SIGNING_KEY_IN_REPO
The signing key, in the repository as well as in each release, so it can be fetched from somewhere other than the release being checked. - const SLOT_LABEL_WIDTH
The width the slot labels are given, so the file names beside them start level. Wide enough for `SHA256SUMS.asc`, which is the longest. - const SLOT_NAME_WIDTH
The width the file name is given, so the three `choose…` buttons land at one x whatever is in the slots. Wide enough for `the signature over that list`, the longest of the three placeholders; a name longer than this pushes its own button - const SLOT_NAME_WIDTH
right, and a row where the name is the interesting part is the right place to spend the space. - impl Verify
- fn is_busy
Whether a check is running, so the app keeps repainting. - fn wants_repaint
Whether the window has to keep drawing for this panel's sake. Busy *or* hovering. An idle egui window repaints only when something asks it to, and dragging a file over it is not by itself something that does -- so without this the drop - fn wants_repaint
target never lit up and the dropped file did not appear until the mouse moved for some other reason. - fn drain
Take the worker's answer if it has one. Never waits. - fn accept
Put a dropped or chosen file into the slot its name says it belongs in. - fn fill_from_beside
Fill the empty slots from the folder this file came from. **Only the empty ones.** A slot somebody chose themselves is never replaced: they said which file they meant, and a guess from a name overriding that is how the wrong thing gets - fn fill_from_beside
verified and reported as right. Only exact names are taken -- `SHA256SUMS` and `SHA256SUMS.asc`, as the release publishes them. Anything looser starts matching files that happen to be nearby, and a hash list is not something to be clever - fn fill_from_beside
about finding. - fn found_beside
Which slots were filled in by looking rather than by being chosen. Shown beside them, because a file that appeared without being asked for is a file somebody should be able to see and change. - fn take_dropped
Read what the window was given this frame. Called from `update` before the tab is drawn. egui reports hovering and dropping through the same input state, so both are taken here and the tab merely renders the result. - fn tab
The whole tab. - fn body
- fn drop_target
The rectangle that lights up while files are over the window. - fn slot_row
One file slot: what it is, what is in it, and a way to change it. The two labels are given fixed-width columns so that the three rows line up and, with them, the three `choose…` buttons beside them. This used to pad the label with - fn slot_row
`{label:<16}` and hope. Trailing spaces line nothing up in a proportional font, which is the same habit the alignment work already took out of this application once: `the download`, `SHA256SUMS` and `SHA256SUMS.asc` are three different - fn slot_row
widths on screen whatever they are padded to, so the second column started in three different places and every button sat somewhere else again. - fn gnupg_section
Roadmap item 90. The same check, with a GnuPG this project did not write. Here rather than on its own tab because this is where somebody is already asking "is this download genuine", and the honest answer to that question includes "and - fn gnupg_section
here is how to ask something other than me". The commands come from [`veilvoice_verify::gnupg::commands`], which the portable verifier also uses, so the window and the command line cannot drift into printing two different recipes. - fn start_survey
Which implementation checks the signature, and the choice behind it. Three of them, and the difference is the whole point: the built-in check is made by a program that came out of the download it is checking, and the others are not. See - fn start_survey
[`veilvoice_verify::gnupg::backend`] for why an installed GnuPG is still not used until it is chosen. Look for GnuPG, on a thread, and never twice at once. On a thread because on Windows the WSL half starts a Linux distribution, which - fn start_survey
takes as long as it takes. The window must not wait for that, so it does not: the result comes back through a channel that is only ever polled. - fn poll_survey
Take the result if it has arrived, without ever waiting for it. `try_recv` rather than `recv`: this runs on the thread that draws, and blocking it on a WSL start is the whole thing being avoided. - fn checker_section
- fn copyable_command
One command, shown as it would be typed, with a button that copies it and says it did. - fn verdict
The answer, in the colour it deserves. - fn contents_verdict
Roadmap item 97. Every extracted file against the signed contents list. - fn gnupg_verdict
Roadmap item 97. What this machine's own GnuPG made of the same signature. - fn archive_verdict
The archive against the signed hash list. - fn start
Run the check on a thread of its own. Hashing a release archive is tens of megabytes of reading, and OpenPGP verification is not free either. `update()` may start work; it may never wait for it. - fn examine
The whole check, off the drawing thread. A free function rather than a method so it cannot reach the tab's state: everything it needs is in its three arguments and everything it found is in what it returns, which is the only shape that is - fn examine
safe to run on a thread while the window carries on drawing. - fn examine_contents
Roadmap item 97. Every file in the extracted folder, against the signed list. The order is the one the whole project keeps: `CONTENTS.sha256` is checked against the signed hash list **before** it is parsed, because it decides which paths - fn examine_contents
get read and what they are compared against. - fn examine_gnupg
Roadmap item 97. The same signature, through the GnuPG this machine already has. - mod tests
- fn a_dropped_file_lands_in_the_slot_its_name_says
- fn dropping_the_archive_finds_the_hash_list_and_signature_beside_it
**The three files arrive together.** A release is downloaded as an archive plus `SHA256SUMS` plus `SHA256SUMS.asc`, into one folder. Making somebody find each of them by hand is making them give up. - fn a_file_that_was_chosen_is_never_replaced_by_one_found_nearby
A slot somebody chose is never replaced by a guess. They said which file they meant, and overriding that is how the wrong thing gets verified and reported as right. - fn only_the_exact_names_are_taken_from_the_folder
Only the exact published names. Anything looser starts matching files that merely happen to be nearby, and a hash list is not something to be clever about finding. - fn accepting_files_fills_the_three_slots
- fn a_new_file_clears_the_previous_answer
A new file makes the last answer stale, and a stale verdict shown beside a different file is the worst thing this panel could do. - fn hovering_keeps_the_window_awake
The window has to keep drawing while a file is over it, not only while a check is running. This is the assertion behind that. - fn a_check_cannot_start_without_all_three
- fn a_worker_that_dies_without_answering_is_reported
- fn the_signing_key_link_points_at_the_key_that_is_compiled_in
The signing key this tab points at is a file that exists here. A link to a key is worth exactly as much as the key being at the other end of it. The path is the one `veilvoice_verify::check` compiles the key in from, so if the file moves, - fn the_signing_key_link_points_at_the_key_that_is_compiled_in
both this link and that include break together rather than the link rotting quietly. - fn the_gnupg_section_is_drawn_once
The GnuPG section is drawn once, not once for each file slot. It used to be the last statement of `slot_row`, and `slot_row` is called three times: for the download, the hash list and the signature. So the verify tab carried three copies - fn the_gnupg_section_is_drawn_once
of the heading "The same check, typed by you", three copies of the paragraph under it and three copies of "choose a hash list and a signature above", interleaved with the three file rows. It shipped in every screenshot of that tab. Nothing - fn the_gnupg_section_is_drawn_once
caught it because every piece of it was correct: the section draws what it should, and `slot_row` draws what it should. Only the place one is called from was wrong, and no test looked at that. This reads the call site rather than the - fn the_gnupg_section_is_drawn_once
drawing, because where it is called from is exactly what was wrong. - fn the_slot_rows_use_a_column_and_not_padding
The slot labels are given a column rather than padded with spaces. `format!("{label:<16}")` lines nothing up in a proportional font, so the three file names started in three different places and the three `choose…` buttons beside them did - fn the_slot_rows_use_a_column_and_not_padding
too. The same habit was taken out of `security.rs` once already, which is why the fix now lives in `layout::column` and both call it. - fn the_window_prints_the_shared_gnupg_commands
Roadmap item 90. The window and the command line must print the same GnuPG recipe, which is why the body of it lives in `veilvoice_verify::check` and both call it rather than each keeping a copy. - fn a_perfect_hash_under_a_bad_signature_still_fails
The whole thing, end to end, against a file this test makes: the signature check has to fail before any hash is believed, even when the hash is perfect. - fn a_release_without_a_contents_list_draws_nothing_about_one
**Roadmap item 97.** A release with no contents list produces no row about one. Everything published before v0.1.15 is in that position, and a panel that reported the absence of an optional file would teach people to worry about it. - fn a_contents_list_that_is_not_the_signed_one_checks_nothing
**Roadmap item 97.** A contents list that is not the signed one is refused, and nothing in the folder is reported on. Parsing it first and checking afterwards would be letting a downloaded text file choose which paths get read. - fn a_gnupg_that_gave_no_answer_is_not_a_disagreement
**Roadmap item 97.** A GnuPG that cannot run is never drawn as a failure of the download. The distinction is the one this is most tempted to get wrong, and getting it wrong tells somebody not to run a sound release. - fn nothing_is_reported_about_a_folder_when_the_archive_failed
**Roadmap item 97.** The extracted folder is only looked at once the archive itself has passed. Reporting on a folder after the archive failed would be answering a question nobody should still be asking.
crates/veilvoice-gui/src/watchfeed.rs
- (module)
The device monitor, moved off the thread that paints. # Why this file exists The monitor used to be polled straight from `update`, which is the user-interface thread. Asking the operating system which applications hold the microphone is - (module)
not free, because on Windows it means running `reg.exe` and on Linux it means walking `/proc`, and anything that costs tens of milliseconds is several frames. The shipped v0.1.12 did it the expensive way as well as in the wrong place: two - (module)
subprocesses per application, 68 of them on the machine this was found on, costing at least 449 ms measured, every two seconds, on the thread that draws. The window froze repeatedly. `veilvoice-watch` now costs two subprocesses whatever is - (module)
installed, and this file makes sure the remaining cost never lands on a frame. **Both halves were needed.** A cheap scan on the painting thread is still a scan on the painting thread: it would be a stutter rather than a freeze, on a - (module)
machine slower than the one it was tested on, and it would come back. # One thread for the life of the window Not one per poll. Spawning a thread every two seconds to do 45 ms of work is most of a thread's lifetime spent being created, and - (module)
it would put the monitor's own state, which is what makes "started" and "stopped" different from "is using", somewhere it has to be moved back and forth. So the worker owns the [`veilvoice_watch::Monitor`] and keeps it. It polls, sends, - (module)
sleeps, repeats. The window drains whatever has arrived once a frame and never waits. # It stops when the window does Nothing tells the thread to exit. When the window closes the receiver is dropped, the next `send` fails, and the loop - (module)
ends, which is the whole shutdown protocol and needs no flag, no channel back and no chance of hanging on exit waiting for a sleep to finish. # In plain words Keeps the microphone and camera monitor running somewhere other than the thread - (module)
that draws the window. Asking the operating system which programs are using a device takes long enough to be visible if it happens while the window is being painted. So it happens on its own thread and the window reads whatever has - (module)
arrived. If that thread ever stops, the panel says so plainly, because a monitor that has quietly not updated for an hour looks exactly like a machine where nothing is listening. - const EVERY
How often to look. Two seconds is frequent enough that a notification is timely and rare enough that the work is invisible. - struct Update
One look at the machine. - struct WatchFeed
The window's end of the monitor. - const MOST_ALERTS
A log that grows without bound is a memory leak with a user interface. - impl Default for WatchFeed
- fn default
- impl WatchFeed
- fn idle
A feed that watches nothing. What tests and `Default` use, so neither starts a thread or touches the machine. - fn start
Start watching, on a thread of its own. Does nothing on a platform that cannot answer: a thread that would only ever report "not supported" is a thread that need not exist, and [`WatchFeed::support`] is what a front end reads to say so. - fn unseen
Alerts that have arrived and not yet been shown as a notification. Separate from [`WatchFeed::log`], which is the history and stays. This is a queue of things still to say, and it is drained by whoever shows them -- so an alert that - fn unseen
arrives while the reader is on another tab is still waiting when they come back, rather than having scrolled past in a log they were not looking at. - fn drain
Take whatever has arrived. Never waits. Returns true when something new came in, so the caller can decide whether the frame needs anything else done to it. - fn active
What is holding a device right now. - fn log
What has started and stopped, oldest first. - fn error
Why the monitor is not answering, if it is not. - fn support
What this platform can detect at all. - fn is_watching
Whether there is a worker running. - mod tests
- fn an_idle_feed_starts_nothing_and_reports_nothing
- fn draining_an_idle_feed_does_nothing
Draining an idle feed must be free and must not claim anything arrived. - fn draining_never_blocks_the_frame
The whole point: a drain must never wait for the machine. Measured as a minimum over many runs, because one sample on a busy machine says nothing. - fn a_worker_that_stops_is_reported_rather_than_looking_quiet
A worker that has gone must be reported, not left looking like a quiet machine -- which is the failure mode this project guards hardest against. - fn the_log_is_capped
The log is capped, or a machine left open overnight grows one string at a time until it is a problem. - fn a_failed_look_keeps_the_last_known_state
A failed look must not wipe the last known state to nothing, which would read as "the microphone was released". - fn starting_on_this_machine_agrees_with_what_it_supports
Starting for real must not panic, and must agree with what the platform says it can do.
crates/veilvoice-gui/src/window.rs
- (module)
How big the window opens, and why it is not a constant. # The two failures a single number produces The window used to open at 1100 by 720 always. That is too small for this application: the longest panel is 1288 pixels of content, so it - (module)
opened on a panel with its bottom third missing, and a reader had to discover the scrollbar to find out there was more. Opening at 1400 by 1000 instead fixes that and breaks something else. A 1366 by 768 laptop is still a common machine, - (module)
and a window taller than the screen opens with its lower edge, and often its buttons, off the bottom. That looks like a broken program rather than a generous one. So the size is not a constant. It is a preference, clamped to what the - (module)
screen can actually show, worked out on the first frame when the monitor size is known. On a large display the window opens big enough to read; on a small one it opens as big as fits and scrolls, which is what scrolling is for. # The floor - (module)
is about width, and it stays where it was Everything is inside one scroll area, so a short window loses nothing. Width is different: the layout is column-based and below roughly 720 across the columns start to overlap. That floor is - (module)
enforced by the window manager through `with_min_inner_size`, so a person who wants a small window can still drag it down to the floor. Nothing here stops them; it stops the program from *opening* somewhere unreadable, which is a different - (module)
thing. - const PREFERRED
The size the window opens at when the screen has room for it. Measured rather than picked. At 1400 across, the nine tab labels fit with room to spare and the longest panel is 1288 pixels tall; 1000 shows all of every panel but that one, - const PREFERRED
and that one scrolls. - const MINIMUM
The floor, enforced by the window manager. - const CHROME
What the window has to leave for the desktop, in pixels. A proportion is the wrong model here and was the first thing tried: nine tenths of a 1080-pixel screen is 972, which would shrink the window on the commonest desktop display in the - const CHROME
world to avoid a task bar that is 40 pixels tall. What the window actually loses is a title bar at the top and a task bar or dock at the bottom, and those are a fixed size whatever the screen is. Generous rather than exact, because it - const CHROME
cannot be exact: a dock can be enormous, and the cost of over-reserving is a slightly smaller window while the cost of under-reserving is buttons under the task bar. - fn requested_size
The size asked for by `--size <W>x<H>`, if one was and it parses. A malformed value opens the default window rather than refusing to start. This is a convenience for somebody who wants a bigger window and for the screenshot harness; - fn requested_size
neither is worth a program that will not open, and a window that is the wrong size says so by being the wrong size. Clamped to the minimum the window enforces anyway, so `--size 1x1` gives a usable window instead of one whose columns - fn requested_size
overlap. - fn size_from
The parsing half, separated from the environment so it can be tested. `main` cannot be called from a test and `std::env::args` cannot be set by one, so a parser that reads the environment directly is a parser nothing checks. This takes the - fn size_from
arguments instead. - fn opening_size
The size to open at on this screen, given the monitor and what was asked for on the command line. `--size` wins outright, clamped only by the floor: somebody who names a size means it, and the screenshot harness needs exactly the size it - fn opening_size
asked for. Otherwise the preferred size is used, shrunk to fit the screen. - mod tests
- fn parse
- fn a_size_is_read_in_either_spelling
- fn the_size_is_found_beside_other_arguments
- fn a_size_below_the_minimum_is_raised_to_it
The window enforces a floor of 720x520, so a smaller request is raised to it rather than producing a window whose columns overlap. - fn nonsense_falls_back_to_the_default
A value that makes no sense opens the default window. The alternative is a program that will not start over a convenience, and a window of the wrong size reports itself by being the wrong size. - fn the_help_text_mentions_the_size_option
The help text has to name the option, because the manual page is generated from that text: an option the program accepts and the help does not mention is one nobody can find. - mod opening
- fn a_large_screen_gets_the_preferred_size
A big screen gets the size the application actually wants. - fn a_small_laptop_gets_a_window_that_fits_on_it
The machine this is really about: a window taller than the screen opens with its buttons off the bottom. - fn the_commonest_screen_of_all_gets_the_full_size
1080p is the commonest desktop screen there is, and the window should open at its full preferred size on one. A proportional allowance got this wrong, which is why the allowance is a number of pixels. - fn a_tiny_screen_still_gets_a_readable_window
Never below the floor, however small the screen claims to be. A window narrower than this has overlapping columns, which is worse than a window that does not fit. - fn an_explicit_size_is_not_second_guessed
`--size` is somebody saying what they want, including the screenshot harness, which needs the exact size or the pictures are not comparable. - fn an_unknown_screen_gets_the_preferred_size
With nothing known about the screen, the preferred size is the answer: opening too small is the complaint, and the first frame will report the monitor if it turns out to matter.
crates/veilvoice-meta/src/audio.rs
- (module)
Audio tag removal and replacement. Tags are handled through `lofty`, which understands ID3v1/ID3v2, Vorbis comments, MP4 atoms and APE, so one code path covers every format VeilVoice imports. Only the tag blocks are rewritten: the audio - (module)
stream is never re-encoded, so cleaning a file is lossless. # What tags give away Far more than a title. Recording software writes its own name and version; phones write a device model; some encoders write a timestamp, and a few write a - (module)
serial number or a user name taken from the account that made the file. None of that is audible, all of it survives de-identification untouched, and any of it can identify a speaker whose *voice* no longer does. Stripping the voiceprint - (module)
and leaving the tags would be a complete failure wearing the appearance of success, which is why this crate exists and why the CLI cleans by default. # Removal, or plausible replacement [`crate::Policy`] chooses between deleting tags - (module)
outright and writing bland ones. Both are legitimate: an empty tag block is itself a signal that a file has been processed, and in some situations looking ordinary matters more than being empty. # The gap `lofty` cannot close `lofty` - (module)
understands ID3v1/ID3v2, Vorbis comments, MP4 atoms and APE through one interface -- but **it cannot remove an ID3v2 block from a WAV file**. A WAV is a RIFF container and an ID3 chunk inside one is a chunk, not a tag, so the tag library - (module)
does not see it. That is what [`crate::wav`] is for: a chunk-level cleaner that walks the RIFF structure directly. Without it, cleaning a WAV reported success and left the identifying block in place. # In plain words Strips the hidden - (module)
information out of an audio file: the artist, the title, the comments, the software that made it, the date, and anything else somebody wrote into it. None of that is audible, and all of it travels with the file. A recording whose voice has - (module)
been carefully veiled is not anonymous if the file still says who recorded it and on what. - fn read_head
Read just enough of a file to identify its container. - const REALISTIC
Tags written in [`Policy::Realistic`] mode. Deliberately bland: the point is a file that looks ordinary, not one that tells a story. Nothing here names a device, a person, a place or a date, and the encoder string is one of the most common - const REALISTIC
in the world, so it blends in rather than fingerprinting VeilVoice itself. - fn clean_audio_file
Strip or replace the tags of an audio file, in place. The file is rewritten only if something actually changed. - fn clean_audio_tags
Report which tag blocks a file carries, without modifying it. Useful for showing the user what is about to be removed, and for verifying afterwards that nothing was left behind. - mod tests
- fn tagged_wav
A tiny but valid WAV, then tagged with something identifying. - fn audio_bytes
- fn strip_removes_every_tag
- fn identifying_strings_are_gone_from_the_bytes
- fn audio_samples_are_untouched
- fn realistic_leaves_plausible_tags_not_real_ones
- fn cleaning_twice_is_stable
- fn a_non_media_file_is_rejected_cleanly
crates/veilvoice-meta/src/image.rs
- (module)
Image EXIF/GPS removal. Images are handled at the container level with `img-parts`: the EXIF and XMP segments are dropped and the compressed pixel data is copied through untouched. Nothing is re-encoded, so cleaning is lossless and cannot - (module)
introduce visible artefacts. GPS coordinates are the reason this matters most. A single holiday snapshot attached to an otherwise anonymous message can place someone within a few metres, and no amount of voice processing helps with that. # - (module)
In plain words Strips the hidden information out of a picture, including where it was taken. Photographs from a phone routinely carry the exact location, the time, the device and its serial number. The picture is copied through untouched - (module)
and only those sections are dropped, so nothing about how it looks changes. - enum ImageKind
Image container formats this crate can clean. - impl ImageKind
- fn sniff
Identify a format from its magic bytes. Deliberately content-sniffed rather than taken from the extension: a file named `.png` that is really a JPEG must still get cleaned. - fn clean_image_bytes
Strip identifying metadata from encoded image bytes. Returns the cleaned bytes and a report of what was removed. - fn clean_image_file
Strip identifying metadata from an image file, in place. - mod tests
- fn jpeg_with_exif
A minimal JPEG carrying an APP1/EXIF segment with a GPS-looking payload. - fn sniffing_recognises_the_formats_it_supports
- fn sniffing_ignores_the_file_extension
- fn exif_and_its_gps_payload_are_removed
- fn the_result_is_still_a_valid_jpeg
- fn cleaning_twice_is_stable
- fn unsupported_input_is_rejected
- fn file_round_trip
crates/veilvoice-meta/src/lib.rs
- (module)
# veilvoice-meta Strip or spoof the identifying metadata that rides along with media files. ## Why this exists De-identifying a voice accomplishes nothing if the file still says who recorded it. A phone recording routinely carries the - (module)
device model, the recording software, a precise timestamp and, for images, GPS coordinates accurate to a few metres. That is often a far easier way to identify someone than analysing their voice, and it survives every DSP transform because - (module)
it is not in the audio at all. ## Strip versus spoof Removing every tag is not always the least conspicuous choice. A file with *no* metadata whatsoever is itself a signal: it says the sender was trying to hide something, and it stands out - (module)
in a set of otherwise ordinary files. [`Policy`] therefore offers two approaches: - [`Policy::Strip`] removes everything. Best when the file is expected to be sanitised anyway, or when any false statement would be worse than an obvious - (module)
absence. - [`Policy::Realistic`] replaces the tags with plausible, non-identifying values so the file looks unremarkable rather than scrubbed. ## What this crate cannot do It removes *container* metadata. It cannot remove information - (module)
encoded in the media itself: a photograph still shows the room it was taken in, and audio still carries its room acoustics and background noise. Nor does it touch filesystem timestamps or the filename, both of which are outside the file, - (module)
callers that care must handle those separately. # In plain words This strips the hidden labels off a file. Photographs and recordings carry information you never typed: where the picture was taken, which phone or microphone made it, what - (module)
the file was called before, sometimes a name. Removing the sound of a voice and leaving that behind would be pointless, so this takes it out -- not by blanking the fields, but by removing the parts of the file that hold them. - mod audio
- mod image
- mod wav
- const VERSION
Crate version string, surfaced in the About panel. - enum Policy
How aggressively to rewrite metadata. - struct Report
What changed in a single file. - impl Report
- fn note
- enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
crates/veilvoice-meta/src/wav.rs
- (module)
Chunk-level RIFF/WAVE metadata removal. # Why WAV gets its own path `lofty` handles tags in every other container, but it cannot remove an ID3v2 block from a WAV file: the attempt fails with an encoding error and the tag stays put. - (module)
Silently leaving metadata in place is exactly the failure this crate exists to prevent, and WAV is the format VeilVoice writes itself, so it gets a direct implementation rather than a caveat. # Whitelist, not blacklist A RIFF file is a - (module)
flat list of chunks, and metadata hides in a lot of them: `LIST`/`INFO` (artist, software, comments), `id3 ` and `ID3 `, `bext` (the broadcast extension, carrying originator, date and even a coding history), `iXML`, `_PMX` (XMP), `axml`, - (module)
`cart`. Enumerating those would leave every chunk nobody thought of, and new ones keep being invented. So this keeps only the chunks needed to decode the audio and drops everything else. Anything unrecognised is discarded by default, which - (module)
is the right bias for a privacy tool: the worst case is a lost non-essential chunk, not a leaked identity. # In plain words Strips the hidden information out of a WAV file specifically. WAV is built as a series of labelled sections, and - (module)
the ones carrying the sound sit alongside ones carrying text somebody or some program wrote. Those are removed and the sound is copied through exactly, byte for byte. It gets its own path because WAV is what VeilVoice writes, so this is - (module)
the one that runs on nearly every file it produces, and doing it directly means not handing VeilVoice's own output to a general-purpose parser. - const KEEP
Chunks required to interpret the audio. Everything else goes. - const REALISTIC_INFO
Tags written in [`Policy::Realistic`] mode, as `LIST`/`INFO` sub-chunks. - fn is_wav
Whether `bytes` looks like a RIFF/WAVE file. - fn clean_wav_bytes
Rewrite a WAV, keeping only the chunks needed to decode it. - fn info_chunk
Build a bland `LIST`/`INFO` chunk. - fn show
- mod tests
- fn chunk
- fn dirty_wav
A WAV carrying audio plus several places metadata likes to hide. - fn find
- fn the_bland_metadata_written_in_is_word_aligned_and_walkable
The bland tags written in are themselves a well-formed RIFF list. Stripping metadata is a signal, so plausible tags go in instead; that only works if a reader walking them by declared length lands on the end. - fn an_odd_chunk_at_the_very_end_is_not_read_past
An odd-sized last chunk with no pad byte after it, which is what a truncated recording looks like. - fn recognises_wav
- fn every_metadata_chunk_is_dropped
- fn identifying_strings_are_gone
- fn audio_and_format_survive_intact
- fn the_riff_size_header_is_corrected
- fn unknown_chunks_are_dropped_by_default
- fn odd_sized_chunks_keep_their_alignment
- fn realistic_mode_leaves_bland_tags_only
- fn cleaning_twice_is_stable
- fn a_lying_chunk_size_is_rejected_not_panicked
- fn a_wav_with_no_audio_is_rejected
crates/veilvoice-policy/src/lib.rs
- (module)
# veilvoice-policy Settings somebody else decided, sealed so they cannot be edited without a passphrase, and, more importantly, built so that editing them without one buys nothing worth having. ## The design decision this crate turns on A - (module)
policy file has an obvious problem. To *apply* a policy at every launch, the program has to be able to read it at every launch. If reading it needs a passphrase, the user types one every time, and if it does not, then anybody who can write - (module)
the file can rewrite the policy. The usual answers are a privileged daemon holding the key, or a key hidden in the binary, and neither is honest here: this project needs no privileges, and a key in a binary anybody can download is not a - (module)
key. So the constraint is moved into the shape of the data. **A requirement can only make VeilVoice stricter.** There is no requirement that turns encryption off, none that lowers the de-identification floor, none that disables the app - (module)
lock, and there is no room in [`Requirement`] to express one, because every variant is a tightening and the type has no other kind. Then somebody who edits the plain policy file without the passphrase can do exactly one thing: make this - (module)
machine's VeilVoice **more** restrictive than its owner asked for. That is a nuisance, and it is not a privacy failure: which is the failure this project exists to avoid. The passphrase-sealed copy is what proves the policy is the one the - (module)
administrator wrote; the shape of the type is what makes the answer survive the seal not having been checked yet. ## What the seal is for, and what it is not [`Policy::seal`] uses the same container as everything else here: Argon2id over - (module)
the passphrase, X25519 with ML-KEM-768 for the hybrid modes, XChaCha20-Poly1305 for the contents. [`verify`] opens the sealed copy and compares it against the plain one, which is how anybody with the passphrase establishes that the policy - (module)
in force is the policy that was written. It is **not** enforcement. Anything with write access to VeilVoice's own executable can replace VeilVoice, and no file it reads can prevent that. Anything running as the user can delete the policy - (module)
entirely. What a sealed policy gives is a policy that cannot be *quietly rewritten into something weaker*, and that is a smaller claim than "enforced" on purpose. See [`SCOPE`]. Detecting deletion is `veilvoice-guard`'s job, not this - (module)
one's: put the policy files in a tamper manifest and the removal shows up there. ## Reading a policy costs nothing [`Policy::load`] reads the plain file and applies it. It never asks for a passphrase, never blocks, and reports the seal as - (module)
[`Verification::Unchecked`] rather than pretending to have looked. A front end that wants the stronger statement calls [`verify`] when it has a passphrase to offer. # In plain words This lets settings be locked down, and only in one - (module)
direction. Someone setting up a machine for other people can seal a set of settings so they can be made stricter but never looser. Nobody needs a password to read what the rules are -- only to change them -- because a rule people cannot - (module)
see is a rule they will trip over. - mod mandate
- mod policy
- mod workspace
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What a sealed policy is worth, in the words a front end should show. Single-sourced and asserted by the tests, exactly as the app lock's and the tamper detector's notes are, so it cannot quietly turn into a promise. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl From<veilvoice_crypto::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn the_scope_note_states_the_limits_rather_than_a_guarantee
The claim must keep stating the limits. If somebody edits this into a promise, this is what stops it shipping. - fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-policy/src/mandate.rs
- (module)
The two things VeilVoice insists on unless you say otherwise. # What this is By default VeilVoice requires **an app lock** and **encryption of every recording at rest**. Both matter for the same reason: de-identification removes the - (module)
voiceprint but keeps the words, so a veiled recording is still a recording of everything that was said, and an unlocked application sitting open is still a window into what you have processed. So both are on, by default, without anybody - (module)
choosing them. This module is how you *stop* insisting on one or both -- a deliberate, recorded choice rather than a setting that quietly drifts. # It is not the sealed policy, and the difference is the point [`crate::Policy`] is the - (module)
sealed, administrator-set policy that can only ever make VeilVoice **stricter** and cannot be weakened without the passphrase. This is the opposite tool for the opposite person: it is *your own* baseline, plainly stored, that you may - (module)
relax. The two compose safely -- the effective requirement is this baseline OR whatever the sealed policy adds -- so an administrator can still force on something you turned off, and never the other way round. # Why it keeps a history - (module)
Turning off encryption or the app lock is exactly the kind of change someone should be able to see was made, when, and away from what. So every change is appended to a log with its timestamp and whether the value it left was the default. - (module)
Nothing here is secret -- it is your own record of your own decisions -- so it is a plain file you can read. # In plain words VeilVoice asks for a password for itself and encrypts your recordings, unless you deliberately turn one or both - (module)
off. It remembers when you did, and what it was before, so the choice is never a mystery later. - const MAGIC
The magic on the first line, so a stray file is not mistaken for this one. - enum Field
Which requirement a change concerns. - impl Field
- fn key
The word used in the file and on the command line. - fn from_key
The field a key in the file names, or `None` for one this version does not know. An unknown key is not an error here: it is a file written by a newer VeilVoice, and refusing to read the rest of it would lose settings this version does - fn from_key
understand. - struct Change
One recorded change. - struct Mandate
The current requirements, and the log of how they got there. - impl Default for Mandate
- fn default
Both required. This is what a machine that has never been told otherwise insists on. - fn now
The clock as seconds since the epoch, without panicking on a clock set before it. A machine whose clock is behind 1970 gives a negative answer rather than an error, because a timestamp in the history is a record of when something happened - fn now
and a broken clock is not a reason to refuse to record it. - impl Mandate
- fn requires_app_lock
Whether an app lock is required. - fn requires_encryption
Whether encryption of recordings at rest is required. - fn requires
The value of one field. - fn is_default
Whether this is still the default: both required, nothing turned off. - fn history
The change log, oldest first. - fn set
Set one requirement, recording the change if it is actually a change. Returns whether anything changed. Setting a field to the value it already has is a no-op and is not logged, so the history stays a record of real decisions rather than - fn set
repeated commands. - fn set_at
Set one field as of `at`, answering whether anything actually changed. Taking the time as an argument rather than reading the clock is what lets the history be tested. An unchanged value writes nothing, so re-applying a setting does not - fn set_at
fill the log with entries that say nothing happened. - fn reset
Return to the default (both required), recording the changes. - fn reset_at
Turn every requirement on as of `at`, answering whether anything changed. Every field here only ever tightens, so the reset is to the strictest position rather than to a default. - fn parse
Parse the file format. - fn to_text
Render the file format. - fn load
Load from `path`, or the default if it is not there. A file that will not parse is an error rather than a silent default: silently defaulting would turn a corrupt file into "both required", which is the safe direction but hides that - fn load
something is wrong. The caller decides what to do with the error; the desktop application shows it and keeps the strict default. - fn save
Write to `path`, owner-only. - fn default_path
Where the mandate file lives: beside the app lock, under its own name. - fn parse_bool
A yes or no in any of the spellings a person might write, or `None`. Anything unrecognised is refused rather than read as false: a policy file with a typo in it must not quietly become a policy that requires nothing. - fn yesno
A boolean in the spelling this file is written in. - impl Change
- fn when
When the change was made, as a UTC civil timestamp. - fn describe
A whole sentence describing the change, for a log a person reads. - fn utc
Unix seconds as `YYYY-MM-DD HH:MM:SS UTC`. Written out rather than pulled from a date crate. The history is the one place this program shows a wall-clock time it did not get from the operating system's own formatter, and a dependency whose - fn utc
whole job is this line would be a supply chain nobody has read, for one line. UTC, always, and it says so. A local time here would be a time whose meaning depends on where the reader was standing when they read it, in a log whose entire - fn utc
purpose is to settle when something happened. - fn civil_from_days
Days since 1970-01-01 to a civil year, month and day. Howard Hinnant's `civil_from_days`, which is exact for the whole proleptic Gregorian calendar and needs no table of month lengths or leap years: the era arithmetic makes 1 March the - fn civil_from_days
start of the year, so the leap day lands at the end where it stops being a special case. - mod tests
- fn the_epoch_and_the_dates_that_usually_break_this
- fn a_time_before_the_epoch_does_not_wrap_into_a_negative_clock
- fn a_change_describes_itself_in_both_directions
- fn the_default_requires_both
- fn turning_one_off_records_it
- fn setting_a_field_to_what_it_already_is_changes_nothing
- fn reset_puts_both_back_and_logs_only_what_moved
- fn reset_from_default_does_nothing
- fn it_round_trips_through_its_text_format
- fn a_file_without_the_magic_is_refused
- fn a_missing_file_is_the_default_not_an_error
- fn it_survives_a_real_file
- fn a_corrupt_file_is_an_error_rather_than_a_silent_default
- fn unknown_requirements_are_refused_rather_than_ignored
crates/veilvoice-policy/src/policy.rs
- (module)
The policy itself: what can be required, and what requiring it does. # Every requirement tightens, and there is nowhere to write one that does not [`Requirement`] has five variants and all five move VeilVoice in the same direction. There - (module)
is no `AllowPlaintext`, no `MaximumIntensity`, no `SkipMetadataCleaning`. That is not an oversight to be filled in later; it is the property the whole crate rests on, and [`Posture::is_at_least_as_strict_as`] exists so a test can hold it. - (module)
Anybody adding a variant should read [`crate`]'s documentation first. A loosening variant does not merely add a feature: it removes the reason the plain file can be read without a passphrase. # Format Text, one requirement per line, for - (module)
the same reason the tamper manifest is text: the point of the file is to be readable by the person it constrains. ```text VEILPOLICY1 note Set by the IT department. Ask before changing. require encrypt-recordings require clean-metadata - (module)
require minimum-intensity 80 ``` The floor is a whole number of hundredths, not a decimal. A policy has to compare equal to its own sealed copy, and a value that reads back as 0.7999999 would make [`crate::verify`] report `Differs` for - (module)
ever. An unknown `require` keyword is an **error**, not a line to skip. A policy written by a newer build says something this one cannot honour, and quietly honouring the rest would leave the machine less restricted than the person who - (module)
wrote it believes. Refusing says so. # In plain words A way to say "these settings must always be on", so that they cannot be turned off later by accident. Everything here only ever tightens. There is deliberately no way to write a rule - (module)
that makes VeilVoice do less, because a settings file that could weaken the program would be the first thing worth attacking. If a rule and a control disagree, the rule wins and the window shows you the value that will actually be used, - (module)
rather than one that quietly changes when you press the button. - const MAGIC
Magic first line. The digit is a format version. - const PLAIN_FILE
The plain policy, read at every launch and needing no passphrase. - const SEALED_FILE
The same policy sealed under a passphrase, for proving the plain one is what was written. - enum Requirement
One thing a policy can insist on. **Every variant tightens.** See the module documentation before adding one. - impl Requirement
- fn keyword
The keyword this is written as. - fn describe
What this means, in the words a front end should show beside the control it has taken away. A disabled control with no explanation is the thing people complain about; a disabled control with a reason is a decision somebody made. - struct Posture
The settings a policy can reach, as a front end holds them. Deliberately small. This is not a copy of the preferences file. It is the subset a policy is allowed to constrain, which is the subset where being *more* strict is never a loss. - impl Default for Posture
- fn default
VeilVoice's own defaults, which are the strict ones. - impl Posture
- fn most_permissive
The most permissive arrangement the controls can reach. Not a default anybody gets. It exists so a test can start from the loosest possible state and prove that applying a policy never loosens it further. - fn is_at_least_as_strict_as
Whether `self` is at least as strict as `other` in every dimension. The property the whole crate rests on: `policy.constrain(p)` must always be at least as strict as `p`, for every policy and every `p`. - struct Policy
A set of requirements, and an optional note from whoever wrote them. - impl Policy
- fn new
A policy that requires nothing. - fn require
Add a requirement. - fn with_note
Set the note shown beside every control the policy has fixed. Line breaks are refused rather than escaped: the format is one record per line, and a note containing a newline could forge a `require` line. - fn note
The note, if there is one. - fn is_empty
Whether anything at all is required. - fn len
How many requirements there are. - fn requirements
The requirements, in a stable order. - fn requires
Whether a particular requirement is in force. - fn minimum_intensity
The intensity floor, or 0.0 when none is set. - fn constrain
Apply the policy to a posture. Only ever tightens. The test suite holds that as a property across every subset of requirements and a range of postures, rather than trusting the five lines below to keep saying what they say today. - fn to_text
Serialise to the text format described at the top of this module. - fn parse
Parse the text format. An unrecognised requirement is refused. See the module note: honouring the rest of a policy this build does not fully understand leaves the machine less restricted than whoever wrote it believes. - fn seal
Seal the policy under a passphrase. The sealed copy is what proves the plain one is the policy that was written. It is not what makes the policy apply. See [`crate`]. - fn open_sealed
Open a policy sealed by [`Policy::seal`]. F-92, the third place the same question comes up. The generous four-gigabyte ceiling is for a container somebody was sent and chose to open. This file is not that: it sits at a fixed path beside - fn open_sealed
the policy, and the person running `veilvoice policy verify` chose the command, not the file. [`Policy::seal`] writes it at this crate's default cost, so a ceiling of one gigabyte leaves four times the headroom anything legitimate needs - fn open_sealed
and refuses a planted file instead of allocating for it. Changed at the same time as the sealed manifest, deliberately. Fixing the two places a campaign happened to point at and leaving the third would be the exclusion list that names the - fn open_sealed
files somebody thought of. - fn save
Write the plain policy into `dir`, and the sealed copy beside it. Both, always. A plain file with no sealed copy beside it is a policy nobody can check, and writing one silently would make [`verify`]'s [`Verification::NotSealed`] - fn save
indistinguishable from a sealed copy that somebody deleted. - fn load
Read the plain policy from `dir`. Never asks for a passphrase. `Ok(None)` when there is no policy at all, which is the ordinary state and not an error. - fn requirement_from
- enum Verification
What is known about the seal on a policy. - impl Verification
- fn describe
One line for a front end. Says what is known, never more. - fn wants_attention
Whether this is a state somebody should look at. - fn verify
Check the plain policy in `dir` against its sealed copy. This is the only function here that needs a passphrase, and nothing calls it at launch. - mod tests
- fn the_example_in_the_user_guide_parses
The policy file printed in `docs/USER_GUIDE.md` has to parse. It did not. The first version of that section documented `key = value` with a single space, because that is what a policy file *looks* like it should be; the format is two - fn the_example_in_the_user_guide_parses
spaces and no equals sign, and it also wants `VEILPOLICY1` on the first line and an intensity from 0 to 100 rather than 1 to 5. Three mistakes in six lines, none of which a reader could have diagnosed from the documentation, all of which - fn the_example_in_the_user_guide_parses
the parser rejects with a clear message the moment anybody runs it. So the documented example is extracted from the guide and parsed here. A file format described in prose beside a parser that disagrees is a documentation defect with a - fn the_example_in_the_user_guide_parses
working reproduction, and this is it. - fn every_requirement_the_guide_lists_is_a_real_one
And the guide's table of requirements has to name real ones. - fn sealed_with
A cheap KDF for the tests. The default is 256 MiB and three passes, deliberately, and running it in twenty tests would make the suite take minutes for no extra coverage of anything in this crate. - fn every_requirement
- fn a_policy_can_only_ever_tighten
**The property the crate rests on.** Every subset of every requirement, against a range of postures: applying a policy must never loosen one. Written as an exhaustive check rather than an example, because the interesting failure is a - fn a_policy_can_only_ever_tighten
variant somebody adds later that goes the other way, and no example test would catch it. - fn constraining_is_idempotent
Applying a policy twice must give the same answer as applying it once, or a front end that constrains on every frame drifts. - fn an_empty_policy_changes_nothing
- fn a_floor_raises_a_low_intensity_and_leaves_a_high_one
- fn a_second_floor_replaces_the_first
Two floors in one policy would serialise as two lines and the higher would silently win, which is a file that does not say what it does. - fn a_policy_survives_a_round_trip_through_text
- fn every_floor_round_trips_exactly
The floor must come back exactly, or a policy can never equal its own sealed copy and `verify` says `Differs` for ever. - fn a_note_may_not_contain_a_line_break
- fn an_unknown_requirement_refuses_the_whole_policy
A policy this build only half understands would leave the machine less restricted than whoever wrote it believes. - fn a_malformed_policy_is_refused_rather_than_half_read
- fn blank_lines_are_tolerated
- fn a_sealed_policy_opens_with_the_right_passphrase_and_not_the_wrong_one
- fn verification_reports_a_match
- fn verification_reports_an_edited_plain_file_and_says_what_was_sealed
The case the seal exists for: somebody edited the plain file. - fn a_plain_policy_with_no_seal_is_reported_as_unsealed
- fn a_seal_with_no_plain_file_says_nothing_is_being_applied
A sealed copy with the plain file deleted means nothing is applied, and that is a different state from "no policy here". - fn no_policy_at_all_is_not_an_error
- fn saving_writes_both_the_plain_and_the_sealed_copy
`save` writes both files, always. - fn the_unchecked_state_explains_why_it_is_still_applied
Loading never asks for a passphrase, so what it can say about the seal is nothing -- and it says exactly that. - fn every_requirement_explains_itself_without_overclaiming
Every requirement explains itself beside the control it disables, and none of them overstates what VeilVoice does. - fn keywords_are_unique_and_match_what_parses
- fn the_strictness_comparison_is_honest_in_both_directions
crates/veilvoice-policy/src/workspace.rs
- (module)
Named profiles and saved projects. Two things, which sound alike and are not: * A [`Profile`] is a **way of working**, a named set of settings you can switch to. "Anonymise one person, as thoroughly as this can." "A group, everybody the - (module)
same voice." Three are built in and you can save your own. * A [`Workspace`] is **one piece of work**: which recording, which plan, who is in it and what they are called, and the profile it was done under. Saved beside the recording, so - (module)
opening it a week later puts everything back where it was. # Why a project file is worth having here in particular Setting up a group recording is a dozen small decisions: who is in it, what each is called, what colour each is drawn in, - (module)
whether they share a voice. Getting halfway through and having to stop is ordinary. Losing all of it because the window was closed is not, and it is worse than usual here, because the *recording* may be the only copy of something that - (module)
cannot be made again. # Plain text, and the same shape as a plan `VEILWORK1`, then one `key value` per line. The same format `veilvoice-conversation` uses for a plan, for the same reasons: it is readable, diffable, greppable, and has no - (module)
syntax in which something surprising can hide. A project file is a description of your work; it should not require this program to read it. # What a project file does **not** contain **No audio and no passwords.** It records the *path* to - (module)
a recording, not the recording, and nothing about how anything is encrypted. A workspace is a thing you might send somebody so they can set their machine up the same way; if it carried a passphrase, sending it would be handing over the - (module)
recordings too, and people would find that out afterwards. Speaker names *are* in it, because the whole point is to put them back, and a name is a name. The file says so. # In plain words This is the "save my project" and "load my setup" - (module)
part. Profiles are named ways of working you can flip between: one for anonymising a single person as thoroughly as possible, one for a group where everybody keeps their own disguised voice, one for a group where everybody sounds identical - (module)
and only the names tell you who is speaking. A project file remembers the rest: which recording you were working on, who is in it, what you called them and what colour each one is. Open it next week and everything is where you left it. It - (module)
does not contain the recording itself and it does not contain any password. It does contain the names you typed, because putting those back is the point of it. - const MAGIC
The first line of a project file. - enum Error
Something that could not be read. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- struct Profile
- impl Profile
- fn applied_to
The engine settings this profile asks for, over a starting point. The sample rate is left alone: it belongs to the recording, not to a way of working, and a profile that overwrote it would resample somebody's audio because they picked a - fn applied_to
preset. - const INDIVIDUAL
Anonymise one person, with everything this engine has turned on. - const GROUP_VOICES
A group, each person with their own disguised voice. - const GROUP_ONE_VOICE
A group where nobody can be picked out by sound at all. - const BUILT_IN
The profiles that ship, in the order a picker shows them. - fn profile
The profile with this identifier. `None` rather than a fallback: a project file naming a profile this build does not have should say so, not open in a different one and let somebody render under settings they did not choose. - fn default_profile
The profile a fresh install starts in. - struct Member
One person in a saved project. - struct Workspace
One piece of work, saved. - impl Workspace
- fn new
A new, empty project under the default profile. - fn to_text
Serialise to the text format. - fn parse
Parse the text format. An unknown keyword is **refused**, not skipped. A project file written by a newer build may describe a setup this one cannot reproduce, and quietly honouring the half it understands would put somebody's recording - fn parse
through settings they did not choose, which is the same reasoning `veilvoice-conversation` gives for refusing an unknown line in a plan. - fn load
Read a project file. - fn save
Write a project file, creating the directory if needed. - fn profile
The profile this project names, if this build has it. - fn take_token
The first whitespace-delimited token, and everything after it. The remainder keeps its own internal spacing, because the last field on a line is a name and a name may contain spaces. - fn one_line
A value with no line break in it. A newline inside a name would let one line become two, and the second could claim to be any keyword it liked, the same forging a plan's speaker names are already guarded against. - fn path_text
A path as one line, with Windows separators normalised. - mod tests
- fn sample
- fn a_project_round_trips_through_its_text_format
- fn every_shape_of_project_round_trips
Saving and reading back must give the same project, for every shape a project can be in -- not only the tidy one in `sample`. This is where F-66 came from: `Some(" ")` was written as `title ` and read back as `Some("")`, which is neither - fn every_shape_of_project_round_trips
what was saved nor absent. A round trip that quietly changes its answer is worse than one that fails, because nothing reports it. - fn an_empty_value_is_absent_rather_than_present_and_empty
A value that trims to nothing is absent, in both directions. - fn a_new_project_writes_all_three_outputs
- fn it_saves_and_loads_through_a_real_file
- fn a_saved_project_is_readable_only_by_this_account
A saved project is readable only by the account that saved it. The panel that writes these says what they hold -- where your files are, who is in the recording, what you called them -- and says it to reassure: no audio, no passwords. The - fn a_saved_project_is_readable_only_by_this_account
names are the part that reassurance does not cover, and this was written 0644, readable by every other account on the machine. - fn a_project_file_carries_no_secret_and_no_audio
**No passwords and no audio.** A project file is a thing somebody might send; if it carried a passphrase, sending it would hand over the recordings too, and people would learn that afterwards. - fn an_unknown_keyword_is_refused_rather_than_ignored
An unknown keyword is refused rather than skipped, and the refusal says why that is the safe answer. - fn a_file_that_is_not_a_project_is_refused_by_its_first_line
- fn a_gap_in_the_slots_is_refused
A gap in the slots would move everybody after it onto a different voice from the one they were saved with -- silently, and only audible as "somebody sounds wrong". - fn a_repeated_slot_is_refused
- fn a_member_with_no_name_is_refused
- fn a_name_cannot_forge_a_second_line
A name with a line break in it could forge a record. The writer strips it rather than producing a file that reads back as something else. - fn an_unknown_profile_is_reported_rather_than_replaced
A profile this build does not have is reported, not silently swapped for one it does. Rendering under settings somebody did not choose is the failure to avoid. - fn every_built_in_profile_is_findable_and_uniquely_named
- fn every_profile_note_states_a_limit
Every profile's note has to say what it does **not** do as well as what it does. A name like "highest security" doing the work of an explanation is the thing this project refuses everywhere else. - fn a_profile_leaves_the_sample_rate_alone
A profile shapes the engine but must not touch the sample rate, which belongs to the recording. Overwriting it would resample somebody's audio because they picked a preset. - fn the_two_group_profiles_differ_only_in_the_voices
The two group profiles differ in exactly one thing, which is the point of there being two of them. Looked up by identifier rather than named directly: an assertion about two `const`s is one the compiler can fold away, and clippy is right - fn the_two_group_profiles_differ_only_in_the_voices
that a test which cannot fail is not a test. Going through `profile` is the path a front end takes anyway. - fn no_profile_turns_off_a_protection
Every profile seals what it writes and strips metadata. A preset that quietly turned either off would be a preset that loses somebody data they thought was protected.
crates/veilvoice-setup/src/companions.rs
- (module)
Optional third-party software, detected rather than assumed. Four programs make VeilVoice easier to live with and none of them is part of VeilVoice. A virtual audio cable is what lets live mode feed a veiled microphone into a video call; - (module)
an audio editor is how most people trim a recording before veiling it. This module says which are already on the machine, who makes each one, under what licence, and, for the ones that are not, exactly what command would install it. # - (module)
Three rules, and none of them relaxes **Nothing is installed without an explicit yes.** There are no checkboxes here to leave ticked. [`Companion::offer`] produces a command; running it is a separate deliberate act by the caller, on one - (module)
named program at a time. **VeilVoice never runs somebody else's installer.** Where the software is proprietary or ships as a driver, and VB-CABLE is both, the offer is to open the vendor's page, not to fetch and execute an unverified - (module)
binary. This project's front page is about verifying what you run; downloading a signed release, checking its signature, and then silently running an unchecked third-party `.exe` from the same program would be a strange thing to do with - (module)
that reputation. **Privilege is reported, never requested.** A system package manager needs root, and a graphical program cannot honestly collect a `sudo` password. [`Offer::Command`] carries a `needs_privilege` flag, [`run`] refuses any - (module)
command that sets it, and a front end shows such a command rather than pretending to run it. # Detection is a probe, and says when it could not tell [`Presence`] has three states, not two. "I looked in the places this software installs - (module)
itself and found nothing" and "I could not read the place I wanted to look" are different answers, and reporting the second as the first is how a tool ends up offering to install something that is already there. Every probe here is a - (module)
file-system or `PATH` lookup: no subprocess, no registry sweep, and nothing that takes long enough to need a spinner. The probes look where each program installs itself by default. Somebody who has put Audacity somewhere unusual will be - (module)
told it was not detected, which is exactly what the words say: [`Presence::NotDetected`] is not a claim that the software is absent from the machine. # In plain words Optional extra software that makes VeilVoice easier to live with, none - (module)
of which is part of VeilVoice. Each one is looked for rather than assumed, and each is described first: what it is, who makes it, what licence it has and why VeilVoice mentions it at all. Nothing is installed without an explicit yes, and - (module)
the exact command is shown before the question. - enum Presence
What a probe found. - impl Presence
- fn is_present
True only for [`Presence::Present`]. Named so the reading is unambiguous at a call site: an `Unknown` is not a `false` about the software, it is a `false` about the probe. - fn describe
A short line for a user interface, in the project's usual register. - enum Offer
What VeilVoice can offer to do about a companion that is not there. - impl Offer
- fn is_runnable
True when a front end may run this itself. False for everything that needs privilege, opens a browser, or has no route, each of which is a decision for the person at the keyboard. - fn command_line
The command as a single line, for showing or copying. `None` when this offer is not a command. - struct Companion
One piece of optional third-party software. The prose fields are not decoration. Somebody being asked to install software is entitled to know who wrote it and under what licence before they answer, and burying that in a manual is the same - struct Companion
as not saying it. - impl Companion
- fn detect
Look for it, without changing anything. - fn offer
What could be done about it on this platform. - const ALL
Every companion this project knows about, in the order a front end should show them. The audio routing one comes first because it is the one live mode actually needs; the editor is a convenience. Entries for other platforms are - const ALL
deliberately kept in the list rather than compiled away: [`for_this_platform`] filters, and a reader looking at the source should be able to see the whole set without switching machines. - fn for_this_platform
The companions that mean anything on the platform this is running on. Audacity is on every platform; each virtual-cable entry is on exactly one. Offering a macOS driver to a Windows user is noise, and noise in a security tool's interface - fn for_this_platform
is how the parts that matter stop being read. - fn by_key
Find one by [`Companion::key`]. - fn run
Run an offer, and return what it printed. Refuses anything [`Offer::is_runnable`] rejects. That refusal is the point: a front end that has a "yes" button should not be able to turn it into `sudo` by passing a different offer, and the check - fn run
lives here rather than in each front end so there is one of it. - fn open_page
Open a companion's own page in the user's browser. This is what VeilVoice does instead of installing proprietary software: it takes you to the people who wrote it, so their licence is accepted by the person it binds and their installer is - fn open_page
run by the person who chose it. Refuses anything that is not `https://`. The URLs here are constants in this file rather than anything a user or a file supplies, so this cannot currently fail -- which is the reason to check now, while it - fn open_page
is cheap, rather than after somebody makes the list configurable. - fn on_path
Is `stem` an executable on this process's `PATH`? Walks the entries rather than spawning `where`/`which`, which would cost a subprocess to answer a question about a string this process already holds. - fn first_existing
The first of `candidates` that exists. - fn env_path
- fn detect_vb_cable
VB-CABLE installs an audio driver, and a driver is a file in a directory any user can read. That is a faster and steadier probe than sweeping the uninstall registry, which is large, slow, and describes what was installed rather than what - fn detect_vb_cable
is loaded. - fn detect_blackhole
BlackHole is a HAL plug-in, and those live in exactly one place. - fn detect_pipewire
PipeWire is running or it is not, and `pw-cli` beside it is the sign that the userspace tools are installed. Either name is enough to say "present". - fn detect_audacity
Audacity is on `PATH` on Unix and in one of three directories on Windows. - fn unix_package
The install command for `package` from whichever Unix package manager is here. **Written once.** This loop existed twice, in `gnupg_offer` and `audacity_offer`, and adding `ffmpeg` would have made three copies of a decision about which - fn unix_package
package managers this project recognises and in what order. A third copy is the point at which they start disagreeing. The managers below are the same set `install/install.sh` recognises, so the two agree about what this machine is rather - fn unix_package
than each having their own opinion of it. `sudo` is in the command and the command is marked as needing privilege, so a front end shows it rather than running it. This program does not ask for a root password. - fn detect_ffmpeg
ffmpeg, which is on `PATH` or is not. `PATH` only, and for the same reason GnuPG is: the command a render hands over is `ffmpeg ...`, so an ffmpeg the shell cannot find is one the reader cannot use, and calling it present would be - fn detect_ffmpeg
reporting something untrue about the thing they are about to try. - fn ffmpeg_offer
How ffmpeg would be installed here. - fn brew
- fn detect_gnupg
GnuPG, which is on `PATH` or is not. No hunt through program directories, unlike the audio companions. A `gpg` that is installed but not on `PATH` is one the commands printed beside the verifier would not find either, so reporting it as - fn detect_gnupg
present would be reporting something the reader cannot use. - fn gnupg_offer
How GnuPG would be installed here. On Windows this is Gpg4win through winget, which is the packaging almost everybody means by "GnuPG on Windows". The other route on Windows is a `gpg` inside WSL, which is not an install of anything on - fn gnupg_offer
Windows itself and is offered separately by `veilvoice_verify::gnupg`. - fn audacity_offer
The route to Audacity differs per platform, and on Linux per distribution. - const UNIX_PACKAGE_MANAGERS
Package managers, and the arguments that install one named package non-interactively. The same set and the same order as `install/install.sh`. - mod tests
- fn every_probe_and_offer_documents_itself
Every probe and every offer says what it is for, on itself. **F-172.** `audacity_offer` lost its documentation and `detect_gnupg` gained it, because factoring `unix_package` out of three copies left the old block behind and the next item - fn every_probe_and_offer_documents_itself
down inherited it. A doc comment attaches to the item that follows it, which is F-169 in a different syntax and the second time an insertion has taken a block from the item it belonged to. It reached the published wiki, which listed "the - fn every_probe_and_offer_documents_itself
route to Audacity differs per platform" as the description of the function that looks for GnuPG. Generated documentation does not know a sentence is about the wrong thing; it repeats it faithfully, which is what makes this worth a guard - fn every_probe_and_offer_documents_itself
rather than a careful read. This reads the file rather than the items, because a private function carries no documentation `rustdoc` or `missing_docs` can see. It cannot tell whether a sentence is *about* the function it sits on, but it - fn every_probe_and_offer_documents_itself
does catch the half that is mechanical: an offer or a probe with nothing on it at all. - fn every_companion_has_a_probe_and_a_route
- fn detection_changes_nothing_and_is_repeatable
- fn keys_are_unique
The keys a front end stores must be stable and unique. - fn each_one_says_who_wrote_it_and_under_what_licence
Every companion names its author and its licence. Somebody being asked to install software is entitled to both before they answer, and an empty string here would render as a blank line nobody notices. - fn proprietary_software_is_only_ever_a_link
VB-CABLE is proprietary donationware, and the offer must never become a download-and-run. This is a locked position, not a default. - fn run_refuses_anything_needing_privilege
A front end must not be able to hand `run` something that needs root. - fn a_sudo_command_is_always_marked_privileged
Any command that starts with `sudo` must be marked as needing privilege, or `run` would happily spawn it and block on a password prompt no window can answer. - fn not_detected_does_not_claim_absence
Presence has three states and the middle one is not a claim about the machine. If this ever reads "not installed" the wording has drifted. - fn this_platform_has_at_least_the_editor
The platform list is a filter, never an empty screen: Audacity applies everywhere, so there is always at least one entry. - fn one_virtual_cable_at_most_per_platform
Exactly one virtual-cable companion is shown per platform. Two would mean somebody is being offered a driver for an operating system they are not running. - fn only_https_pages_are_opened
Only https, and the check is here rather than at each call site. - fn every_page_is_one_that_could_be_opened
Every page in the list must be one `open_page` would accept, or a button in the interface leads to an error message. - fn a_missing_key_is_none
- fn nothing_on_path_is_found_for_a_nonsense_name
- mod ffmpeg_tests
- fn ffmpeg_is_a_companion_and_says_what_still_works_without_it
ffmpeg is in the list, because the video render is what asks for it. It was not, which is the whole of this addition: a render without ffmpeg said what was missing and the tab that installs things had never heard of it, so the message - fn ffmpeg_is_a_companion_and_says_what_still_works_without_it
named something the reader could not act on. - fn ffmpeg_is_offered_on_this_platform
It is offered on every platform this program runs on. The virtual-cable entries are each on exactly one platform, deliberately. ffmpeg is not: the video render exists everywhere, so a reader who cannot see the row would be told to install - fn ffmpeg_is_offered_on_this_platform
something the tab denies exists. - fn the_ffmpeg_route_is_either_a_readable_command_or_a_stated_reason
Its route is either a command that can be shown, or a reason there is none. Never a silent nothing: the two failures this guards are an empty button and a command that appears without the reader being able to read it first. - fn every_command_route_names_where_it_came_from
Every companion whose route is a command names a package manager. The loop that builds these is now written once rather than three times, and this is what says the three callers still get the same shape out of it. - fn anything_that_says_sudo_admits_it_needs_privilege
A `sudo` command is always marked as needing privilege. A front end may run anything not so marked. One that says `sudo` and claims otherwise would be a prompt for a root password appearing from a button somebody pressed for a different - fn anything_that_says_sudo_admits_it_needs_privilege
reason.
crates/veilvoice-setup/src/install.rs
- (module)
Put this program somewhere the system can find it. Reached as `veilvoice install` on the command line and as the setup tab in the desktop application. Both call the functions below; neither has a copy of them. See the crate documentation - (module)
for why that mattered enough to move this file out of the binary it used to live in. # Portable is the default, and installing is the exception VeilVoice runs from wherever it is unpacked. Nothing has to be installed, nothing is written - (module)
outside the folder unless the user does something that writes outside the folder, and deleting the folder removes it. That is the posture this project has always had and it is not being given up. This exists because "runs from anywhere" - (module)
and "I would like to type `veilvoice` in a terminal" are both reasonable, and the second needs three things a portable folder cannot provide: a stable location, an entry on `PATH`, and a way for the operating system to list and remove it. - (module)
# No administrator, and nothing outside the user's own account Everything here is per-user: `%LOCALAPPDATA%` on Windows, `~/.local` on everything else, and on Windows the `HKCU` registry rather than `HKLM`. No elevation is requested, - (module)
nothing is written to a system directory, and no service is created. That is a deliberate limit rather than an oversight. A per-user install can be undone by the user who made it, needs no privilege to audit, and cannot break anybody - (module)
else's account. A machine-wide install would need administrator rights, and the reason to ask for those has to be better than "so the program is on everyone's PATH". # Every change is reversible, and `uninstall` reverses exactly these | - (module)
What | Where | Undone by | |---|---|---| | The binaries | `<prefix>/VeilVoice` | removing that directory | | `PATH` entry | `HKCU\Environment`, or a shell profile line | removing just that entry | | Uninstall entry | - (module)
`HKCU\...\Uninstall\VeilVoice` | deleting that key | The `PATH` edit is the one that can damage something, so it is the one handled most carefully: the existing value is read, the entry is appended only if absent, and removal takes out - (module)
that entry and nothing else. A tool that overwrites `PATH` wholesale has broken a machine, and doing it during an *uninstall* is worse -- that is the moment somebody is least inclined to check. # Why the registry through `reg.exe` The same - (module)
reason `veilvoice-watch` reads it that way: this workspace carries `#![forbid(unsafe_code)]` in every crate, and the Win32 registry API needs `unsafe` FFI. Shelling out to a system tool keeps that guarantee and costs a subprocess on an - (module)
operation that runs once. `reg.exe` is resolved by absolute path -- resolving it by name would search the working directory first, which is finding F-13. # In plain words Copies VeilVoice somewhere your system can find it, and adds that - (module)
place to your path so typing `veilvoice` works. It installs for you alone and needs no administrator rights. It also registers with the system's own list of installed programs, so removing it works the way removing anything else does. - (module)
Running VeilVoice straight out of a folder is a perfectly good way to use it, and the setup screen says so rather than treating portable as something missing. - const NAME
The name of the directory and the uninstall entry. Windows-only: elsewhere the prefix follows the XDG convention and is lower-case, so this constant has no reader. - const PROGRAMS
Files that make up an installation, if they are beside the running binary. The two the release publishes. A third, `veilvoice-verify`, existed until 0.1.18 and is now inside both, so looking for it here would only ever find a stale copy - const PROGRAMS
left behind by an older install. - fn reg_exe
`reg.exe`, by absolute path. Never by bare name: Windows searches the current directory before most of `PATH`, so running this from a folder containing `reg.exe` would run that instead. This is the program that edits `PATH`, so it is a - fn reg_exe
poor place to be relaxed about which binary is doing it. - fn prefix
Where an installation goes, for this user only. - fn bin_dir
The directory a `PATH` entry should point at. - struct Status
What an installation currently looks like. Read by [`status`], which changes nothing. Every field is a separate fact on purpose: "something is installed" and "you are running the installed copy" are different, and a front end that - struct Status
conflates them tells somebody editing a portable folder that their changes took effect. - fn status
Read the current state without changing anything. - fn exe_name
A program's file name on this platform. - fn path_contains
Is `dir` already on this user's `PATH`? Reads the *current process* environment, which is what "will typing `veilvoice` work in this terminal" actually depends on. A registry value that a new terminal would pick up is a different question, - fn path_contains
and the report says which one it answered. - fn copy_programs
Copy the binaries into place. Returns what was copied. - fn add_to_path
Add `dir` to the user's `PATH`, if it is not there already. Reads the existing value and appends. Never writes a `PATH` it did not first read: replacing that variable wholesale is how a tool breaks a machine, and there is no undo. - enum UserPath
- fn read_user_path
Read this user's `PATH`, distinguishing "not set" from "could not tell". The first version returned an empty string for both, and the caller treats empty as "there is no PATH yet, write a fresh one" -- so a query that failed for any reason - fn read_user_path
would have replaced the user's entire `PATH` with a single entry. The comment at the top of this file already said that was the thing to avoid; the code did not implement it. `reg query` exits non-zero for a missing value *and* for every - fn read_user_path
other failure, so the two are told apart by what it says. Anything not recognisably "value does not exist" is an error, and an error refuses the write rather than guessing. - fn add_to_path
The Unix half: report that nothing was written, because nothing was. Answering `Ok(false)` rather than doing it is the decision, and the body says why. The caller prints the line for the person to add themselves. - fn register_uninstall
Register with Add/Remove Programs, so the system can list and remove it. - fn register_uninstall
The Unix half: nothing to register. There is no Add/Remove Programs here, and `veilvoice uninstall` is the reversal on these platforms. - fn remove_from_path
Take this directory back out of the user's `PATH`, leaving the rest of it exactly as it was. The same care as [`add_to_path`], for the same reason: the variable is read and rewritten rather than replaced, and a `PATH` that never had this - fn remove_from_path
directory in it is left untouched rather than rewritten to itself. - fn remove_from_path
The Unix half: nothing was written to a profile, so nothing is taken out. - fn unregister_uninstall
Take the Add/Remove Programs entry away again. A failure is ignored on purpose: the entry not being there is the outcome wanted, and refusing to finish an uninstall because the registry key was already gone would leave the person with a - fn unregister_uninstall
half-removed program. - fn unregister_uninstall
The Unix half: nothing was registered, so nothing is unregistered. - fn install
Install for this user. Returns the lines to report. - fn uninstall
Remove what `install` added. - mod tests
- fn a_prefix_is_found_on_this_platform
- fn status_reads_without_changing_anything
- fn the_executable_name_matches_the_platform
- fn path_membership_is_an_exact_directory_match
crates/veilvoice-setup/src/lib.rs
- (module)
# veilvoice-setup Everything that puts VeilVoice on a machine, and everything that reports what is already on it. Two modules: [`install`] does the per-user install and its exact reversal, [`companions`] finds the optional third-party - (module)
software that live mode is easier with and says who makes each piece. # Why this is a library and not part of the command line It was part of the command line. `install.rs` lived inside the `veilvoice-cli` **binary** crate, which meant the - (module)
desktop application could not call a single line of it, because a binary crate has no consumers. The choice was to reimplement the installer behind the graphical front end, or to move the logic somewhere both front ends can reach. - (module)
Reimplementing it would have produced two programs that edit `PATH`, drifting apart at whatever rate nobody noticed, and the `PATH` edit is the one operation here that can damage a machine. So this crate is the installer, and both front - (module)
ends are front ends. The command line calls [`install::install`]; so does the desktop app's setup tab. There is one implementation of the careful part, and one set of tests covering it. # What it will not do **It never runs somebody else's - (module)
installer.** [`companions`] reports what is present and prepares an exact command for what is not, and where that command is "open the vendor's download page" it says so rather than fetching and executing an unverified binary. A project - (module)
whose entire subject is verifying what you run has no business being casual about that. **It never asks for administrator rights.** Everything [`install`] does is inside the user's own account. Where a companion genuinely needs privilege, - (module)
such as a system package manager or an audio driver, that fact is reported and the command is handed over rather than run, because a graphical program cannot honestly collect a `sudo` password and this one does not try. **Nothing is ticked - (module)
by default.** There are no checkboxes at all: each companion is a separate, deliberate action. The rule that predates this crate, which is to detect, say what it is and who makes it, and act only on an explicit yes, is unchanged by the - (module)
interface getting prettier. # No `unsafe`, and therefore some subprocesses `#![forbid(unsafe_code)]` holds here as everywhere else in the workspace, so the Windows registry is reached through `reg.exe` rather than the Win32 API. - (module)
[`command`] wraps every spawn so that none of them flashes a console window when the desktop application is the caller, which is the defect that shipped in v0.1.10, and a test reads this crate's own source to catch a spawn that forgets. # - (module)
In plain words This installs the program, if you want it installed. You do not have to. Unzipping it and running it is a perfectly normal way to use it, and this says so rather than treating it as a mistake. Installing does three small - (module)
things -- copies the program into your own folder, adds it to your PATH so typing its name works, and adds an entry so Windows can remove it -- and nothing else. No administrator rights, no service. It also looks for the few other programs - (module)
VeilVoice can work alongside, tells you who makes each one, and installs none of them unless you say so. - mod companions
- mod install
- mod space
- mod update
- mod volumes
- const VERSION
Crate version string, surfaced in the About panel. - fn command
Spawn without a console window. On Windows a `Command` for a console program creates a console, and when the caller is the desktop application that console flashes on screen. That is not cosmetic: three of them at startup were most of what - fn command
"it flashes a command prompt and loads in an unusable state" meant in the v0.1.10 report. `creation_flags` is a **safe** API, so this costs nothing against `#![forbid(unsafe_code)]`. Every spawn in this crate goes through here, and a test - fn command
in this file reads the source to prove it. - fn hide_console
The Windows half of [`command`]. A named function per platform rather than a `cfg` block inside one, because the block version needs `let mut` on Windows and no `mut` anywhere else -- which compiles here and fails the Linux and macOS - fn hide_console
runners on `unused_mut`. That exact shape has cost this project three CI failures, so the difference is expressed as two signatures the compiler can check instead of one body that means different things. - const CREATE_NO_WINDOW
- fn hide_console
The everywhere-else half of [`command`]: nothing to hide, and no console is created by spawning a process in the first place. - mod tests
- fn every_subprocess_is_spawned_without_a_console_window
Every subprocess in this crate must be spawned through [`super::command`]. This reads the crate's own source rather than exercising the behaviour, because "no console window appeared" cannot be observed from a test -- which is precisely - fn every_subprocess_is_spawned_without_a_console_window
why the defect reached a release. A `Command::new` added later without the wrapper fails here rather than on a desktop. It scans every module, not just this file, because the wrapper now lives in one place and the spawns do not. Each file - fn every_subprocess_is_spawned_without_a_console_window
is cut at its `cfg(test)` marker first. A test module that searches for the name of a type necessarily contains that name, and a check that trips over its own source is a check somebody deletes. - const NEEDLE
crates/veilvoice-setup/src/space.rs
- (module)
How much room is actually free where VeilVoice keeps things. # Why this is measured rather than assumed Decoy vaults are only worth making if there is somewhere to put them, and how many to make is a question with a real answer on each - (module)
machine. A number picked here would be a guess dressed as advice: nine decoys is careless on a nearly full laptop and timid on a four-terabyte disk. So this asks the operating system, and where the operating system will not say, it returns - (module)
[`None`] and the interface says the count is a suggestion rather than a measurement. **Not measuring and not saying so** is the answer this module exists to avoid. # No `unsafe`, and no new dependency The free-space call is `statvfs` on - (module)
Unix and `GetDiskFreeSpaceEx` on Windows, and reaching either from Rust means an `unsafe` block or a crate. This workspace forbids the first everywhere and weighs the second against a dependency graph people are invited to read, and - (module)
neither is worth paying for one integer. So it runs the tool the platform already ships. On Unix that is `df` with `-Pk`, whose output format is specified by POSIX rather than left to the implementation, which is the whole reason that flag - (module)
is there: the columns are fixed, the block size is 1024, and one line describes the filesystem asked about. # In plain words Asks the system how much space is free where VeilVoice is putting files, so it can suggest a sensible number of - (module)
decoy vaults instead of inventing one. If the system will not say, it says so rather than guessing. - fn free_bytes
Free space at `path`, in bytes, or `None` if the platform would not say. `None` is a real answer and callers must treat it as one. It happens on a system with no `df`, in a sandbox that refuses to start processes, and on any platform this - fn free_bytes
has no branch for. Every one of those is "we do not know", which is different from zero and must not be shown as a measurement. - fn unix_free
`df -Pk`, parsed from the format POSIX specifies. - fn parse_df
The available column of a `df -Pk` report. Split out so it can be tested against real output from each platform without running anything, which is the only way to check a parser against systems this is not running on. POSIX fixes the - fn parse_df
columns as filesystem, 1024-blocks, used, available, capacity, mount point. The **fourth** field is the one wanted, and it is counted from the left rather than the right because a mount point may contain spaces and a device name may not. - fn windows_free
`fsutil volume diskfree`, which is on every Windows since Vista. - fn parse_fsutil
The free-bytes figure from `fsutil volume diskfree`. # Why this reads the numbers rather than the labels `fsutil` translates its output, so an English machine says "Total # of free bytes" and a German one does not. Matching the label would - fn parse_fsutil
work on the machine it was written on and nowhere else, which is a worse failure than not measuring at all because it looks like it worked. The three figures it prints are free bytes, total bytes and available bytes, and free is never - fn parse_fsutil
larger than total. Taking the **smallest** of the numbers found is therefore the conservative reading in any language: it can suggest fewer decoys than there is room for, and never more. - mod tests
crates/veilvoice-setup/src/space/tests.rs
- (module)
The parsers, against real output from platforms this cannot run on. A parser tested only against the machine that wrote it is a parser tested on one platform. These are transcripts of what `df -Pk` actually prints on each system VeilVoice - (module)
ships for, so the columns are checked against the shapes they really take rather than against the one in front of the author. - fn linux_output_gives_the_available_column
- fn macos_output_gives_the_available_column
- fn freebsd_output_gives_the_available_column
- fn a_device_name_wrapped_onto_its_own_line_still_parses
- fn a_header_alone_reports_nothing_rather_than_zero
- fn nothing_at_all_reports_nothing
- fn a_mount_point_with_spaces_does_not_shift_the_columns
- fn an_absurd_block_count_saturates_upward_rather_than_wrapping
- fn the_real_filesystem_answers_or_says_it_cannot
crates/veilvoice-setup/src/update.rs
- (module)
Ask, **only when told to**, whether a newer VeilVoice release exists. # What this is, and the claim it changes Until this crate existed, VeilVoice's front page said *"no telemetry, no update check"*. Half of that is unchanged and half of - (module)
it is not, and the wording moved in the same commit as the code rather than afterwards: * **No telemetry.** Unchanged, and nothing here sends anything about you. The request is a plain `GET` of a public URL that anybody can open in a - (module)
browser; it carries no identifier, no configuration and no counter. * **No *automatic* update check.** Nothing runs on a timer, at startup, or in the background. [`check`] runs because a person pressed a button in this run of the program, - (module)
and it does nothing else ever. An update checker that runs by itself is a beacon: it tells a server that this machine has VeilVoice on it, roughly how often it is used, and from which address. That is the thing being refused. A button - (module)
somebody presses, once, when they want to know, is a different act with different consequences, and it is the only one on offer. # There is still no HTTP client in the dependency graph This crate has **no dependencies**. It runs the - (module)
transfer tool the operating system already ships, exactly as `veilvoice-verify` has fetched releases since it existed, and reads its output. `cargo tree` shows no `reqwest`, no `hyper`, no `ureq`; the CI job that fails the build if one - (module)
appears is unchanged and still passes. The tool is found at an **absolute path**, never by bare name. Resolving a program by name on Windows searches the current directory before most of `PATH`, so a file called `curl.exe` sitting beside - (module)
the program would be run instead of the system one. That is finding F-13, and it does not get to happen twice. # What it will not do It does not download a release, it does not install anything, and it does not restart the program. It - (module)
reports a version string and leaves every decision to the reader. Downloading a release and checking its signature is `veilvoice-verify`'s job, and that is a separate, deliberate act too. An update checker that could install its own answer - (module)
is an update checker that can be made to install somebody else's. # What a "newer version" is worth here The answer comes from a public web page over TLS. That is enough to say *"there is probably something newer, go and look"* and it is - (module)
**not** enough to act on: a name in a document is not a signature. Nothing in this crate verifies anything, and [`Report::caveat`] says so in the words the user sees rather than only in this comment. # In plain words This is the "check for - (module)
updates" button, and nothing else. It runs when you press it and at no other time. There is no timer and nothing in the background, because a program that checks by itself is telling somebody else's computer that yours exists and how often - (module)
you use it. It reads a public page anybody can open, tells you the newest version number, and stops there. It does not download anything and it does not install anything. - const REPO
The repository asked about. - const LATEST_URL
The page fetched. A plain URL a person can open themselves and compare. Deliberately the human-readable redirect rather than an API endpoint: it is checkable by eye, it needs no token, and it is not rate limited per address in the way the - const LATEST_URL
API is. The redirect's target carries the tag. - const RELEASES_URL
Where releases are listed, for somebody doing this by hand. - const TIMEOUT
How long the transfer tool is given before it is given up on. Short on purpose. This runs because somebody pressed a button and is waiting; a check that hangs for a minute on a captive portal is worse than one that says it could not reach - const TIMEOUT
anything. - enum Verdict
How this build's version compares with the newest published one. - struct Report
What a check found. - impl Report
- fn caveat
What this answer is worth, in the words the user should see. Carried on the report rather than written into whichever front end happens to be showing it, so a second front end cannot show the answer without the caveat. - enum Error
Why a check could not be completed. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- const VERSION
The version this build was compiled as. - fn check
Ask whether anything newer than `current` has been published. **Runs a subprocess and waits.** Never call it from a thread that paints: a network round trip on the UI thread is the freeze the user reports. - fn report
Compare two version strings and build the report. Split out from [`check`] so the comparison is testable without a network, a subprocess, or a machine that has either. - fn parse
`1.2.3` or `v1.2.3` as three numbers. Anything else is `None` rather than a guess. A pre-release suffix makes the string unreadable on purpose: ordering `1.0.0-rc1` against `1.0.0` correctly needs the whole of semantic versioning's - fn parse
precedence rules, and a checker that gets it subtly wrong tells people to downgrade. - fn tag_in
The tag in whatever the transfer tool printed. With curl this is the one-line redirect target and the tag is the whole point of it. With wget it is the page body, which names the same tag. One scanner for both, because the shape being - fn tag_in
looked for is identical and a second code path is a second thing to get wrong. Scanned rather than parsed as HTML: parsing a document to read one substring is a dependency and an attack surface for a job a search does exactly as well. # - fn tag_in
Every match, not the first one The first version of this took the first `/releases/tag/` it found. Run against the real page, it returned nothing: GitHub's release page contains an **empty** `/releases/tag/` before any real one -- a - fn tag_in
template link with no tag after it -- so the first match yielded an empty string and the check reported "no version number" against a page that plainly had one. Found by running it, not by reading it. Bounded on both ends: at most 32 - fn tag_in
characters, and only characters a version tag is made of. A page that came back as something else entirely produces no match rather than a run of somebody's markup shown to the user as a version. - const MARKER
- const NULL_DEVICE
This platform's bit bucket, for a reply whose body is not wanted. - const NULL_DEVICE
This platform's bit bucket, for a reply whose body is not wanted. - struct Tool
Where a transfer tool was found, and how to drive it. - fn find_tool
Absolute paths only. See the module note on finding F-13. - fn fetch
Run the tool and hand back what it printed. - const MAX
- const SCOPE
What this crate does and does not do, in one paragraph, for a front end to show beside the button. - mod tests
- fn the_guide_names_the_tool_this_looks_for
The guide describes the tool this actually looks for. Written after the guide said "PowerShell's web request on Windows", which this has never used: it looks for `System32\\curl.exe` by absolute path. That was a sentence about the program, - fn the_guide_names_the_tool_this_looks_for
written beside the program, wrong on the day it was written -- which is the shape of finding after finding in this repository, and is why the two are compared here rather than trusted to agree. - fn a_higher_published_version_is_newer
- fn the_same_version_is_up_to_date
- fn a_build_ahead_of_the_newest_release_is_told_so
An unreleased build is ahead, and is told so rather than told it is current. Saying "up to date" to somebody running `main` hides the one fact that matters about what they are running. - fn a_version_that_cannot_be_compared_is_refused_rather_than_ordered
Ordering is refused rather than guessed. A checker that gets pre-release precedence subtly wrong tells people to downgrade. - fn the_leading_v_is_optional_on_both_sides
- fn the_tag_is_read_out_of_a_release_page
- fn an_empty_tag_link_before_a_real_one_is_stepped_over
A page that is not a release page yields nothing, rather than a fragment of somebody's markup shown to the user as a version. The real page carries an empty `/releases/tag/` before any real one. Taking the first match returned nothing - fn an_empty_tag_link_before_a_real_one_is_stepped_over
against a page that plainly had a version on it, which is how this was found: by running it. - fn the_redirect_target_alone_is_enough
curl is asked for the redirect target rather than the page, so what the scanner sees is one line. This is the shape it has to handle. - fn a_page_with_no_tag_yields_nothing
- fn a_hostile_tag_is_bounded_in_length_and_alphabet
Whatever comes back, what is shown is short and made of characters a version number is made of. - fn the_url_is_this_project_over_tls
The URL asked about is this project's own, over TLS, and is a page a person can open and compare by hand. - fn the_scope_note_states_what_it_does_not_do
The scope note has to state the limits, not only the capability. This is the wording the front page's claim now depends on. - fn a_report_carries_what_the_answer_is_worth
Every report carries the caveat, so no front end can show the answer without it. - fn every_failure_says_what_to_do_instead
Errors explain what to do instead rather than only that something failed. A tool that cannot reach the network still has a user who wants to know whether there is an update.
crates/veilvoice-setup/src/volumes.rs
- (module)
Encrypted volumes this machine already has: Cryptomator and VeraCrypt. # What this does, and the two things it deliberately does not It **reads**. It finds whether either program is installed, and which of their volumes are mounted right - (module)
now, so VeilVoice can offer to write veiled recordings into one instead of into a Downloads folder somebody meant to clear out. It does **not drive either program**. No launching, no mounting, no unlocking, and it never sees a volume - (module)
passphrase. Mounting somebody's encrypted volume is their act, taken in the tool they chose, and a voice de-identifier is not the program to be doing it for them. This is roadmap item 39's rule about privilege in a second place: use what - (module)
is already there, ask for nothing. It does **not decide whether a volume is hidden**. VeraCrypt's hidden volumes exist so that somebody under compulsion can hand over one passphrase and reveal an outer volume, and the two are - (module)
indistinguishable from outside *by design*. No amount of looking will tell them apart, so [`Hidden`] has an `Unknown` state and it is the caller's job to ask rather than to guess. See [`Hidden`] for why guessing wrong destroys data. # By - (module)
the time VeilVoice sees one, it is a directory That is what makes this honest and small. A mounted Cryptomator vault and a mounted VeraCrypt volume are both ordinary directories to anything that writes a file. The encryption is entirely - (module)
the other tool's, VeilVoice adds none of its own here, and calling any of this "VeilVoice encryption" would be the overclaim this project refuses. # What it is worth, which is less than it sounds A vault protects the file inside it. It - (module)
does not protect the temporary file an operating system wrote while the file was being produced, the swap or hibernation image the kernel wrote, the thumbnail a file manager made, or the recently-opened list a desktop keeps. Full-volume - (module)
encryption is what covers those. [`DISK_ADVICE`] is that sentence, single-sourced so the command line and the window cannot drift into two different promises. # In plain words Notices whether you already have Cryptomator or VeraCrypt, and - (module)
which of their encrypted folders are open right now, so VeilVoice can offer to save into one. It never opens or closes them for you and never asks for their password. It also cannot tell whether a VeraCrypt volume is the hidden one, - (module)
because nothing can, which is why VeilVoice asks you instead of assuming. - const DISK_ADVICE
What VeilVoice tells the user about the disk under the volume. Single-sourced, and asserted by a test, so the command line and the window cannot end up making two different promises about the same thing. - enum Tool
One of the two tools this module knows about. - impl Tool
- const ALL
Every tool, in the order a user interface should offer them. - fn name
The name to print. - fn key
A stable identifier, for a settings file. - fn from_key
The tool with this key, if it is one. - fn home_page
Where to read about it, for a user who has neither installed. - enum Hidden
Whether a destination is, or might be, a VeraCrypt hidden volume. # Why this cannot be detected, and what happens if it is guessed A VeraCrypt container can hold a second, hidden volume inside the free space of the first. Two passphrases - enum Hidden
open the same file and produce two different filesystems, and that is the point: somebody compelled to open it can reveal the outer one truthfully and the existence of the inner one is not provable from outside. Nothing VeilVoice can read - enum Hidden
distinguishes them, and nothing ever will, because a construction that could be distinguished would not be doing its job. The danger is specific rather than theoretical. **Writing into the outer volume of a container that has a hidden one - enum Hidden
can destroy the hidden data**, because the outer filesystem does not know the inner one is there and will happily allocate over it. VeraCrypt offers a protection mode for exactly this, and it requires the hidden volume's passphrase, which - enum Hidden
VeilVoice does not have and will not ask for. So the only safe behaviour is to ask the person who knows, once, before the first write, and to refuse to write until there is an answer. - impl Hidden
- fn safe_to_write
Whether VeilVoice may write here. False for `Unanswered`, because an unanswered question is not a "no", and false for `OuterVolumeOfAHiddenPair`, because that is the case the question exists to catch. - fn refusal
Why writing is refused, in the words a user reads. - struct Volume
A mounted volume VeilVoice could write into. - impl Volume
- fn found
A volume found by probing, with its hidden state not yet asked. - fn ready
Whether VeilVoice may write here right now. Cryptomator has no hidden-volume concept, so the question does not apply and is not asked; VeraCrypt must be answered. - fn blocked
Why it is not ready, if it is not. - fn installed
Whether `tool` looks installed on this machine. A file-system and `PATH` probe, like every other probe in this crate: no subprocess, nothing that needs a spinner, and an [`Presence::Unknown`] when the probe itself could not answer rather - fn installed
than a false "not there". - fn candidates
Where each tool installs itself, per platform. - fn on_path
The tool's command on `PATH`, if it is there under its usual name. - fn mounted
Every mounted volume either tool is currently offering. Reads the platform's mount table and nothing else. An empty list means "nothing recognisable is mounted", which is not the same as "neither tool is installed": see [`installed`]. - fn covers
Whether `path` is inside one of `mounts` right now. # F-93, and why existence is not the question The obvious check is whether the directory is still there, and it is wrong in the one direction that matters. A mount point is an ordinary - fn covers
directory with a filesystem grafted onto it, and unmounting takes the filesystem away and **leaves the directory**. `/media/veracrypt1` exists before the volume is mounted, while it is mounted, and after it is unmounted. So a destination - fn covers
checked by existence still looks fine the moment its volume is locked, and VeilVoice would write a veiled recording onto the ordinary unencrypted disk while its owner believed it had gone into the vault. That is the exact failure the whole - fn covers
feature exists to prevent, arriving through the check that was supposed to catch it. Equal to, or inside, so that a folder chosen within a vault counts: somebody who points at `~/Vault/recordings` has chosen a place inside the mounted - fn covers
vault, not a different thing. - fn from_proc_mounts
Parse a Linux mount table into the volumes we recognise. Separated from the read so it can be tested against a fixed table rather than against whatever this machine happens to have mounted. - fn recognise
Which tool a mount belongs to, judged by where it is mounted and what mounted it. Deliberately conservative. A directory that merely has "vault" in its name is not evidence of anything, and offering to write veiled recordings into a - fn recognise
directory VeilVoice guessed at is worse than offering nothing. - fn unescape_mount
Undo the octal escaping `/proc/mounts` applies to a path. - fn from_mount_directories
Volumes found by looking in the directories each platform mounts into. The fallback for platforms with no `/proc/mounts`. Weaker than reading a mount table, and it says so by being a separate function rather than pretending to be the same - fn from_mount_directories
probe. - mod tests
- const SAMPLE
- fn a_mount_table_yields_the_volumes_and_nothing_else
- fn ordinary_mounts_are_not_mistaken_for_encrypted_ones
The ordinary parts of a machine must not be offered as encrypted storage. Offering `/` as a vault would be worse than offering nothing. - fn an_escaped_mount_point_is_read_back_whole
- fn a_freshly_found_veracrypt_volume_may_not_be_written_to
The whole point of `Hidden`. A found volume starts unanswered, and an unanswered volume is not writable. - fn the_outer_volume_of_a_hidden_pair_is_refused
- fn a_cryptomator_vault_is_not_asked_the_veracrypt_question
Cryptomator has no hidden-volume concept, so asking would be a question with no meaning and a user trained to click through questions. - fn a_mount_point_that_is_no_longer_mounted_is_not_covered
F-93. Existence is not the question, because unmounting leaves the directory behind. - fn every_tool_round_trips_through_its_key
- fn the_disk_advice_names_every_platform_and_claims_nothing_extra
The advice about the disk underneath must keep naming the platforms it claims to cover, and must not quietly become a boast about vaults. - fn a_probe_that_finds_nothing_says_so_about_the_probe
Detection must never be reported as a certainty it is not. The probe answers about the places it looked. - fn nothing_here_launches_either_program
Nothing in this module may start a process. Detection reads.
crates/veilvoice-verify/src/builder.rs
- (module)
Build VeilVoice here, and compare what came out against what was published. # The question this answers, and the one it does not `veilvoice verify file` answers *is this download the one that was published*. This answers the harder one: - (module)
**is the published build the one this source produces**. A signature says who made a file. Only a build says what the file is made of. It cannot answer it for anybody else. A build here proves something about this platform and this - (module)
machine, and that is exactly how a reproducible-build claim is normally checked -- three machines give you three platforms verified. It is a real answer rather than a pretended one. # "Builds for every operating system" means "builds for - (module)
the one it is on" A build needs that platform's headers and linker. `veilvoice-cli` cannot be compiled for Linux from Windows because `alsa-sys` needs ALSA's headers, and a macOS build needs Apple's SDK, which Apple's licence does not - (module)
allow to be redistributed or run elsewhere. Every other crate cross-checks cleanly with `--target`, and that is a *type check*, not a binary anybody should install. So this builds VeilVoice for the machine it is on, and compares that - (module)
against the published build **for that platform**. # A difference is a finding, not an accusation Reproducibility is a property of the release, not of the checker. If a build here and the published build differ, that is something to look - (module)
into and publish -- and this prints both hashes and the names of the differing files rather than a verdict, because "not reproducible" has several causes and most of them are boring: a different compiler version, a path baked into a panic - (module)
message, a timestamp. It exits [`Status::NotReproducible`], which is deliberately not the status that means tampering. # In plain words Anyone can sign a file. A signature tells you who put their name to something; it does not tell you - (module)
that the thing they signed was built from the source code they published. This builds VeilVoice on your own computer, from the source in front of you, and checks whether what comes out is byte-for-byte the same as what was released. If it - (module)
is, you know the released program is the source code -- not because anybody said so, but because you produced the same thing yourself. If it is not, that is worth knowing and worth reporting, and it is usually something dull rather than - (module)
something sinister. So this shows you both answers and which files differed, and leaves the conclusion to you. - const RELEASE_ARGS
The profile a release is built with. Written out rather than taken from a flag: the whole point is to run **the same build the release does**, and a build with different settings answers a different question while looking like it answered - const RELEASE_ARGS
this one. - const RELEASE_DIR
Where a release build leaves its binaries, relative to the target directory. - const SHIPPED
The binaries a release publishes, without any platform extension. Two, not three. `veilvoice-verify` was a third executable until 0.1.18 and is now a library inside these two, reached as `veilvoice verify` and as the desktop application's - const SHIPPED
Verify tab. Leaving it here made this report a file short on every platform: a name in `absent` that no release has published since, which reads as a build that failed to produce something. - struct Built
What a build produced. - fn looks_like_the_source
Whether the source tree this is pointed at is really one. Checked before a compiler is started rather than after: a build in the wrong directory takes minutes to fail and fails with a message about Cargo rather than about the mistake. - fn pinned_toolchain
The compiler version the source tree pins itself to. Read rather than assumed. A reproducible build is reproducible *against a stated compiler*, and reporting a comparison without saying which compiler produced it leaves the most common - fn pinned_toolchain
cause of a difference unmentioned. - fn host_triple
The platform triple this build is for, as `rustc` names it. - fn target_directory
Where this workspace's build output actually goes. F-69. This used to be `root/target`, which is right on a default machine and wrong on a great many others. `CARGO_TARGET_DIR` in the environment, `build.target-dir` in a - fn target_directory
`.cargo/config.toml`, and a shared target directory across several checkouts all move it, and none of them are exotic -- the machine this was written on has it set. The symptom was the worst shape available: the build **succeeded**, took - fn target_directory
several minutes, and then the hashing step reported that `.\target\release` was not there. Minutes of correct work, discarded, with a message pointing at the wrong place entirely. So it asks cargo, which is the only thing that knows. - fn target_directory
`--no-deps` because the dependency graph is not wanted and resolving it is slow. - fn json_string_field
One top-level string out of cargo's JSON, without a JSON parser. A dependency is not worth taking for one field, but the field is a Windows path and Windows paths are full of backslashes, so the escapes have to be undone properly rather - fn json_string_field
than by taking the text between two quotes. Getting that wrong gives a path with `\\` in it that looks almost right and does not open. Only the escapes cargo can actually emit here are handled; anything else is left as it was written - fn json_string_field
rather than guessed at. - struct Environment
Everything that would otherwise differ between two builds of one source. F-70. The build ran `cargo build --release` and nothing else, which meant a comparison against a published build was **guaranteed** to differ. Two builds of this tree - struct Environment
in two directories on this machine produced three different binaries out of three -- measured, not supposed. The cause is the dull one this module's own documentation already named: the absolute path of the source tree is baked into panic - struct Environment
messages and debug info, so a build in `C:\src\veilvoice` and a build in `/home/a/veilvoice` cannot be the same bytes. `docs/REPRODUCIBLE_BUILDS.md` has said so all along, and the release workflow sets the flags that fix it. The checker - struct Environment
did not. A reproducibility checker that always answers "not reproducible" is worse than no checker. It teaches the one reader who took the trouble to build from source that the release does not match -- and the next time it says so for a - struct Environment
real reason, they will have learned to ignore it. So this reproduces the release environment rather than approximating it. Every value here has a counterpart in `.github/workflows/release.yml`, and [`describe`] prints them, because a - struct Environment
comparison whose settings are invisible cannot be checked by the person reading the result. - impl Environment
- fn describe
The settings, for printing before a build. - fn repro_link
Flags that make this platform's linker deterministic. Each one is here because that linker writes something into the output that is not a function of the input, and each is the same flag the release uses. - fn cargo_home
Where cargo keeps downloaded crates, whose paths are also baked in. - fn commit_date
The date of the commit being built, as seconds since the epoch. - fn as_the_compiler_sees_it
A path as the compiler will see it, for the remapping to match. Not [`std::fs::canonicalize`], which on Windows returns an extended-length path beginning `\\?\`. Cargo does not hand rustc that form, so a remap built from it matches nothing - fn as_the_compiler_sees_it
and silently does nothing at all -- the exact failure the release workflow's own comment warns about on macOS, arriving through the other platform's door. - fn environment
The environment the release is built in, for this tree on this machine. - fn build
Run the release build. The compiler's own output goes to the terminal at `--verbose` and is captured otherwise, so a failure can still be shown in full: a build that stops with its reason discarded is a build nobody can act on. - fn hash_what_was_built
Hash every binary a release ships, from a directory a build left behind. - fn with_platform_extension
A binary's name on this platform. - enum Compared
How a built file compared against the published list. - fn compare
Compare a build against a hash list. **The caller must have verified the signature over `sums` first.** This function takes text, not a path, precisely so it cannot be handed an unverified file by accident: there is nowhere in it that - fn compare
reads from disk. - fn all_matched
Whether every file that could be compared matched. A build with nothing to compare against is **not** reproducible-and-fine. It is unanswered, and saying so is the difference between a check and a formality. - fn report_dependencies
Report what a dependency check found. Returns whether a build can go ahead. - fn install
Run one install command, having been told yes. Separated from everything that decides *whether* to, so the decision and the act are never in the same place. Nothing in [`deps`] can reach this. - fn agreed
Ask, and take only an unambiguous yes. Anything that is not `y` or `yes` is a no, including an empty line and a closed input. A prompt whose default is yes is not a prompt. - fn status_for
The status a comparison should exit with. - mod tests
- fn built
- const A
- const B
- fn a_build_that_matches_is_reported_as_matching
- fn a_difference_carries_both_hashes_and_is_not_called_tampering
Both hashes are carried, because a reader cannot check a verdict. - fn a_comparison_against_nothing_is_not_a_pass
A build with nothing to compare against has not passed. This is the failure mode the whole exercise is vulnerable to: a hash list naming nothing that was built would otherwise report success by vacuum. - fn one_good_file_does_not_carry_a_bad_one
One file matching does not excuse another differing. - fn a_file_the_release_does_not_ship_is_neither_a_match_nor_a_failure
A file built here and absent from the list is not counted as a match -- but it does not fail the run on its own either, because a release may legitimately ship a subset. - fn nothing_in_the_comparison_reads_a_file
`compare` takes text, never a path. That is what stops an unverified hash list being read by this half of the program by accident -- the signature check happens before, in the caller, and there is nothing here that could skip it. - fn the_release_build_is_the_one_the_release_uses
- fn the_pinned_compiler_is_read_out_of_the_tree
- fn a_wrong_directory_is_refused_before_anything_is_compiled
A directory that is not the source tree is refused before a compiler is started, with the reason. - fn deciding_and_acting_are_in_different_places
Nothing is built until a directory has been checked, and nothing is installed until somebody has said yes. Both decisions live outside the functions that act, and this is the test that keeps them there. - fn a_question_nobody_was_shown_is_answered_no
Silence is a no. At `--quiet` nothing explained the question, so there is nobody who could have agreed to it. - fn the_target_directory_comes_from_cargo_rather_than_from_a_guess
F-69. Where the build output goes is asked, never assumed. The check that matters is the one against the environment: this test suite runs with `CARGO_TARGET_DIR` set, so a function that returned `root/target` would disagree with reality - fn the_target_directory_comes_from_cargo_rather_than_from_a_guess
right here. - fn a_directory_cargo_cannot_read_says_so_rather_than_guessing
A directory that is not a workspace fails with cargo's own words, before anything is compiled. - fn a_json_string_is_unescaped_rather_than_taken_between_quotes
The escapes in a Windows path have to be undone, or the answer is a path with `\\` in it that looks almost right and does not open. - fn the_release_environment_is_reproduced_rather_than_approximated
F-70. The build has to be run in the environment the release is built in, or the comparison is decided before it starts. Measured before it was written: two builds of this tree in two directories on this machine produced three differing - fn the_release_environment_is_reproduced_rather_than_approximated
binaries out of three. The cause is the dull one -- the absolute source path is baked into panic messages and debug info -- and it is exactly what the release workflow's `--remap-path-prefix` exists to remove. - fn the_settings_are_shown_rather_than_applied_silently
Every setting is printed. A comparison whose settings are invisible cannot be checked by the person reading the result, and "not reproducible" with no environment attached is unactionable. - fn a_tree_with_no_commit_says_so_instead_of_inventing_a_date
Outside a git checkout there is no commit to date the build from. Said, not substituted: an invented timestamp would make the build differ from the published one for a brand new reason. - fn the_remapped_path_is_the_one_the_compiler_is_given
The remap has to match the path the compiler is given, or it matches nothing and does nothing -- silently, with every check still passing. On Windows `canonicalize` returns an extended-length path beginning `\\?\`, which cargo never hands - fn the_remapped_path_is_the_one_the_compiler_is_given
to rustc. The release workflow's own comment records the same failure on macOS through `/tmp` against `/private/tmp`. - fn the_flags_are_the_same_ones_the_release_workflow_uses
The flags match the release workflow, which is the only thing that makes the comparison meaningful. Checked against the workflow file itself, so changing one and not the other fails the build. - fn the_output_directory_accounts_for_the_explicit_target
The build is run with an explicit `--target`, because the release is, and that moves the output down a level. - fn repo_root
This repository's own root, for the tests that need a real checkout. - fn a_binary_gets_this_platforms_extension
- fn hashing_an_empty_directory_says_so_rather_than_reporting_success
- fn the_published_binaries_are_the_ones_the_release_job_stages
The list of published binaries is read from the job that publishes them. Three copies of this list exist: here, in `extracted::PROGRAMS`, and in `veilvoice-setup`'s installer. All three named `veilvoice-verify` for a release after it - fn the_published_binaries_are_the_ones_the_release_job_stages
stopped being a binary, which cost nothing visible and was wrong in the same way in three places, because nothing tied them to the one authority on the question. The authority is `.github/workflows/release.yml`, which stages exactly what - fn the_published_binaries_are_the_ones_the_release_job_stages
goes into an archive. Reading it rather than restating it means a binary added or removed there fails here on the same commit, instead of being noticed a release later by somebody reading a report.
crates/veilvoice-verify/src/check/contents.rs
- (module)
The signed list of what is inside each release archive. **Roadmap item 97.** `SHA256SUMS` covers the archives. That proves a download is the one that was published, and it says nothing at all about the folder somebody unzipped it into, - (module)
which is the copy they actually run. # The gap this closes Until this existed, a verifier could check the archive and could not check the extracted directory beside it, and the reason was not laziness: nothing on disk records which archive - (module)
a directory came from, and no signed list covered the loose files. The honest report was therefore two separate answers and the advice to extract the checked archive again. That advice is still correct and it is a poor substitute for an - (module)
answer. A release now publishes `CONTENTS.sha256`, which lists every file inside every archive with its SHA-256, and it is staged **before** `SHA256SUMS` is computed, so the hash list covers it and the signature therefore covers it too. - (module)
The chain is complete and every link in it is checkable: ```text SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each file on disk ``` So the question "is the program I am about to run the one that was published" now has an arithmetic - (module)
answer rather than an inference. # What it still does not prove The same limit as everywhere else in this crate, and it is worth repeating rather than assuming somebody read it in [`crate`]: this proves the files are the ones the holder of - (module)
the key published. It does not prove they are safe, and it does not prove they were built from the source you can read. # Why the paths are checked before they are used The manifest is signed, and a caller is told in the plainest terms to - (module)
verify the signature before parsing it. That is a rule a caller can get wrong, and the cost of getting it wrong here would be a file of somebody else's choosing deciding which paths this reads. So [`parse`] refuses an absolute path, a path - (module)
with a `..` component, a Windows drive letter and a backslash, rather than trusting the order the caller did things in. Refusing rather than sanitising, because a manifest containing such a path is not a manifest with one bad line in it: - (module)
it is a file that did not come from this project's release job, and the useful thing to do with it is say so. # In plain words The list of every file inside a release, with its fingerprint, signed along with everything else. It is what - (module)
lets a checker tell you that the program sitting in your folder is the one that was published, rather than only that the zip you downloaded was. - const CONTENTS
The name a release publishes its contents list under. - struct Member
One file inside a release archive, and the hash published for it. - struct ArchiveContents
Everything one archive carries. - impl ArchiveContents
- fn roots
The top-level directories the archive extracts into. One, for every archive this project publishes. Returned as a set rather than assumed, because an archive with two roots is a thing that can exist and quietly walking only the first would - fn roots
leave files unchecked. - enum Verdict
What checking one published file against the disk found. - struct Outcome
One published file, checked against the disk. - impl Outcome
- fn is_good
Whether this one is as published. - fn safe
Whether a path from the manifest is safe to join onto a directory. See the module note. Empty, absolute, `..`, a drive letter and a backslash are all refused; everything else is an ordinary relative path. - fn looks_like_a_digest
Whether a string is 64 lowercase hex characters. - fn parse
Read a `CONTENTS.sha256`. **Verify the signature over `SHA256SUMS`, and this file against `SHA256SUMS`, before calling this.** Parsing an unverified manifest and reporting on it would be checking a download against a list that came with - fn parse
it, which proves nothing. Strict on purpose. A line this cannot make sense of fails the whole file rather than being skipped: a signed manifest is either the one the release job wrote or it is not, and half-understanding one is how a - fn parse
verifier reports a pass over files it never looked at. - fn for_archive
The section of a manifest covering one archive. - fn check
Check every file the archive published, against `root`. `root` is the directory the archive sits in, because the manifest's paths already carry the release directory name. So an archive and the folder it was extracted into, side by side in - fn check
a downloads folder, need no argument beyond the folder itself. - struct Sweep
What a sweep of the extracted directory found. - impl Sweep
- fn is_clean
Whether the folder is exactly what the release published. False when anything extra was found **and** false when anything could not be read, which is the distinction F-98 was about. - fn extras
Files sitting in the extracted directory that the release never published. Reported rather than ignored. Everything else here answers "is what should be there, there"; this answers the other half, and the other half is the one an attacker - fn extras
uses. A directory holding every published file, unmodified, plus one extra program, passes every check above and is not the release. - fn walk
Every file under `directory` that is not in `published`. Iterative rather than recursive. Measured on Linux, the deepest directory an absolute path can name is about 1988 levels, so the recursion this replaced would not in fact have - fn walk
overflowed a stack -- but the bound came from `PATH_MAX` rather than from this code, and a bound nobody here chose is not a bound this code can rely on. An explicit stack has one. A symbolic link is never walked into and never hashed - fn walk
through. An extracted release with a link back to `/` in it would otherwise walk the whole filesystem, which is a denial of service written by the person being checked. The link is reported as an extra, because it is not a file the release - fn walk
published. - mod tests
- const SAMPLE
- fn a_manifest_reads_back_as_its_archives_and_their_files
- fn an_archive_is_found_by_its_published_name
- fn a_line_that_makes_no_sense_fails_the_whole_manifest
A line that is not a hash and a path fails the file. See [`parse`]. - fn a_path_that_leaves_the_release_is_refused
The paths this will later read are refused before they are used, not after. See the module note. - fn a_file_that_is_there_and_wrong_is_not_a_pass
The hash is compared, not the presence of the file. - fn a_file_nobody_published_is_reported
A directory holding every published file plus one more is not the release, and saying so is the whole reason [`extras`] exists. - fn a_link_pointing_at_the_right_bytes_is_still_not_the_published_file
**F-99.** A link where a file should be is not the published file, even when what it points at hashes correctly. The release published a file. A link is a name somebody else may be able to repoint after this has looked, which is the one - fn a_link_pointing_at_the_right_bytes_is_still_not_the_published_file
substitution a hash check cannot notice, and the sweep for extra files already refuses to walk through links: accepting one here was the two halves of this module disagreeing about what a link is. - fn a_directory_where_a_file_should_be_is_not_the_published_file
A directory standing where a file should be is refused for the same reason, and without hashing anything. - fn a_directory_that_cannot_be_read_is_not_reported_as_empty
**F-98.** A directory that cannot be read is reported, never treated as empty. Measured with a permission bit here, because it is the way this happens to ordinary people: a folder extracted by another account, or one whose mode came out of - fn a_directory_that_cannot_be_read_is_not_reported_as_empty
the archive wrong. The deep-tree case that found it is the same failure with a different cause. Skipped where the test can read anything regardless, which is what running as root means, since the case cannot be created there. - fn the_sweep_does_not_recurse
The walk keeps its own stack, so its depth is not the call stack's. - fn an_empty_manifest_lists_nothing
An empty manifest is not an error and is not a pass either: it lists no archives, so no caller can find theirs in it. - fn tempdir
Somewhere to put files, without a dependency for it.
crates/veilvoice-verify/src/check/mod.rs
- (module)
Check a VeilVoice release: a file's SHA-256, its line in a `SHA256SUMS`, and the detached OpenPGP signature over that list. # Why this is a library and not only a program `veilvoice-verify` did all of this and did it inside a binary crate, - (module)
which has no consumers by construction. The desktop application was asked for a **verify tab**, where you drag a download onto the window and are told whether it is the published one, and there were two ways to have that: * link `eframe` - (module)
into the 1.5 MB portable verifier, which is the one binary in this project whose smallness is a feature: it is what somebody downloads *before* they trust anything else here; * or move the checking out of the binary, so both front ends - (module)
call the same code rather than two implementations of it. This is the second. The verifier is unchanged in what it does and prints; it just no longer owns the arithmetic. # What a pass actually proves, and what it does not A good signature - (module)
over `SHA256SUMS`, plus a matching hash, proves the file is **the one the holder of this key published**. It does not prove the file is safe, that the source compiles to it, or that the key belongs to anybody in particular. The second of - (module)
those is the check worth having and it is the one this project cannot perform for you, because it needs somebody other than the author to have built the same tag and got the same hash. Every front end that uses this has to say so. The - (module)
words are here, in [`SCOPE`], rather than in whichever interface happens to be showing the answer, so a second front end cannot show a pass without them. # No network, and no GnuPG Nothing in this crate opens a socket or runs a program. It - (module)
is given bytes and it reports on them. Downloading is the caller's problem, and the two callers that do it shell out to the system's own transfer tool. # In plain words This is the checking behind "is this download the real one". You give - (module)
it the file you downloaded, the published list of fingerprints, and the signature over that list. It checks the signature first -- because a list nobody signed proves nothing -- and only then compares your file against it. A pass means the - (module)
file is the one the person holding that key published. It does not mean the file is safe, and it does not mean the program in it matches the source code you can read. That last one is the check worth the most, and it is the one this cannot - (module)
do for you: it needs somebody other than the author to have built the same thing and got the same answer. - mod contents
- mod reproduce
- const PUBLIC_KEY
The project's signing key, in ASCII armour. Read from the copy the website serves, so there is exactly one key file in this repository and no chance of a second one drifting from it. A test asserts this key's fingerprint is - const PUBLIC_KEY
[`FINGERPRINT`]; if somebody swaps the file, the build fails rather than a verifier trusting a new key. - const FINGERPRINT
The fingerprint, written out rather than derived. Deriving it from [`PUBLIC_KEY`] would make this constant agree with the key automatically, which sounds like an improvement and is the opposite of one: the whole point is that a reader can - const FINGERPRINT
compare this string against the one published in `README.md`, on the website and in the release notes. A value computed from the very file it is meant to authenticate checks nothing. - const SCOPE
What a passing check is worth, in the words a user should be shown. - enum Error
Something that went wrong, in words a person can act on. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- fn key
The embedded key, with its fingerprint checked. - fn fingerprint_of
A key's fingerprint, uppercase hex, no spaces. - fn sha256_file
SHA-256 of a file, read in chunks. Streamed rather than read whole: a release archive is tens of megabytes and there is no reason for this to need that much memory at once. The web verifier had the same problem in the other direction -- - fn sha256_file
finding F-36. - fn sha256_bytes
SHA-256 of bytes already in memory. - fn hex
- fn digests_match
Compare two hex digests without caring about case or stray whitespace. Not constant time, and deliberately so: both values are public, and there is no secret here for a timing difference to leak. Saying that plainly is better than a - fn digests_match
`subtle` dependency that implies there was a threat. - fn digest_from_sums
Find a file's line in a `sha256sum`-format list. The format is `<hex> <name>`, and `sha256sum` writes a `*` before the name for a binary-mode hash. Only the file's base name is compared: the list is written with plain names, and the file - fn digest_from_sums
being checked is usually somewhere else entirely. - fn names_in_sums
Every name a `SHA256SUMS` lists, in order. For an interface that wants to say *what it could have checked* when the file dropped on it is not in the list. "Not listed" on its own leaves the reader guessing whether they downloaded the wrong - fn names_in_sums
thing or the wrong release. - fn verify_detached
Verify a detached signature over `data` using `key`. - struct Checked
What a full check found. - fn check_file
The whole check: signature first, then the hash. # The order is the point The signature is verified over the **bytes of the list** before any number in that list is read. A checker that compared the hash first and verified afterwards - fn check_file
would, for the moment between the two, be trusting an unsigned document, and an attacker who can hand you a file can hand you a `SHA256SUMS` to go with it. Getting this order wrong produces a program that passes all its own tests and - fn check_file
proves nothing. - mod tests
- fn the_embedded_key_parses_and_is_the_published_fingerprint
- fn a_known_vector_hashes_correctly
- fn a_file_hashes_the_same_as_its_bytes
- fn digests_compare_without_caring_about_case_or_space
- fn a_sums_line_is_found_by_base_name_in_either_mode
- fn a_nameless_line_matches_nothing
A line with a digest and no name must not answer a lookup for "", which is what a path with no final component gives. - fn a_path_with_no_file_name_is_refused
And a path with nothing to look up is refused rather than turned into that empty string in the first place. - fn the_names_can_be_listed_for_an_error_message
- fn a_bad_signature_stops_the_check_before_any_hash_is_believed
The order this checks things in is the whole of its value, so it is asserted rather than assumed: an unverifiable signature must stop the check before any hash from that list is read. - fn a_file_missing_from_the_list_is_its_own_answer
"Not listed" is a different answer from "does not match", and a front end has to be able to tell them apart: one means the wrong release, the other means a bad file. - fn the_scope_note_says_what_a_pass_does_not_prove
The scope note has to state the limits, not only the capability. Every front end shows this text, so it is checked here once.
crates/veilvoice-verify/src/check/reproduce.rs
- (module)
A script that rebuilds a release and compares it with what was published. # What this is for, and why it is stronger than checking a hash Checking a download against `SHA256SUMS` proves the file is the one whose hash was signed. It says - (module)
nothing about what is *in* it. The signature and the hash are both made by whoever published the release, and if that machine was compromised, or the person was, both are made over a binary nobody wants. Rebuilding closes that. Compile the - (module)
source at the tag, hash what came out, and compare it with the published hash for this platform. If they match, the published binary is what that source compiles to, and the question moves from "do I trust the publisher" to "do I trust the - (module)
source", which is a question anybody can act on because the source is here to read. This is the check the verify tab describes as the one worth more than any of the others and says it cannot perform for you. This writes the script that - (module)
performs it. # Why the toolchain version is not typed in here Reproducibility depends on the exact compiler, and this project pins it in `rust-toolchain.toml`. A script carrying its own copy of that version would be wrong the first time - (module)
the pin moved, and wrong in the worst possible way: it would report a mismatch on a genuine release and tell somebody their download had been tampered with. So the script does not name a version at all. It relies on `rustup` reading the - (module)
file in the checkout, which is what `rustup` does with no encouragement, and it says so where the reader can see it. # What it will not do It does not install a compiler, and it does not fetch the release. It says what to run. A script - (module)
that installed a toolchain would be asking somebody to let an unfamiliar program put a compiler on their machine as part of deciding whether to trust that program. - enum System
The system a script is being written for. The split is by what the shell and the tools are called, not by processor: an Intel Mac and an Apple Silicon Mac run the same commands, and the difference between them shows up in the *name of the - enum System
archive*, which the script works out at run time rather than being told. - impl System
- fn file_name
The name to save the script under. - fn key
The name this is called on the command line. - fn from_key
The system with this name, if it is one. - const ALL
Every system, in the order a front end should offer them. - fn here
The one this program is running on, when it is one of these. - fn hash_check_command
How this system checks a folder against a `SHA256SUMS`. **Public because it is the one answer.** `crate::gnupg`'s verification script asks this rather than carrying a second copy: it used to carry its own, that copy knew only Linux and - fn hash_check_command
macOS, and a BSD reader was therefore told to run `sha256sum -c` by one script in this repository and `sha256 -c` by another. See F-167. - fn hash_tool
How this system spells "hash this file". - fn veilvoice_check_fingerprint
The project's signing fingerprint, from the one place it is written. - fn script
The script for this system. - fn posix
The POSIX script, which covers Linux, macOS and the BSDs. One script rather than three, because they differ in exactly one place: the name of the program that computes a SHA-256. Writing three would mean three places for the *logic* to - fn posix
drift, which is the part that matters. - fn windows
The Windows script, for `cmd.exe`. A `.cmd` rather than PowerShell on purpose: PowerShell's execution policy refuses unsigned scripts by default, so a script somebody downloads to check a download is the exact case it blocks. - mod tests
- fn no_script_carries_its_own_copy_of_the_toolchain_version
The version pinned in `rust-toolchain.toml` must not appear in any script. This is the one thing that would turn a helpful script into a harmful one. A script carrying its own copy of the compiler version keeps working until the pin moves, - fn no_script_carries_its_own_copy_of_the_toolchain_version
and then reports a *genuine* release as not matching, which is the most alarming thing this project could tell somebody and would be false. - fn each_system_uses_a_hash_tool_it_has
Each system gets the hash tool it actually has. `sha256sum` is not on macOS and is not on the BSDs, and a script that calls one there fails at the last step, after appearing to work for several minutes. - fn every_script_pins_the_build_date_to_the_commit
The commit date has to be exported, or the binary carries the time of this build and can never match anything. - fn no_script_installs_a_toolchain
It builds; it does not install a compiler. - fn every_script_builds_from_the_committed_lockfile
A locked build, or the dependency versions are whatever resolved today and the result cannot match. - fn every_system_survives_being_written_down_and_read_back
- fn this_machine_is_one_of_the_systems
The system this is running on is one of the four, whatever it is.
crates/veilvoice-verify/src/deps.rs
- (module)
What this machine needs before it can build VeilVoice, and who ships it. # The rule, which predates this module Installing a build dependency means running somebody else's package manager, as root, on somebody else's machine. The companion - (module)
setup already makes that trade and it gets the same rule here: **detect what is there, say what each thing is and who ships it, and install only on an explicit yes.** Never silently, never ticked by default, and never as a side effect of - (module)
asking a question. What it will not do is add a network client to VeilVoice. It shells out to the tool the platform already has, exactly as the verifier does for downloads, so the claim that this project's dependency graph contains no HTTP - (module)
client is unchanged and still checkable with `cargo tree`. # Why there is a table at all Almost all of VeilVoice is pure Rust and needs nothing but a compiler. The exceptions are real and they are the reason a build fails on a fresh - (module)
machine with a message about a missing header rather than about a missing package: * **Linux**: `cpal` reaches ALSA through `alsa-sys`, which is a `-sys` crate: it compiles against ALSA's C headers and asks `pkg-config` where they are. - (module)
Neither ships with a base install of most distributions. * **macOS**: CoreAudio comes from Apple's SDK, which arrives with the Xcode command line tools. Apple's licence does not permit redistributing it, which is also why this tool cannot - (module)
build a macOS binary anywhere else. * **Windows**: the MSVC toolchain needs a linker, which comes with the Visual Studio build tools. Everything else -- the engine, the container format, the app lock, the website generators -- has no - (module)
system dependency at all. # What "detected" means, and what it does not [`Need::detect`] answers from what is on `PATH` and from `pkg-config`. That is a real answer for a linker or a compiler and a *good* answer for a library, but it is - (module)
not a build. A machine can pass every probe here and still fail to compile, and this module says so rather than promising otherwise: the build in roadmap item 55 is the only thing that actually knows. # In plain words Before you can build - (module)
this program yourself, your computer needs a few pieces: a Rust compiler, and -- on Linux and macOS -- one or two things from your operating system that VeilVoice's sound handling is built on top of. This works out which of those you - (module)
already have and which you do not, tells you exactly what each one is and who makes it, and offers to install the missing ones **only if you say yes**. It will never install anything on its own, and it does not download anything itself -- - (module)
it asks the software installer your system already came with, so you can see exactly what is being run. - enum Presence
Whether a dependency is here, missing, or unanswerable. - impl Presence
- fn is_satisfied
Whether this counts as satisfied. - fn describe
One line, for a report. - enum Route
What could be done about a missing dependency, on this machine. - impl Route
- fn command_line
The command line, for showing before asking. Shown in full, every time, before the question. A yes to a command nobody read is not consent to anything. - struct Need
One thing a build needs. - const ALL
Everything a build can need, on every platform. Kept whole rather than compiled down to this platform's subset: [`for_this_platform`] filters, and somebody reading the source should be able to see what a build needs elsewhere without - const ALL
owning that machine. - impl Need
- fn detect
Look for it. Changes nothing. - fn route
What could be done about it here. - fn for_this_platform
What this platform needs, in the order to report them. - fn linux_package
A package under the three names the major families give it. The package manager on `PATH` decides which name is used. A machine with none of them gets [`Route::Yourself`] with all three names in it, because a reader on a distribution - fn linux_package
nobody here has heard of can still translate. - fn which
Whether a program is on `PATH`, and where. Asks the system's own resolver rather than walking `PATH` here: the rules differ per platform (`PATHEXT` on Windows, for one) and reimplementing them is how a probe reports something as missing - fn which
that is sitting right there. - fn program_version
The first line a program prints when asked its version. - fn detect_linker
A linker, by whichever name this platform calls it. - fn detect_alsa
ALSA's headers, through the tool the build script itself uses. - fn missing
What is missing, split by whether a build stops without it. Returned rather than printed so the caller decides how loudly to say it -- and so the two are never conflated. A missing optional dependency means a build that succeeds with less - fn missing
in it, and reporting that as a failure would send somebody installing things they do not need. - mod tests
- fn by_key
The one with this identifier. Here rather than in the module above, because the tests are the only thing that looks a need up by name -- everything else walks the whole list. - fn every_entry_is_complete_and_uniquely_keyed
- fn every_need_explains_itself_rather_than_just_naming_a_package
Every need says *why* VeilVoice wants it. A list of packages with no reasons is a list somebody installs without reading, which is the exact habit this table exists to avoid feeding. - fn looking_is_safe_wherever_this_runs
Detection must not panic, hang, or change anything, on any machine. - fn the_toolchain_is_found_because_it_is_running_this_test
A compiler is certainly here: this test is being compiled by one. - fn the_windows_linker_is_not_looked_for_by_name_on_path
F-68. A probe must not answer from a program that merely shares a name with the one it is looking for. This reported "found" on a Windows machine because Git for Windows ships `usr/bin/link.exe`, GNU coreutils' hardlink tool. A build on - fn the_windows_linker_is_not_looked_for_by_name_on_path
that machine would have stopped with a linker error after the dependency check had said everything was fine. - fn no_route_is_ever_taken_by_this_module
Nothing in this module runs a package manager. The routes are values; something else has to decide to run one, after asking. - fn could_not_tell_is_not_the_same_as_not_there
An unanswerable probe must never be reported as an absence. - fn a_missing_optional_dependency_is_not_a_failed_build
Optional and required are kept apart, or somebody installs ALSA headers on a machine that will never run live mode. - fn a_machine_with_no_known_package_manager_still_gets_the_names
The Linux package name is given for all three families whatever happens, so a reader on a fourth can translate. - fn nothing_in_this_module_speaks_http
Nothing here downloads anything itself.
crates/veilvoice-verify/src/discover.rs
- (module)
Finding a release to check, without being told where it is. # Why this exists Verifying a download used to require naming three files and knowing a tag. That is fine for somebody who has already read the instructions and is wrong for - (module)
everybody else, and "everybody else" is precisely the population a verifier exists to serve. Somebody who has just downloaded an archive and wants to know whether it is the real one should be able to run this and be told. So: look in the - (module)
obvious places, in an obvious order, and say what was found. Nothing here downloads, nothing here guesses at a hash, and nothing here reports "verified" on the strength of a filename. # Where it looks, and why in that order 1. The - (module)
directory given, if one was. 2. The current working directory, where somebody who has just `cd`-ed to their downloads will be. 3. The directory the running binary is in, where somebody who unpacked the archive and double-clicked the - (module)
verifier inside it will be. 4. The usual download directories for the platform. Each is searched one level deep only. A recursive walk of a home directory is slow, surprising, and would let a verifier wander into places nobody asked it to - (module)
look at. # What counts as a release archive A file whose name starts `veilvoice-` and ends in one of the archive extensions this project publishes. That is a **filename** test and it proves nothing at all: it is how candidates are found, - (module)
never how they are judged. Every candidate still has to survive the signature and the hash, and a file that merely looks the part fails exactly as loudly as one that does not. # In plain words Looks for a downloaded release to check, so - (module)
you can double-click the verifier and have it work. It looks in the folder it is in, the current folder, one level up from each of those, and your Downloads and Desktop. If it finds nothing it says exactly where it looked, rather than - (module)
reporting a failure that leaves you guessing. - const ARCHIVES
The extensions this project publishes releases as. - const SUMS
The names of the two files a signed release carries beside its archives. - const SUMS_SIG
The detached signature over [`SUMS`]. - const CONTENTS
The list of what is inside each archive, itself covered by [`SUMS`]. **Roadmap item 97.** Optional, and its absence is not a failure: releases before v0.1.15 do not carry one, and a verifier that refused them would be refusing files it can - const CONTENTS
check perfectly well. - struct Found
What was found in one directory. - impl Found
- fn is_complete
Whether this directory holds everything needed to verify offline. - fn is_empty
Whether anything at all turned up. - fn missing
What is missing, in words, for a message to the user. - fn looks_like_archive
Whether a filename looks like one of this project's release archives. A filename test, and therefore evidence of nothing. See the module note. - fn look_in
Look in one directory, one level deep. - fn places
Every place worth looking, in order, without duplicates. - fn search
Look everywhere worth looking and return the first directory that holds a complete, checkable set, or, failing that, everything that turned up. "Complete" means an archive, a hash list and a signature in one place, which is what an offline - fn search
check needs. A directory with an archive and no hash list is reported rather than used: it is exactly the situation where somebody needs to be told what else to download, and silently reaching for a hash list from a *different* directory - fn search
would be checking one release against another's list. - mod tests
- fn touch
- fn a_release_one_level_up_from_the_extracted_folder_is_found
F-107. The download is one level up from the folder you extracted. Reproduces the arrangement every archive tool produces and every user therefore has: the archive, `SHA256SUMS` and its signature in one directory, and the unpacked folder - fn a_release_one_level_up_from_the_extracted_folder_is_found
beside them. Standing in that folder and asking for a check has to find the release, because it is the most likely way this program is ever run, and it was the one arrangement that answered "no VeilVoice release was found to check". - fn the_search_does_not_climb_past_one_level
The search stops one level up, and does not go wandering. A verifier that climbed until it found something to check would read directories that have nothing to do with the download. One level is the extract-into-a-subfolder case; anything - fn the_search_does_not_climb_past_one_level
past it is somebody else's filesystem. - fn the_published_archive_names_are_recognised
- fn anything_else_is_not_a_candidate
- fn a_complete_directory_is_recognised
- fn a_directory_missing_the_hash_list_says_which_part_is_missing
The case somebody actually hits: the archive downloaded, the hash list forgotten. They need to be told which, not left with a failure. - fn several_archives_come_back_in_a_stable_order
- fn an_empty_or_missing_directory_finds_nothing_rather_than_failing
- fn unrelated_files_are_not_candidates
A directory of unrelated files must not produce candidates. - fn the_search_does_not_descend
A directory is only ever searched one level deep. A recursive walk of a home directory is slow, surprising, and lets a verifier wander into places nobody asked about. - fn the_places_list_starts_where_it_was_told_and_has_no_duplicates
- fn a_complete_set_is_found_when_it_is_pointed_at
The whole point of the search: it must find a complete set without being told where anything is. - fn an_incomplete_directory_is_reported_and_not_completed_from_elsewhere
An incomplete directory must be reported rather than silently paired with a hash list from somewhere else -- which would be checking one release against another's list. - fn searching_this_machine_does_not_panic
Searching the real machine must not panic whatever is on it.
crates/veilvoice-verify/src/extracted.rs
- (module)
What came out of the archive, and the GnuPG somebody already has. **Roadmap item 91.** Two halves of the same request: check the extracted copy as well as the archive, and offer the check through GnuPG for anybody who would rather trust - (module)
their own tools than this binary. # This used to be the honest limit, and it is not the limit any more Worth reading before the code, because the module changed shape around it. A release signs `SHA256SUMS`, and `SHA256SUMS` covers the - (module)
**archives**. So verifying `veilvoice-0.1.14-linux-x86_64.zip` proved that archive was the one that was signed, and proved nothing at all about the folder sitting beside it. The folder may predate the download, may have come out of a - (module)
different copy, may have been edited since. Nothing on disk records which archive a directory was extracted from. That was written here as a limit that could not be lifted, and it could not be lifted **from this side**. It was lifted from - (module)
the other one. A release now also publishes `CONTENTS.sha256`, listing every file inside every archive with its SHA-256, staged before `SHA256SUMS` is computed so that the signature covers it too. `crate::check::contents` reads it and - (module)
`lib.rs` checks the extracted folder against it, file by file, and reports anything in that folder the release never published. The lesson is worth keeping beside the code: "no signed list covers loose files" was a true statement about the - (module)
release format, and it was being treated as a fact about the world. Publishing one more file changed it. What is left here is the part no hash can answer. A file can be byte for byte correct and still not start, because the tool that - (module)
unpacked it dropped the execute bit, and somebody in that position has a folder that looks perfect and does nothing. That is what [`look_in`] and [`Program::runnable`] are for, and they are still asked after every hash has matched. - (module)
Releases published before v0.1.15 carry no contents list, and for those the old report and the old caveat are exactly what is printed, because they were honest then and still are. # GnuPG VeilVoice checks the signature itself, with the key - (module)
compiled into this binary, so that somebody with no GnuPG installed is not stuck. That is a convenience and it has an obvious circularity: the program telling you the download is genuine is a program from the same download. - (module)
[`gnupg_commands`] is the answer to that. It prints the exact commands to run with a GnuPG this project did not write, against a key fingerprint published somewhere this project does not control. Anybody who wants the independent check has - (module)
it, spelled out, with nothing to work out. # In plain words Checks the folder you unzipped, as well as the zip. From v0.1.15 a release publishes a signed list of everything inside each archive, so every file in that folder is checked - (module)
against it, and anything in there that was not part of the release is named. For older releases, which carry no such list, it can only tell you the programs are there and that your system will run them, and it says so rather than implying - (module)
more. - const PROGRAMS
The programs a release archive carries. Two since 0.1.18, when the verifier stopped being an executable of its own and became part of both of them. An archive is not missing anything by not holding a third. - struct Program
One program found in an extracted directory. - struct Extracted
What an extracted directory turned out to hold. - impl Extracted
- fn is_empty
Whether anything was found at all. - fn not_runnable
The programs the operating system will not run. - fn directory_for
The directory an archive would extract into, by this project's naming. `veilvoice-0.1.14-linux-x86_64.zip` extracts into `veilvoice-0.1.14-linux-x86_64`. Returns `None` for a name that is not one of ours rather than stripping whatever - fn directory_for
happens to be after the last dot. - fn look_in
Look in `directory` for the programs a release carries. - fn runnable
Whether the operating system will run this file. - mod tests
- fn an_archive_name_yields_the_folder_it_extracts_into
- fn a_name_that_is_not_ours_yields_nothing
Anything not ours produces nothing, rather than a folder name invented by stripping whatever came after the last dot. - fn an_empty_directory_holds_no_programs
- fn a_program_without_its_execute_bit_is_reported_as_such
- fn the_gnupg_commands_check_the_signature_and_then_the_hashes
- fn a_failed_archive_stops_before_the_extracted_report
Roadmap item 91. The extracted report must never run when the archive itself failed. "The archive is bad, and here are the programs in the folder beside it" reads as reassurance, and there is none to give: an archive that failed its - fn a_failed_archive_stops_before_the_extracted_report
signature says nothing good about anything unpacked from it. - fn it_prints_the_commands_rather_than_running_them
This module must not run GnuPG. A verifier that shells out to `gpg` and reports what it said has not escaped the circularity it exists to escape, because the thing running `gpg` is the binary under suspicion.
crates/veilvoice-verify/src/fetch.rs
- (module)
Download a release, without putting an HTTP client in the dependency graph. # The constraint this is written around VeilVoice is **offline by construction**, and that is not a slogan: a CI job fails the build if `reqwest`, `hyper`, `curl`, - (module)
`ureq`, `tungstenite`, `isahc` or `surf` appears anywhere in `cargo tree`. The claim on the front page -- "no network code in the dependency graph" -- is one a reader can check in ten seconds, and it is a large part of why this project is - (module)
worth trusting. Fetching a release to check it is a genuinely useful thing for *this* binary to do, and it is also the one thing that claim forbids. So the download is done by **the tool the operating system already ships**, invoked as a - (module)
subprocess: | Platform | Used | Already present because | |---|---|---| | Windows 10+ | `curl.exe` | shipped in `System32` since 2018 | | macOS | `curl` | part of the base system | | Linux, BSD | `curl`, else `wget` | one or the other is - (module)
on essentially every install | `cargo tree` stays exactly as clean as it was, the CI job that enforces it is untouched, and nothing in the *library* crates gained the ability to talk to anything. What changed is that one command-line tool, - (module)
whose entire purpose is checking downloads, can now also make one -- when asked, never on its own. This is the same pattern the rest of the project already uses for platform work it will not link a dependency for: `veilvoice-watch` reads - (module)
the registry through `reg query`, and `veilvoice-guard` reads the event log through `wevtutil`. # What is deliberately not done here **No downloader is resolved by bare name.** Finding `curl` by searching `PATH` on Windows includes the - (module)
current directory, so running this from a folder containing a hostile `curl.exe` would run that instead -- finding F-13, in the one program whose job is deciding whether a download is genuine. Absolute paths are tried first, and a bare - (module)
name is only ever a last resort on platforms where the search order does not include the working directory. **Nothing is fetched implicitly.** A download happens because the user passed a subcommand that says so. There is no update check, - (module)
no telemetry, and no "just in case" request. **Only one host is ever contacted**, and it is compiled in. A URL cannot be supplied on the command line, so this cannot be turned into a general downloader by an argument. # In plain words - (module)
Downloads a release, without VeilVoice containing any networking code. It asks the tool your system already has to do the fetching. That is what keeps a real promise the rest of the project makes: there is no HTTP client anywhere in what - (module)
VeilVoice is built from, which you can check yourself, and this is the one command that touches the network at all. - const HOST
The only host this will ever talk to. Compiled in rather than accepted as an argument. A verifier that can be pointed anywhere is a download tool wearing a verifier's name, and the fingerprint check below is only meaningful against - const HOST
artefacts from the project it was built for. - const REPO
The repository releases are fetched from. - const MAX_BYTES
The largest file this will accept. A release archive is tens of megabytes. This bounds what a redirected or substituted response can make the tool write to disk, and it is checked after the download rather than trusted from a header, - const MAX_BYTES
because a header is something the other end chose. - struct Downloader
Where a downloader was found, and what to call it. - enum Style
- fn find_downloader
Absolute paths first, and a bare name only where that is safe. Resolving a program by bare name on Windows searches the **current directory** before most of `PATH`, so a file called `curl.exe` sitting beside a downloaded archive would be - fn find_downloader
run instead of the system one. That is finding F-13, and this is the program where it would matter most. - fn no_downloader_message
Say what could not be found, and what to do instead. A tool that cannot download should explain how to proceed without it, not merely report an absence. Everything this fetches can be fetched by hand. - fn download
Fetch one URL into `into`. Returns the path written. Every failure is reported rather than retried. A verifier that quietly tries again is a verifier whose output does not say what actually happened. - fn asset_url
The URL of one file in one release. - const SUMS
The three files every release publishes for checking itself. - const SIGNATURE
- fn valid_tag
A release tag, rejected unless it looks like one. The tag becomes part of a URL and part of a filename, so it is validated rather than trusted: without this, a tag containing `../` would write outside the download directory, and one - fn valid_tag
containing a shell metacharacter would be passed to a subprocess. The argument is passed as a separate argv entry rather than through a shell, so the second is already handled -- but a check that only holds because of how the caller - fn valid_tag
happens to invoke things is not a check. - fn valid_asset
An asset filename, rejected unless it looks like one. - mod tests
- fn a_url_outside_the_compiled_in_host_is_refused
- fn a_tag_that_would_escape_the_directory_is_refused
- fn an_asset_name_that_would_escape_the_directory_is_refused
- fn an_asset_url_is_built_from_the_compiled_in_host_and_repository
- fn the_absent_downloader_message_says_how_to_proceed_without_one
crates/veilvoice-verify/src/gnupg/backend.rs
- (module)
Which program checks the signature, and who decides. VeilVoice can check a release signature three ways, and the difference between them matters more than it looks. 1. **Built in.** The check in this binary, written in Rust, with no - (module)
external program involved. It always works, on every platform, with nothing installed. 2. **A `gpg` on this machine.** GnuPG itself, the reference implementation, run as a separate program. 3. **A `gpg` inside WSL**, on Windows. The same - (module)
GnuPG, in a Linux distribution alongside Windows, reached through `wsl.exe`. # Why the built-in check is not enough on its own, and why it is the default Both things are true at once. The built-in check is the one telling you a download is - (module)
genuine, and it came out of that download. A tampered release ships a tampered checker. That is not a hypothetical objection to fix by writing better code: no program can vouch for itself, and this one says so on the tab. GnuPG is a second - (module)
opinion from software this project did not write, and having one is the entire point of running it. And yet nothing here reaches for GnuPG on its own. **An external checker is used only when the person using VeilVoice has chosen it**, even - (module)
when one was already installed long before VeilVoice first ran. Running another program is a thing a privacy tool should do because it was asked to, not because it found something on `PATH`; on Windows the WSL route starts a whole Linux - (module)
distribution, which is not a side effect to have by accident. So: the default is the built-in check, the tab says what else is available, and choosing one is one press. That is the honest arrangement. It is not the most convenient one. # - (module)
What this module does not do It does not run anything to find out what is here. [`Survey`] is a value somebody else fills in, and every decision below is made from it, so the rules can be tested without a `gpg`, without a WSL, and without - (module)
a Windows machine. - enum Choice
What the person using VeilVoice chose. `None` is not "decide for me". It is "nothing has been chosen", and it resolves to the built-in check whatever is installed. - impl Choice
- fn key
The name this is stored under, in settings and on the command line. - fn from_key
The choice with this name, if it is one. - const ALL
Every choice, in the order a front end should offer them. - struct Wsl
A `gpg` reached through WSL. - struct Survey
What is on this machine. Filled in by a caller that is willing to look. - impl Survey
- fn supports
Whether a choice could actually be used right now. - enum Backend
Which checker will actually run, and why. - enum Because
Why the built-in check is the one running. - impl Because
- fn plainly
What to tell the reader. - fn resolve
The checker to use, from what was chosen and what is here. The one rule worth stating on its own: an unset choice gives the built-in check, whatever is installed. A `gpg` that was on this machine before VeilVoice was ever run is still not - fn resolve
used until somebody says to use it. - impl Backend
- fn is_second_opinion
Whether an OpenPGP implementation other than this one is doing the work. The whole value of choosing one is that it is not this program, so this is the question the tab actually asks. - fn plainly
A one-line description for a reader. - fn spell
How a command written for `gpg` is spelled for this backend. A WSL command is the same command with `wsl` in front of it, which is worth showing rather than hiding: it is what the reader would type, and it makes plain that the check is - fn spell
happening in the Linux distribution rather than on Windows. - fn look
What is here, without running anything. `PATH` is read and files are looked at. No program is started, including `wsl.exe`: starting WSL boots a Linux distribution, and that is not something to do because a window opened. So `wsl` is - fn look
reported as present or absent, and whether GnuPG is *inside* it stays unknown until [`look_in_wsl`] is called, which is a thing the reader asks for. - fn wsl_program
`wsl.exe`, if this is Windows and it is installed. - fn look_in_wsl
Ask the WSL distribution where its `gpg` is. **This starts WSL**, which starts a Linux distribution, which is why it is a separate call and not part of [`look`]. `command -v` is the shell builtin, so this needs nothing installed to answer - fn look_in_wsl
that nothing is installed. - fn install_in_wsl
The command that installs GnuPG inside the WSL distribution. Shown rather than run. It needs root inside that distribution, and a program that asks for somebody's password to install something is a program to be suspicious of; run in a - fn install_in_wsl
terminal, the reader can see what they are approving. This is the same rule the companion list follows. - mod tests
- fn native_only
- fn wsl_with_gpg
- fn an_installed_gnupg_is_not_used_until_it_is_chosen
The rule this module exists for. - fn the_same_is_true_of_a_gnupg_inside_wsl
- fn choosing_one_uses_it
- fn a_choice_that_is_no_longer_installed_falls_back_and_says_so
A choice that has gone away falls back to a check that works, and says which one it made. Falling back silently would let somebody believe a second opinion had been taken when none had. - fn wsl_without_gnupg_in_it_is_not_a_route
WSL being installed is not the same as GnuPG being installed inside it. - fn a_wsl_command_is_the_same_command_run_through_wsl
- fn every_choice_survives_being_written_down_and_read_back
- fn the_wsl_install_command_is_the_one_a_person_would_type
The WSL install command is shown, not run, and it is the command a person would type. - fn looking_does_not_run_a_program
Looking must not start anything. The check is on the source, because the failure would be a Linux distribution booting when a window opened and no assertion about a return value would catch that. - fn the_built_in_check_is_always_available
The built-in check is always available. It is the only one that can be promised, because it is the only one that is part of this program.
crates/veilvoice-verify/src/gnupg/mod.rs
- (module)
Run the GnuPG that is already on this machine. **Roadmap item 97.** VeilVoice checks a release signature itself, with a key compiled into the binary, so that somebody with no GnuPG installed is not stuck. That is a real convenience and it - (module)
has an obvious circularity: the program telling you the download is genuine came out of that download. Until now the answer to that was to print the commands and leave the running of them to the reader. That is correct, almost nobody does - (module)
it, and a check almost nobody runs is a check that is not protecting anybody. So this runs them. When GnuPG is on the machine, the signature is checked by **two independent implementations** -- this project's built-in one and somebody - (module)
else's -- and both answers are reported. Two implementations agreeing is worth more than either alone, and two disagreeing is the loudest thing a verifier can find. # What this does not fix Running `gpg` from inside the binary under - (module)
suspicion does not make the answer independent of that binary. What it makes independent is the *implementation*: the packet parsing, the hashing and the signature arithmetic are somebody else's code. The independent *invocation* is still - (module)
the reader typing the commands themselves, and every front end that uses this goes on printing them for exactly that reason. # Why the status lines and not the words `gpg --verify` prints "Good signature from ..." in the reader's own - (module)
language. Parsing that would be a verifier whose answer depends on a locale, and the failure mode is the bad one: an unrecognised string reads as "no good signature found" in one direction, or matches a translated word in the other. - (module)
`--status-fd` is GnuPG's machine-readable channel and it is not translated. Measured against GnuPG 2.4.4, which is where these shapes come from rather than from the manual: ```text good: [GNUPG:] GOODSIG <keyid> <uid> [GNUPG:] VALIDSIG - (module)
<fingerprint> ... exit 0 tampered: [GNUPG:] BADSIG <keyid> <uid> exit 1 no key: [GNUPG:] ERRSIG ... / NO_PUBKEY <keyid> exit 2 import: [GNUPG:] IMPORT_OK <flags> <fingerprint> exit 0 ``` # A good signature from *some* key proves nothing - (module)
This is the mistake the whole thing exists to avoid making on somebody's behalf. `gpg --verify` reports success for a valid signature by **any** key in the keyring, and anybody who can hand you an archive can hand you a signature by a key - (module)
they made this morning. So [`Gnupg::verify`] is given the fingerprint that is expected and compares `VALIDSIG` against it. A good signature by a different key is its own outcome, [`Outcome::AnotherKey`], and it is a failure rather than a - (module)
pass with a note attached. # In plain words If you have GnuPG, this uses it, so the answer does not come only from a program you have just downloaded. It adds the VeilVoice signing key to your keyring, checks the signature with your GnuPG, - (module)
and tells you exactly what your GnuPG said. It also checks that the signature is by the right key, which is the part that matters and the part that is easiest to skip. - enum Error
Something that stopped GnuPG being asked at all. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- mod backend
- mod script
- fn on_path
Where GnuPG is, if it is on `PATH`. A lookup and nothing else: it never runs the program to find out. - enum Outcome
What GnuPG made of a signature. - impl Outcome
- fn is_good
Whether this is the one outcome that means the file is as published. - fn plainly
One line saying what happened, in words a reader can act on. - struct Run
One run of GnuPG, and what it said. - enum Imported
Whether the key was already in the keyring. - struct Import
The key, in the keyring. - impl Import
- fn note
What was done to the reader's keyring, and how to undo it. GnuPG has no field for a note about somebody else's key. Measured: there is no `--comment` that survives an import, and the only way to attach text to a key you do not own is a - fn note
local certification, which needs a secret key of your own that a reader may not have. So the note is said here, where the person who will wonder about it is looking, and the removal is one line rather than a paragraph of `--edit-key`. - struct Gnupg
A GnuPG to run, and the keyring to run it against. A value rather than a pair of free functions, because the keyring is part of the question. The default is the one the reader already uses, which is the point of the feature: after this, - struct Gnupg
their own `gpg --verify` works. A caller that wants isolation -- this crate's own tests, and anything that should not touch somebody's keyring -- names a directory instead and gets a GnuPG that can neither see nor change anything else. - impl Gnupg
- fn found
The GnuPG on `PATH`, if there is one. - fn at
A particular GnuPG, using the keyring its owner already has. - fn in_home
The same GnuPG, against a keyring of its own. Nothing outside that directory is read or written, so this neither adds a key to somebody's own keyring nor sees one that is in it. - fn program
Where this GnuPG is. - fn base
The arguments every call here shares. `--batch` and `--no-tty` so nothing ever waits for a person who is not there. `--status-fd 1` for the machine-readable channel, on standard output, where GnuPG's prose does not go. - fn import
Put a key into the keyring this GnuPG is using. The armour goes in on standard input, so nothing is written to disk and no temporary file is left behind if this is interrupted. `expected` is checked against what GnuPG says it imported. The - fn import
two can only differ if the armour handed in is not the key it claims to be, which is worth failing on rather than reporting a successful import of something else. - fn verify
Check a detached signature with this machine's GnuPG. `expected` is the fingerprint that must have signed it. A valid signature by any other key is [`Outcome::AnotherKey`] and is a failure; see the module note for why that is the check - fn verify
nobody should be asked to remember to make. - fn status_lines
The `[GNUPG:]` lines out of what GnuPG printed, prefix removed. - fn field
The rest of the first status line whose first word is `keyword`. The space is load bearing: without it `NO_PUBKEY` would match a line beginning `NO_PUBKEYS`, and a keyword GnuPG adds later could quietly change what this decides. - fn fields
Every status line whose first word is `keyword`. **F-100.** A file can carry more than one signature, and GnuPG reports each one: several `NEWSIG`/`VALIDSIG` blocks in a single run. Reading only the first meant that a release signed by the - fn fields
project key *and* by somebody else's, in that other order, was reported as signed by the wrong key and refused. That is the safe direction and it is still wrong: refusing a genuine release teaches people that the verifier is unreliable, - fn fields
and a verifier people work around is worse than none. What matters is whether the expected key signed this data, and that question is asked of every signature rather than of whichever GnuPG happened to print first. - fn same_fingerprint
Whether two fingerprints are the same, ignoring case and spacing. GnuPG prints them unspaced and uppercase; a fingerprint copied off a website often arrives in groups of four. Comparing the two as typed would refuse a correct key over its - fn same_fingerprint
formatting. Two empty strings are not a match, so a run that produced no fingerprint at all cannot compare equal to an expectation of nothing. - fn commands
The commands a reader can type to get this answer without VeilVoice. Printed as well as run, always. See the module note: running GnuPG makes the implementation independent and does not make the invocation independent, and only the reader - fn commands
can supply that. - mod tests
- const KEY
The project's own key, so the import test imports the real thing. - const FINGERPRINT
- fn a_fingerprint_is_compared_by_its_digits_and_not_its_spacing
- fn nothing_does_not_match_nothing
Two empty strings are not a match. Otherwise a run that produced no fingerprint would compare equal to an expectation of nothing. - fn the_status_channel_is_read_and_the_prose_is_not
The four shapes, taken from GnuPG 2.4.4 rather than from the manual. - fn the_expected_key_is_looked_for_in_every_signature_not_the_first
**F-100.** A file can carry more than one signature, and the expected key's may not be the one GnuPG prints first. Reading only the first meant a genuine release signed by two keys was reported as signed by the wrong one and refused. That - fn the_expected_key_is_looked_for_in_every_signature_not_the_first
is the safe direction and it is still wrong: a verifier people learn to work around is worse than none. - fn a_signature_made_by_a_subkey_is_the_primary_key_that_owns_it
**F-104.** A signature made by a subkey belongs to its primary key. `VALIDSIG` names the signing key first and the primary key last, and only the first was read. For a key that signs with itself the two are the same string and the mistake - fn a_signature_made_by_a_subkey_is_the_primary_key_that_owns_it
cannot be seen, which is the shape of key this was first measured against. This project's release key signs with a subkey, so the real release exercised the case the fixture could not, and the verifier called a genuine release unverified. - fn a_signature_made_by_a_subkey_is_the_primary_key_that_owns_it
The line below is the one GnuPG actually printed for v0.1.15. - fn a_translated_good_signature_line_is_not_a_status_line
Prose is never read, so a translated GnuPG cannot change the answer. - fn a_longer_keyword_is_not_the_one_being_looked_for
A keyword matches only as a whole word. - fn only_the_expected_key_is_a_pass
Only one outcome is a pass, and a good signature by the wrong key is not it. This is the check the release notes ask a reader to make by hand. - fn the_printed_commands_are_runnable_as_printed
The commands are the independent article and must name both files. - fn every_call_is_unattended
Nothing here ever asks a person for anything: these run where no terminal exists, and a GnuPG that stopped to prompt would hang a verifier somebody double-clicked. - fn the_note_says_what_changed_and_how_to_put_it_back
The note says what was done and how to undo it, because a key that appears in somebody's keyring without explanation is a thing they will later find and not understand. - fn scratch_home
Somewhere for a keyring that is nobody's real one. # Unique per call, not per instant F-106. This used to name the directory after the clock alone, and every test that takes one deletes it when it finishes. Cargo runs these tests on - fn scratch_home
threads at the same moment, so two calls landing in the same tick share a directory, and the first test to finish deletes the second test's keyring out from under it. On Linux the nanoseconds always differed and it never happened; on macOS - fn scratch_home
the clock is coarser and it did, as a keyring that existed for the first import and was gone for the second. So the name carries a counter that is unique within the process whatever the clock does, and the clock only separates one run from - fn scratch_home
the next. `create_dir` rather than `create_dir_all` is the other half: `create_dir_all` treats "it is already there" as success, which is exactly the case that must not be silent. - static NEXT
- fn scratch_homes_are_never_the_same_directory_twice
Two scratch homes are two directories, however close together they are asked for. The defect this exists to stop coming back is F-106, and it was invisible precisely because it needed two calls in one clock tick. Asking for a hundred in a - fn scratch_homes_are_never_the_same_directory_twice
loop is the cheapest way to make that certain: with the clock alone as the key, a run of these collides on any machine whose clock is coarser than the loop is fast. - fn the_real_key_imports_into_a_real_gnupg_and_says_so_the_second_time
The real key, into a real GnuPG, twice. Skipped where GnuPG is not installed, because that is a fact about the machine rather than a failure of this code, and the whole crate already reports [`Error::NotInstalled`] for it. - fn a_signature_with_no_key_to_check_it_is_never_a_pass
A signature nobody can check is `NoKey`, not a pass. Run against an empty keyring, so the answer cannot come from a key that happens to be on the machine running the tests.
crates/veilvoice-verify/src/gnupg/script.rs
- (module)
A shell script that checks a release, for people who would rather read one. # Why a script at all, when there is a verifier `veilvoice-verify` does this check, and it does more of it: every extracted file, not just the archive. But it has - (module)
the problem every such program has, and this project says so on the tab: **it came out of the download it is checking.** A tampered release ships a tampered verifier. This is the answer to that. Sixty lines of shell, using `gpg` and - (module)
`sha256sum` and nothing else, short enough that somebody can read the whole of it before running it. That is the point of it: not convenience, but that the thing doing the checking is not this project's code. # Why it is generated rather - (module)
than committed The fingerprint in it is [`crate::check::FINGERPRINT`], and the one thing that must never happen is a script checking against a fingerprint that has drifted from the one the project actually signs with. A committed script is - (module)
a second copy of that string. This is written from the first copy, every time, so there is no second one to go stale. # What it deliberately does not do It does not install anything, and it does not download the release. It checks files - (module)
that are already in the current directory, and it says what to run if `gpg` is missing rather than running it. A verification script that fetched things would be a verification script with a network path in it. - enum Flavour
Which system the script is being written for. The only real difference is how the hashes are checked: coreutils calls it `sha256sum`, macOS ships `shasum`, and the BSDs ship `sha256`. WSL is Linux, and is listed separately only because - enum Flavour
saying so is what a Windows reader needs to hear. **The BSDs were missing and fell through to Linux**, which is F-167: this script told a reader on FreeBSD, OpenBSD or NetBSD to run a command that `crate::check`'s reproduce script, in the - enum Flavour
same release, says they do not have. The command now comes from that module rather than from a second copy here, so the two cannot disagree again. - impl Flavour
- const ALL
Every one of them, so a caller writing all the scripts writes all of them rather than the ones it remembered. - fn system
The same system, as the reproduce scripts name it. The two halves of "check what you downloaded" are this script and `crate::check`'s, and a reader on one machine may follow either. They answer to one enumeration so that they cannot - fn system
describe different machines. - fn hash_check
The command that checks a file against `SHA256SUMS`. Asked of [`crate::check::reproduce::System`] rather than answered here. This function used to answer it, knew two systems, and was wrong about the third. - fn install_hint
What to type if GnuPG is not installed. - fn file_name
The name a reader would give the file. - fn shell
The script, with the fingerprint compiled in from the one source of it. - mod tests
- fn the_script_carries_the_fingerprint_the_programs_use
The one thing that must never drift. - fn the_signature_is_checked_before_the_hashes
Signature before hashes. Getting this order wrong makes the whole script worthless while still printing a pass, so it is checked rather than trusted to stay written correctly. - fn no_script_names_another_machines_hash_tool
It checks what is here; it does not fetch anything. A verification script with a network path in it is a different and worse thing. **F-167.** The two scripts a reader might follow agree about their machine. This repository ships two shell - fn no_script_names_another_machines_hash_tool
scripts that check a download: this one, which verifies a signature and a hash list, and `crate::check`'s, which reproduces the build. A reader on one machine may run either, and for a while they named different hash tools on the BSDs, - fn no_script_names_another_machines_hash_tool
because this module carried its own copy of the answer and that copy knew two systems. The copy is gone and this is what keeps it gone: every flavour asks the other module, and a flavour added here without a system there would not compile. - fn the_guides_table_of_systems_is_what_the_program_prints
**Roadmap item 129.** The guide's per-system table is the program's answer. The row this comes from asks for the verification to be written up per platform "rather than left as a Linux instruction somebody has to translate". A table of - fn the_guides_table_of_systems_is_what_the_program_prints
commands in a document is a copy of what the program prints, and a copy goes stale: F-167 was two copies of exactly this answer disagreeing. So the table is checked here against the code it describes, which is what `CLAUDE.md` asks for - fn the_guides_table_of_systems_is_what_the_program_prints
when a fact cannot be derived. Read out of the guide's source. If the table moves, this fails saying it cannot find it, rather than passing because there was nothing left to check. - fn every_flavour_is_saved_under_its_own_name
Every flavour has a file name of its own. Two flavours sharing one would mean a reader saving the second over the first, which is how somebody on a BSD ends up running the macOS script. - fn the_script_downloads_nothing
- fn the_script_installs_nothing
It does not install anything either. It says what to type. The distinction is between running `sudo` and *mentioning* it, and the script does mention it: the message shown when GnuPG is missing is the command to install it. So this tracks - fn the_script_installs_nothing
quoting rather than searching for the word. A first attempt did search for the word and failed on the help text, which would have meant either deleting a useful message or keeping a test that could not tell a command from a sentence. - fn each_flavour_uses_the_hash_tool_that_system_has
macOS has no `sha256sum`, and a script that calls one there fails at the last step after appearing to work. - fn the_script_says_where_the_files_come_from
The script says where the files come from. Somebody running it in the wrong folder needs to know what to fetch and from where.
crates/veilvoice-verify/src/lib.rs
- macro_rules! out
A line of ordinary progress: a step being taken, a check that passed. Every `println!` in this program goes through one of these three macros, so the verbosity level is applied in one place rather than remembered at each call. A quiet mode - macro_rules! out
with one loud line left in it is not a quiet mode, and that is exactly what "remember to check the level here" produces. - macro_rules! verdict
**The answer.** Printed at every level except `--quiet`, where the exit status carries it instead. This is what `--brief` is for: the fingerprint, the hash, the verdict on a file. If a reader at `--brief` would be left without the thing - macro_rules! verdict
they ran the command to find out, the line belongs here rather than in `out!`. - macro_rules! outp
The same as [`out`], without the newline, for a progress line that is finished by a later `out!`. - macro_rules! note
Working detail: a command line, a path, a hash being compared. Only at `--verbose`. - mod builder
- mod check
- mod deps
- mod discover
- mod extracted
- mod fetch
- mod gnupg
- mod report
- fn embedded_key
The embedded key, with its fingerprint checked against [`FINGERPRINT`]. - fn sha256_file
SHA-256 of a file, as this program's `Result<_, String>`. - fn verify_detached
Verify a detached signature, as this program's `Result<_, String>`. - fn help_text
The verifier's own help, with its verbosity and exit-status tables. Exposed so `veilvoice verify --help` can print it. clap knows the flags this command adds; it does not know the verifier's subcommands, and a help page that lists half the - fn help_text
commands is worse than one that lists none. - const USAGE
- const EXPLAIN
- fn good
One line of a passing check, in the shape every other line here uses. - fn fail
A failure that is not a refusal: something did not happen, rather than something was checked and found wrong. Kept apart from [`deny`] on purpose. "The download failed" and "the signature is bad" are different facts and a reader must not - fn fail
have to work out which one they were told -- the second means somebody may have tampered with a release, and the first usually means a network hiccup. - fn deny
Every refusal goes through here, so every refusal names the check. - fn incomplete_deny
Nothing could be checked, with the same shape of detail as a refusal. The distinction that matters is the one in the last line and in the exit status: a release that is not there has not been found wanting. - fn cannot
A file this program was told to read could not be read. Nothing was checked and nothing was found wrong, so it carries [`Status::Incomplete`] and says so in the words -- the old code told a reader with a mistyped path that their download - fn cannot
might be compromised. - fn usage
The command line could not be understood, so nothing was attempted. Kept apart from [`deny`] because they are different facts and the old code printed the same words for both. A mistyped path is not a reason to tell somebody their download - fn usage
may have been tampered with, and "do not run it" is nonsense advice when nothing was examined. It also carries [`Status::Usage`], so a script can tell its own mistake from a finding. - fn read_text
A file as text, with the path in the error rather than just the reason. - fn command_key
`veilvoice verify key`: what the key compiled into this binary is. Printed so a reader can compare it against the fingerprint published on the website and in the README before trusting anything this program says about a signature. - fn command_sums
`veilvoice verify sums`: the signature over a hash list, and nothing else. The narrowest of the checks. It says the list was signed by the key this binary carries; it says nothing about whether any file matches it. - fn command_file_against_sums
`veilvoice verify file`: the whole chain for one download. The signature over the list, the file's own SHA-256, and that digest's line in the list. All three have to hold, and each is reported separately so a failure says which link broke. - fn command_file_against_hash
`veilvoice verify file --sha256`: one file against one hash typed by hand. No signature is involved, so this proves only that the bytes are the ones whoever gave you that hash meant. The refusal for a malformed hash is deliberate: silently - fn command_file_against_hash
treating a typo as a mismatch would read as a failed download. - fn command_hash
`veilvoice verify hash`: print a file's SHA-256 and stop. No verdict, because there is nothing to compare against. It is the half of the check somebody does by eye against a number from elsewhere. - fn take_value
The value after a flag, or a message naming the flag that is missing one. - fn command_release
Fetch a release and check it, in one step. The order is the same one every other path in this tool takes and the same one the install scripts take: **the signature over the hash list first**, then the file against that list. Checking the - fn command_release
hash first would prove only that a download matches a list which might itself have been replaced. Downloads go to a directory the caller can inspect afterwards. Nothing is deleted on success: somebody who has just verified a release - fn command_release
usually wants the release. - fn command_auto
Find a release near the user and check it, with nothing else to type. The command somebody who has just downloaded an archive actually wants. Everything it does is offline: it looks in a few obvious places, and if it finds an archive with - fn command_auto
its hash list and signature beside it, it runs exactly the same check `file --sums --sig` runs. A directory holding an archive but no hash list is **reported**, never completed from a hash list found somewhere else -- that would be - fn command_auto
checking one release against another release's list, and it would say "verified". - fn report_extracted
Roadmap item 97. Every file in the extracted folder, against the signed list. # What changed, and why the old caveat is gone This used to report that the programs were present and runnable, and then say in as many words that it could not - fn report_extracted
tell whether the folder came out of the archive it had just checked. That was true: `SHA256SUMS` covers the archives, nothing on disk records what a directory was extracted from, and no signed list covered the loose files. A release now - fn report_extracted
publishes `CONTENTS.sha256`, which lists every file inside every archive with its SHA-256 and is itself covered by `SHA256SUMS` and so by the signature. The chain is complete: ```text SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each - fn report_extracted
file on disk ``` So the question is now answered rather than deferred. Where a release does not carry that file -- everything published before v0.1.15 -- the old report and the old caveat are what is printed, because they were honest and - fn report_extracted
still are. Returns how many things were wrong, so the caller can fail the run. - enum Manifest
What the release said is inside its archives, if anything usable. - fn manifest
Read `CONTENTS.sha256`, having first proved it is the published one. The order is this program's usual one and it matters more here than anywhere else: this file decides which paths get read and what they are compared against, so checking - fn manifest
it against the signed hash list **before** parsing it is the difference between a verifier and a program that does what a downloaded text file tells it to. - fn report_against_manifest
Check one extracted folder against the section of the list that covers it. - fn digest_for
The published hash for one path, for a `--verbose` line. - fn report_runnable
Whether the operating system will run the programs that are there. The other half of what an extracted folder can be wrong about, and it survives the manifest: a hash says a file is byte for byte correct, and an unpacking tool that dropped - fn report_runnable
the execute bit leaves that correct file unrunnable. - fn report_presence_only
The old report, for a release that published no contents list. - fn report_gnupg
Roadmap item 97. The same check, run through the GnuPG the reader already has. Two implementations, both reported. This program checked the signature with a key compiled into itself, and it came out of the same download; GnuPG's answer is - fn report_gnupg
arrived at by somebody else's code. Where the two disagree, that is the loudest thing this tool can find and it fails the run. **GnuPG failing to run is not a disagreement.** A missing keyring directory, a read-only home, an agent that - fn report_gnupg
will not start: none of those is a statement about the file that was downloaded, and counting them as refusals would tell somebody not to run a release that is entirely sound. Only an answer counts, and only a bad answer counts against. - fn report_gnupg
The commands are still printed, every time. Running GnuPG from inside the binary under suspicion makes the *implementation* independent and does not make the *invocation* independent, and only the reader can supply that. Returns how many - fn report_gnupg
things were wrong. - fn command_gnupg
Roadmap item 91. Print the commands that check this release with somebody else's GnuPG, and nothing else. A separate subcommand rather than only a footnote under `auto`, because the person who wants this is the person who does not want to - fn command_gnupg
be told the answer by this binary. Making them run the full check first to reach the commands would be the wrong way round. - fn command_deps
What this machine needs before it can build VeilVoice. `install` here means *offer*: every missing thing is named, described, and the exact command line is shown before the question. Nothing runs without a yes typed by a person, or `--yes` - fn command_deps
typed on this command line -- which is the same explicit yes, given in advance and in writing. - fn command_build
Build the workspace from source and hash what came out. No comparison: this is the half somebody runs to get a build, and it says what it produced. `reproduce` is the half that checks it against a release. - fn do_build
Everything both build commands do before they differ. - fn command_reproduce
Build here, and compare against the published hashes for this platform. **The signature is verified before any hash from the list is read**, by the same code path `sums` uses. A hash list that has not been verified is a list of numbers - fn command_reproduce
somebody sent you. - fn command_install
Put binaries where a shell will find them. Only from a directory this program was pointed at, and it says which files it copied and to where. It does **not** verify anything itself: `file`, `auto` and `reproduce` are how a directory earns - fn command_install
being installed from, and folding a check into a copy would mean two things happening under one yes. - fn asked_for
Print something the reader asked for by name, at any level. The one exception to "every line goes through the level", and it is narrow on purpose: `--help`, `--explain`, `--version` and `--exit-status` are not reports about a check. - fn asked_for
Somebody who types `--help` wants the help, whatever else they passed, and a `--quiet --help` that prints nothing is a bug dressed as consistency. One function rather than four bare `print!` calls, so the exception has a single place, a - fn asked_for
stated reason, and one line for the source-level test to know about. - fn run
Run the verifier over `args`, which are the words after `veilvoice verify`. This was `fn main` in a binary of its own until the verifier was folded into `veilvoice`. The body is unchanged: the same commands, the same output, the same exit - fn run
statuses. What changed is who calls it. # The double-click that used to work The standalone binary did the useful thing when run with no arguments -- looked for a release nearby, checked it, and waited so the window did not vanish -- - fn run
because a person who has just downloaded a zip and is nervous about it will double-click the small program next to it. That flow is gone with the binary, and it is worth naming rather than quietly dropping. What replaces it is the desktop - fn run
application's verify tab, which does the same check with the same code in `crate::check`, and `veilvoice verify` with no arguments, which still runs `auto`. The waiting-for-Enter is not kept: a subcommand of a command-line tool is being - fn run
run from a shell that is not about to close. - mod tests
crates/veilvoice-verify/src/report.rs
- (module)
How much this program says, and what it returns when it says nothing. # The exit status comes first, and that is not an accident This module exists because of one requirement: **there is a verbosity level called "nothing"**. A tool that - (module)
prints nothing and returns zero when a signature did not verify is worse than a noisy one -- it is a tool that reports success by staying quiet, which is the shape every failure takes when nobody is watching. So the statuses are defined - (module)
and documented first, and `--quiet` is only usable *because* they are. [`Status`] gives every outcome its own number, they are stable, and `--help` prints the table. # Failing, refusing, and the difference Two outcomes are both "not - (module)
success" and must never be confused: * [`Status::Refused`] -- a check ran and the answer was wrong. The signature did not verify, or a hash did not match. Somebody may have tampered with a release. * [`Status::Incomplete`] -- a check did - (module)
not run. A download failed, a file was missing, a tool was not installed. **Nothing has been proven either way**, which is not the same as nothing being wrong. The existing reporting already keeps these apart in the words it prints. This - (module)
gives them separate numbers so a script can tell them apart too, since a script is exactly the reader who gets no words. # In plain words This decides how much the program prints -- from every detail down to absolutely nothing -- and makes - (module)
sure that when it prints nothing it still *tells* you the answer, through the number every program hands back when it finishes. Something has to carry the answer. If it is not the text on the screen, it has to be the number. And it keeps - (module)
two different bad outcomes apart: "I checked and it was wrong" is not the same as "I could not check". The first means somebody may have tampered with your download. The second usually means your internet hiccuped. - static LEVEL
The level in force, set once at startup and read everywhere. A process-wide value rather than a parameter threaded through every command: there are around forty places that print, the level is the same for all of them for the whole run, - static LEVEL
and a parameter that has to reach all forty is a parameter that will one day not reach one of them. - fn set_level
Set the level. Called once, from `main`, before anything is printed. - fn level
The level in force. - enum Status
What happened, as a number a script can read. These are **stable**. A number that changes meaning between versions breaks every script that trusted it, silently, in the direction of "it worked". - impl Status
- fn code
The number this outcome exits with. - fn meaning
One line, for the table in `--help`. - const ALL
Every status, for printing the table and for testing it is complete. - fn table
The table, as `--help` prints it. - impl From<Status> for ExitCode
- fn from
- enum Loudness
How much to print. Ordered, so a message can ask "am I loud enough to be said". - impl Loudness
- fn flag
The flag that selects this level. - fn describes
What this level shows. - fn take_from
Read the level out of the arguments, removing the flags that set it. The **loudest** flag given wins rather than the last one. `--quiet --verbose` is a contradiction, and of the two possible readings, the one that prints more is the one - fn take_from
that cannot hide an answer. - fn table
The table, as `--help` prints it. - mod tests
- fn every_status_has_the_number_it_has_always_had
The numbers are stable. Written out rather than derived, because the point of the test is to fail when somebody reorders the enum. - fn the_statuses_are_distinct_and_the_list_is_complete
No two outcomes share a number, and every one is in `ALL`. - fn nothing_but_success_returns_zero
Only success is zero. This is the whole reason the module exists: at `--quiet` the number is the entire answer. - fn a_failed_check_and_an_unfinished_one_are_different_numbers
Checked-and-wrong and could-not-check must have different numbers, or a script cannot tell tampering from a network hiccup. - fn a_reproducibility_difference_is_not_reported_as_tampering
A difference between this build and the published one is a finding, not an accusation, and it gets its own number to say so. - fn the_default_is_normal
- fn each_flag_selects_its_level_and_is_removed
- fn the_loudest_flag_wins_rather_than_the_last
Contradictory flags resolve towards saying more. Of the two readings, only one can hide an answer, so it is not the one taken. - fn the_levels_are_ordered_from_silent_to_everything
The levels are ordered, because every message asks "am I loud enough". - fn the_help_tables_name_every_level_and_every_status
Both tables have to be printable and complete: they are the whole documentation of the quiet mode.
crates/veilvoice-verify/src/tests.rs
- (module)
The verifier's own tests. The property that matters most here is not "a good signature is accepted" but **"a bad one is refused"**. A verifier that accepts everything passes every happy-path test ever written, and would ship looking - (module)
perfect while doing the opposite of its job -- so most of what follows is negative: corrupted signatures, wrong keys, truncated input, mismatched hashes. This file is `//!`-documented rather than `//`-commented so that the reasoning above - (module)
appears in the generated documentation. A reader deciding whether to trust `veilvoice-verify` should be able to see what it was tested *against* without cloning the repository, because the whole purpose of that binary is to be the thing - (module)
you check a download with. # In plain words The verifier's own tests, and most of them are about failure rather than success. That is deliberate. A verifier that accepted everything would pass every happy-path test ever written and would - (module)
ship looking perfect while doing the opposite of its job. So most of what is here is corrupted signatures, wrong keys, truncated files and mismatched hashes, and the question each time is whether it says no. - fn the_embedded_key_parses_and_is_the_expected_one
- fn the_embedded_key_carries_no_email_address
- fn the_fingerprint_constant_is_written_out_not_computed
- fn a_hash_is_found_by_its_file_name
- fn a_binary_mode_star_is_not_part_of_the_name
- fn a_file_that_is_not_listed_is_not_found
- fn a_name_that_merely_contains_the_wanted_one_does_not_match
- fn blank_and_comment_lines_are_skipped
- fn a_malformed_line_is_skipped_rather_than_panicking
- fn digests_compare_case_insensitively_and_ignore_surrounding_space
- fn a_signature_that_is_not_openpgp_is_refused
- fn an_empty_signature_is_refused
- fn an_armoured_block_that_is_not_a_signature_is_refused
- fn every_line_printed_by_a_check_goes_through_the_level
**Nothing may print without asking the level first.** `--quiet` is a promise that this program says nothing, and the exit status is the whole answer. One forgotten `println!` breaks that promise, and it breaks it invisibly: every test - fn every_line_printed_by_a_check_goes_through_the_level
still passes, the output is still correct at the default level, and the only reader who finds out is the one running it in a pipeline where a stray line is a parse error. So the source itself is checked. Every `print!`, `println!` and - fn every_line_printed_by_a_check_goes_through_the_level
`eprintln!` in `lib.rs` must be reached through one of the three macros that gate on the level, or from inside an explicit `if report::level() >= ...` block, or be one of the few lines that are not reports about a check at all. Both - fn every_line_printed_by_a_check_goes_through_the_level
streams, in one pass. They were two tests to begin with, and the standard-output one did not understand the explicit gate, so the first four commands written after it were flagged for doing exactly the right thing. A rule enforced two ways - fn every_line_printed_by_a_check_goes_through_the_level
is a rule with two definitions. - fn nothing_exits_with_an_undocumented_status
Every exit this program can take is one of the documented statuses. `ExitCode::FAILURE` is the shape this used to have and the one to keep out: it is 1, which now means "the command line could not be understood", so a leftover `FAILURE` - fn nothing_exits_with_an_undocumented_status
would report a bad signature as a typing mistake. - fn every_test_that_reads_source_normalises_its_line_endings
**F-72.** Every `include_str!` of this project's own source is normalised before it is searched. Three tests here searched for `"\n}\n"` and passed on every machine whose checkout uses LF. GitHub's Windows runners default to - fn every_test_that_reads_source_normalises_its_line_endings
`core.autocrlf=true`, so the file arrives with CRLF, the pattern matches nothing, and the tests failed there and nowhere else -- including on the Windows machine that had just run them and watched them pass, because its git is set to - fn every_test_that_reads_source_normalises_its_line_endings
`input`. `.gitattributes` now pins the whole tree to LF, which is the real fix and also protects every generator's byte-for-byte `--check`. This is the second line of defence, because a test that depends on a git setting is a test somebody - fn every_test_that_reads_source_normalises_its_line_endings
will trip over on a machine nobody here owns. - fn searching_for_a_brace_on_its_own_line_fails_against_crlf
The failure mode itself, so it is on record as reachable rather than theoretical. This is what the three failing tests were doing, against the two forms the same file takes on two machines. - fn the_repository_pins_its_line_endings
`.gitattributes` exists and pins text to LF. Checked from a test rather than trusted, because it is the thing that keeps every generator's byte comparison honest on a contributor's machine, and nothing else in the build would notice it - fn the_repository_pins_its_line_endings
being deleted. - fn the_contents_list_is_verified_before_it_is_parsed
**Roadmap item 97.** The contents list decides which paths get read and what they are compared against, so it is checked against the signed hash list *before* it is parsed. Written as a test over the source because the ordering is the - fn the_contents_list_is_verified_before_it_is_parsed
whole property and it cannot be observed from outside: a version that parsed first and checked afterwards would give the same answers on every good release and would be doing what a downloaded text file told it to on a bad one. - fn a_release_without_a_contents_list_is_not_a_failure
A release that published no contents list is still checkable. Everything before v0.1.15 is in that position, and a verifier that refused them would be refusing files it can check perfectly well. `None` is a state, not an error. - fn a_contents_list_with_nothing_to_check_it_against_is_unusable
A contents list with no signed hash list beside it cannot be used, and "cannot be used" is reported rather than quietly skipped. - fn matches_name
A name for a [`Manifest`], for a failing assertion to print. - fn a_gnupg_that_cannot_run_is_never_counted_against_the_release
**Roadmap item 97.** GnuPG being unusable is not a statement about the download. The distinction is the one a verifier is most tempted to get wrong: a missing keyring directory reads like a failure, and reporting it as one tells somebody - fn a_gnupg_that_cannot_run_is_never_counted_against_the_release
not to run a release that is entirely sound. Only an answer from GnuPG counts, and only a bad answer counts against. - fn a_named_directory_that_is_not_there_is_refused_before_anything_is_searched
**F-108.** A directory that was named has to exist, or nothing is checked. The search falls back through the current directory, the folder holding this program, Downloads and Desktop, which is right when nobody said where to look and wrong - fn a_named_directory_that_is_not_there_is_refused_before_anything_is_searched
the moment somebody does. Naming a directory that is not there used to fall through to that list, check whatever it turned up, print INTACT and exit 0, without the path the person typed appearing anywhere. Read out of the source rather - fn a_named_directory_that_is_not_there_is_refused_before_anything_is_searched
than by running the binary, because the failure needs a machine with a release lying around somewhere findable to reproduce, which is exactly the condition that made it invisible. What has to stay true is that `command_auto` refuses before - fn a_named_directory_that_is_not_there_is_refused_before_anything_is_searched
it searches. - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
No interface string carries a run of spaces left behind by its own source indentation. A multi-line string literal in Rust joins its lines only if each one ends in a backslash. Drop the backslashes and the literal still compiles, still - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
passes every test that looks for a word in it, and renders in the window with a twenty-space hole in the middle of a sentence, because the source file's indentation is now part of the text. That is what happened to eleven strings across - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
the interface, and nothing failed. The rule below is `rustfmt`'s own width. `rustfmt` reflows code but never the inside of a string literal, so a source line past 100 characters that holds a gap inside a literal is a literal that was - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
joined by hand and not put back together. A deliberate column of help text, meanwhile, is written short and stays well inside the limit: every one in this repository fits in 88 characters, so none of them trips this. Two things are - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
deliberately out of scope. Test modules are skipped, because a test legitimately holds wide fixtures: `reg query` output is reproduced space for space, and the tests that read this repository's own source carry needles with the indentation - fn no_interface_string_has_a_gap_where_a_line_continuation_belongs
they are searching for. And an escape is not a word, so the `n` of a `\n` cannot be the letter that starts a gap. - fn every_command_line_drawing_is_shown_in_the_readme
Nothing a reader is meant to type still names a `veilvoice-verify` program. The verifier was an executable of its own until 0.1.18 and is now part of `veilvoice` and of the desktop application. What was left behind was not one stale - fn every_command_line_drawing_is_shown_in_the_readme
sentence but a scattering of them: three lists of the binaries a release ships, a recorded terminal session on the front page whose prompt showed a command that no longer runs, the front page's own "ships in every archive" paragraph, a - fn every_command_line_drawing_is_shown_in_the_readme
Windows icon check looking for a third executable, and the help text this program prints when it cannot download. Each was harmless on its own and the set of them told a reader to run something that does not exist. So the rule is checked - fn every_command_line_drawing_is_shown_in_the_readme
rather than remembered: `veilvoice-verify` followed by a flag or a subcommand, or with a path in front of it, is an instruction to run a program, and there is no such program. **What is deliberately not checked.** The crate is still called - fn every_command_line_drawing_is_shown_in_the_readme
`veilvoice-verify` and naming it is correct. So is describing what the program used to do: `docs/AUDIT.md`, `CHANGELOG.md` and the release notes generated from it are records of what happened, and rewriting a record to match the present is - fn every_command_line_drawing_is_shown_in_the_readme
how a project loses the ability to say when something changed. Those files are named here with that reason rather than skipped quietly. # In plain words Checks that no page tells you to run a program that was removed, while leaving the - fn every_command_line_drawing_is_shown_in_the_readme
history that mentions it alone. Every command line drawing that gets made is shown somewhere. `tools/shots/terminal.py` draws one picture per help screen in its `COMMANDS` list. The README lists them by hand, so a screen added to that list - fn every_command_line_drawing_is_shown_in_the_readme
produced a drawing nobody ever saw: `cli-fix.svg` was generated, committed, checked against the program's own output, and referenced from no page at all. The same shape as the tabs, one file along. A generated set and a hand-written list - fn every_command_line_drawing_is_shown_in_the_readme
of it drift the moment the set grows. - fn every_tab_has_a_picture_in_the_readme_and_on_the_website
Every tab the window shows has a picture in the README and on the website. The count was checked and the *list* was not, so both carried a hand-written table of nine tabs and went on carrying it after there were eleven. The Studio and the - fn every_tab_has_a_picture_in_the_readme_and_on_the_website
Browser shipped in v0.1.20 and appeared in neither, which is the whole "what it looks like" section quietly describing a different application from the one released. The keys come from `Tab::key` in the window's own source, so a tab added - fn every_tab_has_a_picture_in_the_readme_and_on_the_website
tomorrow fails this rather than being noticed by a reader. - fn the_readme_counts_the_window_tabs_the_window_actually_has
The README's count of the window's tabs is the number of tabs there are. It said "three modes" for as long as there had been nine, because a sentence written when the window had three was never revisited. A count is exactly the kind of - fn the_readme_counts_the_window_tabs_the_window_actually_has
fact this repository has a rule about: it appears in prose, nothing derives it, and it is wrong the moment a tab is added. Counting the variants of `Tab` rather than the strings a reader sees, because the enum is what decides how many - fn the_readme_counts_the_window_tabs_the_window_actually_has
there are. - fn no_page_tells_a_reader_to_run_a_program_that_no_longer_exists
- fn gather
- fn no_desktop_test_opens_a_device_a_dialog_or_a_window
**No test in the desktop crate may open a device, a dialog or a window.** # Two access violations, a day apart, from the same mistake **F-163.** A Studio test played a recording and then locked the window, to assert that locking releases - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
what is playing. Its comment said in as many words: no audio device in a test runner, so `play` will not start a stream. The Windows runner has one. A stream started, tearing it down took the whole test binary with it, and every test in - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
the crate had already passed. **F-165.** A setup-card test asked the machine how many audio devices it has, twice, to check the answer was stable. The crate's test binary already enumerates once, deliberately, and a second enumerator - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
beside it killed the process the same way. Both were fixed one at a time. This is the guard, so the third is caught here rather than on a build machine somebody has to go and read. # Why this crate and not every crate The desktop crate's - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
test binary is the one that links cpal, `rfd`, egui and winit together, and it is where both crashes happened. A narrower guard that is exactly right is worth more than a wide one that has to be argued with; widen it the day another binary - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
does the same thing. # What a test may do instead Read the source of the function it is about, which is how `locking_the_window_stops_a_take_that_is_playing` asserts the one line in `close` that matters, and how the setup card is checked - fn no_desktop_test_opens_a_device_a_dialog_or_a_window
to be measuring the machine rather than carrying a number. A test whose correctness depends on the machine it runs on is not testing the thing it names. - const REACHES_THE_PLATFORM
What reaches the platform. Each of these opens something the machine owns: a sound card, a file panel, or a stream on either. - const ALLOWED
The one deliberate exception, and it is one call in one test. Enumerating once, on purpose, is how the window's device pickers are known to survive a machine with no sound card. A second enumerator is what F-165 was, so the exception is - const ALLOWED
the test's name rather than the call: another test may not borrow it. - fn only_the_session_builds_a_recorder
**F-166.** Nothing outside the session builds a recorder. The rate a recorder is built with is written into the WAV header, and it has to be the rate the device agreed to. Only `LiveSession::start_recording` knows that, because it is the - fn only_the_session_builds_a_recorder
function that asks the device. Both front ends used to build their own from `config.sample_rate`, which is the rate that was asked for, and on any machine not running at 48 kHz the take came out fast and sharp. The signature no longer - fn only_the_session_builds_a_recorder
carries a rate, so a third caller cannot repeat it by passing the wrong one. This is the other half: a third caller cannot repeat it by going around the session either. - const HOME
Where the rate is known, and therefore the one place this may appear. - fn the_desktop_starts_a_live_session_in_exactly_one_place
**Roadmap item 130.** One place in the window starts a live session. There were two: the live tab and the Studio, each with a session of its own, so veiling on one and recording on the other opened the same microphone twice. Live scramble - fn the_desktop_starts_a_live_session_in_exactly_one_place
is the Studio now, and `start_session` is the only starter, which is most of what moving it was worth. - fn no_audio_callback_allocates_or_blocks
**Roadmap item 126.** Nothing in an audio callback allocates, locks or prints. A callback runs on the operating system's audio thread with a deadline measured in milliseconds. Allocating in one takes a global lock in the allocator, - fn no_audio_callback_allocates_or_blocks
blocking on a mutex hands the thread to whoever holds it, and printing takes the lock on standard output. Each of those is somebody else's schedule deciding when this thread runs again, and missing the deadline is an audible click in the - fn no_audio_callback_allocates_or_blocks
veiled voice, or a dropped block in a recording. Every buffer these callbacks use is sized once, before the stream starts. That is a fact about how they are written, and until now it was a fact nothing checked: the comments say "sized - fn no_audio_callback_allocates_or_blocks
once, here, so the callback never allocates", and a comment is not a guard. This reads the callbacks themselves. Written rather than measured, deliberately. A test that counted allocations would need a global allocator hook and a running - fn no_audio_callback_allocates_or_blocks
stream, which means a machine with a sound card, which is what F-163 and F-165 were about. Reading the source finds the same mistake on a build machine with no audio at all. - const OPENS_A_STREAM
Where a realtime callback is handed to the platform. The closure that follows one of these is the body with the deadline on it. - const FORBIDDEN
What may not appear inside one, and what each would cost. `try_lock` and `try_push` are the non-blocking forms and are what this code already uses, so a needle that is a prefix of one is matched on the call rather than on the name: see - const FORBIDDEN
`reaches` below.
crates/veilvoice-video/src/accel.rs
- (module)
What hardware this machine has, and the one place VeilVoice can use it. # The honest answer about the audio engine, with the number **The de-identifier is not going on a graphics card, and it would be slower if it did.** That is a - (module)
measurement rather than an opinion: veiling sixty seconds of audio takes about 0.58 seconds on one core of an ordinary desktop, which is roughly a hundred times faster than real time. Live mode works on 1024-sample frames, so each frame - (module)
has about 21 ms to be finished in and takes about 0.05 ms. A graphics card is fast at doing the same arithmetic to a very large batch at once. It is not fast at answering small questions quickly: getting 1024 samples onto the card, waiting - (module)
for a kernel, and getting them back costs more than the whole computation. Offering a "use the GPU" switch for that work would make VeilVoice slower and would be exactly the kind of claim this project refuses to make. # Where it genuinely - (module)
helps, which is video Encoding a video is the opposite shape of problem: a great deal of the same work, on large frames, where a dedicated encoder block on the card does in hardware what `libx264` does on the processor. Every current - (module)
NVIDIA card has **NVENC**, every current AMD card has **AMF**, and Intel's integrated graphics have **Quick Sync**. That is what this crate detects and what `veilvoice conversation video` can be pointed at. # Detection asks the system, and - (module)
can fail There is no portable way to enumerate graphics hardware from the standard library, and every native route is FFI. So this asks a tool the platform already ships, exactly as the rest of this workspace does, and when it cannot it - (module)
says so rather than reporting an empty machine. **Finding a card is not the same as being able to use it.** An encoder needs a driver, and it needs the copy of `ffmpeg` on this machine to have been built with support for it. - (module)
[`Adapter::caveat`] says so, and nothing here reports a device as usable on the strength of its name. # In plain words Changing a voice is already about a hundred times faster than listening to it, so there is nothing for a graphics card - (module)
to speed up, and pretending otherwise would just make VeilVoice slower. Making a **video** is different: that is real work, and most graphics cards have a dedicated chip for it. So this finds the graphics hardware you have, tells you which - (module)
of it can encode video, and lets you pick. If you have two cards, an integrated one and a separate one, you can say which. And it is honest that finding a card is not proof it will work: that also depends on your drivers and on the copy of - (module)
ffmpeg you have. - enum Vendor
Who made a graphics device. - impl Vendor
- fn of
The vendor a device name belongs to. - fn encoder
The `ffmpeg` encoder this vendor's hardware provides, if any. The name only. Whether this copy of `ffmpeg` was built with it is a separate question, and one this crate does not guess at. - fn encoder_name
What to call the encoder in front of a person. - struct Adapter
One graphics device. - impl Adapter
- fn encoder
The encoder to ask `ffmpeg` for, if this device has one. - fn caveat
What finding this device does and does not establish. - fn describe
One line, for a list. - struct Found
Everything found, and anything that went wrong looking. - impl Found
- fn is_answerable
Whether anything could be established at all. - fn recommended
The device to suggest, and why. A separate card before integrated graphics, because its encoder block is usually the faster of the two. Nothing here measures that, and the wording says "usually" rather than pretending otherwise: a real - fn recommended
answer would mean encoding the same video on each, which is a minute of somebody's time to save a few seconds of it. - fn why_recommended
Why that one, in the words to show. - fn look
Look for graphics hardware. Changes nothing. - fn windows_adapters
Ask Windows through its own management interface. - const CREATE_NO_WINDOW
- fn tool
Resolve a tool to an absolute path, never through `PATH`. A bare program name is a search, and anything earlier on the path that happens to share the name is what runs. - fn linux_adapters
Ask Linux through `lspci`. - fn macos_adapters
Ask macOS through `system_profiler`. - fn parse_pairs
`name|driver` lines into adapters. - fn adapter
One adapter from a name. - fn usable_threads
How many threads this machine can usefully run at once. Used for **batches**, never to split one recording. The engine's ratchet and its phase state run forward in time, so two halves of one file cannot be veiled in parallel and produce - fn usable_threads
the file the whole of it would have. Batching several files is a different thing and parallelises exactly. - const WHY_NOT_THE_ENGINE
Why the audio engine is not offered a graphics card, with the numbers. - const WHAT_IT_CHANGES
What hardware encoding is for, and what it does not change. - mod tests
- fn no_spawn_here_searches_the_path
Every spawn in this file names an absolute path. A bare name is resolved through `PATH`, so whatever is earliest on it and shares the name is what runs. Windows and macOS were already doing this; the Linux branch searched for `lspci`. - fn every_vendor_this_build_knows_has_an_encoder_and_a_name
- fn the_vendors_are_recognised_from_the_names_systems_really_use
The names real machines actually report, including the ones that do not contain the maker's name at all. - fn a_windows_listing_becomes_adapters_with_drivers
- fn a_listing_with_no_driver_column_still_parses
- fn the_recommendation_prefers_a_separate_card_and_admits_it_is_a_guess
A separate card is suggested over integrated graphics, and the reason says "usually" because nothing here measured it. - fn integrated_graphics_alone_are_still_recommended
Integrated graphics on their own are still offered. "Supports integrated graphics" was asked for by name, and refusing them because they are the slower option would leave a laptop with nothing. - fn a_device_with_no_known_encoder_is_not_suggested
A device with no encoder this build knows is never recommended, and its absence is not reported as an absence of hardware. - fn a_failed_look_is_not_an_empty_machine
"I could not look" is never reported as "there is nothing here". - fn the_engine_note_carries_the_numbers
The two notes have to state the measurement rather than assert a preference, because "we did not bother" and "we measured and it is slower" are different claims. - fn the_thread_count_is_sane_and_documented_as_being_for_batches
Threads are for batches. One recording cannot be split, because the ratchet and the phase state run forward in time. - fn looking_is_safe_wherever_this_runs
Asking the real machine must not panic or change anything.
crates/veilvoice-video/src/ffmpeg.rs
- (module)
The video file, which needs a codec this project does not ship. # Why there is no encoder here A conversation an hour long is about 108,000 frames at 30 per second. Turning those into something a phone will play means H.264 or AV1, and - (module)
writing either is not a sensible thing for a voice de-identifier to do. Pulling one in is worse: every usable encoder is a large C library, and this project's dependency graph containing no such thing is a claim on its front page that a - (module)
reader can check with `cargo tree` in ten seconds. So the honest arrangement is the one the rest of the project already uses for exactly this kind of problem: the download in `veilvoice-verify`, the registry in `veilvoice-watch`, the - (module)
driver list in `veilvoice_watch::drivers`. Find the tool the machine already has, prepare the exact command, and let the person decide. # And VeilVoice will not run it for you [`command`] builds the argument list. Running it is the - (module)
caller's, and a front end should print it rather than execute it silently, the same rule the companion installer follows, for the same reason. # If the machine has no ffmpeg, nothing has failed [`crate::page::player`] has already written a - (module)
file that plays everywhere and needs nothing installed. The video file is the extra, not the product. # In plain words This works out the command that would turn the pictures and the veiled audio into a video file, and prints it for you to - (module)
run. It does not run it, and VeilVoice does not contain a video encoder. Every usable one is a large piece of C code, and adding one would mean this project no longer being something you can read the whole of. So it writes out the command - (module)
for `ffmpeg`, which many people already have, and leaves running it to you. - fn found
Where `ffmpeg` is, if this machine has one. Looks along `PATH` rather than spawning `which` or `where`: the answer is a string this process already holds, and spawning a program to ask where a program is costs a subprocess to learn nothing - fn found
new. - struct Encoding
How to render the file. - impl Default for Encoding
- fn default
- fn command
The command that turns a directory of numbered frames and a WAV into a video file. Returned as an argument list rather than a shell string, because a shell string has quoting rules and a path with a space in it is the ordinary case on two - fn command
of the three platforms here. `frames` is a printf-style pattern such as `frame-%05d.png`. - fn concat_command
The command that turns a **concat list** of held pictures and a WAV into a video file. This is what [`crate::frames::write`] produces a list for, and it is the one a render actually uses. # Why not the numbered sequence [`command`] builds - fn concat_command
`image2`, which reads `frame-%05d.png`, gives every picture the same duration. That is right when there is one picture per frame of video, and wrong here: the frames module writes a picture only when the drawing changes, so a picture may - fn concat_command
stand for one frame or for fifty, and each carries its own `duration` line. Feeding held frames to `image2` would play an hour of conversation in the few seconds its handful of distinct pictures cover, which is a video that is wrong rather - fn concat_command
than one that fails to encode. `-safe 0` is needed because the list names files rather than a pattern, and ffmpeg refuses relative paths in a concat list without it. The names are ones this program wrote into a directory this program made, - fn concat_command
which is the case the switch exists for. - fn black_command
The command that turns a veiled recording into a video with a black frame. **Roadmap item 87.** Somewhere that accepts only video is a common place to need to put a recording: a message that will not take an audio file, a platform that - fn black_command
wants something to show. The picture is not the point and does not need to be, so this is a black frame for the length of the audio and nothing else. No frame sequence, which is what makes this different from [`command`]. ffmpeg can - fn black_command
synthesise a colour source, so there is nothing to render, no temporary directory holding thousands of PNGs, and no wait proportional to the length of the recording beyond the encode itself. The size comes from `encoding` like every other - fn black_command
render. A frame of solid black costs almost nothing at any size, so there is no reason for this one to disagree with the rest of the settings: it used to be pinned at 720p, which meant asking for 4K and getting 720p with no mention of it. - fn extract_command
The command that takes the sound out of a recording made somewhere else. **Roadmap item 88.** OBS writes `.mkv`, `.mp4`, `.mov`, `.flv` and `.ts`, and VeilVoice reads none of them: they are containers holding a video stream and an audio - fn extract_command
stream, and demuxing one means a demuxer this project does not ship, for the same reason it ships no encoder. So this prepares the one command that produces something VeilVoice can read: a WAV, at the sample rate and depth the engine works - fn extract_command
in, with the video discarded. From there it is an ordinary input file. `-vn` rather than a stream selector, so a file with two video tracks and one audio track does the obvious thing instead of failing on a mapping the user never wrote. - const OBS_CONTAINERS
Containers OBS writes, which [`extract_command`] can take the sound out of. Named rather than "any file ffmpeg reads", which is true and useless: a person wants to know whether their recording will work, and the answer is a list they can - const OBS_CONTAINERS
check their own file against. Anything else ffmpeg supports still works; this is what is promised. - fn is_container
Whether a file looks like something [`extract_command`] should be offered for. - fn command_line
The command as one line, for printing. Quoted where a part contains a space. For a person to read and paste, not for a shell this program runs. Nothing here runs it. - fn describe
What to tell the user about their machine's `ffmpeg`. - mod tests
- fn the_black_video_stops_when_the_audio_does
Roadmap item 87. A synthesised colour source never ends, so without `-shortest` ffmpeg encodes black for ever and the only thing that stops it is the disk filling up. - fn extracting_audio_discards_the_picture_and_keeps_the_rate
Roadmap item 88. Taking the sound out has to discard every video stream, not the first one: an OBS recording with a camera and a screen capture has two, and a stream selector written for one fails on the other. - fn every_container_obs_writes_is_recognised
- fn the_two_commands_point_in_opposite_directions
The two commands must not be confusable: one makes a video from audio, the other takes audio out of a video, and swapping them silently would produce a file with no sound. - fn argv
- fn the_command_names_both_inputs_and_the_output
- fn the_pixel_format_is_the_one_every_device_accepts
Without this a great many phones silently refuse to play the result, which is a worse failure than an error. - fn the_command_refuses_to_overwrite
A tool a person runs by hand over a directory they may have put something else in must not overwrite without being asked. - fn the_command_stops_at_the_shorter_stream
A rounding error in the frame count must not leave frozen picture on the end. - fn the_frame_rate_and_quality_are_the_documented_defaults
- fn every_way_into_a_video_encodes_it_the_same_way
The three command builders agree about how to encode. They differ in how the picture gets in, and only in that: a numbered sequence, a concat list, or a synthesised black source. Everything after the inputs is the same decision made three - fn every_way_into_a_video_encodes_it_the_same_way
times, in three functions, with nothing until now comparing them. That matters because of which one is which. `concat_command` is what the window runs. `command` is what `veilvoice conversation` **prints for somebody to run by hand**, - fn every_way_into_a_video_encodes_it_the_same_way
under a line saying VeilVoice never runs it for you. If those two drift, the instruction this program gives is not the thing this program does, and nobody would find out from a passing build. - fn after
The value after a switch, or nothing if the switch is absent. - fn the_chosen_size_reaches_the_command_rather_than_a_written_in_one
The chosen size reaches both commands. `black_command` had `1280x720` written into it, so a person who asked for 4K got 720p and was told nothing. Both are checked here because they build their arguments separately and only one of them was - fn the_chosen_size_reaches_the_command_rather_than_a_written_in_one
wrong. - fn a_path_with_a_space_is_quoted_when_printed
A path with a space in it is the ordinary case on two of the three platforms here, so the printed line has to survive one. The separator is whatever `Path::join` produced, which differs by platform -- so the test checks the **quoting**, - fn a_path_with_a_space_is_quoted_when_printed
which is the thing this function is responsible for, rather than a path spelling it is not. - fn looking_for_ffmpeg_is_free_of_side_effects
Looking for ffmpeg must not panic whatever the machine has, and must give the same answer twice. - fn the_description_never_offers_to_install_anything
Whatever the machine has, the message must not promise to fetch it.
crates/veilvoice-video/src/font.rs
- (module)
A monospace face, five pixels by seven, drawn here. # Why this exists rather than a font file [`crate::frames`] draws the video's pictures as actual pixels, and names are text. Text needs a face and a rasteriser, and the two usual ways to - (module)
get those are both wrong for this project. Loading one of the system's fonts makes the output depend on which machine drew it: the same recording rendered on two computers would produce different files, and this project publishes - (module)
reproducible builds and checks them. Pulling in a font rasteriser means a large dependency to draw eight names, in a program whose front page invites the reader to run `cargo tree` and find nothing large. So the face is here, it is - (module)
ninety-five glyphs, and it is the same everywhere. # What it can and cannot draw Printable ASCII, from space to `~`. **Anything else is drawn as an open box**, which is the honest way to render a character this cannot: a name in Cyrillic - (module)
or Japanese comes out as boxes rather than as nothing, and [`crate::frames`] says so in its notes rather than letting somebody find out by watching the finished video. The preview page does not have this limit, because it is markup and - (module)
uses whatever the reader's machine has. That is a real difference between the two outputs and it is written down rather than glossed over. # Five by seven Small enough to write out and check by eye, large enough to stay legible when scaled - (module)
up by whole numbers, which is the only way it is ever scaled: a bitmap glyph drawn at a fractional size is a blurred glyph, so [`Face::scale_for`] picks a whole-number multiple and the text is crisp at any frame size. # In plain words The - (module)
letters used in the video, drawn dot by dot inside this program. It is here rather than taken from the computer so that the same recording makes the same video on every machine, and so that this program does not have to carry a large piece - (module)
of somebody else's code to write eight names. - const FIRST
The first character the face has a glyph for. - const LAST
The last character the face has a glyph for. - const WIDTH
Width of one glyph, in pixels, before scaling. - const HEIGHT
Height of one glyph, in pixels, before scaling. - const GAP
The gap between two glyphs, in unscaled pixels. One column. A monospace face with no gap runs its letters together, and two makes eight names wider than the picture they sit in. - const GLYPHS
The glyphs, one row of five bits per line, seven lines per character. Indexed by `character as usize - FIRST as usize`. Written out rather than generated, so that what is in this file is what gets drawn. - const UNKNOWN
The box drawn for a character this face has no glyph for. Open rather than solid: a filled block reads as a redaction, and nothing has been redacted. This is "the video cannot draw this letter", which is a different thing and should not - const UNKNOWN
look like the other one. - fn glyph
The rows of `character`, and whether the face actually had it. The second half of the answer is what lets a caller say "three of these names cannot be drawn" instead of quietly producing boxes. - fn can_draw
Whether every character in `text` can be drawn. - fn width_of
How wide `text` is at `scale`, in pixels. - fn scale_for
The largest whole-number scale at which `text` fits inside `room` pixels. **Whole numbers only.** A bitmap glyph drawn at 2.5 times its size has to put half a pixel somewhere, and every way of doing that is a blurred letter. Rounding down - fn scale_for
to a whole multiple keeps every edge on a pixel boundary, so the text is as crisp at 4K as it is at 720p, and one size smaller is a much better outcome than one that is soft. Never returns zero: a scale of zero draws nothing, and a name - fn scale_for
too long for the space it was given should be drawn small and overflow rather than vanish. - mod tests
- fn every_printable_ascii_character_can_be_drawn
Every printable ASCII character has a glyph of its own. The face is written out by hand, so a missing row is a real possibility and would show up as a box in the middle of an ordinary name. - fn anything_else_is_a_box_and_reports_itself
A character the face does not have says so rather than drawing nothing. - fn no_two_characters_are_drawn_the_same
No two characters share a glyph. Two identical rows in a hand-written face means two letters a reader cannot tell apart, which in a list of speaker names is the same failure as two voices nobody can separate. - fn nothing_is_drawn_outside_the_five_columns
A glyph only uses the five columns it claims to. - fn text_is_scaled_by_whole_numbers_and_fits
The scale is a whole number and the text fits at it. - fn nothing_is_no_pixels_wide
The empty string is nothing wide, at any scale.
crates/veilvoice-video/src/frames.rs
- (module)
The video's pictures, and how many of them there really are. # What this finishes [`crate::ffmpeg::command`] has always known how to turn a directory of pictures into a video file. Nothing filled the directory, so what a render produced - (module)
was veiled audio over a black picture, and the roadmap row said so rather than letting somebody find out by playing the file. This fills it, with [`crate::raster`] for the pixels and [`crate::font`] for the names. # A frame is written when - (module)
the picture changes, not thirty times a second An hour at thirty frames a second is 108,000 pictures. Written out at 1080p that is gigabytes of intermediate files to make one video, and almost all of them are identical to the one before. - (module)
So the frame rate decides when the picture is *looked at*, and a new file is written only when what it would contain has actually changed. Three things can change it, and [`Signature`] is exactly those three: * the playhead, which moves - (module)
one pixel at a time and not one frame at a time, * the level bars, which move when the envelope column changes, * who is lit, which changes at a turn boundary. On a 1920-wide waveform over an hour the playhead moves a pixel about every two - (module)
seconds, so runs of fifty-odd identical frames collapse into one file held for the length of the run. That is not a guess: [`plan`] reports how many pictures it actually produced against how many frames the video has, and a caller can show - (module)
the ratio. **A short recording saves nothing, and should not.** The playhead crosses the whole waveform however long the recording is, so under about forty seconds it moves more than a pixel per frame and every frame is genuinely a - (module)
different picture. The saving arrives with length, which is exactly where it was needed. **This is why the ffmpeg command is a concat list rather than a numbered sequence.** `image2` gives every file the same duration; a held frame needs - (module)
its own. See [`crate::ffmpeg::concat_command`]. # What the video cannot draw that the page can Names outside printable ASCII. The page is markup and uses whatever face the reader's machine has; this has one face, written here, for the - (module)
reasons [`crate::font`] gives. A name it cannot draw comes out as open boxes and is **named in the notes**, because a person who typed a name in Cyrillic should be told before they render an hour of video rather than after. # In plain - (module)
words This draws the pictures the video is made of. It only draws a new one when something on screen has actually moved, which for a long recording is a tiny fraction of the frames the video has, so a render writes hundreds of pictures - (module)
rather than hundreds of thousands. - struct Signature
What decides whether two moments look the same. Compared rather than the pixels themselves: drawing a 1080p frame to find out it matched the last one costs more than the frame it saves. These three are everything on the picture that moves. - impl Signature
- fn at
- struct Frame
One picture, and how long the video shows it for. - struct Plan
The pictures a render will write, worked out without drawing any of them. - impl Plan
- fn saving
How many pictures were saved by holding the ones that did not change. `1.0` means every frame was distinct and nothing was saved. Above that is how many video frames each written picture covers. - fn plan
Work out which moments need a picture. The frame rate says when to look; the [`Signature`] says whether what would be drawn has changed since the last look. Nothing is drawn here, so a front end can ask what a render will cost before - fn plan
starting one. - struct Notes
What a drawn frame carried with it. - fn draw
Draw the picture at `at_secs`. The same layout the page uses, from [`page::layout`], so the video and the preview cannot drift into being two different pictures of one recording. - fn dim
Mix `colour` towards `background`, keeping `amount` of it. The page dims a speaker with an opacity. A PNG frame is opaque, so the same effect is the same mix done here, against the colour that would have shown through. - fn draw_wave
The envelope as filled columns inside the waveform's box. - struct Written
What a written sequence produced. - fn write
Draw and write the whole sequence into `directory`. Writes `frame-00000.png` and up, and `frames.txt`, which is the concat list naming each picture and how long it is held. See [`crate::ffmpeg::concat_command`] for what is done with it. - fn write
`progress` is called with the number written and the total, so a front end can show a bar without this module knowing what one is. - mod tests
- fn conversation
- fn envelope
- fn a_long_recording_holds_most_of_its_frames
A long recording writes far fewer pictures than it has frames. This is the whole reason the sequence is planned rather than drawn at the frame rate. Ten minutes at thirty is 18,000 frames; the playhead crosses a 1184-pixel waveform in that - fn a_long_recording_holds_most_of_its_frames
time, so it moves a pixel every fifteen frames and the other fourteen are the picture that was already there. - fn a_short_recording_draws_every_frame_because_every_frame_differs
A short recording holds nothing, and that is correct. The playhead crosses the whole waveform in four seconds, which at thirty frames is ten pixels a frame, so every frame really is a different picture. Worth pinning down: a saving that - fn a_short_recording_draws_every_frame_because_every_frame_differs
appeared here would mean the playhead was being drawn in the wrong place. - fn the_holds_cover_the_recording_exactly_once
The holds tile the whole recording with no gap and no overlap. A gap is a black flash in the finished video and an overlap is drift against the audio, and both are the kind of thing only noticed once the file is played. - fn a_turn_boundary_always_gets_its_own_picture
Who is lit changes at a turn boundary, so a picture is written there. - fn a_frame_is_the_size_asked_for_and_the_same_every_time
The drawing is the size it was asked for, and deterministic. - fn the_speaker_with_the_turn_is_the_brighter_one
The speaker with the turn is drawn brighter than the one without. - fn a_name_that_cannot_be_drawn_is_named
A name the face cannot draw is reported rather than quietly boxed. - fn writing_produces_the_pictures_and_a_list_that_names_them
A written sequence is files plus a list ffmpeg can read.
crates/veilvoice-video/src/lib.rs
- (module)
# veilvoice-video A watchable version of a veiled conversation: the waveform, a circle per speaker, the title, the subtitles, and a background. ## What this produces, and what needs something else **A self-contained page, always.** - (module)
[`page::player`] writes one HTML file that plays the veiled audio, draws its waveform, lights each speaker's circle as they talk and shows the subtitles. It needs nothing installed, it contacts nothing, it opens in any browser on any - (module)
device, and it is what this crate is for. **A video file, only if `ffmpeg` is there.** Turning a few thousand frames into an `.mp4` needs a codec, and this project ships none: writing an H.264 encoder is not a sensible thing for a voice - (module)
de-identifier to do, and pulling one in would put a large C dependency into a graph whose emptiness is a front-page claim. So [`ffmpeg`] finds the tool if the machine has it and prepares the exact command; if the machine does not, the page - (module)
is still there and nothing has failed. VeilVoice never downloads or runs `ffmpeg` on your behalf, exactly as it never installs any other companion. ## The page needs a little JavaScript, and says so Lighting the right circle means knowing - (module)
where the audio has got to, and only the audio element knows that. There is a small inline script, with no file, no network and no library, and a `<noscript>` that says what it does. Without it the audio still plays, the subtitles still - (module)
appear and the waveform is still drawn; the circles simply do not light up. ## A picture is not veiled A speaker may have a portrait, and it is drawn exactly as supplied. Nothing here anonymises an image: a photograph of somebody's face - (module)
beside their veiled voice identifies them completely. The default is a plain filled circle for that reason, and [`SCOPE`] says it where a user will read it. # In plain words This draws the picture. A waveform, a circle for each person in - (module)
their own colour with their name under it, a title, and a page that plays all of it together in a browser and needs nothing installed. It does not make a video file. That needs an encoder, and this project ships none -- so it prints the - (module)
command that would do it with `ffmpeg`, if you have `ffmpeg`, and leaves running it to you. - mod accel
- mod ffmpeg
- mod font
The monospace face the video frames are lettered with. - mod frames
The video pictures, drawn as pixels and written as PNG. - mod page
- mod palette
- mod raster
Pixels, and the PNG they are written into. - mod size
- mod waveform
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What a rendered video is worth, in the words a front end should show. Single-sourced and asserted by the tests, exactly as every other scope note in this project is, so it cannot quietly turn into a promise. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn the_scope_note_states_the_limits_rather_than_a_guarantee
The claim must keep stating what a picture does and does not hide. - fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-video/src/page.rs
- (module)
The picture: one still for a preview, and one page that plays. # Why a page rather than a video file It needs nothing installed, it contacts nothing, and it opens on every browser and every phone. A video file needs an encoder this project - (module)
does not ship, as [`crate::ffmpeg`] explains, so the page is the thing that always exists and the file is the extra. It is also the honest artefact for this particular job. What is being drawn is a waveform, some circles and a title: flat - (module)
colour, hard edges and text. That is what vector graphics are for, and it stays sharp at any size on any screen rather than being resampled from a fixed grid of pixels. # The layout, and why the padding is a setting A title across the top, - (module)
a row of circles under it, one per speaker, in their colour and with their name beneath, and the waveform along the bottom with a line that moves through it. [`Look::padding`] is a setting because the same picture is wanted at very - (module)
different sizes: a thumbnail wants little and a full-screen render wants a lot, and a fixed margin looks wrong at one end or the other. # Everything user-supplied is escaped Names and titles are typed by a person and end up inside markup. - (module)
They are escaped on the way in, every time, through one function. A name containing `</text>` would otherwise end the element it is in and the rest of the file would be whatever that person wrote, which is a nuisance in a local file and a - (module)
real problem in one that gets sent to somebody. # The script, and what happens without it Lighting the circle of whoever is speaking means knowing where the audio has got to, and only the audio element knows that. There is a small inline - (module)
script, with no file, no library and no network, and a `<noscript>` saying what it does. Without it the audio plays, the subtitles appear, the waveform is drawn and the circles simply stay dim. # In plain words Draws the picture: a - (module)
waveform, a circle for each person that lights up when it is their turn, and the names. It produces two things. A still, so you can see what you will get before anything is rendered, and a self-contained web page that plays the veiled - (module)
audio with the picture moving along beside it. A page rather than a video file, because a page needs nothing installed to watch and can be opened by anybody. If you want an actual video file, the command to make one is printed for you. - struct Look
What the picture looks like. - impl Default for Look
- fn default
- enum Background
What sits behind the picture. - impl Look
- fn black
Black, for somebody who wants the plainest possible picture. - fn themed
The same look in another palette. The background follows unless it was set to something specific. A reader who asked for Gruvbox and got a Gruvbox picture on a Tokyo Night page would reasonably call that a bug; a reader who asked for - fn themed
Gruvbox *and* `--background #123456` asked for two things and gets both. - fn checked
Whether these numbers describe a picture that can be drawn. Checked rather than clamped: a caller who asked for a 10-pixel-wide render of nine speakers meant something, and quietly producing an illegible one would be answering a question - fn checked
they did not ask. - struct Layout
Where each part of the picture goes. Worked out once and shared by the still and the player, so the preview shows the layout the page will use rather than one that merely resembles it. - fn layout
Work out the layout for a look and a number of speakers. - fn escape
Escape text for markup. One function, used everywhere anything a person typed reaches the output. A name containing `</text>` would otherwise end the element it sits in. - fn base64
Base64, for embedding an image so the page stays one file. Written out rather than depended on: it is twenty lines, and the dependency graph containing nothing surprising is a claim this project makes on its front page. - const ALPHABET
- fn media_type
The media type for an image, by extension. - fn data_uri
Read an image and turn it into a `data:` URI. An unknown extension is refused rather than guessed at: a browser handed the wrong media type shows a broken image, and a broken image in a rendered picture looks like the program failed rather - fn data_uri
than like the file was a `.bmp`. - fn inline_vtt
A WebVTT track as a `data:` URI, so a page opened from disk still has it. # The defect this fixes The page referenced the subtitles by file name, beside the audio, and said in its own `<noscript>` that "the captions still appear". Opened - fn inline_vtt
from a folder, which is how somebody who has just rendered one opens it, **they do not**: a browser treats every `file:` URL as its own origin, so a caption track loaded from the file next to the page is a cross-origin request and is - fn inline_vtt
refused. Chromium says so in the console and shows no captions; nothing in the page said anything at all. The audio is unaffected, because a media element is allowed what a text track is not, which is why this was invisible: the page - fn inline_vtt
played, the circles lit, and only the captions were missing. Inlined, they are part of the page and no request is made. The separate `.vtt` file is still written, because it is what every other player wants. - fn background_markup
The background element, and anything worth telling the user about it. - fn player_head
The document head and the stylesheet. Split out of [`player`] because the page grew transport controls and a correction panel, and one `format!` holding a whole document is a thing nobody can read or change safely. - fn player_body
The visible page: the drawing, the audio, the transport and the panel. # Why there are transport controls at all The browser's own audio element has a scrubber and a play button, and for listening that is enough. This page is not only for - fn player_body
listening: it is where somebody checks a plan before rendering it, and checking means going back ten seconds because the wrong circle lit up, and going through a long recording faster than it was spoken. Speed goes to five. Past that the - fn player_body
audio is unintelligible and the point of playing it at all is gone; five is fast enough to cross an hour in twelve minutes and still hear where the voices change. - fn player_script
The script: lighting the right circle, the transport, and the corrections. # It suggests, it does not apply The correction panel writes `veilvoice conversation fix` commands and nothing else. It could have rewritten the plan file's text in - fn player_script
the browser and offered it as a download, and that would have meant a second implementation of the plan format, in JavaScript, able to drift from the one in `veilvoice-conversation` that every other reader uses. A plan produced by the - fn player_script
drifted copy would look right and render somebody in the wrong voice, which is the failure the corrections exist to prevent. So the page says what to run, the program does it, and the validation is the same validation everything else goes - fn player_script
through. - fn names_array
The speakers' names as a JavaScript array literal. Escaped for a **string inside a script**, which is a different job from escaping for markup: `</script>` inside a JavaScript string ends the element as far as the parser is concerned, - fn names_array
whatever the quotes around it say. Every character that could do that is written as an escape. - fn js_string
One name, safe to sit inside a double-quoted JavaScript string in HTML. - fn speaker_markup
One speaker's circle and name, as SVG. - struct Drawn
What a render produced, and anything the user should know about it. - fn still
A single frame, as standalone SVG. `at_secs` decides which speaker is lit and where the playhead sits. This is the preview: it is the same layout function and the same drawing code the page uses, so what it shows is what will be produced - fn still
rather than something that resembles it. - fn player
The self-contained page that plays. `audio_href` and `subtitles_href` are written into the page as they are given: relative names, so the three files can be moved together. Embedding the audio would double the size of a recording somebody - fn player
already has on disk beside it. - mod tests
- fn plan
- fn envelope
- fn a_still_is_well_formed_svg_with_everything_in_it
- fn a_rendered_page_carries_names_and_no_engine_detail
**The engine's settings are for the person operating it, not for the recording.** The desktop application shows each destination voice as "low register, narrow tract (94 Hz, 620 Hz)", which is exactly what somebody choosing between them - fn a_rendered_page_carries_names_and_no_engine_detail
needs. None of it belongs in the file that gets shared: it describes the *destination* rather than the speaker, so it leaks nothing about who was recorded, but it is noise to a viewer and it invites a reader to think the numbers say - fn a_rendered_page_carries_names_and_no_engine_detail
something about the people. What a viewer needs is who is talking, which is the name and the lit circle. This checks the picture carries the first and not the second. - fn only_the_speaker_with_the_turn_has_a_level
**Roadmap item 139.** The bar moves for whoever has the turn, and for nobody else. A lit circle says who is speaking. It says nothing about whether they are mid-sentence or mid-pause, and those look identical for as long as the turn lasts, - fn only_the_speaker_with_the_turn_has_a_level
which is what this adds. - fn a_silent_moment_gives_the_speaker_no_level
Silence draws a bar of nothing, not a bar of something small. - fn the_page_moves_its_bars_from_the_envelope_it_drew
The numbers the page animates with are the ones it drew the wave from. Two arrays would be two things to keep in step, and the failure would be a bar that disagreed with the waveform under it while both looked plausible. - fn filled_width
The filled part of slot `slot`'s level bar, if it has one. - fn the_speaker_who_is_talking_is_the_one_that_is_lit
The circle that is lit must be the one whose turn it is. - fn two_people_talking_at_once_are_both_lit
An interruption is two people talking, and dimming one would be choosing a winner the audio did not. - fn a_name_cannot_break_out_of_the_markup
A name containing markup must not end the element it is in. - fn escaping_covers_every_character_that_matters
- fn the_player_is_a_whole_page_that_needs_nothing_else
- fn an_impossible_look_is_refused
A look that cannot be drawn is refused rather than producing an illegible picture that looks like a design decision. - fn more_padding_leaves_less_room_for_the_waveform
Padding is a setting because the same picture is wanted at very different sizes, so it has to actually move things. - fn the_layout_stays_inside_the_frame_for_every_speaker_count
Everything must stay inside the picture, at any speaker count. - fn base64_matches_the_standard_including_its_padding
- fn an_image_becomes_a_data_uri_and_an_unknown_one_is_refused
- fn an_unusable_picture_falls_back_and_is_reported
A picture that cannot be used must draw the plain circle and say so, rather than leaving a broken image that looks like a crash. - fn a_background_image_is_embedded_and_a_large_one_is_mentioned
A background image is embedded so the page stays one file, and a large one is reported rather than silently producing a huge page. - fn a_missing_background_falls_back_and_says_so
A missing background must not stop the picture being drawn. - fn a_plan_with_no_speakers_still_draws_the_waveform
An empty plan still draws: a recording with nobody assigned is a picture of a waveform, which is better than an error at this stage. - mod player_tests
- fn plan
- fn envelope
A short signal with something in it, so the waveform is not a flat line. - fn page
- fn the_transport_is_there_and_stops_at_five_times
- fn the_page_names_who_is_speaking_and_offers_the_others
- fn a_correction_is_a_command_rather_than_an_edit
- fn a_name_cannot_end_the_script_element_it_sits_in
- fn the_awkward_characters_in_a_name_are_escaped_for_a_script
- fn a_chosen_colour_reaches_the_correction_buttons
- fn captions_can_be_carried_in_the_page_rather_than_fetched
- fn the_page_still_says_what_it_does_without_a_script
crates/veilvoice-video/src/palette.rs
- (module)
Colours: the site's own tokens, and one per speaker. # One source of colour, cross-checked by a test The hexes below are Tokyo Night, and they are the same ones `website/css/themes.css` declares. They are written out here rather than - (module)
parsed at run time because a crate should not need the website to be on disk to draw a circle, and a test reads that stylesheet and fails if the two ever disagree, which is the same arrangement `veilvoice-gui` has had since the themes - (module)
existed. # Ten speaker colours, ordered by measurement rather than by eye Six of them are palette tokens. The other four are from the wider Tokyo Night set, chosen to sit between the tokens. The **order** was first written down by looking - (module)
at a hue wheel, and it was wrong: a test comparing every pair found a further-apart pair than the one put first. So the order is now computed rather than judged, by [`distance`], the "redmean" approximation, which is the cheap standard - (module)
stand-in for perceptual difference and weights green most because the eye does. Slot 0 and slot 1 are **the** furthest-apart pair in the set, because two speakers is the common case. Every slot after that is the colour whose nearest - (module)
neighbour among the ones already used is furthest away, which is a maximin order, so the table degrades gracefully: a recording with four people uses four colours chosen to be as separable as four can be, rather than the first four - (module)
somebody listed. Ten colours cannot all be far apart. Under this metric the furthest pair scores 507 and the closest pair anywhere in the set scores 63, and the closest pair is only ever reached by a recording with nine or ten people in - (module)
it. One colour is deliberately not a hue at all: the near-white foreground token, separated from every saturated colour by **lightness**, the axis that is still free once the wheel is full. # Colour is never the only signal Somebody who - (module)
cannot separate two of these needs the name, and the name is always drawn beside the circle and always in the subtitles. A player that showed only colours would be one about eight per cent of men could not use. # In plain words The - (module)
colours, and which one each speaker gets. They are the same colours the website uses, taken from one place so the application, the website and anything VeilVoice draws cannot drift apart. A test compares them against the site's own - (module)
stylesheet and fails the build if they do. Speaker colours are handed out to be as distinct from each other as the number of people allows, so that a glance at the picture tells you who is talking. - const BG
The page background. - const BG_INSET
A panel or inset behind the waveform. - const BORDER
Hairlines and dividers. - const FG
Body text. - const MUTED
Secondary text. - struct Palette
One complete colour scheme, matching one `[data-theme]` block in `website/css/themes.css` and one entry in `veilvoice-gui`'s theme table. The field names are the CSS custom properties one for one: `bg` is `--bg`, `accent_2` is - struct Palette
`--accent-2`. A test reads that stylesheet and fails if any of them ever disagree, which is the same arrangement the desktop application has had since the themes existed. - const PALETTES
Every palette, in the order the pickers show them. Index 0 is the default, and is what an unknown identifier falls back to. - const DEFAULT_ID
The palette a render uses unless one is named. - fn by_id
The palette with this identifier. `None` rather than a fallback: a caller who typed a theme name meant it, and silently drawing in a different one answers a question they did not ask. The command line turns this into an error that lists - fn by_id
what it could have been. - fn default_palette
The default palette. Tokyo Night, and the same hexes the constants above carry. - fn ids
Every identifier, for an error message or a picker. - const SPEAKERS
The ten speaker colours, in the order slots are handed out. See the module note for why the order is what it is, and why the tenth is pale rather than another hue. # One set, for every palette These do **not** change with the chosen - const SPEAKERS
palette, and that is a decision rather than an omission. A palette here has six chromatic tokens; ten mutually separable colours cannot be got out of six without inventing four, and four invented colours are four colours whose separation - const SPEAKERS
nobody has measured. This set was measured: the closest pair anywhere in it scores 63 under [`distance`], and that pair is only ever reached by a recording with nine or ten people in it. What the palette *does* decide is everything around - const SPEAKERS
them -- the page, the panel, the hairlines, the text -- so a render in Gruvbox is a Gruvbox picture with these ten circles in it. And the ink drawn on each circle is computed by [`ink_on`] rather than assumed, so the names stay readable on - const SPEAKERS
a light palette as well as a dark one. - fn speaker
The colour for a speaker slot. Wraps past ten, exactly as the voice table does and for the same reason: the function is total so a caller cannot panic, but two speakers sharing a colour is a real collision and the conversation crate - fn speaker
refuses an eleventh speaker long before this is reached. - fn distance
How far apart two colours look, by the "redmean" approximation. A cheap, widely used stand-in for a perceptual colour distance: it weights green most, because the eye takes most of its luminance from green, and shifts the red and blue - fn distance
weights by where the pair sits on the red axis. Used to *order* the speaker colours rather than to make any claim about what somebody can see. Nothing here decides that colour is sufficient -- see the note at the top of this file about the - fn distance
name always being drawn. - fn rgb
Parse `#rrggbb` into its three channels. Returns `None` for anything that is not exactly that, rather than guessing: a colour that half-parsed would be drawn in some arbitrary shade and look like a design decision. - fn luminance
Relative luminance, as WCAG defines it, from 0.0 to 1.0. Used to pick readable text over a speaker's colour. The project already computes contrast rather than trusting it. Writing that check for the custom palettes found the default - fn luminance
theme's `--muted` failing at 2.76:1, and this is the same arithmetic in the same spirit. - fn contrast
The contrast ratio between two colours, from 1.0 to 21.0. - fn ink_on
Black or white, whichever is readable on `background`. - mod tests
- fn every_token_matches_the_website_stylesheet
The hexes here must be the ones the website declares. If a colour is changed in one place, this is what says so. - fn every_speaker_colour_is_a_valid_hex_and_they_are_all_different
- fn every_palette_matches_the_website_stylesheet
Two speakers is the common case, so slots 0 and 1 must be **the** furthest-apart pair in the set. The first version of this table failed exactly here, which is why the order is computed rather than judged. Every palette here is a palette - fn every_palette_matches_the_website_stylesheet
the website has, with the same hexes. The same arrangement `veilvoice-gui` has had since the themes existed: the values are written out so a crate needs no website on disk to draw a circle, and this reads the stylesheet and fails if the - fn every_palette_matches_the_website_stylesheet
two ever part company. Without it a theme could be changed on the site and a rendered video would quietly keep the old colours. - fn the_stylesheet_has_no_theme_this_crate_is_missing
The stylesheet must not hold a theme this crate has never heard of. A picker offering nine and a renderer knowing eight is a picker with one entry that silently draws in the wrong colours. - fn the_default_is_tokyo_night_and_matches_the_constants
- fn an_unknown_identifier_is_refused_rather_than_defaulted
An unknown name is refused rather than falling back. A caller who typed a theme meant it, and drawing in a different one answers a question they did not ask. - fn every_identifier_is_unique_and_every_colour_parses
- fn body_text_is_readable_on_the_page_in_every_palette
Text has to be readable on the page in every palette, light or dark. WCAG's floor for body text is 4.5:1; this is the arithmetic, not a judgement. - fn a_name_on_a_speaker_circle_is_readable_whatever_the_palette
The speaker circles are one measured set shared by every palette, so the thing that has to hold per palette is that a name drawn on a circle is readable. `ink_on` computes that rather than assuming it. - fn the_first_two_slots_are_the_furthest_apart_pair_in_the_set
- fn the_order_is_maximin_so_it_degrades_gracefully
Every slot after the first two must be the colour whose nearest neighbour among those already used is furthest away. That is what makes a four-speaker recording use four well-separated colours rather than the first four somebody happened - fn the_order_is_maximin_so_it_degrades_gracefully
to list. - fn the_spread_is_what_the_documentation_says
The figures quoted in the module documentation, checked. - fn a_distance_between_two_non_colours_is_zero_rather_than_a_panic
- fn every_speaker_colour_is_visible_on_the_background
Every speaker colour has to be visible against the page it is drawn on. Computed, not assumed -- the same rule the custom palettes follow. - fn the_ink_chosen_for_each_colour_is_readable_on_it
A name drawn on a speaker's colour must be readable on it. - fn a_colour_that_is_not_a_colour_is_refused_rather_than_guessed_at
- fn luminance_and_contrast_are_the_documented_ranges
- fn asking_past_the_table_wraps
crates/veilvoice-video/src/raster.rs
- (module)
Pixels, and a PNG to put them in. # Why the video's pictures are drawn here rather than converted Everything else this crate draws is SVG, which is right for a page: the reader's browser has a renderer and a font library and neither is - (module)
this project's problem. A video file is not a page. `ffmpeg` reads a directory of raster images, and **no build of ffmpeg can be assumed to read SVG**: that needs librsvg, which most builds do not have, and a picture that fails to encode - (module)
on somebody's machine is worse than one that was never offered. The other way is to convert the SVG here, which means an SVG rasteriser. Every usable one is large, and [`crate::ffmpeg`] already spends a page explaining why this project - (module)
will not pull in a large library to make a video file. Doing it anyway, one module over, would make that argument a thing this project says rather than a thing it does. So the picture is a few hundred lines: rectangles, circles, a face - (module)
([`crate::font`]) and a PNG writer. That is the whole of what the drawing needs, and it is small enough to read. # The same picture on every machine No system fonts, no floating-point that varies by platform in a way that reaches a pixel, - (module)
and no dependency that could quietly change its output. Two people rendering the same recording get identical files, which is the same property the reproducible builds have and is checked the same way: by comparing bytes. # One dependency, - (module)
and what it is for `miniz_oxide` deflates the pixel data, because PNG is deflate and a PNG written with stored blocks is roughly six megabytes a frame. It is pure Rust and it is **already in this tree**, underneath `flate2`, which `lofty` - (module)
and `pgp` both pull in, so naming it here adds nothing to the dependency graph that was not being compiled already. # In plain words The part that draws the video's pictures, dot by dot, and writes them as PNG files. It is written here - (module)
rather than borrowed because the video tool this hands its pictures to cannot be relied on to read drawings, and converting them would mean carrying a large piece of somebody else's code, which is the thing this program is careful not to - (module)
do. - type Rgb
A colour, as the three bytes a PNG stores. - fn colour
Read `#rrggbb` into three bytes. The palettes are written as hex strings because that is what the website and the SVG both want, so this is the one place they become numbers. Anything that is not six hex digits after a `#` gives `None`: a - fn colour
colour that could not be read is a caller's mistake, and guessing at it would paint a frame a colour nobody chose. - struct Canvas
A picture being drawn, one byte per channel, three channels per pixel. - impl Canvas
- fn new
A canvas of `width` by `height`, filled with `background`. - fn width
How wide it is. - fn height
How tall it is. - fn at
The colour at a point, or `None` outside the canvas. For tests. - fn blend
Mix `colour` into the pixel at `x`, `y` by `alpha`, from 0.0 to 1.0. Everything that draws a curve goes through this: a circle with a hard edge looks like a mistake at any size, and the fix is to let the edge pixels be part one colour and - fn blend
part the other. - fn rect
A filled rectangle, clipped to the canvas. Takes signed coordinates because the layout is computed in floats and a box can legitimately start off the left edge; clipping here is one check rather than one at every call site. - fn rounded_rect
A rectangle with rounded ends, which is how the level bars are drawn. The radius is capped at half the shorter side, because a corner rounder than the box it is on is not a shape. - fn circle
A filled circle with a smooth edge. Coverage is sampled on a four-by-four grid inside each pixel that the edge crosses. Sixteen samples is enough that no step is visible at any frame size this renders, and pixels well inside or well - fn circle
outside are filled or skipped without sampling at all, so the cost is the outline rather than the area. - fn ring
A ring, drawn as a filled disc with the middle taken back out. - fn text
Draw `text` with its left edge at `x` and its top at `y`. Returns how many characters had no glyph, so a caller can say which names came out as boxes rather than leaving somebody to notice. - fn text_centred
Draw `text` centred on `centre_x`, with its top at `y`. - fn png
The picture as a PNG file. Truecolour, eight bits a channel, no interlacing and no alpha: the frames are opaque, and an alpha channel nothing uses is a third more bytes through the encoder for every frame of the video. - fn chunk
Append one PNG chunk: length, type, data, and the checksum over both. - struct Crc
The CRC-32 PNG puts on every chunk. Fifteen lines rather than a dependency: this is the one checksum PNG needs and the polynomial is in the specification. - impl Crc
- fn new
- fn eat
- fn done
- mod tests
- const BLACK
- const WHITE
- fn a_colour_is_read_or_refused
- fn a_new_canvas_is_its_background_everywhere
- fn drawing_outside_the_canvas_is_clipped
Drawing off the edge clips rather than wrapping or panicking. The layout is computed in floats and a box can legitimately start left of zero. Wrapping would put a bar on the wrong side of the picture. - fn a_circle_is_round_and_its_edge_is_not_a_staircase
A circle is filled in the middle, empty at the corners, and soft at the edge. - fn a_ring_leaves_what_was_under_its_middle
A ring is hollow. - fn text_is_drawn_and_says_what_it_could_not
Text lands where it was put and reports what it could not draw. - fn centred_text_sits_on_its_centre
Centred text is centred. - fn the_png_is_shaped_the_way_the_format_says
The PNG is a PNG: signature, the three chunks, and correct checksums. Checked by reading the bytes rather than by opening the file with something, because "a decoder accepted it" and "it is what the format says" are different claims and - fn the_png_is_shaped_the_way_the_format_says
this writer makes the second. - fn the_pixels_come_back_out_as_they_went_in
The pixels survive the round trip. Deflated and inflated again, and compared against what was drawn. A writer that produced a well-formed file of the wrong pixels would pass every structural check above. - fn the_same_picture_encodes_to_the_same_bytes
The same drawing gives the same bytes. Two people rendering one recording should get one file. Nothing here may depend on a hash order, a system font or a clock.
crates/veilvoice-video/src/size.rs
- (module)
The size and frame rate a video is rendered at. # Why this is a module rather than two numbers The renderer used to write `1280x720` at thirty frames a second and offer no way to say otherwise. Both numbers were reasonable defaults and - (module)
neither was a choice anybody could make, which is the whole of the problem: somebody rendering a conversation to put on a screen wants it to look like the screen they are putting it on. Getting that wrong is expensive in a way a wrong - (module)
colour is not. Frames are rendered one file at a time before `ffmpeg` is asked for anything, so a choice made at the start decides how long the render takes, how much disk it wants while it runs, and whether it finishes at all. - (module)
[`Plan::estimate`] exists so a front end can say that before starting rather than after. # The rules a size has to obey, and where they come from **Both dimensions have to be even.** H.264 with `yuv420p`, which is what every player and - (module)
every platform accepts, stores colour at half resolution in both directions, so an odd dimension has half a chroma sample in it. `ffmpeg` refuses outright: "width not divisible by 2". A person typing 1921 has made a typo rather than a - (module)
request, so [`Size::new`] says so and [`Size::nearest_valid`] offers the size they meant. **There is a floor and a ceiling**, and both are about somebody's typo rather than about taste. Under [`MIN_EDGE`] the subtitles are unreadable and - (module)
the waveform is a line. Over [`MAX_EDGE`], which is 8K, an extra digit turns a ten-minute render into one that fills the disk: at 4K a frame is about eight megabytes before compression, and at 60 frames a second that is half a gigabyte for - (module)
every second of recording. # The default is the screen it will be watched on, when the screen will say [`Choice::Monitor`] is the default and it is *resolved late*: the size is decided when the render starts, from the display the program - (module)
is actually running on, rather than stored as a number that becomes wrong when somebody plugs in a different monitor. Where nothing can say what the display is running at, and a command line on a machine with no display server is the - (module)
ordinary case, it falls back to [`Preset::Hd1080`] **and says so**. A guess about somebody's monitor presented as a detection would be worse than a stated default. # In plain words How big the video is and how many pictures a second it - (module)
has. By default it matches the screen you are using, because that is usually the screen you are going to watch it on. You can pick 720p, 1080p, 1440p or 4K instead, or type your own size, and anything up to 60 frames a second. Bigger and - (module)
faster is not better here. The picture is a waveform, some circles and words on a flat background, none of which move quickly, so 4K at 60 costs a great deal of time and disk for something that looks the same as 1080p at 30 to almost - (module)
everybody. - const MIN_EDGE
The shortest edge a render may have, in pixels. Below this the subtitles cannot be read and the waveform is one pixel tall, so the file would be a video of nothing. 256 is the smallest that survives a phone screen. - const MAX_EDGE
The longest edge a render may have, in pixels. 8K. Not because anybody needs it, but because a limit has to be somewhere and this is the largest thing that exists to be watched on. - const MAX_FPS
The most frames a second a render may have. **Sixty, and it is a cap rather than a target.** Nothing in this picture moves quickly: a waveform scrolls, a circle brightens, words appear. Sixty doubles the frames, the render time and the - const MAX_FPS
file against thirty and looks the same to almost everybody. It is offered because somebody cutting this into 60fps footage needs it to match, which is a real reason, and it is not the default because it is not an improvement. - const MIN_FPS
The fewest frames a second a render may have. Under this the waveform stutters rather than scrolls. - struct Size
A frame size in pixels, known to be one a render can actually use. Constructed only through [`Size::new`], so a value of this type has already been checked: both edges even, both inside [`MIN_EDGE`] and [`MAX_EDGE`]. - impl Size
- fn new
A size, if it is one a video can be rendered at. The error says which rule was broken and what the nearest allowed size is, because "invalid size" sends somebody to guess and this does not. - fn nearest_valid
The nearest size that obeys every rule, for offering after a refusal. Rounds each edge down to even and clamps it into range. Always returns a value: the clamping cannot fail, because both bounds are themselves even and inside the range. - fn width
Width in pixels. Always even. - fn height
Height in pixels. Always even. - fn pixels
How many pixels one frame holds. `u64` deliberately: 7680 by 4320 is 33 million, which fits a `u32`, but multiplying it by a frame count does not, and this is the number that gets multiplied. - fn label
What `ffmpeg` wants after `-s`, and what a person reads in a menu. `1920x1080`, with the familiar name after it where there is one. Nobody says "1920 by 1080" out loud and everybody says "1080p". - fn geometry
Just the digits, for a command line argument. - enum Preset
The sizes offered by name. Sixteen by nine throughout, because that is what every player, every phone and every platform expects, and because a conversation rendered to a shape nobody uses gets black bars added by somebody else's software. - impl Preset
- const ALL
Every preset, in the order a menu should list them. - fn size
The size this preset means. - fn name
What a person calls it. - fn key
What it answers to on a command line. Lower case and stable. - fn matching
The preset a size is, if it is one of them. - enum Choice
What the user asked for, before anything has looked at the display. Held rather than resolved, so that "match my screen" stays true when the screen changes. See the module documentation. - struct Resolved
A size, and why it is that size. The reason travels with the number because a front end has to be able to say "1080p, because this machine could not tell us what your display is running at" rather than showing 1080p as though it had been - struct Resolved
detected. - impl Choice
- const KEYS
What this answers to on a command line, in the order help should list it. One list, used by the command line's help, the command line's parser and the window's menu, so a name that works in one works in all of them. - fn parse
Read a choice somebody typed. Accepts a name from [`Choice::KEYS`] or an explicit `WIDTHxHEIGHT`. The `x` may be an `X` or a `*`, because both are what people type, and the error names every accepted form rather than only refusing. - fn describe
What this reads as in a menu or a report. - fn resolve
Turn a choice into a size, given what the display said. `monitor` is the display's size where something could ask for it, and `None` where nothing could: a headless command line, a platform with no interface for it, a remote session. Both - fn resolve
cases are ordinary and neither is an error. A monitor size that breaks the rules is corrected rather than refused. A display running at an odd height is a fact about somebody's hardware and not a mistake they made, so the nearest usable - fn resolve
size is taken and the note says what happened. - struct FrameRate
Frames per second, known to be inside the range a render allows. - impl FrameRate
- fn new
A frame rate, if it is one a render allows. - fn get
The number. - const OFFERED
The rates offered by name, in the order a menu should list them. Twenty-four because it is what film runs at and what somebody cutting this into film footage needs; thirty as the default; fifty and sixty for matching the two broadcast - const OFFERED
rates. - impl FrameRate
- fn parse
Read a frame rate somebody typed. A trailing `fps` is accepted because it is what people write. - impl Default for FrameRate
- fn default
- struct Plan
A size and a frame rate together, with what they will cost. - struct Estimate
What a render is going to want before it is started. - fn human_bytes
A byte count, in the units a person reads. Here rather than in a front end because both the window and the command line print the same estimate, and two roundings of one number is two numbers. Binary units, because a disk with "1 GB free" - fn human_bytes
has 1 GiB free and the estimate is about whether the render fits. - const UNITS
- impl Plan
- fn new
A plan. - fn estimate
What rendering `seconds` of recording will want. Saturating throughout. An hour of 8K at sixty frames a second is a number nobody should reach, and reaching it should produce a large figure a front end can refuse, not an overflow. - impl Default for Plan
- fn default
- mod tests
- fn every_preset_is_a_size_a_video_can_be
- fn a_preset_knows_its_own_size_and_back_again
- fn an_odd_side_is_refused_and_the_nearest_even_one_is_offered
- fn sizes_outside_the_range_say_which_end_they_fell_off
- fn nearest_valid_always_produces_something_new_accepts
- fn the_default_choice_follows_the_display
- fn a_display_nothing_can_ask_about_falls_back_and_says_so
- fn an_awkward_display_is_corrected_rather_than_refused
- fn a_display_below_the_floor_is_lifted_to_it
- fn a_named_or_exact_choice_ignores_the_display_entirely
- fn the_frame_rate_stops_at_sixty
- fn an_estimate_grows_with_size_and_rate_and_never_overflows
- fn every_offered_name_parses_back_to_what_it_names
- fn the_forms_people_actually_type_are_read_rather_than_refused
- fn a_size_that_is_not_one_says_what_would_be
- fn a_choice_describes_itself_without_needing_a_display
- fn bytes_are_printed_in_units_somebody_reads
- fn a_label_names_the_preset_where_there_is_one
crates/veilvoice-video/src/waveform.rs
- (module)
The shape of the audio, reduced to something a page can draw. # Peaks, not samples A minute of audio at 48 kHz is 2.88 million samples and a waveform is about a thousand pixels wide. Drawing every sample would produce a path megabytes long - (module)
that renders as a solid block. So the audio is divided into as many buckets as there are columns, and each bucket keeps its **minimum and maximum**, not its average and not its root-mean-square. The extremes are what a waveform is: they - (module)
are what makes a plosive look like a plosive, and an average over a bucket of a symmetric waveform is approximately zero however loud it was. # It is drawn from the veiled audio Worth stating, because the alternative is an easy mistake. - (module)
The picture is of the **output**, not the input. A waveform is not a voiceprint, since it carries no formants and no phase, but it does carry timing and loudness, and a picture of the original would show the original's timing and loudness - (module)
beside a recording that had gone to some trouble to replace them. # What a waveform still shows Silences, the rhythm of speech, how loud somebody was, and roughly where a sentence ends. That is the same turn-taking structure a conversation - (module)
render keeps on purpose, drawn rather than heard, and it is not additional exposure beyond what the audio already carries. # In plain words Turns a recording into the wavy shape you see drawn along the bottom of an audio player. A minute - (module)
of sound is nearly three million numbers, which is far more than a picture a few hundred pixels wide can show or a web page should carry. So the sound is divided into as many pieces as there are columns to draw, and each piece is reduced - (module)
to its loudest point. The loudest point rather than the average, because averaging smooths a recording into a flat sausage and loses exactly the peaks that make a waveform worth looking at. - struct Envelope
The peak envelope of a signal: one minimum and one maximum per column. - impl Envelope
- fn len
How many columns this envelope has. - fn is_empty
Whether there are no columns at all. - fn envelope
Reduce `samples` to `columns` peak pairs. `columns` of zero, or an empty signal, gives an empty envelope rather than dividing by zero. A signal shorter than the column count gives every column a bucket of at least one sample, so a very - fn envelope
short file draws as a few tall columns rather than as nothing. - fn level_at
How loud the recording is at `progress`, from 0.0 to 1.0. **Roadmap item 139.** The bar beside each speaker's name is drawn from this. It reads the same envelope the waveform is drawn from rather than the samples, so the bar and the wave - fn level_at
under it can never disagree: one array, two things drawn from it. `progress` is a fraction of the recording rather than a time in seconds, because the envelope has no idea how long the audio is. The caller has the duration and does that - fn level_at
division once. # What this is, and what it is not It is the loudness of the **mix** at that moment. The renderer produces one mixed track, so there is no separate signal per person to measure, and the picture attributes this to whoever the - fn level_at
plan says is speaking. While one person is talking those are the same thing. Where two turns overlap they are not: both speakers show the same bar, because that is one number and there are two of them. It is what a listener hears, and it - fn level_at
is not a claim that each of them was that loud. - fn svg_path
The envelope as an SVG path, filled, inside a box. Traced left to right along the maxima and right to left along the minima, then closed: one filled shape rather than a thousand rectangles, which is a tenth of the markup and draws in one - fn svg_path
operation. Coordinates are rounded to two decimals. A waveform does not need more, and full `f32` precision triples the size of the path for a difference nothing can display. - mod tests
- fn ramp
- fn an_envelope_has_one_pair_per_column
- fn a_loud_symmetric_signal_does_not_average_away
The extremes are the point. An average over a symmetric waveform is approximately zero however loud it was, and a waveform drawn from averages is a flat line. - fn silence_is_flat_and_still_has_its_columns
- fn a_non_finite_sample_does_not_poison_its_column
One NaN would make a column NaN and the path unparseable, which fails as a blank page rather than as a wrong one. - fn a_signal_past_full_scale_stays_inside_its_box
Samples outside full scale are clamped, so the path cannot escape its box and draw over the rest of the page. - fn nothing_in_gives_nothing_out_rather_than_dividing_by_zero
- fn more_columns_than_samples_still_draws
More columns than samples must still give a column each, so a very short file draws as something rather than as nothing. - fn the_path_is_a_single_closed_shape
One closed shape, not a thousand rectangles. - fn coordinates_are_rounded
Two decimals is all a waveform needs, and full precision triples the markup for a difference nothing can display.
crates/veilvoice-watch/examples/scan_once.rs
- (module)
Print what is using the microphone and camera right now. # In plain words Looks once at which programs are using the microphone or camera, prints them, and stops. For checking what the monitor can actually see on a particular machine, - (module)
without running the whole application to find out. - fn main
crates/veilvoice-watch/src/appctl.rs
- (module)
Learn what normally runs on this machine, then notice what does not. # What this is, said before anything else **This is not application control.** It does not stop a program starting, it cannot stop one starting, and nothing in this crate - (module)
tries. It is a *baseline*: you tell it to watch for a while, it records what it sees, and afterwards it can tell you when something runs that was not in that picture. The name is the roadmap's and it is kept because renaming it would leave - (module)
two names for one thing, but [`SCOPE`] is the wording every front end must show, and it says outright that nothing here prevents anything. Real enforcement means a kernel driver or a signed policy blob and an application identity to sign - (module)
it with, and this project is published under a pseudonym on purpose. Shipping something called "app control" that quietly only *watches* would be the exact failure this project's second rule exists to prevent. # Learning, and why it has an - (module)
end [`Baseline::learning`] records what runs. It is not left on: a baseline that is always learning has learned nothing, because whatever an attacker starts becomes part of the picture the moment it starts. So learning is a phase with an - (module)
end, and [`Baseline::freeze`] closes it. # Grants expire, and that is the whole design Allowing something for ever is how an allowlist becomes a list of everything anybody ever ran. [`Grant`]s carry an expiry, [`Baseline::allowed`] checks - (module)
it against the clock it is given, and an expired grant is simply not a grant. Nothing sweeps them: an expired entry is kept, because *"this was allowed until Tuesday"* is worth more to somebody reading the log than a row that vanished. - (module)
Permanent grants exist and are spelled [`Grant::forever`], so that choosing one is a thing somebody typed rather than a default they never saw. # The log is append-only, and it is the point Every decision is recorded: what was seen, - (module)
whether it was known, which grant covered it and when that grant ends. A control whose decisions cannot be reviewed afterwards is a control nobody can check, and this one is *only* ever going to be reviewable, since it does not enforce. # - (module)
In plain words For a while, this watches which programs you normally run and writes them down. After that, it can tell you when something starts that was not on that list. It does **not** block anything. It cannot. It is a way of noticing, - (module)
not a lock on the door, and anything that told you otherwise would be lying to you about how safe you are. You can allow a program you recognise, and when you do you say for how long -- an hour, a day, or permanently if you really mean it. - (module)
Temporary is the normal case, because a list that only ever grows stops meaning anything. Everything it decides is written to a log you can read. - const MAGIC
The file format's first line. The digit is a version. - enum Grant
How long a grant lasts. - impl Grant
- fn for_duration
A grant lasting `how_long` from `now`. Returns `None` when the duration would run off the end of the clock, rather than saturating: a grant that silently became permanent because somebody typed too many digits is precisely what - fn for_duration
[`Grant::Forever`] exists to make explicit. - fn forever
A permanent grant. Named, so choosing one is deliberate. - fn covers
Whether this grant still covers `now`. - fn describe
How this reads in a report. - fn plain_duration
A duration a person can read. - enum Verdict
What a baseline says about one program. - impl Verdict
- fn phrasing
The wording a front end should use. - struct Entry
One line of the decision log. - struct Baseline
What normally runs here, what has been allowed, and what has been decided. - impl Baseline
- fn new
A baseline that has learned nothing and is not learning. - fn learning
A baseline in its learning phase. - fn is_learning
Whether the learning phase is open. - fn freeze
Close the learning phase. Refuses to close on an empty baseline. A baseline that learned nothing calls everything unknown, which is the same as calling nothing unknown: the reader gets a page of noise, learns to ignore it, and is worse off - fn freeze
than before they started. - fn len
How many programs the baseline holds. - fn is_empty
Whether the baseline holds nothing. - fn programs
Every program in the baseline, in order. - fn allow
Allow a program until a moment, or for good. A grant for something already in the baseline is refused: it would be a row in the allowlist that never does anything, and later reads as though somebody had to permit an ordinary program. - fn revoke
Withdraw a grant. - fn grant
The grant covering a program, expired or not. - fn allowed
Whether this program is allowed to be running, as of `now`. - fn verdict
What this baseline says about a program, without recording anything. - fn observe
Record what is running now, and return what was decided. While learning, everything seen joins the baseline. Afterwards, nothing does -- which is the whole point of the phase having an end. - fn log
The decision log, oldest first. - fn unknown
Everything running that is neither known nor granted. - fn to_text
Write the baseline as text. Plain text on purpose, like the project format: it can be read, edited and diffed, and a control whose state is opaque is a control nobody can audit. The log is included, because a decision record kept - fn to_text
separately from the decisions is one that can be lost separately. - fn parse
Read a baseline back. - fn take_token
The first whitespace-separated token, and the rest. - fn normalise
A process name as this crate compares them. - fn verdict_key
The word written to the file for a verdict. - fn verdict_from_key
The verdict a word means. - enum Error
Why something was refused. - impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- const SCOPE
What a reader must be told, in the words to tell them. - mod tests
- fn at
- fn names
- fn nothing_this_crate_says_claims_to_have_blocked_anything
**The most important test here.** Nothing this crate says may suggest it stopped anything, because it did not and cannot. - fn a_grant_stops_covering_the_moment_it_expires
A grant that has run out is not a grant, and the check is against the clock rather than against a sweep that may not have run. - fn forever_is_its_own_thing_and_not_a_distant_timestamp
Permanent is spelled out, never a very large date. - fn learning_must_end_and_must_have_learned_something
Learning has an end, and a baseline that learned nothing may not be frozen -- it would call everything unknown, which is noise. - fn a_frozen_baseline_does_not_learn_from_what_runs_next
After learning ends, nothing joins the baseline by running. That is the entire reason the phase has an end. - fn while_learning_the_answer_is_learning_and_not_known
While learning, the verdict says so rather than saying everything is fine -- which is true and useless and reads as a clean bill of health. - fn the_log_records_what_is_worth_reading_and_not_every_sighting
Only the decisions worth reading are logged. A line for every ordinary program every time it is seen is a log nobody reads. - fn a_grant_for_something_already_in_the_baseline_is_refused
Allowing something already ordinary is refused: the rule would never do anything, and later reads as though it had been needed. - fn a_name_is_matched_however_it_is_typed
Names compare the same however they are written. - fn every_shape_of_baseline_round_trips
Every shape a baseline can be in survives being written and read back. - fn an_unreadable_line_is_refused_rather_than_skipped
A keyword this build does not know is **refused**, never skipped: a line silently ignored is a security setting somebody wrote and this program did not apply. - fn the_file_explains_itself_and_carries_no_credentials
The file holds no secrets, and says what it is. - fn what_is_unknown_is_listed_once_and_in_order
- fn durations_read_as_english
crates/veilvoice-watch/src/capture/comms.rs
- (module)
Communication programs, and how to put VeilVoice between you and them. # What this does, and the one thing it deliberately does not It finds the calling and messaging programs on this machine and tells you, for each one, exactly where to - (module)
point it so your voice goes through VeilVoice before it goes to anybody else. It does **not** reach inside any of them. It does not read Signal's or Matrix's traffic, hook their audio, inject into their processes or decrypt anything. That - (module)
is not a limitation being apologised for: it is the same act this whole project exists to make useless, and a privacy tool that shipped a way to intercept an end-to-end encrypted call would be arguing against itself. # The route, and why - (module)
it is a virtual cable Every program here lets you choose which microphone it uses. So: ```text your microphone -> VeilVoice -> a virtual audio cable | v Discord / Signal / anything, with the cable chosen as its "microphone" ``` Nothing has - (module)
to know VeilVoice exists. The program asks the operating system for a microphone, the operating system hands it the cable, and the cable carries a voice that is not yours. That is why this works with programs nobody here has ever tested, - (module)
including ones that do not exist yet. # Your voice, not theirs This route veils **what you send**. It does nothing to what you receive: the other people on the call are not going through VeilVoice, and their voices arrive as they always - (module)
did. Veiling a whole call, meaning everybody including the people at the other end, means capturing what the program *plays*, which is a different mechanism on every platform and is not built. [`INCOMING`] says so in the words a front end - (module)
should show, rather than letting somebody assume a recording of a call is veiled on both sides. # In plain words If you want to talk to people through Discord, Signal, Telegram or anything like them without your real voice going out, this - (module)
tells you how: send your microphone through VeilVoice, out to a virtual cable, and then tell the chat program that the cable *is* your microphone. It never knows the difference. It only changes what **you** send. Everybody else on the call - (module)
still sounds like themselves, and this does not record or read anything they send you. - struct Comm
One communication program, and where its microphone setting lives. - const COMMS
The programs this build knows where to look in. Not a list of what is supported: **anything** that lets you choose a microphone works, because the trick is done at the operating-system level and the program is not consulted. This table - const COMMS
exists to save somebody hunting through a settings menu, and nothing depends on a program being in it. - fn instructions
What to do, for one program. - const ANY_PROGRAM
The general route, for a program not in the table. - const INCOMING
What this route does **not** cover, in the words a front end should show. - const NO_INTERCEPTION
What this crate will not do, and why that is not an oversight. - fn running
Which of these are running now, and anything that went wrong looking. The same reading of the process list [`crate::capture::programs`] uses, and with the same two limits. It sees a program that is **running**, not one that is in a call -- - fn running
a front end must say "is running" and not "is on a call". And the second value is why the list may be short: a list that came back empty because a tool failed is not an empty list, and saying so is the difference between "nothing is - fn running
running" and "I could not tell". - fn by_key
The program with this identifier. - fn weight
How a running communication program should be described. Deliberately [`Purpose::Capable`]'s weight rather than a recorder's: a chat program being open says almost nothing, and treating it as an event is how a monitor becomes noise nobody - fn weight
reads. - mod tests
- fn every_entry_is_complete_and_uniquely_keyed
- fn the_programs_that_were_asked_about_are_there
The four the request named, plus the ones people actually use. - fn instructions_name_the_menu_and_the_device
- fn instructions_without_a_cable_still_read_as_english
With no cable found, the instructions still make sense rather than naming an empty string. - fn the_scope_notes_state_the_limit_outright
The two notes that keep this honest have to say the thing, not hint at it. A reader who skims must not come away thinking a recorded call is veiled on both sides. - fn the_general_route_says_the_table_is_not_a_list_of_what_is_supported
The table is a convenience, and the crate has to say so -- otherwise somebody whose program is missing concludes it will not work. - fn a_running_chat_program_is_reported_at_the_lower_weight
A chat program being open is not an event. Weighting it like a screen recorder is how a monitor becomes noise nobody reads. - fn asking_what_is_running_is_safe_on_any_machine
Reading the process list must not panic or hang, whatever is running.
crates/veilvoice-watch/src/capture/mod.rs
- (module)
# capture Which screen-recording programs are running, an allowlist for the ones you meant to run, and a plain account of the two things this cannot do. ## VeilVoice does not hide itself from your recorder Stated first, because it is the - (module)
question people actually have: **you can record VeilVoice's window with OBS, and nothing here stops you.** Screen capture of this application is not blocked, not degraded, and not detected as an attack. If you are making a video about - (module)
VeilVoice, or streaming while you use it, it will appear on the recording exactly like any other window. That is not only a choice, it is also a limit. Excluding a window from capture means `SetWindowDisplayAffinity` on Windows and the - (module)
equivalent elsewhere, which is FFI, and every crate in this workspace carries `#![forbid(unsafe_code)]`, which is a front-page claim. So the exclusion is **not built**, and `ROADMAP.md` records it as a decision waiting on the maintainer - (module)
rather than as an oversight. Anybody who needs a window that cannot be recorded does not have it here, and should know that before they rely on it. ## Telling you, and then not telling you again A monitor that says "OBS is running" every - (module)
thirty seconds while you deliberately record a tutorial is a monitor you turn off, and then it is not watching for the recorder you did **not** start. So [`Allowlist`] exists: name a program once, and it stops raising a notification. - (module)
Allowed is not hidden. [`Sighting::allowed`] is a flag on a sighting that is still in the report, and [`Report::all`] still lists it. Only [`Report::worth_saying`] filters, because that is the one a notification reads. Something that - (module)
vanished from the interface entirely would be a setting for lying to yourself. ## Running is not recording, and this crate never confuses them Zoom being open does not mean Zoom is sharing your screen; Discord running does not mean anybody - (module)
is watching. This reports **what is running**, and [`programs::Purpose`] separates a program whose job is recording from one that merely can. Every sentence produced here keeps the distinction, because a privacy tool that announces - (module)
surveillance every time somebody opens a chat application has taught its user to ignore it. Knowing whether a capture is actually in progress means asking the compositor who holds a capture session, and that is FFI on every platform here. - (module)
Same trade, same answer, same place it is recorded. ## And it only knows the programs it knows [`programs::ALL`] is a table of names. Something not in it is not reported, and something written to record a screen quietly would not be called - (module)
`obs64.exe`. An empty report is not evidence that nothing is recording, and [`SCOPE`] says so. # In plain words This tells you which screen-recording programs are running. If you are about to say something private and a recorder is going, - (module)
that is worth knowing first. Programs you run on purpose can be marked as expected, and they stay in the list rather than disappearing from it. It cannot hide VeilVoice's window from a recorder, and it does not pretend to. A camera pointed - (module)
at the screen would not care anyway. - mod comms
- mod processes
- mod programs
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What this is worth, in the words a front end should show. Single-sourced and asserted by the tests, exactly as every other scope note in this project is, so it cannot quietly turn into a promise. - struct Allowlist
Programs the user has said they meant to run. A set of [`Program::key`] values, kept in a plain text file. Nothing here is secret and there is nothing to protect: an allowlist is a note to yourself about which notifications you have - struct Allowlist
already read. - const MAGIC
Magic first line. The digit is a format version. - impl Allowlist
- fn new
An allowlist that allows nothing. - fn allow
Stop notifying about this program. Refuses a key this build does not know. An allowlist entry for a misspelled program silently allows nothing, and the user believes they have turned a notification off. - fn deny
Start notifying about this program again. - fn allows
Whether this program is allowed. - fn keys
The allowed keys, in a stable order. - fn len
How many programs are allowed. - fn is_empty
Whether nothing is allowed. - fn to_text
Serialise to a text format, one key per line. - fn parse
Parse the text format. A key this build does not know is an **error**, not a line to skip: it is either a typo or a file from a newer build, and both mean the user believes a notification is off when it is not. - fn save
Write the allowlist to `path`. - fn load
Read an allowlist written by [`Allowlist::save`], treating a missing file as "nothing is allowed". A missing file is the ordinary state before anything has been allowed. A file that exists and will not parse is reported: quietly starting - fn load
from an empty allowlist would turn every suppressed notification back on with no explanation, which reads as the tool having gone wrong. - struct Sighting
One capture-capable program found running. - impl Sighting
- fn describe
One line for a terminal, a log or a notification. Says what is running and what that does and does not mean. Never says that anything is being recorded, because this cannot know that. - struct Report
What is running, and anything that got in the way of finding out. - impl Report
- fn take
Look at what is running now. - fn all
Everything found, allowed or not. - fn worth_saying
The sightings a notification should raise: the ones not allowed. The only thing in this crate that filters on [`Sighting::allowed`]. - fn is_empty
Whether anything at all was found, allowed or not. - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn sighting
- fn the_scope_note_states_the_limits_rather_than_a_guarantee
The claim must keep stating all three limits. If somebody edits this into a promise, this is what stops it shipping. - fn allowing_and_denying_a_known_program
- fn allowing_a_program_this_build_does_not_know_is_refused
A misspelled key would silently allow nothing while the user believed a notification was off. - fn an_allowlist_survives_a_round_trip_through_text
- fn an_allowlist_survives_a_round_trip_through_a_file
- fn a_missing_allowlist_allows_nothing_without_complaining
Nothing allowed yet is the ordinary state, not an error. - fn a_malformed_allowlist_is_refused_rather_than_half_read
- fn blank_lines_are_tolerated
- fn an_allowed_program_stays_in_the_report_and_out_of_the_notification
Allowed is muted, never hidden: it stays in the report and only a notification filters it out. - fn an_allowed_sighting_says_why_it_is_quiet
An allowed sighting says it is allowed, so somebody reading the full list knows why it is quiet. - fn no_sighting_claims_a_recording_is_happening
No line may claim that anything is being recorded, because nothing here can know that. - fn a_program_is_reported_once_however_many_processes_it_has
A recorder with four helper processes is one thing running. - fn taking_a_real_report_does_not_panic
Looking at the real machine must not panic, and an empty report must never be silent about why. - fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-watch/src/capture/programs.rs
- (module)
The programs this build knows can capture a screen. # A list, and therefore incomplete This is a table of names. Anything not in it is not reported, and the table will never be complete: new recorders appear, people rename executables, and - (module)
a program written specifically to capture a screen quietly would simply not be called `obs64.exe`. [`crate::capture::SCOPE`] says so, and no front end may present an empty report as "nothing is recording". What it is genuinely good for is - (module)
the ordinary case, which is also the common one: you have OBS open, VeilVoice notices, and you either wanted that or you did not. # Capable of capturing is not the same as capturing Zoom being open does not mean Zoom is sharing your - (module)
screen. Discord running does not mean anybody is watching it. This crate reports **what is running**, and the distinction is carried in every string it produces, because the alternative is a privacy tool that cries wolf every time somebody - (module)
opens a chat application. Telling the difference needs the compositor to say who is holding a capture session, and reaching that is FFI on every platform here. See [`crate`] for what that costs and why it is not paid. # In plain words A - (module)
list of programs that can record a screen, with what each one is and whether recording is its purpose or merely something it can do. The difference matters. A screen recorder being open is worth telling you about. A chat program being open - (module)
is worth much less, because it can share a screen and almost never is, and treating the two the same is how a warning becomes noise that everybody learns to ignore. This is a list of names, not a list of threats. Almost everything on it is - (module)
software somebody installed on purpose. - struct Program
One program known to be able to capture a screen. - enum Purpose
Why a program is in the table. - impl Purpose
- fn phrasing
The wording a front end should use for this kind of program. - const ALL
Every program in the table. Ordered with the dedicated recorders first, so a front end that shows a few shows the ones that matter. - fn matching
Find the program a process name belongs to, if this build knows it. The comparison is on the bare executable name, lower-cased. A path is stripped first: process listings differ about whether they give one, and a match that worked on one - fn matching
platform and not another would be the sort of bug nobody notices until a user is relying on it. - fn by_key
Find a program by its [`Program::key`]. - mod tests
- fn every_program_has_a_name_that_survives_the_linux_truncation
The Linux kernel truncates `/proc/<pid>/comm` to fifteen characters, so a longer name in the table never matches there -- silently, and on one platform only, which is the exact shape of bug this project keeps finding in itself. The rule is - fn every_program_has_a_name_that_survives_the_linux_truncation
therefore not "every name is short" but "every program that has a Unix name has **one** that survives truncation". Both spellings are listed where they differ, because the full one is still what `ps` and every Windows listing give. - fn keys_are_unique
- fn no_process_name_belongs_to_two_programs
One executable name must not belong to two programs, or which one is reported depends on the order of the table. - fn a_known_recorder_is_found_however_it_is_spelled
- fn an_unknown_process_matches_nothing
- fn veilvoice_is_not_in_its_own_table
VeilVoice must never report itself. It draws a window; it does not capture one. - fn a_program_that_merely_can_share_is_not_described_as_recording
A recorder and a chat application must not be described alike. - fn every_program_says_what_it_is
- fn the_browser_entry_matches_nothing_on_purpose
The browser entry is deliberately unmatched, and must stay that way: a browser is open on nearly every machine, so matching one would make the report permanently true and permanently useless. - fn recorders_are_listed_before_the_merely_capable
Dedicated recorders come first, so a front end showing a few shows the ones that matter.
crates/veilvoice-watch/src/drivers/linux.rs
- (module)
Linux: `/proc/modules`, cross-checked against `/sys/module`. # Two files, no subprocess The kernel publishes the loaded module list as a file, so this reads it. Nothing is spawned, nothing needs privileges, and the whole operation costs - (module)
two directory reads, cheap enough to run on a timer without becoming the reason the interface stopped painting. # The format ```text nvidia 56807424 42 nvidia_uvm,nvidia_modeset, Live 0xffffffffc0e00000 ``` Name, size in bytes, reference - (module)
count, the modules that depend on it, the state, and the load address. The address is **not** recorded: on most systems `kptr_restrict` makes it read as zeros for an unprivileged reader, and on the rest it changes at every boot, so - (module)
recording it would make every module look altered after a restart, which is a report nobody reads. # The cross-view check `/sys/module` has a directory per module, and the two lists should agree. A name in one and not the other is worth - (module)
reporting: unlinking from `/proc/modules` and forgetting `/sys/module` is a mistake real rootkits have made. It catches carelessness only. Both lists come from the same kernel, so anything with the privilege to edit one has the privilege - (module)
to edit both. One honest wrinkle: `/sys/module` also lists built-in modules and boot parameters, which were never in `/proc/modules` and never will be. Those have no `initstate` file, so only directories that have one are compared, - (module)
otherwise the check would produce dozens of discrepancies on every machine and be switched off within a day. # In plain words Asks Linux which kernel modules are loaded, by reading two files the system keeps. Nothing is run at all: the - (module)
answer is already written down. Both files are read and compared, because a module that appears in one and not the other is itself worth reporting. - fn parse_proc_modules
Parse the contents of `/proc/modules`. Ungated so the parser is exercised by the test suite on every platform, not only on the one that can produce the file. Real Linux output is checked in below rather than being generated by the same - fn parse_proc_modules
assumptions the parser makes. - fn read
Read both views and compare them. - fn sys_module_names
The names under `/sys/module` that correspond to loadable modules. Only directories with an `initstate` file. The rest are built-ins and boot parameters, which are never in `/proc/modules`. Including them would report dozens of - fn sys_module_names
discrepancies on an ordinary machine, and a check that cries wolf every time is a check somebody turns off. - mod tests
- const SAMPLE
Real `/proc/modules` output, kept verbatim. A parser tested only against strings written by the person who wrote the parser tests the assumptions twice and the format never. - fn every_line_becomes_a_module
- fn size_reference_count_and_state_are_kept
- fn dependants_are_kept_and_the_trailing_comma_is_not
- fn a_module_nothing_depends_on_says_nothing_about_dependants
A dash means nothing depends on it, and must not be printed as though something called `-` did. - fn the_load_address_is_not_recorded
The load address is deliberately dropped: it is zeroed for an unprivileged reader, and changes at every boot on the machines where it is not. Recording it would make every module look altered after a restart. - fn nothing_and_whitespace_produce_nothing
- fn a_short_line_is_survived
A truncated line must produce a module rather than panicking: this parses a file the kernel writes while modules are loading and unloading, and a short read is a thing that happens.
crates/veilvoice-watch/src/drivers/macos.rs
- (module)
macOS: `kmutil showloaded`, falling back to `kextstat`. # Two tools, because Apple is mid-transition `kextstat` is the old one and is deprecated; `kmutil` is the replacement and does not exist on older systems. Both are tried, newest - (module)
first, and if neither answers the reason is reported rather than an empty list being passed off as "no kernel extensions", which would be false on every Mac ever made. Both print the same shape, which is why one parser handles both: - (module)
```text Index Refs Address Size Wired Name (Version) UUID <Linked Against> 1 88 0 0 0 com.apple.kpi.bsd (20.6.0) 134 0 0xffffff7f8312d000 0x9000 0x9000 com.example.driver (1.2.3) <7 5 4 1> ``` The **address is deliberately dropped**, for - (module)
the same reason as on Linux: it is zeroed for an unprivileged reader on a machine with kernel-pointer restriction, and changes at every boot on one without. Recording it would make every extension look altered after a restart, and a report - (module)
that is entirely false positives after every reboot is a report nobody opens twice. # Why a subprocess The native answer is IOKit, which is FFI. `#![forbid(unsafe_code)]` holds here as everywhere else in the workspace, so this asks a tool - (module)
the system already ships, the same trade the Windows and Linux readers make. # In plain words Asks macOS which system extensions are loaded. There are two tools depending on how new the machine is, because Apple is part way through - (module)
replacing one with the other. The newer one is tried first and the older is the fallback, so this works on both without being told which you have. - fn parse_kextstat
Parse `kextstat`-style output, which `kmutil showloaded` also produces. Ungated so the parser is exercised by the test suite on every platform. - fn read
Ask `kmutil`, then `kextstat`. - mod tests
- const SAMPLE
Real `kextstat -l` output, kept verbatim. - fn every_extension_becomes_a_module
- fn the_header_is_dropped
- fn a_banner_above_the_header_does_not_eat_an_extension
A banner above the header must not push a real extension out. - fn size_reference_count_and_version_are_kept
- fn the_load_address_is_not_recorded
The address changes at every boot on a machine that shows it, and is zeroed on one that does not. Recording it would report every extension as altered after a restart. - fn nothing_and_a_short_line_are_survived
- fn a_missing_version_leaves_no_dangling_label
An extension with no version in brackets must still be listed, with no dangling "version " on the end of its detail.
crates/veilvoice-watch/src/drivers/mod.rs
- (module)
# drivers What is loaded into the kernel, recorded, and compared later. A driver appearing between two looks is worth knowing about: it is the step almost everything that wants to watch a microphone from underneath has to take. ## The - (module)
limit, stated first **This reads a list the operating system hands out.** Anything able to lie to that list is not in it. A kernel module that has unlinked itself from the module list, or a driver that hooks the enumeration this calls, - (module)
will be invisible here, and would be invisible to any unprivileged program asking the same question, which is why the answer is "detect carelessness", not "detect rootkits". See [`SCOPE`]. ## The cross-view check, and what it is actually - (module)
worth On Linux the kernel publishes the same fact twice: `/proc/modules` and the directories under `/sys/module`. [`Report::discrepancies`] lists modules that appear in one and not the other, which catches something that unlinked itself - (module)
from one list and forgot the other. That has been a real mistake in real rootkits. It is a check for carelessness and nothing more. Both views come from the same kernel, so anything with the privilege to edit one has the privilege to edit - (module)
both. A quiet cross-view check is not evidence that nothing is hiding, and this crate never says it is. No other platform here has two independent views to compare, so [`Report::discrepancies`] is empty on them, which is reported as "there - (module)
was nothing to cross-check", not as "the check passed". ## A new driver is not by itself a finding Plugging in a printer loads a driver. So does a graphics update, a VPN client, a virtual audio cable, which VeilVoice recommends, and a - (module)
game's anti-cheat. [`Change::Appeared`] is a fact about a list, and the question it raises is "did you install something?", which the person at the keyboard answers in a second. Nothing here tries to answer it for them. ## Where the answer - (module)
comes from | Platform | Source | Needs privilege | |---|---|---| | Linux | `/proc/modules`, cross-checked against `/sys/module` | no | | Windows | `driverquery.exe /FO CSV /NH` | no | | macOS | `kmutil showloaded`, falling back to - (module)
`kextstat` | no | | others | nothing is read, and [`support`] says so | n/a | Linux reads two files and spawns nothing. The other two shell out to a tool the system already ships, for the same reason the rest of this workspace does: - (module)
`#![forbid(unsafe_code)]` holds here too, and the native APIs are FFI. # In plain words This lists what is loaded deep inside the operating system, and tells you when that list changes. Drivers run below almost everything else, so - (module)
something that gets in there can see a great deal. Knowing a new one has appeared is worth something. It asks the system twice, in two different ways, and says so when the two answers disagree -- which is a hint, not a detection. Anything - (module)
already down there can lie to both. - mod linux
- mod macos
- mod windows
- const VERSION
Crate version string, surfaced in the About panel. - const SCOPE
What this is worth, in the words a front end should show. Single-sourced and asserted by the tests, exactly as the app lock's and the tamper detector's notes are, so it cannot quietly turn into a promise. - struct Module
One loaded driver or kernel module. Build one with [`Module::new`], which normalises the two strings so that anything constructed here can survive [`Report::to_text`] and come back equal. The fields are public for reading; writing one by - struct Module
hand and skipping that normalisation is how a record stops round-tripping. - impl Module
- fn new
A module, with both strings made safe for the record. The text format separates a name from its detail on a **double space** and one record from the next on a newline. A name containing either would be split in the wrong place on the way - fn new
back in -- silently, and only for that one module, which is the worst shape a bug can have in a list somebody is comparing against yesterday's. So every run of whitespace becomes a single space here. No real driver name contains whitespace - fn new
at all, so nothing is lost on any machine; what is gained is that a *hostile* name cannot forge a second record, and a display name that happens to be padded does not report itself as altered on the next run. - fn collapse_whitespace
Every run of whitespace becomes one space, and the ends are trimmed. - enum Change
How a module differs from the record. - impl Change
- fn name
The module's name, whichever side it came from. - fn describe
One line for a terminal or a log. States the fact, never a cause. - struct Support
What this platform can and cannot answer. - fn support
Report what this platform can do. Check this before showing anything. An empty list on a platform that cannot tell is not "no drivers"; it is "no answer", and presenting the first as the second is the failure this project guards against - fn support
hardest. - struct Report
What is loaded right now, and anything odd about how it was found. - const MAGIC
Magic first line of a saved report. The digit is a format version. - impl Report
- fn take
Ask the platform what is loaded. - fn len
How many modules were listed. - fn is_empty
Whether nothing was listed. - fn modules
The modules, in a stable order. - fn with_taken
Replace the recorded time. For tests, which cannot wait. - fn from_modules
Build a report from a list, for tests and for another front end that has already obtained one. Every module is passed through [`Module::new`] on the way in, so a caller cannot hand this a name the text format would split in the wrong place. - fn to_text
Serialise to a text format, one record per line. ```text VEILDRIVERS1 taken 1700000000 problem /sys/module: permission denied discrepancy hidden_thing: in /sys/module and not in /proc/modules module nvidia 56807424 bytes, 42 refs, Live ``` - fn to_text
Text, for the same reason the tamper manifest is text: a record of what was on a machine is worth more if it can be read without this crate. - fn parse
Parse the text format. An unknown keyword is an error. A record of what was loaded is a baseline, and half of one compares against a machine that never existed -- every module it dropped reads as newly appeared. - fn save
Write the report to `path`. - fn load
Read a report written by [`Report::save`]. - fn compare
Compare two reports of the same machine. Sorted by name, so the output is identical for the same pair of inputs and two runs can be diffed against each other. - fn now_seconds
- fn read_platform
Ask whichever reader this platform has. Returns the modules, anything that went wrong, and any cross-view discrepancies. A platform with no reader returns three empty vectors, and [`support`] is what says that an empty list means "no - fn read_platform
answer". - enum Error
Everything that can go wrong in this crate. - impl From<std::io::Error> for Error
- fn from
- impl std::fmt::Display for Error
- fn fmt
- impl std::error::Error for Error
- fn source
- mod tests
- fn module
- fn the_scope_note_states_the_limits_rather_than_a_guarantee
The claim must keep stating the limits. If somebody edits this into a promise, this is what stops it shipping. - fn an_unsupported_platform_says_no_answer_rather_than_no_drivers
Support must never be read as a claim about the machine. - fn an_unchanged_machine_shows_no_changes
- fn appearing_disappearing_and_altering_are_told_apart
- fn no_change_line_names_a_cause
A change is a fact about a list, and the wording must not become an accusation. - fn a_name_that_could_forge_a_record_is_collapsed
A name with a double space in it would be split in the wrong place by the reader. No real driver has one; a hostile record could, so the constructor collapses it rather than the parser guessing. - fn a_report_survives_a_round_trip_through_text
- fn a_report_survives_a_round_trip_through_a_file
- fn a_malformed_report_is_refused_rather_than_half_read
- fn taking_a_real_report_does_not_panic_and_matches_support
Asking the real machine must not panic, whatever it answers, and must agree with what `support` promised. - fn looking_twice_is_free_of_side_effects
Two looks a moment apart at the same machine. This deliberately asserts nothing about *what* changed: a driver really can load between two calls, and a test that fails when the machine it runs on does something ordinary is a test somebody - fn looking_twice_is_free_of_side_effects
deletes. What it does hold is that reading twice is free of side effects -- the second look must not be affected by the first -- and that whatever came back compares deterministically. - fn an_io_error_displays_and_keeps_its_source
crates/veilvoice-watch/src/drivers/windows.rs
- (module)
Windows: `driverquery.exe`, which the system already ships. # Why a subprocess The native answer is `EnumDeviceDrivers` or a service-control enumeration, and both are FFI. `#![forbid(unsafe_code)]` holds in this crate as it does everywhere - (module)
else in the workspace, so this shells out to a tool Windows installs by default, the same trade `veilvoice-watch` makes for the registry and `veilvoice-verify` makes for downloading. `driverquery.exe` is resolved by absolute path under - (module)
`%SystemRoot%`. Never by bare name: Windows searches the current directory before most of `PATH`, so running from a folder containing a `driverquery.exe` somebody else wrote would run that one instead. This is a security tool asking what - (module)
is loaded in the kernel; it is a poor place to be relaxed about which program answers. # The format `/FO CSV /NH` gives one quoted record per line: ```text "ACPI","Microsoft ACPI Driver","Kernel ","1/1/1970 12:00:00 AM" ``` Module name, - (module)
display name, driver type, link date. The link date is kept, unlike the Linux load address it is a property of the file rather than of this boot, so it does not change under a machine that has not changed. # What it lists, and what that - (module)
means Installed drivers, which is a superset of what is loaded right now. A driver appearing here is therefore "something installed a driver", not "something is running in the kernel", which is a real distinction, and the front end wording - (module)
keeps it. # In plain words Asks Windows which drivers are loaded, using a tool Windows already ships. A driver runs inside the operating system itself, so one that arrived without you installing it is worth knowing about. Reading that list - (module)
directly would mean writing code that talks to Windows at a level this project has chosen not to, so it asks the existing tool and reads the answer. - fn csv_fields
Split one CSV line into its quoted fields. Written out rather than pulled in, because the workspace does not carry a CSV crate and this format is four quoted fields with no escaping in practice. A doubled quote inside a field is handled - fn csv_fields
anyway: it costs three lines, and a display name containing one is exactly the sort of thing that would otherwise be found by a user rather than by a test. - fn parse_driverquery
Parse `driverquery /FO CSV /NH` output. Ungated so the parser is exercised by the test suite on every platform. - fn read
Run `driverquery` and parse what it says. - const CREATE_NO_WINDOW
- mod tests
- const SAMPLE
Real `driverquery /FO CSV /NH` output, kept verbatim. - fn every_record_becomes_a_module
- fn the_display_name_type_and_date_are_kept
- fn a_missing_date_does_not_leave_a_trailing_comma
An empty trailing field must not leave a dangling separator. - fn a_comma_inside_a_name_does_not_split_the_record
A comma inside a quoted display name must not split the record. This is the whole reason the field splitter exists. - fn a_doubled_quote_inside_a_field_survives
- fn a_header_row_that_survived_the_flag_is_dropped
`/NH` does not suppress the header on every locale, and a phantom driver called "Module Name" in every report would be permanent. - fn nothing_and_whitespace_produce_nothing
crates/veilvoice-watch/src/input.rs
- (module)
What on this machine could be watching the keyboard and the mouse. # This is a heuristic, and the crate is built to keep saying so There is no way to ask an operating system "is anything logging my keystrokes" and get a true answer. The - (module)
mechanisms a keylogger uses are the same ones accessibility software, password managers, remote-support tools, macro utilities and games legitimately use, and the good ones are written not to be found. A tool that claimed to detect - (module)
keyloggers would be making a promise nothing can keep. So this does the one thing that *can* be done honestly: it names the programs running right now that are **able** to see your input, says what each one is for, and leaves the judgement - (module)
where it belongs. Every finding is phrased as capability, never as an accusation, and [`Finding::phrasing`] exists so that no front end has to invent that wording and get it wrong. [`LIMITS`] is the paragraph a front end must show beside - (module)
any result. It says outright that a clean result proves nothing. That is not a disclaimer bolted on; it is the most important thing this crate outputs, because somebody who reads "nothing found" as "nothing there" has been made *less* safe - (module)
by running it. # What it does not do, deliberately It does not hook the keyboard, read input, count keystrokes, time them, or watch the mouse. A program that monitored input to detect input monitoring would be the thing it warns about, and - (module)
on Windows it would need the same `SetWindowsHookEx` that `#![forbid(unsafe_code)]` rules out anyway. It also does not scan memory, inspect other processes' handles or read the registry's autostart keys. `veilvoice-watch` already covers - (module)
persistence, and duplicating it here would give two answers to one question. # In plain words Software that records what you type is real, and there is no honest way for any program to tell you for certain whether it is on your computer. - (module)
Anything that claims otherwise is guessing and not admitting it. What this does instead: it looks at which programs are open, and tells you which of them *could* see your typing or your mouse -- remote-support tools, macro recorders, - (module)
accessibility software, and so on. Most of the time these are things you installed on purpose and there is nothing wrong. The point is that you get to know they are running and decide for yourself. If it finds nothing, that does **not** - (module)
mean nothing is watching. It means nothing it knows how to recognise is open, which is a much smaller claim, and this crate will keep saying so every time. - enum Reach
Why a program is in the table. The distinction decides how loudly a front end should speak, and getting it wrong in either direction is a real failure: treating a password manager as a threat trains people to ignore the warning, and - enum Reach
treating a remote-access tool as background noise wastes the one finding that mattered. - impl Reach
- fn phrasing
The wording a front end should use, and the reason this is not left to each caller to phrase. Every sentence here is about *capability*. None of them says a program is doing anything, because this crate cannot know that and neither can - fn phrasing
anything else that only reads a process list. - struct Watcher
One program able to observe keyboard or mouse input. - const ALL
Every program this build knows how to recognise. **Not a list of keyloggers.** Almost everything here is software somebody installed on purpose and uses every day. It is a list of things that *can* see input, so that a person deciding - const ALL
whether to speak freely knows what is open. Ordered with [`Reach::Purpose`] first, so a front end showing a few shows the ones that carry the most information. - fn by_key
The program with this identifier. - fn matching
The entry a process name belongs to, if any. - struct Finding
One program found running, and how to describe it. - impl Finding
- fn phrasing
The whole sentence to show, capability and all. - struct Report
What was found, and everything that qualifies it. - impl Report
- fn is_answerable
Whether anything at all could be established. False when the process listing itself failed. A caller must not print a reassuring summary in that case, and this is how it knows. - fn summary
A one-line summary, phrased so it cannot be read as a clean bill of health. - fn look
Look, and report. Changes nothing and reads no input. - const LIMITS
What a reader must be told, in the words to tell them. Shown beside every result rather than behind a link. The sentence that matters most is the one about a clean result: somebody who reads "nothing found" as "nothing there" has been made - const LIMITS
less safe by running this. - const WHY_NOT_HOOKING
Why this crate does not watch input in order to detect input watching. - mod tests
- fn every_entry_is_complete_and_uniquely_keyed
- fn every_entry_has_a_name_that_survives_the_linux_truncation
The same fifteen-character rule `proc` documents, tested next to the table it applies to -- because the table is where somebody adds a row, and a sixteen-character name would stop matching on Linux only. - fn nothing_this_crate_says_accuses_a_program_of_anything
**The most important test in this crate.** Every sentence a front end shows has to describe a capability. The moment one of them says a program *is* watching, this crate is making a claim it cannot support, and somebody acts on it. - fn a_clean_result_states_plainly_that_it_proves_nothing
A clean result must never read as a clean machine. This is the sentence that decides whether running this makes somebody safer or less safe. - fn a_failed_look_is_not_reported_as_a_clean_one
"I could not look" and "I looked and found nothing" are opposite answers. Reporting the first as the second is the failure this whole crate exists to avoid making. - fn a_process_name_finds_its_entry_however_it_is_written
- fn the_informative_findings_come_first
The dedicated tools sort first, so a front end showing three shows the three worth showing. - fn looking_is_safe_wherever_this_runs
Looking at the real machine must not panic, hang, or change anything. - fn this_crate_never_reads_input_itself
This crate must not become the thing it warns about. Checked against the source, because the argument for not doing it is only as good as the code continuing not to.
crates/veilvoice-watch/src/lib.rs
- (module)
# veilvoice-watch Find out which applications are using your microphone and camera, right now. ## Why this belongs in a voice-privacy tool VeilVoice protects the audio you choose to send. This answers a different and more basic question: - (module)
*is something listening that you did not choose?* A de-identified voice on a call is worth very little if a second program is recording the raw microphone at the same time. Operating systems have grown indicators for this, the orange dot - (module)
and the taskbar icon, but they are small, easily missed, and tell you only that *something* is active, rarely what. This reports the process, its PID and how long it has held the device. ## What it can actually see, per platform Detection - (module)
is honest about its limits, because a monitor that quietly sees nothing is worse than no monitor at all, because it produces false confidence. [`support`] reports what the current platform can do before you rely on it. | Platform | - (module)
Microphone | Camera | How | |---|---|---|---| | Windows | ✅ | ✅ | The same `CapabilityAccessManager` records the OS privacy indicator uses | | Linux | ✅ | ✅ | `/proc/*/fd` handles open on `/dev/snd/pcm*` and `/dev/video*` | | macOS | ❌ | ❌ - (module)
| No public API exposes it; anything claiming otherwise on macOS is guessing | On Linux you see every process you have permission to inspect. Without root that means your own; other users' processes are invisible, and that is a kernel - (module)
permission boundary rather than something this crate can work around. # In plain words This tells you when something is using your microphone or camera. Not what it is doing with them -- just that a program has them open, and which - (module)
program. That is worth knowing before you start talking, and it is the kind of thing an operating system knows and does not always show you. It cannot see everything. Some ways of getting at a microphone do not go past the place this reads. - mod appctl
- mod capture
- mod drivers
- mod input
- mod privilege
- mod proc
- mod linux
- mod windows
- const VERSION
Crate version string, surfaced in the About panel. - enum DeviceKind
The kind of device being used. - impl fmt::Display for DeviceKind
- fn fmt
- struct DeviceUse
One application holding one device. - impl DeviceUse
- fn key
A stable key for comparing two scans, so an app is not reported as having stopped and restarted when nothing changed. - fn held_for
How long this application has held the device. - struct Support
What detection is possible here. - fn support
Report what this platform can detect. Check this before showing a monitor. Presenting an empty list as "nothing is listening" on a platform that cannot tell is a false assurance, and this is exactly the kind of tool where that matters. - enum Error
Everything that can go wrong here. - impl From<std::io::Error> for Error
- fn from
- impl fmt::Display for Error
- fn fmt
- impl std::error::Error for Error {}
- fn scan
Take one snapshot of what is currently using the microphone and camera. Returns an empty list when nothing is active, which is only meaningful if [`support`] says this platform can tell. - enum Change
A change between two scans. - impl Change
- fn alert
A one-line alert suitable for a notification or an overlay. - impl DeviceUse
- fn describe
`name (pid 1234)`, or just the name when there is no PID. - struct Monitor
Watches for changes between scans. Holds the previous snapshot and reports what appeared or disappeared, so a caller can raise an alert on transitions rather than repeating a list. - impl Monitor
- fn new
A monitor that has not yet seen anything. - fn current
The most recent snapshot. - fn poll
Scan, and report what changed since the previous call. The first call reports everything already active as `Started`. That is deliberate: something that was already recording when the monitor opened is precisely what the user needs to be - fn poll
told about. - fn diff
The comparison, split out so it can be tested without a real system. - mod tests
- fn use_of
- fn a_first_poll_reports_everything_already_active
- fn an_unchanged_list_reports_nothing
- fn starting_and_stopping_are_both_reported
- fn the_same_app_on_two_devices_is_tracked_separately
The same application on the microphone and on the camera is two separate facts, and losing one of them would hide a camera going live. - fn the_same_app_under_two_pids_is_tracked_separately
- fn alerts_name_the_app_the_device_and_the_pid
- fn support_is_reported_honestly_for_this_platform
- fn scanning_this_machine_does_not_panic
A real scan must never panic, whatever the machine looks like.
crates/veilvoice-watch/src/linux.rs
- (module)
Linux detection, via open file handles in `/proc`. # How it works A process using the microphone has a file descriptor open on an ALSA PCM capture node, `/dev/snd/pcmC0D0c`, where the trailing `c` means capture as opposed to `p` for - (module)
playback. A process using the camera has one open on `/dev/video*`. Walking `/proc/*/fd` and resolving the symlinks finds them, along with the PID and the process name, with no dependency and no daemon. Capture and playback are - (module)
distinguished deliberately. Treating every open `/dev/snd` handle as microphone use would report a music player as listening to you, and a monitor that cries wolf gets ignored, which is the worst possible outcome for this feature. # Sound - (module)
servers On most desktops PipeWire or PulseAudio owns the hardware, so the process holding the PCM node is the *server*, not the application behind it. That is reported honestly rather than hidden: the server appearing means something is - (module)
capturing, and where the client can be identified from the ALSA `/proc/asound` bookkeeping, it is named too. # The permission boundary `/proc/<pid>/fd` is readable only by the process owner and root. Without root you therefore see your own - (module)
processes; another user's are invisible. That is a kernel boundary, not a gap in this code, and [`crate::support`] says so rather than letting an empty list imply an empty machine. # In plain words Finds out which programs are using the - (module)
microphone or camera on Linux, by looking at which of them have the device open. That is exactly what the system already knows and nothing has to be installed to ask. It sees what your own account can see, so something running as another - (module)
user may not appear, and an empty list is not proof of a quiet machine. - fn scan
- fn classify
Decide whether an open handle means capture. Returns `None` for playback devices, control nodes and everything else, so a music player is never mistaken for something listening. - fn process_name
- fn started_at
When the process started, from the modification time of its `/proc` entry. This is the process start time, not the moment it opened the device. The kernel does not record the latter. It is reported as the best available answer rather than - fn started_at
omitted, since "running since" is still useful context. - fn approx_now_minus
Unused today; kept because a future PipeWire client lookup will want it. - mod tests
- fn capture_nodes_are_recognised
- fn playback_nodes_are_not_microphone_use
The distinction that keeps this feature trustworthy: playing music must never be reported as using the microphone. - fn video_nodes_are_camera_use
- fn ordinary_files_are_ignored
- fn scanning_does_not_fail_on_inaccessible_processes
Scanning must survive a machine where most of /proc is unreadable.
crates/veilvoice-watch/src/privilege.rs
- (module)
What privilege VeilVoice is running with, and what each level can actually see. # Three levels, and the third one this project does not ship * [`Level::User`] is VeilVoice as you. Everything the de-identifier does happens here, and nothing - (module)
about the engine, the container format or the app lock needs any more than this. * [`Level::Elevated`] is running as administrator or root. The monitoring features see further: processes belonging to other users, service accounts, and a - (module)
few registry and system paths that are unreadable otherwise. * **Kernel level** is not shipped, and not for want of trying. Loading a kernel driver on 64-bit Windows needs an EV code-signing certificate issued to a verified legal entity - (module)
plus Microsoft's attestation signing; macOS needs an Apple Developer ID and an entitlement granted case by case. Both are identity checks, and this project is published under a pseudonym on purpose. [`NO_KERNEL`] says so in the words a - (module)
front end should show. # This crate does not elevate anything It reports. It does not re-launch VeilVoice as administrator, install a service, or ask for a password. Those are changes to somebody's machine and they belong to the person - (module)
whose machine it is: [`Level::how_to_raise`] prints the command, and they type it. That is not caution for its own sake. A privacy tool that silently acquires administrator rights is a privacy tool nobody can reason about, and one that - (module)
installs a background service without being asked is worse, because a service outlives the window it was started from, and somebody who tried VeilVoice once should not find it still running next month. # Detection is a measurement, and it - (module)
can fail There is no `am_i_admin()` in the standard library and reaching the real answer is FFI on every platform here. So this asks a tool the system already ships, exactly as `veilvoice-watch` asks the registry, and when the tool cannot - (module)
be run, the answer is [`Level::Unknown`] rather than a guess. **`Unknown` is not `User`.** Reporting "not elevated" when the truth is "I could not tell" would understate what VeilVoice can see, which sounds like the safe direction and is - (module)
not: somebody would conclude a feature is unavailable and stop looking at its output. # In plain words Most of VeilVoice needs no special permissions at all, because changing a voice is something any program can do with your own account. - (module)
The parts that *watch* your machine can see more when VeilVoice is run as an administrator: programs belonging to other accounts, and a few places on the system that are otherwise off limits. This tells you which of those you are currently - (module)
getting, and how to run it the other way if you want to. It will not do that for you. Running as administrator, or installing a background service, is a change to your computer and it should be one you made on purpose. And there is a third - (module)
level, inside the operating system itself, that VeilVoice does not reach and says so rather than implying it does. - enum Level
What VeilVoice is running with. - impl Level
- fn label
A short name. - fn what_it_sees
What this level can see, and what it cannot. - fn how_to_raise
The command that would run VeilVoice at the higher level. Returned as text to print, never run. Elevating is a change to somebody's machine and it belongs to them. - fn is_full_view
Whether the monitoring features are seeing everything they could. - fn level
What VeilVoice is running with right now. Asks the system's own tool. Never elevates, never prompts, changes nothing. - fn windows_level
On Windows, ask `whoami /groups` for the administrators SID. `S-1-5-32-544` is the built-in Administrators group, and the well-known SID is used rather than the group's *name*, which is translated on a localised system and would make this - fn windows_level
answer "not elevated" on every machine that is not in English. - const CREATE_NO_WINDOW
- fn unix_level
Everywhere else, ask `id -u`. - fn service_installed
Whether a background service is installed. Always `false`, and the function exists so that a front end asking the question gets an answer rather than a missing feature: **VeilVoice does not install a service.** [`NO_SERVICE`] is why. - const NO_SERVICE
Why the opt-in service is not shipped, in the words to show. - const NO_KERNEL
What kernel level would need, and why it is not here. - const NEVER_ELEVATES
What this crate will not do, and why that is deliberate. - mod tests
- fn unknown_is_its_own_answer_and_not_the_unprivileged_one
"I could not tell" must never be reported as "not elevated". - fn every_level_explains_itself
Every level says what it can see, in enough words to be useful. - fn raising_privilege_is_something_the_reader_does
Elevating is a command to print, never an action to take. - fn the_limits_are_stated_rather_than_implied
The three scope notes each say the thing outright rather than hinting. - fn there_is_no_service_and_the_answer_says_so
No service is installed, and the function that says so is honest about it rather than absent. - fn asking_is_safe_wherever_this_runs
Asking the real machine must not panic, hang, prompt, or change anything. - fn the_windows_probe_uses_the_sid_rather_than_a_translated_name
The Windows probe keys on the well-known SID, not the group's name. The name is translated on a localised system, so matching "Administrators" would report every non-English machine as unprivileged -- a wrong answer that would only ever be - fn the_windows_probe_uses_the_sid_rather_than_a_translated_name
seen by people this project is unlikely to hear from.
crates/veilvoice-watch/src/proc.rs
- (module)
Which processes are running, per platform, and what that cannot tell you. # Why this is a crate rather than a module Two of this workspace's security features need the same answer: which programs are running. `capture` asks it about screen - (module)
recorders, `input` asks it about keyboard and mouse monitors, and the answer is one platform-specific listing with one set of limits. It began inside `capture` as a private module. Leaving it there and depending on that crate would mean a - (module)
keyboard-monitoring feature pulling in a table of screen recorders it will never look at, which is exactly what the design note in `ROADMAP.md` says these crates must not do: *each is a crate of its own, so that another project can depend - (module)
on one without taking all of them*. The alternative -- a second copy of the parser -- is worse: this project already pulled the checking out of the verifier's own code so the desktop application and the command line could not drift apart, - (module)
and the reasoning is the same here. # Linux reads files; the other two ask a tool On Linux every process publishes its own name at `/proc/<pid>/comm`, so the list is a directory walk and nothing is spawned. Windows and macOS have no such - (module)
file, and their native APIs are FFI -- `#![forbid(unsafe_code)]` holds here as it does everywhere else in the workspace, so this asks a tool the system already ships, exactly as `veilvoice-watch` asks the registry and `drivers` asks - (module)
`driverquery`. # What this can see, and what it cannot Processes belonging to the user running VeilVoice, and -- depending on the platform and the privileges -- usually not much more. A program running as another user or as a service may - (module)
not appear at all. It sees a program that is **running**. It does not see what that program is doing. Every caller has to phrase its findings accordingly, and [`SCOPE`] is the wording to show rather than an invitation to invent one. `comm` - (module)
on Linux is truncated to fifteen characters by the kernel, so any table matched against this must carry a name of fifteen characters or fewer for every program it expects to find there. A sixteen-character executable would otherwise stop - (module)
matching on one platform only, silently -- and the tables that do this keep their own tests for it, next to the table, because that is where somebody adds a row. # In plain words This asks your computer which programs are open right now, - (module)
in the way each operating system prefers to be asked. It is used by the parts of VeilVoice that warn you when something able to record your screen, or watch your typing, is running. Two honest limits. It can only see programs running as - (module)
you -- something hidden well enough, or running as the system, will not appear. And it only knows a program is **open**, never that it is actually recording or watching. Anything built on top of this has to say so in those words. - fn running
Every process name this build can see, lower-cased and without a path. The second value is anything that went wrong. A list that came back short because a tool failed is not a short list, and reporting the difference is the whole reason - fn running
this returns two things. - fn linux
Walk `/proc` and read each process's own name. - fn spawned
Ask the system's own process lister. - const CREATE_NO_WINDOW
- fn parse
Pull process names out of a listing. Handles both shapes with one function: `tasklist /FO CSV` quotes its first field, and `ps -Ao comm=` gives a bare path per line. Taking the first comma-separated field, stripping quotes, and then - fn parse
stripping any directory covers both, and one parser cannot drift from the other. - const SCOPE
What a reader has to be told, in the words to show them. Here rather than in each caller so that two features cannot describe the same limit two different ways. - mod tests
- const TASKLIST
Real `tasklist /FO CSV /NH` output, kept verbatim. - const PS
Real `ps -Ao comm=` output, kept verbatim. - fn a_windows_listing_gives_bare_lower_case_names
- fn a_unix_listing_has_its_directories_stripped
- fn nothing_and_whitespace_produce_nothing
- fn the_scope_note_says_what_an_empty_list_does_not_prove
The limit has to be stated outright, not hinted at. A caller that shows an empty list without this is telling somebody their machine is clean. - fn listing_this_machine_does_not_panic
Asking the real machine must not panic, and must say something when it cannot answer rather than returning a quiet empty list.
crates/veilvoice-watch/src/windows.rs
- (module)
Windows detection, via the Capability Access Manager. # Where the answer lives Windows records every application's use of the microphone and camera under `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ - (module)
ConsentStore\{microphone,webcam}`. Each application gets a subkey holding `LastUsedTimeStart` and `LastUsedTimeStop` as FILETIME values. The rule that makes this a *live* view rather than a history: an application is using the device right - (module)
now when its start time is non-zero and its stop time is zero. Windows clears the stop value on acquisition and writes it on release. This is the same bookkeeping that drives the taskbar privacy indicator, so what this reports is exactly - (module)
what the OS itself believes. Desktop programs live under a `NonPackaged` subkey, with their path encoded using `#` in place of each separator. Store apps appear directly, keyed by package family name. # One subprocess per capability, and - (module)
why that had to be fixed This originally asked `reg.exe` for the subkeys of the store, then spawned `reg.exe` **twice more for every application it found** to read the two timestamps. The count is `2 + 2n` per capability, where `n` is how - (module)
many applications have ever asked for that device. Measured on the machine this was found on: 7 packaged and 19 desktop applications for the microphone, 6 packaged for the camera, which is **68 process creations per scan**. One `reg.exe` - (module)
spawn there costs 6.6 ms at its fastest, so a scan cost **at least 449 ms**, and that is the warm-cache best case rather than the typical one. The desktop application called `scan` on the user-interface thread every two seconds. The result - (module)
was a window that froze repeatedly, which is what "runs extremely slow and freezes every couple of seconds" meant in the report. Nothing was leaking and nothing was deadlocked: it was doing a great deal of work in the worst possible place. - (module)
`reg query <key> /s` prints the **whole subtree**, keys and values together, in one go. So the scan is now two spawns, one per capability, and [`parse_consent_dump`] does the rest in memory. Measured on the same machine: 45 ms for the - (module)
whole scan, against 449 ms, and it no longer grows with the number of applications installed. The parser is a pure function over text, so it is tested against a captured dump on every platform rather than only on the one that can produce - (module)
it. The front end was fixed too, and separately: a scan that is fast is still not something to do on the thread that paints. # What it cannot give you A PID. Windows tracks this per *application*, not per process, so [`DeviceUse::pid`] is - (module)
`None` here. The trade is worth it: this sees packaged apps, background services and anything else the OS accounts for, which enumerating process handles would miss. # In plain words Finds out which applications are using the microphone or - (module)
camera on Windows. Windows keeps that in its own records of what has been granted access and when, and this reads them. It reports per application rather than per running program, because that is how Windows stores it, and the difference - (module)
is stated rather than papered over. - fn no_window
- const CREATE_NO_WINDOW
- const CONSENT_STORE
The full hive name, not the `HKCU` abbreviation: `reg query` echoes subkey paths back in long form, and the reply has to be matched against what was asked for. Querying with the short form silently matched nothing. - const FILETIME_TO_UNIX_SECS
FILETIME counts 100-nanosecond intervals from 1601-01-01; Unix time starts at 1970-01-01. This is the gap, in seconds. - fn reg_exe
The absolute path of `reg.exe`, or `None` if it is not where it should be. **Never `Command::new("reg")`.** Rust's `Command` resolves a bare program name through the platform search order, and on Windows that order includes the **current - fn reg_exe
working directory** ahead of most of `PATH`. Running `veilvoice watch` from a directory containing a file named `reg.exe` would have executed it, as the user, with no prompt. A downloads folder is enough. Naming the system directory - fn reg_exe
removes the search. Returning `None` rather than falling back to a search is deliberate: this module's failure mode is already "report nothing", and the crate says plainly that an empty list from a blind monitor is not good news. Running - fn reg_exe
an unknown `reg.exe` would be a far worse answer than no answer. - fn scan
- fn collect
Walk one capability's whole subtree, from a single `reg query /s`. - fn query_tree
One `reg query <key> /s`, printing the whole subtree. The single subprocess this module's scan costs per capability. Everything after it is text. - fn parse_consent_dump
Pull `(key, LastUsedTimeStart, LastUsedTimeStop)` out of a `/s` dump. The shape `reg query /s` prints is a key path on its own unindented line, then that key's values indented beneath it, then a blank line: ```text - fn parse_consent_dump
HKEY_CURRENT_USER\...\microphone\SomeApp LastUsedTimeStart REG_QWORD 0x1db5f3a1c2d4e5f LastUsedTimeStop REG_QWORD 0x0 ``` Only keys that carry a `LastUsedTimeStart` come back, which is exactly the set of application entries. Packaged ones - fn parse_consent_dump
sit directly under the store and desktop ones under `NonPackaged`, and this needs to know nothing about that distinction to find both. A key with a start time and no stop time is reported with a stop of zero: Windows clears the stop value - fn parse_consent_dump
on acquisition, so *absent* and *zero* mean the same thing here, and treating a missing line as "still running" is the reading that errs toward telling the user something is listening. - fn flush
- fn hex_value
`Name REG_QWORD 0x...` for one named value, as a number. Matched by name and then by taking the last field, rather than by splitting into three: the value's *name* is fixed here, so the only thing that can vary is how much whitespace `reg` - fn hex_value
used, and the last field is the datum whatever it did. - fn decode_path
Registry keys encode a path with `#` where a separator belongs. - fn friendly_name
The executable name, or the package family name for a Store app. - fn filetime_to_system
- mod tests
- fn every_subprocess_is_spawned_without_a_console_window
Every subprocess in this file must be spawned through `no_window`. This reads the file's own source rather than exercising the behaviour, because "no console window appeared" cannot be observed from a test -- which is precisely why the - fn every_subprocess_is_spawned_without_a_console_window
defect reached a release. A `Command::new` added later without the wrapper fails here rather than on a desktop. - fn registry_paths_are_decoded
- fn names_are_readable
- fn filetime_converts_to_a_sane_instant
- fn a_nonsense_filetime_does_not_panic
- fn the_registry_tool_is_resolved_absolutely_not_searched_for
Regression: the registry tool must be an absolute path in the system directory, never a bare name the OS searches for, because the Windows search order includes the current working directory, so a planted `reg.exe` would have been run as - fn the_registry_tool_is_resolved_absolutely_not_searched_for
the user. - fn a_decoy_in_the_working_directory_is_not_picked_up
And a decoy named `reg.exe` in the working directory must never become the tool we run. Written without a temp-directory crate so this crate stays dependency-free, dev-dependencies included. - const DUMP
Real `reg query <store> /s` output, kept verbatim. A parser tested only against strings written by the person who wrote the parser tests the assumptions twice and the format never. - fn one_dump_yields_every_application_entry
The whole subtree comes out of one dump, packaged and desktop alike, without the parser needing to know which is which. - fn container_keys_are_not_applications
The store key itself and the `NonPackaged` container carry no timestamps, so they must not become entries. - fn a_started_and_unstopped_entry_is_the_one_in_use
The rule the whole feature turns on: started and not yet stopped. - fn a_missing_stop_time_reads_as_still_running
Windows clears the stop value on acquisition, so a missing stop line and a zero mean the same thing -- and the reading that errs toward telling somebody a microphone is live is the right one. - fn a_similarly_named_value_is_not_confused_for_the_real_one
A value whose name merely starts with the one being looked for must not be mistaken for it. - fn nothing_and_rubbish_produce_nothing_rather_than_panicking
- fn an_unparseable_timestamp_is_dropped
A malformed number must drop that value rather than the whole entry silently becoming something else. - fn the_registry_tool_answers_for_a_key_that_always_exists
Regression, and the important one. `reg query` echoes subkey paths back under the **full** hive name, so asking with the `HKCU` abbreviation matched nothing and the monitor reported an empty machine no matter what was recording, which is - fn the_registry_tool_answers_for_a_key_that_always_exists
the worst possible failure for this feature, because it looks like good news. Asked against a key every Windows installation has, rather than against the consent store. The consent store is populated by *applications having asked for the - fn the_registry_tool_answers_for_a_key_that_always_exists
microphone*, and a headless CI runner with no audio hardware has legitimately never had one ask, so an empty result there means "nothing has used the microphone on this machine", which is not the same claim at all. Conflating the two made - fn the_registry_tool_answers_for_a_key_that_always_exists
CI fail on a machine where the code was working perfectly, which is its own kind of silently wrong. - fn the_consent_store_is_well_formed_when_this_machine_has_one
The consent store itself, when this machine has one. Asserts the *shape* of what comes back, never which applications are in it. Both of those are facts about the machine rather than about the code, and both have now failed CI for that - fn the_consent_store_is_well_formed_when_this_machine_has_one
reason: first the store was empty on a runner where nothing had ever asked for a microphone, and then, after that was allowed for, a runner had entries but no `NonPackaged` subkey, which only appears once a *desktop* application has asked. - fn the_consent_store_is_well_formed_when_this_machine_has_one
A test that fails depending on what software a machine has happened to run is not testing this crate. What is worth asserting is that every entry is a real subkey path under the full hive name, because that is what the parser is for. - fn the_consent_store_is_well_formed_when_this_machine_has_one
Whether the parser works at all is covered without any of this ambiguity by `the_registry_parser_reads_a_key_that_always_exists`. - fn a_scan_is_fast_enough_to_run_on_a_timer
The whole reason this was rewritten: one spawn per capability, not one per application. A scan must not take long enough to be noticed. Measured as a minimum over several runs, because a single sample on a machine that happened to be busy - fn a_scan_is_fast_enough_to_run_on_a_timer
says nothing. - fn scanning_the_real_consent_store_is_well_formed
Reading the real consent store must work on any Windows machine, and must not report an application that has already released the device.
Tests and fuzzing
crates/veilvoice-core/tests/hostile_audio.rs
- (module)
The engine against input that is not well-behaved audio. A realistic use of VeilVoice is "someone sent me a recording and I want to veil it before passing it on", so the input file is not necessarily friendly. A 32-bit-float WAV can - (module)
legally contain NaN and infinity, and `symphonia` decodes those faithfully rather than sanitising them. That matters more than it might sound, because the engine keeps *persistent* state: the accent neutraliser's long-term spectrum is an - (module)
exponential moving average, so a single non-finite sample folded into it never washes out. The audit found exactly that: one NaN, and every output sample for the rest of the session was NaN, silently. # In plain words Feeds the engine - (module)
audio designed to break it: silence, deafening noise, values that are not numbers, files that lie about their own length. The question is not whether it sounds good. It is whether anything can make the engine stop, hang, or quietly produce - (module)
silence while reporting success. A de-identifier that fails by outputting nothing is one somebody might not notice had failed. - fn speech
- fn engine
- fn a_single_nan_does_not_poison_the_engine_for_ever
The regression. One bad sample must not end the session. - fn every_flavour_of_non_finite_is_survived
- fn an_entirely_non_finite_buffer_is_handled
An input made entirely of poison must not panic, hang, or emit garbage. - fn digital_silence_stays_silent_and_leaves_the_state_usable
Silence must not drive the long-term averages anywhere strange, and must not come out as anything but silence. - fn pathological_but_legal_audio_is_handled
Full-scale square waves and DC are legal audio and unlike anything the engine was tuned on. # The bound, and why it is not 4.0 This asserts the output does not *run away*. It is not a claim about gain: a Nyquist-rate square wave is the - fn pathological_but_legal_audio_is_handled
worst case for any resampler, and the engine legitimately comes out above unity on one. That is contained where it matters -- `veilvoice_audio::io` clamps to full scale rather than letting a sample wrap, and has its own test for it. The - fn pathological_but_legal_audio_is_handled
bound was 4.0, which is a round number rather than a measured one, and it was about ten percent above what this machine produces. Measured over twenty runs each, on x86-64 Linux: DC 1.38 square 2.13 impulses 0.09 alternating 3.58 Windows - fn pathological_but_legal_audio_is_handled
produced 4.0456 for `alternating` and failed. Nothing was wrong with the engine: a different libm and different floating-point contraction move the last few percent, and the round number sat inside that margin. So the bound is 8.0, which - fn pathological_but_legal_audio_is_handled
is more than twice the worst measured and still an order of magnitude below anything that has actually diverged. Runaway in this engine has always meant thousands or a non-finite value, and the non-finite case is asserted separately just - fn pathological_but_legal_audio_is_handled
above. - fn hostile_input_is_survived_with_accent_neutralisation_off
The same, with accent neutralisation off, since that changes which paths in `spectral.rs` run. - fn a_non_finite_sample_rate_is_refused_rather_than_built
A non-finite sample rate must be refused, not built. Before the fix, `NaN` passed validation (because `NaN < 8_000.0` is false), the engine built happily, and **every output sample was `NaN`**, silently and for ever. `INFINITY` was worse - fn a_non_finite_sample_rate_is_refused_rather_than_built
in a different way: it reached an `as usize` saturation followed by an addition, and panicked with an arithmetic overflow in every build with overflow checks on. - fn an_absurd_sample_rate_is_refused_rather_than_allocated
A sample rate a file can legally declare, but no hardware produces, sizes the reverb and chorus delay lines. `u32::MAX` asked for about two gigabytes of buffers from a four-kilobyte file, and a failed allocation aborts. - fn an_absurd_frame_size_is_refused
`frame_size` had no upper bound at all, and it sizes every internal buffer and the FFT plan. - fn non_finite_parameters_are_refused_and_wild_ones_are_clamped
Every other float is either clamped to something meaningful or refused for being `NaN`. None of them may reach the engine unexamined. - type Poison
- fn every_configuration_that_builds_produces_finite_audio
The whole point, checked end to end: a configuration that survives validation must produce finite audio from finite audio.
crates/veilvoice-crypto/tests/parser_fuzz.rs
- (module)
Randomised robustness testing for the two parsers that read untrusted input. # What is being defended [`container::Header::parse`] reads a file somebody sent you. [`lock::AppLock::parse`] reads the app-lock file, which is worse: it is - (module)
parsed **before anyone has authenticated anything**, so it is the first bytes the program touches on a locked machine. For a parser in a security tool the bar is not "usually returns the right answer". It is: 1. **Never panic.** A panic on - (module)
hostile input is a denial of service, and in a `panic = "abort"` release profile it is the whole process. 2. **Never hang.** Every loop must be bounded by the input, not by a length field the input controls. 3. **Never report success for - (module)
something it did not fully understand.** An `Ok` must come with offsets that are actually inside the buffer. # Why this and not `cargo fuzz` `cargo fuzz` needs nightly and libFuzzer, which is a poor fit for a project that pins a stable - (module)
toolchain and wants every check runnable by anyone who cloned it. This is a deterministic campaign instead: a seeded PRNG, so a failure is reproducible from its seed rather than being a story about a run nobody can repeat, and - (module)
structure-aware mutation, so the bytes spend their time near the interesting boundaries rather than being rejected at the magic number. It is **not** a substitute for a coverage-guided fuzzer and `docs/AUDIT.md` does not claim it is. It is - (module)
the campaign that can actually be run on every commit, on every platform, by everybody. Set `VEILVOICE_FUZZ_ROUNDS` to run it longer than the default. # In plain words Throws malformed and deliberately hostile encrypted files at the code - (module)
that reads them, in bulk. Reading a file somebody else made is where most security problems live. Every one of these has to be refused with a reason: never accepted, and never able to bring the program down. - struct Rng
xorshift32. Deterministic, seeded, and twenty lines. A dependency here would be a dependency in the audit surface for no benefit. - impl Rng
- fn new
- fn next_u32
- fn below
- fn byte
- fn rounds
- fn mutate
Mutate `seed_bytes` in one of the ways that break parsers in practice. Deliberately biased towards length fields and boundaries: uniformly random bytes almost never get past a magic number, so a campaign made only of them tests the first - fn mutate
four bytes very thoroughly and nothing else at all. - fn weak
- fn cheap
Whether it is worth actually running the KDF for these parameters. Mutation cheerfully produces costs that are *valid* and enormous: a 4 GiB-and-three-passes Argon2 is a perfectly legal header. Executing those turns a campaign into a - fn cheap
benchmark, so the KDF-running half of each round is limited to cheap parameters and the expensive ones are covered by the unit tests in `kdf.rs` instead. Worth stating plainly, because it is a real property and not just a test convenience: - fn cheap
an attacker who hands you a container **can** make opening it slow, because the cost travels with the file and that is the whole point of the design. Slow is not the same as crashing, the user chose to open that file, and they can stop - fn cheap
waiting. The crash was the bug; the delay is the documented trade. - fn the_container_header_parser_survives_hostile_input
- fn the_app_lock_parser_survives_hostile_input
- fn both_parsers_survive_pure_noise
Pure noise, with no valid seed to start from. Cheap, and it covers the "someone pointed it at an unrelated file" case that structured mutation never reaches. - fn every_length_around_a_boundary_is_handled
The lengths a parser gets wrong are the ones either side of a boundary, and a random campaign hits them only by luck. This walks them deliberately. - fn the_header_the_coverage_guided_campaign_found_is_refused
**F-82.** The one input the coverage-guided campaign found, kept here. `fuzz/README.md` says these two campaigns are different things and both are kept, and this is what that means in practice: the nightly campaign found an input, and the - fn the_header_the_coverage_guided_campaign_found_is_refused
input lives here, where it is checked on every commit on every platform by anybody who cloned the repository. The bytes are a whole `.veil` header declaring `m_cost` 65535, `t_cost` 4521984 and `p_cost` 1280. Nothing about them overflows, - fn the_header_the_coverage_guided_campaign_found_is_refused
nothing allocates beyond the memory ceiling, and `m_cost >= p_cost * 8` holds, so every check this file's other tests make passed. The only thing wrong with them is that the derivation would not finish: about 74 hours, measured in a - fn the_header_the_coverage_guided_campaign_found_is_refused
release build before `MAX_T_COST` existed. Written out as bytes rather than built from `KdfParams`, because the point is the file, and a test that constructs the parameters would keep passing if the header ever stopped putting them where - fn the_header_the_coverage_guided_campaign_found_is_refused
it puts them.
crates/veilvoice-crypto/tests/timing.rs
- (module)
Timing measurement of the password paths. `docs/AUDIT.md` listed this as outstanding: Argon2id is inherently constant-ish, but nobody had measured the code *around* it. The question is whether the time an attempt takes leaks anything about - (module)
the password, classically, whether a byte-by-byte comparison returns early and turns "how long did that take" into "how many characters were right". # These are ignored by default, and that is deliberate A timing test on a shared CI runner - (module)
measures the neighbours, not the code. Run them on a quiet machine and read the numbers: ```text cargo test -p veilvoice-crypto --release --test timing -- --ignored --nocapture ``` The thresholds below are loose on purpose. They are there - (module)
to catch a *catastrophic* regression, such as someone replacing a constant-time comparison with `==`, which shows up as a difference of orders of magnitude, not to certify a bound in nanoseconds, which this method cannot honestly do. # In - (module)
plain words Measures whether checking a password takes a different amount of time depending on how wrong it is. If it did, somebody could work out a password one character at a time by watching the clock rather than by guessing. This runs - (module)
the comparison many times and checks that the timing says nothing. - fn params
Cheap parameters on purpose: a fast KDF makes the *comparison* a larger share of the total, so a non-constant-time one is easier to see. Measuring with the 256 MiB default would bury any leak under Argon2's own noise, which would be a - fn params
comfortable way to prove nothing. - const SAMPLES
- struct Stats
What a run of samples looked like. The headline figure is the **minimum**, not the mean or the median. Timing noise on a real machine is one-sided: a scheduler, an interrupt or a cache miss can only ever make a sample slower, never faster. - struct Stats
The fastest sample is therefore the closest estimate of the work the code actually does, and it is far more stable across runs than any average, which on the first pass of these tests moved the "ratio" by 50% purely from Windows scheduling. - fn summarise
- fn show
- fn time_it
- fn time_each
Time one call each against a batch of values prepared *outside* the clock. Needed wherever a single measurement would otherwise have to include its own setup: the app lock counts failures, so timing repeated wrong guesses on one lock - fn time_each
either trips the rate limiter or has to reset it inside the timed region, which is how the first version of this test ended up comparing two derivations against one and reporting a meaningless 2× "leak". - fn ratio
- fn opening_a_container_does_not_leak_how_much_of_the_password_was_right
- fn the_app_lock_takes_the_same_time_whether_or_not_the_password_is_right
- fn a_rate_limited_attempt_is_visibly_cheaper_and_that_is_intended
The rate limiter returns **before** touching the KDF, so a locked-out attempt is obviously faster than a real one. That is deliberate, because the point of a rate limit is to refuse to spend the CPU, and it leaks only the state the UI - fn a_rate_limited_attempt_is_visibly_cheaper_and_that_is_intended
displays on screen anyway. Measured so the trade is a number rather than an assumption.
crates/veilvoice-meta/tests/wav_fuzz.rs
- (module)
Randomised robustness testing for the RIFF chunk walker. `clean_wav_bytes` exists because `lofty` cannot remove ID3v2 from a WAV, so VeilVoice walks the chunk list itself. That means it parses a container somebody else produced, with every - (module)
length field under their control, and the textbook setting for an overrun or a loop that never ends. The properties asserted, for any input at all: 1. **It returns.** No panic, and no unbounded loop: every iteration must advance `pos`, - (module)
whatever the chunk sizes claim. 2. **A success is a valid WAV.** If it hands back bytes, those bytes must parse as RIFF/WAVE with a length field that matches what was written, it is not permitted to emit something the next tool chokes on. - (module)
3. **It never invents audio.** Output length is bounded by input length. Set `VEILVOICE_FUZZ_ROUNDS` to run it longer than the default. # In plain words Throws damaged and hostile WAV files at the metadata stripper. A WAV file is a series - (module)
of labelled sections, and a section that lies about its own size is the classic way to make a program read past the end of what it was given. Every malformed file here has to be refused rather than trusted. - struct Rng
- impl Rng
- fn new
- fn next_u32
- fn below
- fn byte
- fn rounds
- fn seed_wav
A minimal but genuinely valid WAV to mutate from. - fn mutate
Mutations aimed at the chunk walker specifically: the interesting bytes are the 32-bit sizes, so a fifth of the rounds corrupt one deliberately. - fn check_output
- fn the_chunk_walker_survives_hostile_input
- fn the_realistic_policy_survives_hostile_input
`Policy::Realistic` appends a chunk of its own, which is the one path that can make the output larger than the input. - fn pure_noise_is_rejected_or_handled
- fn cleaning_is_idempotent
A cleaned file must clean again to itself. If a second pass changes anything, the first pass left something behind. - fn every_truncation_of_a_valid_file_is_handled
Every truncation of a valid file, which is what a partial download or an interrupted recording actually looks like. - fn a_riff_size_of_u32_max_does_not_overflow_the_length_arithmetic
The RIFF size field is a `u32` widened to `usize` and then had 8 added to it. On a 32-bit target, and VeilVoice ships an ARMv7 build, `u32::MAX + 8` overflows `usize` and panics under overflow checks. A 64-bit host cannot reach that, so no - fn a_riff_size_of_u32_max_does_not_overflow_the_length_arithmetic
amount of fuzzing *here* would have found it; it is asserted anyway so the saturating arithmetic is not quietly removed later. - fn zero_sized_chunks_do_not_stall_the_walker
A chunk that declares a size of zero must still advance the walker. If it did not, this would never return, which is why it is asserted rather than assumed.
crates/veilvoice-verify/tests/release_manifest.rs
- (module)
**Roadmap item 97.** The release job's contents list, read back by the parser that will read it for real. # Why this test exists `CONTENTS.sha256` is the newest link in the chain a verifier follows: ```text SHA256SUMS.asc -> SHA256SUMS -> - (module)
CONTENTS.sha256 -> each file on disk ``` Every other link has tests on both sides of it. This one had a writer that ran once a release, in a job nobody can run on a laptop, and a reader with unit tests over hand-written samples. Two halves - (module)
that were never introduced to each other, and the failure mode is the worst shape a verifier has: a manifest the reader parses happily and whose paths do not line up with what is actually on disk, so every file reads as `MISSING` and a - (module)
genuine release is refused. Or worse, paths that line up by accident on one platform. So this builds a release the way the release job does, runs the real generator over it, and checks the real reader against the real extracted files. - (module)
Nothing here is a stand-in. # Why it is allowed to skip It needs Python, and the `test` job does not install one. Every runner this project uses has one anyway, so the test runs on all three in practice; on a machine without one it returns - (module)
rather than failing, because "Python is not installed here" is a fact about the machine and not a defect in the release job. The generator is also run by the release workflow itself, which is where its absence would actually matter and - (module)
where it cannot be absent. - fn repository
The repository root, from this test's own location. - fn python
A Python to run, if this machine has one. - fn room
Somewhere to build a release, removed by the caller. - fn stage
Build one release directory, the shape the release job stages. A program, a second program, a README and a document in a subdirectory: enough that a generator which forgot to recurse, or which wrote paths relative to the wrong place, - fn stage
produces something this notices. - fn have
Whether a program is on this machine at all. - fn what_the_release_job_writes_is_what_the_verifier_reads
The whole seam: stage, archive, generate, parse, extract, check. The one test in this file, deliberately. Splitting it would mean staging a release three times to assert three things about the same run, and the property being tested is - fn what_the_release_job_writes_is_what_the_verifier_reads
that the whole sequence agrees with itself.
fuzz/fuzz_targets/container_header.rs
- (module)
The `.veil` container header, coverage-guided. This is the parser that reads a file somebody sent you. The properties are the same three the deterministic campaign in `crates/veilvoice-crypto/tests/parser_fuzz.rs` asserts -- never panic, - (module)
never hang, never claim success for something it did not fully understand -- but explored by feedback rather than by construction.
fuzz/fuzz_targets/guard_manifest.rs
- (module)
The integrity manifest parser, coverage-guided. A text format rather than a packed one, which removes a whole class of length-field bug and introduces a different one: it is split on a delimiter, indexed by byte offset, and sliced for - (module)
display. `Change::describe` takes `&digest[..8]`, and slicing a `String` at a byte offset that is not a character boundary panics. The manifest is normally the user's own record, but "normally" is not a security property: `veilvoice guard - (module)
check` will read whichever file is at the path, and a record is exactly the kind of thing somebody hands you.
fuzz/fuzz_targets/hybrid_keys.rs
- (module)
Key and encapsulation decoding, coverage-guided. `PublicKey::from_bytes` reads a `.pub` file, which is a file somebody sent you by definition -- the whole point of a public key is that it arrived from elsewhere. Behind it sit `ml-kem` and - (module)
`x25519-dalek`, so this target is as much about those dependencies handling malformed encodings as about the wrapper around them.
fuzz/fuzz_targets/lock_file.rs
- (module)
The app-lock file, coverage-guided. Worse than the container in one specific way: this file is parsed **before anyone has authenticated anything**. It is the first bytes the program touches on a locked machine, so anything that can write - (module)
it gets a free shot at the parser -- and at the Argon2 cost parameters it carries, which is exactly where F-2 and F-3 were.
fuzz/fuzz_targets/release_contents.rs
- (module)
The release contents list parser, coverage-guided. **Roadmap item 97.** `CONTENTS.sha256` lists every file inside every release archive with its SHA-256, and a verifier reads it to decide which paths on disk to open and what to compare - (module)
them against. It is covered by the signed `SHA256SUMS`, and every caller is told to check that before parsing. "Every caller is told to" is not a property of the code. A caller can get the order wrong, a future front end can be written by - (module)
somebody who did not read the note, and the consequence would be a file of somebody else's choosing deciding which paths a verifier opens. So the parser is fuzzed as though nothing had checked it, which is the only assumption that stays - (module)
true. What this is looking for, specifically: a path that escapes the release directory, a panic on a line that is not what the release job writes, and a digest that is accepted without being one.
fuzz/fuzz_targets/wav_chunks.rs
- (module)
The RIFF chunk walker in `veilvoice-meta`, coverage-guided. This one walks a flat list of chunks whose sizes come from the file, so its termination depends on values an attacker chooses -- the shape F-4 had. The interesting property is not - (module)
only "does not crash": a *cleaned* WAV is handed back to the user as safe, so it has to actually be a WAV, and its RIFF size field has to describe the bytes that were written. A cleaner that returns a corrupt file has failed even though it - (module)
did not panic.
fuzz/fuzz_targets/wav_preflight.rs
- (module)
The WAV pre-flight in `veilvoice-audio`, coverage-guided. This check exists because a WAV declaring a sample rate of zero makes `symphonia` panic inside its own probe, before this project sees anything it could inspect -- and under the - (module)
shipped `panic = "abort"` profile that is the process ending, not an error a caller can handle. It is therefore a parser standing in front of a crash, which makes its own robustness load-bearing: it walks chunk sizes taken from the file, - (module)
so it has exactly the termination-depends-on-a-length-field shape that F-4 had. If this ever hangs or panics it has become the bug it was written to prevent.
Website
website/404.html
- 404
That page is not here. It may have been renamed, or the link that sent you was wrong. Nothing was lost and nothing broke — this is a static site, so a missing page is only a missing page. Back to the front page · Search everything - 404
VeilVoice · GPL-3.0-or-later · source · home Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
website/crypto.html
- Security and cryptography
The primitives, the threat model, and what the app lock is and is not. This section is also part of the front page , where it sits in context with the rest. - Why the transform cannot be undone
Three independent mechanisms, each individually lossy. Reversing the output means defeating all three. Mechanism What it destroys Phase discard Every frame's measured phase is thrown away and a synthetic one generated. Phase encodes the - Why the transform cannot be undone
exact waveform and the speaker's micro-timing. It is never stored, and infinitely many waveforms share any given magnitude spectrogram. Many-to-one normalisation Pitch register, vocal-tract length and long-term spectral tilt are each - Why the transform cannot be undone
collapsed onto a single canonical value. A whole population of speakers maps to the same output, so there is nothing to invert. CSPRNG modulation The residual transform changes every frame from a ChaCha20 stream whose seed comes from the - Why the transform cannot be undone
OS CSPRNG, lives only in page-locked RAM, and is zeroized on drop. There is no fixed transform to undo. Rolling seed Every two seconds by default the stream draws a fresh seed from its own output and restarts. ChaCha20 does not run - Why the transform cannot be undone
backwards, so each roll permanently seals off the audio before it: a long recording is a chain of short streams, not one. Configurable, and inaudible: parameters glide across a roll and phase offsets ease over half a second. - At-rest encryption
Layer Primitive Why Password → key Argon2id (RFC 9106) Memory-hard, so GPU and ASIC cracking gains little. Cost parameters travel with the file so old files still open. Public-key X25519 + ML-KEM-768 hybrid An attacker must break both . - At-rest encryption
Guards against harvest-now-decrypt-later: a recording stored today may be attacked decades from now. Payload XChaCha20-Poly1305 192-bit random nonces remove the counter-management failure mode entirely. Header Authenticated as associated - At-rest encryption
data An attacker cannot downgrade the stored KDF cost to make cracking cheap, because tampering makes decryption fail. Keys in memory Page-locked, zeroized, constant-time Keys stay out of the swap file and are wiped on drop. Comparison - At-rest encryption
leaks no timing. Stated plainly: page-locking keeps keys off disk, not away from an attacker who can already read this process's memory, and hibernation writes RAM to disk wholesale and defeats it. A passphrase still sitting in a text - At-rest encryption
field has not reached that protection yet, which is why it is wiped the moment it is used. Recordings are sealed in memory and written once. An encrypted recording never exists on disk in the clear, because a plaintext file that is written - At-rest encryption
and then deleted cannot be reliably taken back on flash storage. - The app lock, and exactly what it is worth
VeilVoice can sit behind a password of its own, separate from the one that encrypts recordings, so that opening the app is not the same act as unsealing everything it has written. What it is What that means An Argon2id verifier, not a key - The app lock, and exactly what it is worth
A password hash is stored and compared in constant time. It encrypts nothing, because there is nothing local it could usefully encrypt. Rate limited, and the limit persists Three attempts are free; then the wait doubles from 5 s to a - The app lock, and exactly what it is worth
15-minute cap. The count is written to disk after every attempt, so killing the app does not hand an attacker a fresh budget. Domain separated Type the same passphrase in both places and you still do not end up with two copies of one - The app lock, and exactly what it is worth
value. Not tamper-proof, and it cannot be. A local application has nowhere to hide a secret from the machine it runs on. Anyone who can write to your files can delete the lock; anyone holding the disk can edit the attempt counter, move the - The app lock, and exactly what it is worth
clock, or attack the stored hash offline. This protects against casual access , meaning the person who sits down at your unlocked session. If the disk is the threat, the answers are full-volume encryption and the at-rest encryption above, - The app lock, and exactly what it is worth
not this. - Libre, and what that buys you
GPL-3.0-or-later. You may use, study, modify and redistribute it; derivatives stay free under the same terms. No unsafe anywhere. Every crate carries #![forbid(unsafe_code)] , including the page-locking path. Whole classes of - Libre, and what that buys you
memory-corruption bugs are impossible by construction. Offline by construction. No telemetry and no accounts, and CI fails the build if an HTTP client so much as enters the dependency graph. One thing reaches the network and only when you - Libre, and what that buys you
press it: the desktop app's check for updates button, which runs then and at no other time, sends nothing about you, and downloads nothing. It borrows your system's own transfer tool, exactly as the release verifier does. Reproducible. - Libre, and what that buys you
Pinned toolchain, committed lockfile, path-remapped builds. Rebuild a release and confirm it matches, byte for byte. Artwork generated from source. Every icon and the banner come out of a readable script, not a committed binary blob. This - Libre, and what that buys you
website too. No CDN, no web fonts, no analytics, no cookies. The only third-party request is the optional repository panel, and it is a button you press. Audited by tilas01 , the author, who wrote and reviewed it. Be clear about what that - Libre, and what that buys you
is worth: a maintainer audit catches what the author can see, and no external firm or independent researcher has reviewed this code . The cryptography uses standard, well-reviewed primitives rather than anything invented here, and the - Libre, and what that buys you
de-identification argument is verifiable by reading two source files. Until an independent review exists, the source is the strongest verification available to you. VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript - Libre, and what that buys you
version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is usually provided by VB-CABLE, which is proprietary donationware and is not bundled: install it separately if you want it. Written and - Libre, and what that buys you
maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
website/css/main.css
- line 1
/* SPDX-License-Identifier: GPL-3.0-or-later * * One stylesheet, no framework, no web fonts, no third-party requests of any * kind. A site for a privacy tool has no business loading a CDN: every remote * asset is a request that tells - line 1
someone else you were here. * * Typography is monospace throughout, from the fonts the reader already has. * * In plain words * * This is what the site looks like: the spacing, the type, the buttons, the * diagrams and the animations. * * - line 1
There is no framework and no font downloaded from anywhere else. Every * remote thing a page loads is a request that tells somebody else you were * here, and a site for a privacy tool has no business making them. The type * you see is one - line 1
you already have. */ *, *::before, *::after { box-sizing: border-box; } html { scroll-behavior: smooth; /* Tell the browser this page is dark, so *native* controls follow. * * Without it, the `<select>` for the colour scheme, the - line 1
`<progress>` bar in * the verifier and the file input all render in the platform's light styling * on a dark page -- a white dropdown list on a near-black background. It is * the one part of the page CSS cannot reach, and one declaration - line 1
fixes it on * every engine. The value is set per theme in themes.css, beside the colours * it has to agree with, so the two cannot drift apart; this is the default * for the moment before a theme is applied. */ color-scheme: dark; /* iOS - line 1
inflates font sizes in landscape unless told not to. On a monospace * layout with fixed column widths that silently breaks the alignment the - line 41
* whole design depends on. */ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation-duration: 0.01ms !important; - line 41
animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } body { margin: 0; background: var(--bg); color: var(--fg); font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", "DejaVu Sans Mono", - line 41
Consolas, ui-monospace, monospace; font-size: 15px; line-height: 1.65; -webkit-font-smoothing: antialiased; } a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; } a:hover { border-bottom-color: - line 41
var(--accent); } /* `:focus-visible` arrived in Safari 15.4. Before that this selector is invalid * and the *whole rule* is dropped, so a keyboard user on an older iPhone got no * focus ring at all -- the page became unnavigable without a - line 41
pointer. The plain * `:focus` rule below is the fallback, and is written first so that * `:focus-visible` overrides it where it is understood. They are separate rules * on purpose: one invalid selector must not take the other down with it. - line 41
*/ a:focus, button:focus, select:focus, input:focus, summary:focus { outline: 2px solid var(--accent); - line 81
outline-offset: 2px; } /* Where `:focus-visible` is supported, drop the ring for pointer clicks only. */ a:focus:not(:focus-visible), button:focus:not(:focus-visible), select:focus:not(:focus-visible), input:focus:not(:focus-visible), - line 81
summary:focus:not(:focus-visible) { outline: none; } a:focus-visible, button:focus-visible, select:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .wrap { max-width: - line 81
900px; margin: 0 auto; padding: 0 20px; } /* ---------- header ---------- */ header.top { position: sticky; top: 0; z-index: 20; /* Two declarations, and the order matters. * * `color-mix` arrived in Safari 16.2, Firefox 113 and Chrome 111 - line 81
-- all 2023. * An engine older than that treats the whole declaration as invalid and * *drops it*, and with nothing before it the header had no background at all: * a sticky bar with the page scrolling visibly through the text. The solid * - line 81
colour below is the fallback, and browsers that understand `color-mix` * simply take the second declaration. */ background: var(--bg); background: color-mix(in srgb, var(--bg) 88%, transparent); /* Safari only unprefixed `backdrop-filter` - line 81
in version 18 (late 2024). Every * iPhone on iOS 17 or earlier needs the `-webkit-` form, and without it the - line 121
* translucent header simply has no blur -- which, combined with the * `color-mix` fallback above, is why that fallback is opaque rather than * translucent. */ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-bottom: - line 121
1px solid var(--border); /* On a notched iPhone in landscape the header would otherwise run under the * rounded corner and the sensor housing. `env()` is zero everywhere it does * not apply, so this costs nothing on a desktop. */ - line 121
padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); } .top .wrap { display: flex; align-items: center; gap: 16px; padding-top: 10px; padding-bottom: 10px; flex-wrap: wrap; } .brand { display: flex; - line 121
align-items: center; gap: 10px; font-weight: 700; letter-spacing: 0.06em; } /* `image-rendering: pixelated` is for pixel art being *enlarged*. Every image * on this site is shrunk instead -- the icon is a 32 px source drawn at 26 px -- * - line 121
and nearest-neighbour shrinking does not blend rows, it deletes them. See the * note on `.hero img.banner`, where deleting rows made the claims unreadable. */ .brand img { width: 26px; height: 26px; } .brand a { color: var(--fg); border: - line 121
0; } nav.links { display: flex; gap: 14px; flex-wrap: wrap; } nav.links a { color: var(--muted); font-size: 13px; /* WCAG 2.5.8 asks for a 24 by 24 CSS pixel target. These links were 22 px * tall, which is small enough to mis-tap on a - line 121
phone -- and mis-tapping in a * nine-item row means landing on the wrong section rather than nothing. The * padding is vertical only, so the row's visual rhythm is unchanged. */ display: inline-flex; - line 161
align-items: center; min-height: 24px; } nav.links a:hover { color: var(--accent); } /* ---------- header on a phone ---------- */ /* * Nine navigation links at 375 px wrap onto four rows, and because the header * is `position: sticky` - line 161
that cost 165 px -- a fifth of an iPhone screen, * permanently, on every scroll position. Measured, not guessed. * * The answer is to stop wrapping and scroll the row sideways instead: one line, * every link still reachable, and the header - line 161
back to a single row. The * scrollbar is hidden because on a touch device there is nothing to grab * anyway, and `-webkit-overflow-scrolling` keeps the momentum that iOS * otherwise drops on a nested scroller. */ @media (max-width: 760px) - line 161
{ .top .wrap { /* Two rows, deliberately. The brand and the theme picker alone are about * 290 px of a 375 px screen, so trying to fit the navigation beside them * left it 43 px wide -- a scroller showing most of one link, which is worse * - line 161
than either wrapping or scrolling. Row one is the identity and the * control; row two is the navigation, at full width. */ flex-wrap: wrap; gap: 8px 10px; padding-top: 8px; padding-bottom: 8px; } .brand { flex: none; } .brand a { - line 161
font-size: 14px; } .theme-pick { flex: none; margin-left: auto; max-width: 46vw; } nav.links { /* Force the row break, then fill it. * * `order` is needed as well as the `100%` basis: the navigation comes * *before* the theme picker in the - line 161
source (which is the right reading * order), so a full-width nav in source order pushed the picker onto a * third row and the header back up to 113 px. Ordering it last puts the - line 201
* brand and the picker together on row one and the nav alone on row two, * without changing the document order a screen reader follows. */ order: 3; flex: 1 0 100%; margin-left: 0; flex-wrap: nowrap; /* `min-width: 0` is what lets a flex - line 201
item become a scroll container at * all: the default `min-width: auto` makes it refuse to be narrower than * its own contents, so it would push the page sideways instead. */ min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; - line 201
-webkit-overflow-scrolling: touch; /* No visible scrollbar: there is nothing to grab on a touch screen, and a * bar under a 24 px row of links costs more than it explains. */ scrollbar-width: none; -ms-overflow-style: none; padding-bottom: - line 201
2px; } nav.links::-webkit-scrollbar { display: none; } nav.links a { white-space: nowrap; } } /* * Anchor targets must clear the sticky header. * * Without this, following `#download` from the navigation scrolls the heading * to y=0 -- - line 201
which is *underneath* the header, so the reader lands on a section * whose title they cannot see. `scroll-margin-top` is the property that exists * for exactly this and needs no JavaScript. * * The offset is a variable rather than a number - line 201
because the header is not one * height. Measured in a browser it is 133px at desktop widths, 171px where the * navigation wraps to three rows, 115px on a phone and 81px on the reference * pages, and this rule used to say 90px for all of - line 201
them. `js/teleport.js` * measures the header and sets `--anchor-offset` from it; the value below is * what applies for the frame before that runs, and on the pages that carry no * scripts. * * Every id, not a list of element names. The old - line 201
list was `section`, `h2` and - line 241
* `h3`, which left every `h4` on the releases page and every roadmap entry * with no offset at all. */ :root { --anchor-offset: 147px; } [id] { scroll-margin-top: var(--anchor-offset); } /* * Where you landed, for a moment. * * A fragment - line 241
jump moves the page without moving anything the eye can follow, * so on a dense page it is not obvious which heading was asked for. This * outlines it and lets the outline fade. * * The outline is a plain declaration and the fade is an - line 241
animation, which is * deliberate: under `prefers-reduced-motion` the rule at the top of this file * collapses the animation and the outline is simply there for the two seconds * the class is applied. Nobody loses the cue for having asked - line 241
for less * movement. */ .landed { outline: 2px solid var(--accent); outline-offset: 6px; border-radius: 4px; animation: landed-fade 2s ease-out 1 forwards; } @keyframes landed-fade { 0% { outline-color: var(--accent); } 70% { - line 241
outline-color: var(--accent); } 100% { outline-color: transparent; } } .theme-pick { background: var(--bg-soft); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; font: inherit; - line 281
font-size: 12px; cursor: pointer; } /* ---------- hero ---------- */ /* The gutter is 20 px, the same as `.wrap`, because the hero is the one * section that is not inside one. On a desktop nothing changes -- the banner * and the tagline - line 281
are capped at 860 and 640 px and centre themselves long * before the padding matters. On a 390 px phone it was the difference between * a paragraph with a margin and one whose first and last characters touched * both edges of the screen. - line 281
Seen, not deduced: the layout measured clean * because nothing overflowed; it simply had no room around it. */ .hero { padding: 54px 20px 34px; text-align: center; } /* The `<picture>` these rules sized is gone -- the hero banner is drawn - line 281
in CSS * now, further down this file. What is worth keeping from them is the finding, * because it applies to every image this site scales down and the next one will * hit it too: * * `image-rendering: pixelated` is for pixel art being - line 281
*enlarged*. banner.png is * 1280 px wide and was drawn at about 343 on a phone, a 3.7x reduction. * Nearest-neighbour sampling keeps roughly one row in four and drops the rest, * so the one- and two-pixel strokes of the banner's own text - line 281
lost more than * half their rows: "THE VOICEPRINT IS DESTROYED. THE WORDS STAY READABLE." * rendered as "TIE VOICEPRIN IS DESTOYED. THE WORDS STAY EDGRE." The first * thing a phone reader saw was this project's own claims, illegible. That - line 281
was * finding F-37, and it is the reason the banner is text today. */ @keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } .tagline { color: var(--muted); margin: 22px auto 0; max-width: - line 281
640px; } .tagline strong { color: var(--fg); } /* Staggered entrance. Each element declares its own delay via --i. */ .stagger > * { animation: rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) both; - line 321
animation-delay: calc(var(--i, 0) * 70ms); } /* ---------- the banner, drawn in CSS ---------- */ /* * The hero banner used to be `assets/banner-animated.png` behind a `<picture>`. * It is now live text with a CSS animation over it, and - line 321
the picture has moved * to the README, where a hundred different clients have to draw it and only a * GIF reaches all of them. * * Three things are better as text than as pixels, and all three were already * written down elsewhere in this - line 321
stylesheet before this banner existed: * * - **It follows the reader's palette.** An image is baked in one theme and * the site has nine. Every colour below is a token. * - **Its claims can be read aloud, selected and searched.** Finding - line 321
F-37 was * this project's own claims rendered illegibly inside an image at phone * width. Text cannot have that failure. * - **It honours `prefers-reduced-motion`.** The global rule at the top of * this file reaches every animation here. - line 321
It cannot reach inside a PNG, * which is why the old markup needed a media query on a `<source>`. * * And it has no soundbar. The waveform is the site's motif and it is used * twice more further down the page -- in the demonstration and in - line 321
the mark * itself -- so a third one at the top was the loudest thing on the page * competing with the two that carry meaning. * * Nothing here needs JavaScript. With scripts off the reader is served the GIF * instead, from a `<noscript>` - line 321
in the markup: they chose the edition of this * site that runs nothing, and one picture is a simpler thing to be served than * a page of rules. `html:not(.js)` hides this one, and `html.js` is set by a * blocking script in `<head>`, so - line 321
there is no frame in which both are visible. */ .bn { position: relative; overflow: hidden; box-sizing: border-box; width: 100%; max-width: 860px; - line 361
margin: 0 auto; padding: 30px 32px 26px; text-align: left; border: 1px solid var(--border); border-radius: 10px; background-color: var(--bg-inset); /* The same faint 16 px grid the drawn banner has, and for the same reason: * texture that - line 361
does not compete with the wordmark. Two repeating gradients * rather than an image, so it costs no request and follows the theme. */ background-image: repeating-linear-gradient(0deg, var(--bg-soft) 0 1px, transparent 1px 16px), - line 361
repeating-linear-gradient(90deg, var(--bg-soft) 0 1px, transparent 1px 16px); } html:not(.js) .bn { display: none; } .bn-mark { display: block; width: 34px; height: 34px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: - line 361
7px; background: var(--bg-soft); padding: 3px; box-sizing: content-box; } .bn-word { position: relative; margin: 0; font-size: clamp(30px, 7.4vw, 62px); line-height: 1.02; font-weight: 700; letter-spacing: 0.1em; color: var(--fg); } /* * - line 361
The veil. A blurred copy of the wordmark, drifting and fading behind the - line 401
* sharp one: the same letters, unresolvable. It is the only illustration on * this page of what the tool actually does to a voice, and it is one * pseudo-element. * * `attr()` for content is the one form of `attr()` every engine has - line 401
supported * since forever -- the typed version that takes a unit is not, and is not used * here. The copy is `aria-hidden` by construction: generated content of a * decorative pseudo-element is not in the accessibility tree. */ - line 401
.bn-word::after { content: attr(data-word); position: absolute; left: 0; top: 0; color: var(--accent-2); filter: blur(7px); opacity: 0.42; pointer-events: none; animation: bn-veil 6.5s ease-in-out infinite alternate; } @keyframes bn-veil { - line 401
from { transform: translate(-6px, 0) scaleX(1.008); opacity: 0.24; } to { transform: translate(7px, 0) scaleX(0.994); opacity: 0.5; } } /* A slow band of light crossing the whole card. Under the wordmark's stacking * order on purpose: it - line 401
should pass behind the letters, not wash them out. */ .bn-sweep { position: absolute; top: -20%; bottom: -20%; left: 0; width: 34%; pointer-events: none; background: linear-gradient(100deg, transparent 0%, var(--accent) 50%, transparent - line 401
100%); opacity: 0.075; animation: bn-sweep 9s linear infinite; } - line 441
@keyframes bn-sweep { from { transform: translateX(-120%); } to { transform: translateX(400%); } } .bn-sub { margin: 10px 0 0; color: var(--accent); font-size: clamp(11px, 2.1vw, 17px); letter-spacing: 0.16em; } .bn-claim { margin: 22px 0 - line 441
0; color: var(--muted); font-size: clamp(10px, 1.7vw, 14px); letter-spacing: 0.08em; } .bn-lines { list-style: none; margin: 10px 0 0; padding: 0; font-size: clamp(10px, 1.7vw, 14px); letter-spacing: 0.08em; } .bn-lines li { margin: 2px 0; - line 441
} .bn-ok { color: var(--ok); } .bn-cyan { color: var(--cyan); } .bn-muted { color: var(--muted); } /* The hairline along the bottom edge, which the drawn banner has as a solid * bar. Here it carries the same light the sweep does, half a - line 441
cycle behind it, * so the card reads as one moving thing rather than two. */ .bn-rule { position: absolute; left: 0; right: 0; bottom: 0; - line 481
height: 4px; background: linear-gradient(90deg, var(--accent) 0%, var(--accent-2) 50%, var(--accent) 100%); background-size: 200% 100%; animation: bn-rule 9s linear infinite; } @keyframes bn-rule { from { background-position: 0% 0; } to { - line 481
background-position: 200% 0; } } /* The `<noscript>` copy, which is the README's GIF. Sized like the drawing it * stands in for so the page does not change shape between the two editions. */ .bn-fallback { display: block; width: 100%; - line 481
max-width: 860px; height: auto; margin: 0 auto; border: 1px solid var(--border); border-radius: 10px; } /* ---------- the journey: a file in, a file out ---------- */ /* * One eight-second cycle, shared by every part. Each element animates - line 481
for its * own share of the cycle and rests for the rest of it, positioned by `--at` in * the markup rather than by a stack of delay classes here -- so the sequence * can be re-timed by reading the numbers in `index.html` in order. * * - line 481
`animation-delay` is positive rather than negative on purpose. A negative * delay starts an animation part-way through, which means the first pass of * the sequence plays out of order before settling; a positive one simply waits, * and the - line 481
loop is in step from the first cycle a reader ever sees. * * The reduced-motion rule at the top of this file collapses all of it. What is * left is the three cards, side by side, with their labels -- which is the * whole point of the - line 481
picture. The motion is the ordering, not the meaning. */ - line 521
.journey { margin: 18px 0 0; } .jr-flow { display: flex; align-items: stretch; justify-content: center; gap: 0; margin: 0; padding: 0; list-style: none; } .jr-step { display: flex; flex-direction: column; flex: 1 1 0; min-width: 0; gap: - line 521
8px; } .jr-card { position: relative; display: flex; flex-direction: column; gap: 4px; flex: 1 0 auto; padding: 14px 16px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-inset); animation: jr-card 8s ease-in-out - line 521
infinite; animation-delay: var(--at, 0s); } @keyframes jr-card { 0% { border-color: var(--border); background: var(--bg-inset); } 3% { border-color: var(--accent); background: var(--bg-soft); } 18% { border-color: var(--accent); - line 521
background: var(--bg-soft); } 32%, 100% { border-color: var(--border); background: var(--bg-inset); } - line 561
} .jr-name { color: var(--fg); font-size: 14px; word-break: break-all; } .jr-fact { color: var(--muted); font-size: 12.5px; } .jr-key { display: inline-block; min-width: 9ch; } .jr-fact b { color: var(--fg); font-weight: 700; } .jr-warn { - line 561
color: var(--warn) !important; } .jr-ok { color: var(--ok) !important; } /* Two lines of room whether the caption needs two or one. The captions sit * *below* the cards in a column, so a caption that wraps makes its card * shorter than the - line 561
others -- three boxes of three different heights, for a * reason that is nothing to do with what they say. */ .jr-caption { color: var(--muted); font-size: 12.5px; line-height: 1.5; min-height: 3em; } .jr-engine { align-items: flex-start; - line 561
} .jr-mark { width: 26px; height: 26px; margin-bottom: 2px; } /* The ring is the only part that is not a label: it is the moment the work * happens, and it needs to read as a moment rather than as a state. A spreading * shadow rather than - line 561
a spinner, because a spinner means "waiting for something * else" and this is not waiting for anything. */ .jr-ring { position: absolute; /* Longhands before the shorthand. `inset` arrived in Safari 14.1, and an * engine older than that - line 561
drops the whole declaration -- see the note on the * legal overlay, where the same gap has real consequences. */ - line 601
top: -1px; right: -1px; bottom: -1px; left: -1px; inset: -1px; border-radius: 9px; pointer-events: none; animation: jr-ring 8s ease-out infinite; animation-delay: var(--at, 0s); } @keyframes jr-ring { 0% { box-shadow: 0 0 0 0 - line 601
var(--accent-2); opacity: 0.5; } 22% { box-shadow: 0 0 0 14px var(--accent-2); opacity: 0; } 23%, 100% { box-shadow: 0 0 0 0 var(--accent-2); opacity: 0; } } .jr-link { position: relative; flex: 0 0 auto; width: clamp(34px, 7vw, 86px); - line 601
margin-top: 26px; align-self: flex-start; height: 2px; background: var(--border); } /* The travelling packet. `left` rather than a transform: the distance is a * percentage of a container whose width is a `clamp()`, and a transform * - line 601
percentage resolves against the moving dot instead -- which put the whole * journey inside eight pixels. */ .jr-packet { position: absolute; top: -4px; left: 0; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); - line 601
opacity: 0; - line 641
animation: jr-packet 8s cubic-bezier(0.4, 0, 0.3, 1) infinite; animation-delay: var(--at, 0s); } .jr-packet-out { background: var(--accent-2); } @keyframes jr-packet { 0% { left: 0; opacity: 0; } 4% { left: 0; opacity: 1; } 17% { left: - line 641
calc(100% - 10px); opacity: 1; } 21%, 100% { left: calc(100% - 10px); opacity: 0; } } /* The mark and the words are centred against each other by the layout rather * than by a `vertical-align` nudge. The nudge was a number that happened to - line 641
* look right at one font size and stopped being right at any other, which is * how the mark ended up sitting oddly beside the text. */ .jr-offline { margin: 16px 0 0; color: var(--muted); font-size: 12.5px; letter-spacing: 0.04em; display: - line 641
flex; align-items: center; justify-content: center; gap: 8px; } /* A prohibition sign, drawn as SVG. * * It was two boxes -- a bordered circle and a rotated `::after` bar -- and the * bar was placed by arithmetic against the box model. - line 641
That arithmetic was * wrong three times running: a 17px bar starting at the ring's left edge sat * two pixels right of centre with four hanging off the side; centring it and * giving it the ring's own diameter left the square corners - line 641
crossing the ring, * because a rectangle's corners are further from its centre than its edge * midpoints; shortening it to clear them stopped it mid-stroke so it no longer * reached the ring at all. * * Each attempt fixed the previous - line 641
symptom and produced the next, which is the * signal that the approach rather than the number was wrong. SVG has none of - line 681
* this: the circle and the line are geometry, the two endpoints are the circle * radius at 45 degrees, and a round cap puts the furthest point of each end on * the bar's own axis, exactly on the outer edge of the stroke. Symmetric by * - line 681
construction, at any zoom and any subpixel position, with no numbers left to * get wrong. */ .jr-offline-mark { flex: none; color: var(--err); } .journey figcaption { margin-top: 20px; color: var(--muted); max-width: 68ch; line-height: - line 681
1.65; } .journey figcaption strong { color: var(--fg); } /* Stacked on a narrow screen, with the connectors turned on their side. The * cards are the content; the arrangement is not worth a horizontal scroll. */ @media (max-width: 700px) { - line 681
.jr-flow { flex-direction: column; align-items: stretch; } .jr-step { flex: none; } .jr-link { width: 2px; height: 30px; margin: 0 0 0 22px; align-self: flex-start; } .jr-packet { animation-name: jr-packet-down; top: 0; left: -4px; } - line 681
@keyframes jr-packet-down { 0% { top: 0; opacity: 0; } 4% { top: 0; opacity: 1; } 17% { top: calc(100% - 10px); opacity: 1; } 21%, 100% { top: calc(100% - 10px); opacity: 0; } } } /* ---------- the demonstration ---------- */ /* - line 721
* A voice going in, the mark lighting as it passes, an unidentifiable wave * coming out. Pure CSS: it follows the reader's theme, needs no download, and * works in the scripts-off edition. * * Deliberately far below the hero. The banner - line 721
already animates, and two * waveforms moving at once was a real defect -- each made the other look like * decoration, which is why one of them was removed from the hero in the first * place. These two are never on screen together. */ .demo - line 721
{ margin: 0; } .demo-flow { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 22px; } .demo-side { flex: 1 1 0; min-width: 0; } .demo-wave { display: flex; align-items: center; gap: 4px; height: 78px; - line 721
/* The bars are the picture, so they get the inset background the rest of the * site uses for a panel rather than sitting on the page. */ background: var(--bg-inset); border: 1px solid var(--border); border-radius: 9px; padding: 0 10px; } - line 721
.demo-wave span { flex: 1 1 0; min-width: 2px; border-radius: 2px; /* A height before the animation starts, and `backwards` so the delay holds * the first keyframe rather than nothing. - line 761
* * Without both, a bar whose `animation-delay` has not elapsed has no height * at all -- these are flex children in a centred row, so "no height" means * "not drawn". Delays here run to 3.7s, so for the first several seconds * after the - line 761
page loaded, most of the waveform was simply absent. It looked * like a five-bar picture that mysteriously filled in later, and nothing in * the CSS reads as wrong. Found by photographing the section. */ height: 14%; animation: demo-bar - line 761
var(--t, 2s) ease-in-out infinite backwards; animation-delay: var(--d, 0ms); } /* Left: one animation, one duration, delays increasing by a fixed step, so the * crest walks along the row. That regularity *is* the voiceprint. */ .demo-clean - line 761
span { background: var(--accent); } /* Right: each bar keeps its own duration as well as its own delay, so they * never line up again. Same heights, same energy, no shared structure. */ .demo-veiled span { background: var(--accent-2); } - line 761
@keyframes demo-bar { 0%, 100% { height: 14%; opacity: 0.75; } 50% { height: 88%; opacity: 1; } } .demo-core { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 12px; } /* What happens between the two - line 761
waveforms, in order, one step lighting at a * time. * * The middle of this picture used to be the mark on its own, glowing, and the * caption summarised the whole engine as the word "discarded". That is true * and it is one word for six - line 761
things, and the one it names is the third of * them. A reader looking at the picture could not see that the audio is - line 801
* framed first, that the phase is measured before it is thrown away, that the * pitch mapping is a separate step and is the *other* reason there is no * inverse, or that the modulation is rolled on an interval. * * So the steps are drawn, - line 801
in the same style as everything else here: no * script, no image, one animation, and it follows the reader's theme. * * The cycle is six seconds and each step holds the light for one of them, so * the sequence reads at about the pace - line 801
somebody reads a short line. Faster * looked like flickering; slower and the reader has moved on. */ .demo-stages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; font-size: 12px; line-height: - line 801
1.5; color: var(--muted); /* The longest label decides this, and it must not decide the whole row: the * two waveforms are `flex: 1 1 0` and this is `flex: 0 0 auto`, so a wide * core takes its width off both of them. */ max-width: 26ch; } - line 801
.demo-stages li { display: flex; align-items: center; gap: 7px; white-space: nowrap; animation: demo-step 6s linear infinite backwards; animation-delay: calc(var(--s) * 1s); } .demo-dot { flex: 0 0 auto; width: 7px; height: 7px; - line 801
border-radius: 50%; - line 841
background: var(--border); animation: demo-step-dot 6s linear infinite backwards; animation-delay: calc(var(--s) * 1s); } /* Colour and nothing else: a step that moved or grew would push the labels * around, and six labels jostling for six - line 841
seconds is a picture nobody reads. */ @keyframes demo-step { 0%, 15% { color: var(--fg); } 17%, 100% { color: var(--muted); } } @keyframes demo-step-dot { 0%, 15% { background: var(--accent); box-shadow: 0 0 7px 1px var(--accent); } 17%, - line 841
100% { background: var(--border); box-shadow: none; } } /* The numbered account under the picture, which says the same six things at * length. The picture names them; this one explains them. */ .demo-steps { margin: 0 0 10px; padding-left: - line 841
20px; max-width: 68ch; } .demo-steps li { margin: 0 0 8px; } .demo-mark { width: 44px; height: 44px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-inset); padding: 4px; animation: demo-glow 2s ease-in-out - line 841
infinite; } /* The glow is a box-shadow rather than a filter: `filter` on an image forces a * new compositing layer on every frame in several engines, and this is a * decoration that must never cost a reader a dropped frame. */ @keyframes - line 841
demo-glow { - line 881
0%, 100% { box-shadow: 0 0 0 0 transparent; } /* Two declarations, and the order matters -- the same rule the sticky header * follows. `color-mix` arrived in 2023; an engine older than that drops the * whole declaration, and with nothing - line 881
before it the mark simply never lights. * The plain colour is the fallback; newer engines take the second. */ 50% { box-shadow: 0 0 18px 2px var(--accent); box-shadow: 0 0 18px 2px color-mix(in srgb, var(--accent) 55%, transparent); } } - line 881
.demo-label { margin: 8px 0 0; text-align: center; font-size: 12.5px; color: var(--accent); letter-spacing: 0.04em; } .demo-label-veiled { color: var(--accent-2); } .demo figcaption { color: var(--muted); max-width: 68ch; } .demo - line 881
figcaption p { margin: 0 0 10px; } .demo figcaption strong { color: var(--fg); } /* On a narrow screen the three parts stack, and the mark turns on its side so * it still reads as "through here" rather than as a third panel. */ @media - line 881
(max-width: 640px) { .demo-flow { flex-direction: column; gap: 12px; } .demo-side { width: 100%; } /* The mark turns on its side so it still reads as "through here" rather than * as a third panel. Only the mark: rotating the core would - line 881
take the six * labels with it, and sideways text is not a label. */ .demo-mark { transform: rotate(90deg); } .demo-stages { max-width: none; } } /* Motion reduced: the bars hold a readable spread of heights and the mark * stops glowing. - line 881
The picture still makes its point -- one row even, one row * ragged -- because the *arrangement* carries the meaning, not the movement. * * Unlike the fact strip this needs no visibility override: a bar's resting * state is a height, not - line 881
`opacity: 0`. Stated because the two look alike and - line 921
* the difference is exactly what made the other one need an exception. */ @media (prefers-reduced-motion: reduce) { /* Every step lit, rather than every step dark. The list is the content here * and the sequence is the decoration, so with - line 921
the movement gone the reader * should still be able to read all six. */ .demo-stages li { animation: none; color: var(--fg); } .demo-dot { animation: none; background: var(--accent); box-shadow: none; } .demo-wave span { animation: none; - line 921
height: 60%; } .demo-clean span { height: 62%; } .demo-veiled span:nth-child(odd) { height: 34%; } .demo-veiled span:nth-child(3n) { height: 84%; } .demo-veiled span:nth-child(4n) { height: 22%; } .demo-mark { animation: none; } } /* - line 921
---------- the questions page ---------- */ /* * The contents list at the top of the questions page. Two columns on a wide * screen, because twenty questions in one column is a page of links somebody * scrolls past to reach the first - line 921
answer, and one column on a narrow one, * where two would be four words wide each. */ .faq-list { margin: 0 0 26px; } .faq-list ul { margin: 0; padding: 0; list-style: none; columns: 2; column-gap: 30px; } .faq-list li { margin: 0 0 7px; - line 921
/* A question must not be split down the middle of the page. */ break-inside: avoid; -webkit-column-break-inside: avoid; } .faq-list a { color: var(--muted); border: 0; } .faq-list a:hover { color: var(--accent); } @media (max-width: - line 921
720px) { - line 961
.faq-list ul { columns: 1; } } /* ---------- the recorded terminal ---------- */ /* * `website/js/sessions.js` replays a recorded session into `#cli-demo`, on the * page rather than in an overlay. * * What this replaced was three times its - line 961
size: an overlay, a card, a mode * picker, and the parts of a hand-drawn model of the desktop application -- * meters, drop zones, checkboxes, a fake title bar with a fake offline badge. * That model was a drawing of an interface published - line 961
beside photographs of the * same interface, and the photographs are the ones a reader can check. * * The rules that remain use the palette tokens and nothing else, so the * terminal follows the reader's theme, and nothing here animates a - line 961
property * that causes layout. */ .term { background: var(--bg-inset); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 6px; } .term-bar { display: flex; align-items: center; gap: 7px; padding: 9px - line 961
12px; border-bottom: 1px solid var(--border); } .term-dot { width: 9px; height: 9px; border-radius: 50%; } .term-name { color: var(--muted); font-size: 12px; margin-left: 6px; } .term-picks { display: flex; flex-wrap: wrap; - line 1001
gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .term-btn { font: inherit; font-size: 12.5px; color: var(--muted); background: var(--bg-soft); border: 1px solid var(--border); border-radius: 7px; padding: 6px 11px; - line 1001
cursor: pointer; } .term-btn:hover { color: var(--fg); border-color: var(--accent); } .term-btn-on { color: var(--accent); border-color: var(--accent); } /* Replaying and skipping are a different kind of action from the five titles beside - line 1001
them, so they are pushed to the far end rather than sitting in the list as two more things to choose between. */ .term-btn-do { color: var(--fg); } .term-btn-do + .term-btn-do { margin-left: 0; } .term-picks > - line 1001
.term-btn-do:nth-last-child(2) { margin-left: auto; } /* The one-line description of the recording being played, between the picker and the terminal, so somebody who clicked a title knows what they are about to watch before it starts. */ - line 1001
.term-note { margin: 0; padding: 10px 14px 0; color: var(--muted); font-size: 13px; } /* The output scrolls in its own box. A help screen is forty lines and the section must not grow to whatever the longest one happens to be. */ .term-out - line 1001
{ margin: 0; padding: 12px 14px; - line 1041
max-height: 360px; overflow: auto; overscroll-behavior: contain; font-size: 12.5px; line-height: 1.5; color: var(--fg); background: var(--bg-inset); border: 0; border-radius: 0; white-space: pre; /* It scrolls and it takes focus, so it - line 1041
needs a visible focus ring: a keyboard reader has to be able to tell which box the arrow keys move. */ outline-offset: -2px; } /* ---------- tooltips ---------- */ /* * `data-tip` on any element, shown on hover and on keyboard focus. No * - line 1041
JavaScript, so the scripts-off edition behaves identically. * * Deliberately *not* the `title` attribute. `title` is unstyleable, appears * after a delay the reader cannot change, never appears on a touch device, and * is announced by some - line 1041
screen readers in addition to whatever else the element * says. Where both existed the reader got two boxes on a desktop and the same * sentence twice through a screen reader. * * Nothing a reader actually needs is stored only here. A - line 1041
tooltip expands an * abbreviation or names a unit; it never carries the point of a sentence, * because on a phone it does not exist. */ [data-tip] { position: relative; /* The dotted underline is the affordance. Without it a tooltip is a - line 1041
secret: * hover-only information with nothing indicating there is anything to hover. * `text-decoration` rather than a border, so it follows descenders and wraps * with the text. */ text-decoration: underline dotted var(--muted); - line 1041
text-underline-offset: 3px; cursor: help; } - line 1081
[data-tip]::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 8px); /* Anchored to the element's left edge, not centred. * * Centring looks better in isolation and overflows the viewport the moment * the annotated - line 1081
word is near the left margin -- which was exactly what * happened to the `Argon2id` tooltip in the left-hand column, half of it * off-screen. Found by rendering the page with the tooltip forced open; * nothing about the CSS looks wrong on - line 1081
its own. * * Left-anchoring cannot overflow on the left at all, and the width cap keeps * it inside a column on the right. `.tip-right` is the opt-out for a term * genuinely close to the right edge. */ left: 0; transform: translate(0, - line 1081
4px); z-index: 40; /* Monospace and the site palette, as asked. Sized in ch so the box is a * predictable number of characters wide whatever the reader's font. */ font-family: inherit; font-size: 12.5px; line-height: 1.5; white-space: - line 1081
normal; width: max-content; max-width: 34ch; padding: 7px 10px; text-align: left; text-decoration: none; color: var(--fg); background: var(--bg-inset); border: 1px solid var(--border); border-radius: 7px; box-shadow: 0 4px 14px rgba(0, 0, - line 1081
0, 0.35); opacity: 0; visibility: hidden; - line 1121
/* `visibility` is in the transition on purpose: without it the box stays in * the accessibility tree and stays hoverable while invisible, so a pointer * passing through empty space triggers a tooltip that is not there. */ transition: - line 1121
opacity 0.14s ease, transform 0.14s ease, visibility 0s linear 0.14s; pointer-events: none; } [data-tip]:hover::after, [data-tip]:focus-visible::after { opacity: 1; visibility: visible; transform: translate(0, 0); transition: opacity 0.14s - line 1121
ease, transform 0.14s ease, visibility 0s; } /* For a term close to the right edge, where a left-anchored box would run off * that side instead. */ [data-tip].tip-right::after { left: auto; right: 0; } /* * On a phone there is no edge left - line 1121
to anchor to. * * The box is `width: max-content` capped at 34ch -- about 255 px -- and it is * anchored to the left of the annotated word. In a 312 px column with the word * two thirds of the way along, that put its right edge at 433 px - line 1121
on a 390 px * screen: half the tooltip off the side, and, because a `visibility: hidden` * box still takes part in layout, the *front page itself* scrolling sideways by * 82 px whether anybody hovered anything or not. Measured with * - line 1121
`tools/render/probe.py overflow --width 390`, which is also how the cause was * narrowed from "something on the page" to one paragraph in one card. * * Pinned to the bottom of the viewport instead: full width, nothing to run off, * and out - line 1121
from under the finger that opened it. `position: fixed` also takes * the box out of the scrollable overflow entirely, which is what removes the * 82 px. * * The query is 900 px rather than the site's usual 760 because this is not a * phone - line 1121
problem: it is a *narrow column* problem, and the columns keep * narrowing until the page reaches its full 900 px width. A tablet at 768 was * still 75 px over. Above 900 the layout stops changing, the measurements are - line 1161
* clean, and `.tip-right` remains the opt-out for a term genuinely close to * the right-hand edge. */ @media (max-width: 900px) { [data-tip]::after { position: fixed; left: 12px; right: 12px; bottom: 12px; top: auto; width: auto; - line 1161
max-width: none; transform: translate(0, 6px); } [data-tip].tip-right::after { left: 12px; right: 12px; } [data-tip]:hover::after, [data-tip]:focus-visible::after { transform: translate(0, 0); } } /* The global reduced-motion rule - line 1161
collapses the transition, which is correct * here: the box appears at once rather than sliding, and it still appears. * Unlike the fact strip, the resting state of a tooltip is *meant* to be * hidden, so no override is needed -- stated - line 1161
because the two components look * alike and the difference is the whole reason one needed an exception. */ /* ---------- the cycling fact line ---------- */ /* * One line at a time, changing every 3.6 seconds: slow enough to read * without - line 1161
becoming something the reader has to wait for. * * Pure CSS, so it works in the no-JavaScript edition, follows whichever palette * the reader has chosen, and costs no bytes beyond the text itself. The * alternative -- an animated image -- - line 1161
would be minutes long at this pace, * would bake one theme in, and would put this project's own claims inside a * picture where they cannot be selected, searched or read aloud. Finding F-37 * was exactly that, rendered illegibly. * * Every - line 1161
fact shares one keyframe and differs only by delay. The percentages * below are one slot of 26, so they are tied to the number of `.fact` * elements in the page: `--fact-count` records it and a site test compares the - line 1201
* two, because adding a line without widening the cycle would overlap two * messages -- readable in neither. */ .facts { --fact-count: 26; --fact-cycle: 93.6s; position: relative; margin: 26px auto 0; max-width: 640px; /* Reserve the - line 1201
line's height so nothing below it moves as messages swap. * Two lines' worth: the longest of these wraps on a narrow phone. */ min-height: 3.2em; } .fact { position: absolute; left: 0; right: 0; top: 0; color: var(--c, var(--muted)); - line 1201
font-size: 14px; line-height: 1.6; opacity: 0; animation: fact-cycle var(--fact-cycle) linear infinite; animation-delay: calc(var(--f) * var(--fact-cycle) / var(--fact-count)); /* `opacity` and `transform` only -- the two properties that - line 1201
animate without * a layout pass, which is the rule the rest of this stylesheet follows. */ will-change: opacity, transform; } @keyframes fact-cycle { 0% { opacity: 0; transform: translateY(5px); } 0.385% { opacity: 1; transform: none; } - line 1201
2.923% { opacity: 1; transform: none; } 3.308% { opacity: 0; transform: translateY(-5px); } 100% { opacity: 0; transform: translateY(-5px); } } /* The global reduced-motion rule at the top of this file collapses every * animation to - line 1201
0.01ms, which for this component would end every message at its - line 1241
* final keyframe -- opacity 0 -- and leave the line blank. So the preference is * handled explicitly here: no cycling, and the first fact simply stays. * * This is the same trap as an animated image ignoring the preference. A * blanket - line 1241
rule that neutralises motion has to be checked against components * whose *resting* state is invisible. */ @media (prefers-reduced-motion: reduce) { .fact { animation: none; opacity: 0; transform: none; } .fact:first-child { opacity: 1; } - line 1241
.facts { min-height: 0; } } /* ---------- the veil animation ---------- */ /* Bars start even and blue on the left, fragment and shift purple to the * right: the product in one picture, matching the icon. Pure CSS. */ /* The bars now live - line 1241
inside a figure in the walkthrough rather than in the hero. * They were directly beneath the animated banner, so the page opened with two * waveforms animating at once -- redundant, and each made the other look like * decoration. Beside - line 1241
the sentence about the voiceprint being destroyed they * illustrate it instead. */ .veil-figure { margin: 14px 0 18px; } .veil-figure figcaption { color: var(--muted); font-size: 13px; max-width: 62ch; margin-top: 4px; } .veil { display: - line 1241
flex; gap: 3px; justify-content: flex-start; align-items: center; height: 54px; margin: 0 0 8px; } .veil span { width: 5px; border-radius: 2px; background: var(--accent); /* Same reason as `.demo-wave span`: without a resting height and * - line 1241
`backwards`, every bar whose delay has not elapsed is undrawn. The delays * here only reach 500 ms so it was half a second of a partial figure rather * than several seconds, which is why it went unnoticed. */ height: 16%; - line 1281
animation: pulse 1.9s ease-in-out infinite backwards; } .veil span.veiled { background: var(--accent-2); opacity: 0.85; } @keyframes pulse { 0%, 100% { height: 16%; } 50% { height: 82%; } } /* ---------- reveal on scroll ---------- */ /* - line 1281
Scoped to html.js, which theme.js sets from a blocking head script. Without * JavaScript these rules never apply and the content is simply visible, and the * one failure mode a reveal effect must not have. * * Only opacity and transform - line 1281
are animated, so the compositor handles it * without a layout pass. `prefers-reduced-motion` is already neutralised * globally at the top of this file, and reveal.js additionally skips the * observer entirely in that case. */ .js .reveal { - line 1281
opacity: 0; transform: translateY(18px); transition: opacity 0.55s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.55s cubic-bezier(0.2, 0.8, 0.2, 1); transition-delay: calc(var(--i, 0) * 80ms); } .js .reveal.in { opacity: 1; transform: none; - line 1281
} /* ---------- the walkthrough ---------- */ .steps { counter-reset: step; list-style: none; padding: 0; margin: 18px 0 0; } .step { counter-increment: step; position: relative; padding: 0 0 22px 46px; margin: 0; } /* The connecting rail, - line 1281
drawn once per step rather than as a separate element. */ .step::before { - line 1321
content: ""; position: absolute; left: 13px; top: 26px; bottom: 0; width: 1px; background: var(--border); } .step:last-child { padding-bottom: 0; } .step:last-child::before { display: none; } .step::after { content: counter(step); - line 1321
position: absolute; left: 0; top: 0; width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--accent); background: var(--bg-soft); color: var(--accent); font-size: 12px; font-weight: 700; } - line 1321
.step h3 { margin: 2px 0 6px; color: var(--fg); } .step p { margin: 0; color: var(--muted); font-size: 13.5px; } .step pre { margin: 10px 0 0; } /* Two panels side by side: what the window shows, what the shell does. */ .split { display: - line 1321
grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } /* A key/value strip for the "two passwords" comparison. */ .compare { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; - line 1321
margin: 16px 0; } .compare > div { background: var(--bg-soft); border: 1px solid var(--border); - line 1361
border-left-width: 3px; border-radius: 0 8px 8px 0; padding: 14px 16px; } .compare .lock { border-left-color: var(--accent); } .compare .vault { border-left-color: var(--accent-2); } .compare h4 { margin: 0 0 6px; font-size: 13.5px; color: - line 1361
var(--fg); letter-spacing: 0.03em; } .compare p { margin: 0; font-size: 13px; color: var(--muted); } /* ---------- buttons ---------- */ /* `align-items` is declared, not defaulted. A flex row that does not say * leaves it at `stretch`, so - line 1361
one button that wraps to two lines on a narrow * viewport makes every button beside it taller. Buttons in a row are the one * place that shows immediately. */ .row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; - line 1361
justify-content: center; margin-top: 26px; } .btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-soft); color: var(--fg); font: inherit; - line 1361
cursor: pointer; transition: transform 0.14s ease, border-color 0.14s ease, background 0.14s ease; } .btn:hover { transform: translateY(-2px); border-color: var(--accent); background: var(--bg-inset); } .btn:active { transform: none; } - line 1361
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 700; } .btn.primary:hover { background: var(--accent); filter: brightness(1.08); } /* ---------- sections ---------- */ section { - line 1361
padding: 40px 0; border-top: 1px solid var(--border); } section:first-of-type { border-top: 0; } - line 1401
h1, h2, h3, h4 { line-height: 1.3; } h1 { font-size: 30px; letter-spacing: 0.04em; margin: 0 0 8px; } h2 { font-size: 20px; color: var(--accent); margin: 0 0 16px; letter-spacing: 0.03em; } h3 { font-size: 15px; color: var(--accent-2); - line 1401
margin: 22px 0 8px; } h4 { font-size: 13.5px; color: var(--cyan); margin: 16px 0 6px; letter-spacing: 0.02em; } .lede { color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 14px; } - line 1401
.card { background: var(--bg-soft); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; transition: border-color 0.15s ease, transform 0.15s ease; } .card:hover { border-color: var(--accent); transform: - line 1401
translateY(-2px); } .card h3 { margin-top: 0; } .card p { margin: 0; color: var(--muted); font-size: 13.5px; } .note { border-left: 3px solid var(--warn); background: var(--bg-soft); padding: 14px 16px; border-radius: 0 8px 8px 0; margin: - line 1401
18px 0; } .note.good { border-left-color: var(--ok); } .note.bad { border-left-color: var(--err); } .note p:first-child { margin-top: 0; } .note p:last-child { margin-bottom: 0; } code, pre, kbd { font-family: inherit; } code:not(pre code) - line 1401
{ background: var(--bg-inset); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; - line 1441
font-size: 0.9em; color: var(--cyan); } pre { background: var(--bg-inset); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; overflow-x: auto; font-size: 13px; line-height: 1.55; } table { width: 100%; - line 1441
border-collapse: collapse; margin: 14px 0; font-size: 13.5px; } /* A reference table has a column of item names that cannot be broken -- they * are code -- so its narrowest possible width can exceed the column it is in. * `display: block` - line 1441
makes the element its own sideways scroller, and the rows * inside are laid out by an anonymous table box. * * What that costs, measured rather than assumed: a table is now **content** * width rather than column width. On the security page - line 1441
the tables come out at * 820 px inside an 860 px column, so the row rules stop forty pixels short of * the text above them. `width: 100%` does not change it -- tried, measured, * still 820 -- because the shrink happens on the anonymous - line 1441
table inside, not on * the block. Forty pixels of rule against a page that no longer scrolls * sideways on a phone is a trade worth making, but it is a trade, and an * earlier version of this comment claimed nothing changed on a desktop. * - line 1441
* Deliberately not inside a width query. The reference pages keep a 200 px * contents column until 720 px, so a tablet at 768 gives a table *less* room * than a phone does: with this scoped to 760, `chain.html` still scrolled to * 908 px - line 1441
at a viewport of 768. Measured, then unscoped. */ table { display: block; width: auto; overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; - line 1481
} th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } th { color: var(--accent); font-weight: 700; } /* `DeidConfig::reseed_range_is_finer_than_a_frame` is one unbreakable word * 385 - line 1481
px wide, and a phone column is about 300. `break-word` breaks a word only * when it would otherwise overflow, so ordinary code spans are untouched -- * and a `<code>` inside a `<pre>` is excluded, because a code block scrolls * sideways on - line 1481
purpose and breaking its lines would change what they say. */ code { overflow-wrap: break-word; } pre code { overflow-wrap: normal; } /* `anywhere` rather than `break-word`, and only inside a table cell. * * The two differ in exactly one - line 1481
way that matters here: `break-word` breaks a * long word when it would overflow, but it does *not* count that break when the * browser works out how narrow the element could possibly be. `anywhere` does. * A table's width comes from that - line 1481
number, so with `break-word` the items table * still insisted on 658 px and scrolled sideways inside a 630 px column on a * desktop -- measured, after the rule above was already in place. With * `anywhere` the same table fits its column - line 1481
and breaks an identifier only in * the cells that actually need it. */ td code, th code { overflow-wrap: anywhere; } ul, ol { padding-left: 22px; } li { margin: 5px 0; } /* ---------- verifier ---------- */ .tool { background: - line 1481
var(--bg-soft); border: 1px solid var(--border); border-radius: 10px; padding: 18px; } .drop { border: 2px dashed var(--border); border-radius: 10px; padding: 34px 18px; text-align: center; color: var(--muted); cursor: pointer; transition: - line 1481
border-color 0.15s ease, background 0.15s ease, color 0.15s ease; } - line 1521
.drop:hover, .drop.hot { border-color: var(--accent); color: var(--fg); background: var(--bg-inset); } input[type="text"] { width: 100%; background: var(--bg-inset); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); - line 1521
font: inherit; font-size: 12.5px; padding: 9px 11px; margin-top: 10px; } .hash { font-size: 12.5px; word-break: break-all; background: var(--bg-inset); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin-top: - line 1521
12px; color: var(--cyan); } .verdict { margin-top: 12px; padding: 12px 14px; border-radius: 8px; font-weight: 700; } /* `background: var(--bg-soft)` first, as the fallback for engines that drop the * `color-mix` line -- see the header rule - line 1521
for the full reasoning. The verdict * is the single most important thing this page ever says, so it must not be * unreadable anywhere. */ .verdict.match { background: var(--bg-soft); background: color-mix(in srgb, var(--ok) 16%, - line 1521
transparent); color: var(--ok); border: 1px solid var(--ok); } .verdict.fail { background: var(--bg-soft); background: color-mix(in srgb, var(--err) 16%, transparent); color: var(--err); border: 1px solid var(--err); } progress { width: - line 1521
100%; height: 6px; margin-top: 12px; } /* ---------- repo panel ---------- */ .stats { display: flex; gap: 22px; flex-wrap: wrap; margin: 6px 0 14px; } .stat { display: flex; flex-direction: column; } .stat b { font-size: 22px; color: - line 1521
var(--cyan); font-variant-numeric: tabular-nums; } - line 1561
.stat span { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; } /* ---------- the repository panel, while it loads ---------- */ /* The fetch is opt-in and can take a moment over a slow link, so - line 1561
the panel * says so with movement rather than leaving four em dashes sitting there * looking like the answer. Only opacity and transform are animated. */ .repo-loading .stat b, .repo-loading #repo-desc { animation: breathe 1.4s ease-in-out - line 1561
infinite; } @keyframes breathe { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.85; } } /* Each panel piece arrives with the same rise the hero uses, staggered by its * own --i so the stats do not all land at once. */ .repo-in { animation: - line 1561
rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both; } .repo-in { animation-delay: calc(var(--i, 0) * 60ms); } /* The spinner on the button itself, so the control that was pressed is the * thing that shows it is working. */ .btn .spin { width: - line 1561
11px; height: 11px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .readme { background: var(--bg-soft); - line 1561
border: 1px solid var(--border); border-radius: 10px; padding: 6px 20px 20px; } /* Rendered from README.md, so the sizes are whatever the file uses and are * capped at the column width -- i.e. shrunk. Same reason as the banner. */ .readme - line 1561
img { max-width: 100%; height: auto; border-radius: 8px; } .readme h1 { font-size: 22px; } .readme h2 { font-size: 17px; } .readme h3 { font-size: 14px; } - line 1601
.readme blockquote { margin: 14px 0; padding: 10px 16px; border-left: 3px solid var(--accent); background: var(--bg-inset); color: var(--muted); } /* syntax highlighting, deliberately small, applied by js/markdown.js */ .tok-kw { color: - line 1601
var(--accent-2); } .tok-str { color: var(--ok); } .tok-num { color: var(--warn); } .tok-com { color: var(--muted); font-style: italic; } .tok-fn { color: var(--cyan); } .tok-attr { color: var(--warn); } /* ---------- the screenshot gallery - line 1601
---------- */ /* The gallery of window captures. * * The gap was 22px, which is close enough to the space inside each picture's * own padding that the pictures read as one continuous field rather than as * ten separate windows. Every - line 1601
capture is now the same height, so they tile * exactly and that crowding is more obvious rather than less. * * 40px between columns and 48px between rows: more down than across, because * each figure carries a caption under it, and a row - line 1601
gap that only matched the * column gap put the next picture as close to a caption as the caption is to * the picture it describes. */ .shots { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); column-gap: 40px; - line 1601
row-gap: 48px; margin: 32px 0 0; } .shot, .cast { margin: 0; } /* `height: auto` beside the width/height attributes on the element: the * attributes give the browser the aspect ratio before the bytes arrive, so the * page does not jump as - line 1601
each picture loads, and this stops that ratio being * applied as a fixed pixel height once it has. */ - line 1641
/* No border, no radius and no background behind these. * * The corners are in the file. `tools/shots/round.py` rounds them into the * alpha channel, because the README is rendered by GitHub, which strips styles * from images, and a - line 1641
picture that is only round on the website is not round. * * Once the shape is in the pixels, painting it again here does harm rather * than nothing. `border-radius` on an `img` clips the content box, and these * are displayed at roughly a - line 1641
third of their captured width, so the file's * 14-pixel radius arrives on screen as about five while a fixed 9 here would * cut into the picture past it. A `background` is worse: it shows through the * corners the file made transparent, as - line 1641
a wedge of inset colour in each one. * And a square 1px border crosses the rounded corner it is meant to follow. * * The captures already carry the application's own window frame, which is the * edge a reader sees. */ .shot img { display: - line 1641
block; width: 100%; height: auto; } .shot figcaption, .cast figcaption { color: var(--muted); font-size: 13px; line-height: 1.55; margin-top: 8px; } .shot figcaption strong { color: var(--fg); } .casts { margin: 20px 0 0; } .cast { margin: - line 1641
0 0 20px; } /* The drawings carry their own width and height, exactly as the flowcharts do, * so they render at their own size and scale down on a narrow screen rather * than being blown up on a wide one. The scroll is the safety net. */ - line 1641
.cast { overflow-x: auto; } .cast img { display: block; max-width: 100%; height: auto; } /* ---------- wiki ---------- */ - line 1681
.wiki-layout { display: grid; grid-template-columns: 200px 1fr; gap: 30px; align-items: start; } .toc { position: sticky; top: 74px; font-size: 13px; } .toc a { display: block; color: var(--muted); padding: 3px 0; border: 0; } .toc - line 1681
a:hover, .toc a.active { color: var(--accent); } /* A grid item's default `min-width: auto` means it refuses to be narrower * than its own contents, and one `<table>` of items has a min-content width of * 658 px. So on a 390 px phone the - line 1681
column stayed 658 px wide and took the whole * reference page sideways with it -- headings, paragraphs and all. Measured * with `tools/render/probe.py overflow --width 390`, which named the table. * * `min-width: 0` is the same fix the - line 1681
navigation row already carries, for the * same reason, and it is what lets the scroll containers below actually * contain anything. */ .wiki-layout > * { min-width: 0; } @media (max-width: 720px) { .wiki-layout { grid-template-columns: - line 1681
1fr; } .toc { position: static; } } /* The flowcharts carry their own width and height and a max-width of 100 %, so a drawing renders at its own size on a desktop and scales *down* on a narrow screen -- never up. Before that they were - line 1681
width="100%" with no intrinsic size, which scaled a 4490 px canvas into a 630 px column at 0.147 and rendered 13 px labels under two pixels tall. The scroll below is the safety net for a single box wider than the column: better a diagram - line 1681
the reader can push sideways than a page that scrolls sideways underneath it. */ .diagram { margin: 14px 0; overflow-x: auto; } .diagram svg { display: block; margin: 0 auto; } /* The roadmap's items, each addressable on its own. * * A - line 1681
roadmap item is the unit people talk about this project in, and until * these anchors existed the only way to point at one was to send the whole * page and say which paragraph. The number is the link because the number is * already there - line 1681
and already unique: adding a separate link glyph beside it * would be two things where one will do. * * The link is quiet until the row is hovered or the link itself is focused. * Permalinks that shout are a distraction on a list of a - line 1681
hundred, and ones - line 1721
* that only appear on hover are unreachable by keyboard, so both. */ ul.items { list-style: none; padding-left: 0; } ul.items li { margin: 0 0 10px; padding-left: 44px; text-indent: -44px; } .item-link { display: inline-block; min-width: - line 1721
34px; color: var(--muted); text-decoration: none; text-indent: 0; opacity: 0.55; transition: opacity 120ms ease, color 120ms ease; } ul.items li:hover .item-link, tr:hover > td .item-link, .item-link:hover, .item-link:focus-visible { - line 1721
opacity: 1; color: var(--accent); } /* The explanation after the name, one step back so the name is scannable in a long list without the explanation being hidden from anybody who wants it. */ .item-detail { color: var(--muted); } /* A row - line 1721
or item arrived at by its own link says so, otherwise following a permalink into a list of a hundred lands you nowhere in particular. */ ul.items li:target, tr:target > td { background: var(--bg-inset); } ul.items li:target { box-shadow: - line 1721
-8px 0 0 var(--bg-inset), 8px 0 0 var(--bg-inset); } h3:target { color: var(--accent); } /* Every box in a flowchart is a link, and on these pages it goes to the file's * source on this site rather than off to GitHub. That is worth making - line 1721
obvious, * and it costs one hover state and no script. * * `stroke-width` and `fill` rather than a colour: the outline already carries - line 1761
* the box's role -- a way in, a public function, a private helper -- and * repainting it would throw that away for the one box the reader is pointing * at. The transition is neutralised by the global reduced-motion rule at the * top of - line 1761
this file. */ .diagram a { cursor: pointer; } .diagram a rect { transition: stroke-width 0.12s ease, fill 0.12s ease; } .diagram a:hover rect { stroke-width: 3; fill: var(--bg); } .diagram a:focus rect { stroke-width: 3; fill: var(--bg); } - line 1761
/* ---------- a source file, on this site ---------- */ /* `white-space: normal` on the block and `pre` on each line, which looks * backwards and is not. Every line is its own element so it can be numbered * and marked, and the newlines - line 1761
*between* those elements are markup rather than * content: left as `pre` each one draws a second, empty line and the whole * file comes out double-spaced. * * With no stylesheet at all this degrades to a plain `<pre>` of inline spans * - line 1761
under the browser's own `white-space: pre`, which renders the file correctly * and loses only the numbers. */ pre.src { white-space: normal; padding: 12px 0; counter-reset: srcline; overscroll-behavior-x: contain; } /* `width: max-content` - line 1761
with a floor of the full column. A block child of a * sideways scroller is only as wide as the *visible* box, so a marked line * stopped being marked the moment the reader scrolled right -- the highlight * ended where the column did, in - line 1761
the middle of the code it was meant to be * pointing at. */ .src .ln { display: block; white-space: pre; width: max-content; min-width: 100%; min-height: 1.55em; border-left: 3px solid transparent; } - line 1801
/* The number stays put while the line scrolls under it. It is a pseudo-element * so that selecting the code and copying it does not take the numbers with it, * which is the thing that makes a numbered listing useless to paste. */ .src - line 1801
.ln::before { counter-increment: srcline; content: counter(srcline); display: inline-block; width: 3.4em; margin-right: 1em; text-align: right; color: var(--muted); background: var(--bg-inset); position: sticky; left: 0; - line 1801
-webkit-user-select: none; user-select: none; } /* The mark, and it is `:target` rather than script: it works with JavaScript * off, it survives a bookmark, and it is the browser's own idea of where the * reader asked to be. A line number - line 1801
marks its line. A box in a diagram marks * the whole function, because a function is what the reader clicked on. */ .src .ln:target, .src .src-item:target .ln { background: var(--bg-soft); border-left-color: var(--accent); } details { - line 1801
border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin: 10px 0; background: var(--bg-soft); } summary { cursor: pointer; color: var(--accent); } /* ---------- the releases page ---------- */ - line 1841
/* Every release is a closed row until somebody opens it, so the list reads as * a list of versions rather than as one release with the rest of the page * behind it. The summary carries the version and a one-line description, and * has to - line 1841
look like a row you can click rather than a paragraph that happens to * be blue. */ .release > summary { font-size: 15px; padding: 4px 0; /* The version and its description sit on one line at any width the line * fits, and wrap as a block - line 1841
rather than around the marker when it does not. */ display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; } .release > summary .muted { font-weight: 400; font-size: 13.5px; } .release > summary strong { color: var(--fg); } - line 1841
/* The notes inside a release: a second, quieter box. The files are above it * because they are what somebody came for, and several hundred lines of prose * in front of a download link is several hundred lines of scrolling. */ .notes { - line 1841
background: transparent; border-style: dashed; margin-top: 14px; } .notes > summary { font-size: 14px; } .notes > summary strong { color: var(--fg); } /* The tables of files and of documentation scroll inside themselves rather * than - line 1841
pushing the page sideways on a phone. */ .release table { display: block; overflow-x: auto; max-width: 100%; } /* ---------- footer ---------- */ footer { border-top: 1px solid var(--border); /* The extra bottom padding clears the iOS home - line 1841
indicator, which otherwise * sits over the last line of the footer. `env()` is zero where it does not * apply, so `max()` keeps the desktop spacing unchanged. */ - line 1881
padding: 28px 0 max(46px, calc(28px + env(safe-area-inset-bottom))); color: var(--muted); font-size: 12.5px; } footer a { color: var(--muted); } footer a:hover { color: var(--accent); } .fp { font-size: 11.5px; word-break: break-all; - line 1881
color: var(--cyan); } .noscript-banner { background: var(--bg-soft); border: 1px solid var(--warn); border-left-width: 3px; border-radius: 0 8px 8px 0; padding: 14px 16px; margin: 20px 0; } /* ---------- welcome / legal gate ---------- */ - line 1881
body.legal-locked { overflow: hidden; } .legal-overlay { position: fixed; /* * Longhands first, then the shorthand. * * `inset` is Safari 14.1 (early 2021), and an engine older than that treats * the declaration as invalid and **drops - line 1881
it**. A `position: fixed` element * with no offsets sits wherever it happened to be in the flow, at its own * content size -- so this overlay would not cover the page. * * That matters more here than anywhere else on the site. The gate is - line 1881
shown * with `body { overflow: hidden }`, so the failure is not "the modal looks * wrong": it is a page the reader cannot scroll, with the thing stopping * them not covering the screen. The `color-mix` fallback a few lines down * exists - line 1881
for the same reader and the same reason. */ top: 0; right: 0; - line 1921
bottom: 0; left: 0; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; /* The most important fallback on the page. * * This overlay is shown with `body.legal-locked { overflow: hidden }`, so - line 1921
if * its background is missing the reader gets a page that has stopped * scrolling with nothing visible to explain why -- an invisible modal, which * is indistinguishable from the site being broken. That is what an engine * older than - line 1921
Safari 16.2 or Firefox 113 got, because a declaration it cannot * parse is a declaration it discards. The opaque colour goes first so there * is always *something* there. */ background: var(--bg-inset); background: color-mix(in srgb, - line 1921
var(--bg-inset) 82%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); animation: fade 0.25s ease both; /* Keep the dialogue clear of the notch and the home indicator. */ padding-top: max(20px, - line 1921
env(safe-area-inset-top)); padding-bottom: max(20px, env(safe-area-inset-bottom)); } @keyframes fade { from { opacity: 0; } to { opacity: 1; } } .legal-box { width: min(680px, 100%); /* `dvh`, with `vh` as the fallback, and the distinction - line 1921
is not cosmetic. * * On iOS Safari `100vh` is the viewport height with the browser chrome * *collapsed*, which is taller than what you can actually see while the URL * bar is expanded. So `88vh` could put the bottom of this box -- which is - line 1961
* where the **continue** button is -- below the visible area. And the page * behind is deliberately scroll-locked (`body.legal-locked`), so there was * no way to scroll down to it: the gate could not be dismissed, on a phone, * on first - line 1961
load, which is exactly when everyone meets it. * * `dvh` is the unit that means "the height you can currently see". Engines * without it (Safari before 15.4) take the `vh` line, which is the behaviour * as it was -- no worse, and better - line 1961
everywhere else. */ max-height: 88vh; max-height: 88dvh; overflow-y: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 26px 28px; animation: rise 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) both; } /* The - line 1961
box is what `legal.js` focuses when the gate opens, so that no control * inside it looks pressed before the reader has done anything. A ring around * the whole dialog would say the same wrong thing at a larger size. */ .legal-box:focus { - line 1961
outline: none; } .legal-box h2 { margin-top: 0; color: var(--accent); letter-spacing: 0.05em; } .legal-box p { font-size: 13.5px; } .legal-box ul { font-size: 13px; color: var(--fg); } .legal-warn { border-left: 3px solid var(--warn); - line 1961
background: var(--bg-soft); padding: 10px 14px; border-radius: 0 8px 8px 0; } .legal-check { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0; font-size: 13px; - line 2001
cursor: pointer; background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; padding: 11px 13px; transition: border-color 0.15s ease; } .legal-check:hover { border-color: var(--accent); } .legal-check input { - line 2001
margin-top: 3px; accent-color: var(--accent); flex: none; } .legal-fine { color: var(--muted); font-size: 12px; } #legal-go { width: 100%; justify-content: center; } #legal-go:disabled { opacity: 0.45; cursor: not-allowed; transform: none; - line 2001
} /* ---------- search ---------- */ /* The motion here is deliberately small. `prefers-reduced-motion` is already * neutralised globally at the top of this file, and everything animated below * is `opacity` or `transform` only, so the - line 2001
compositor handles it without * laying the page out again. */ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip-path: inset(50%); overflow: hidden; white-space: nowrap; } .lead { - line 2001
color: var(--muted); max-width: 68ch; } /* The element is `<div class="wrap search-page">`, and `.wrap` sets * `padding: 0 20px`. A second `padding` *shorthand* here does not add to * that -- it replaces it, and the two zeroes took the - line 2001
side gutters away. * Invisible at 900 px, where the column is narrower than the screen * anyway; on a phone every heading and all 200 index rows ran edge to * edge. Found by measuring how close text sits to the screen edge, which * is a - line 2001
thing no overflow check can see, because nothing overflowed. */ .search-page { padding: 30px 20px 60px; } - line 2041
.search-page h1 { margin-bottom: 6px; } .search-controls { display: flex; flex-wrap: wrap; gap: 10px; margin: 22px 0 10px; position: sticky; top: var(--header-h, 64px); z-index: 5; background: var(--bg); padding: 8px 0; } .search-box { - line 2041
flex: 1 1 320px; display: flex; } .search-box input { width: 100%; font: inherit; font-size: 15px; color: var(--fg); background: var(--bg-inset); border: 1px solid var(--border); border-radius: 8px; padding: 11px 14px; /* Only the border - line 2041
and the ring move: no layout, no reflow, no jump. */ transition: border-color 0.16s ease, box-shadow 0.16s ease; } .search-box input::placeholder { color: var(--muted); } .search-box input:focus { outline: none; border-color: - line 2041
var(--accent); /* Plain colour first: an engine older than 2023 drops a `color-mix` * declaration whole, and with nothing before it the focused input would lose * its ring entirely (F-30). */ box-shadow: 0 0 0 3px rgba(122, 162, 247, - line 2041
0.22); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } .search-box input:disabled { opacity: 0.55; cursor: progress; } - line 2081
.search-filters { display: flex; gap: 8px; flex-wrap: wrap; } .search-filters select { font: inherit; font-size: 13px; color: var(--fg); background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; - line 2081
cursor: pointer; transition: border-color 0.16s ease; } .search-filters select:hover { border-color: var(--accent); } .search-count { color: var(--muted); font-size: 13px; margin: 6px 0 14px; } .search-results { list-style: none; margin: - line 2081
0; padding: 0; } .search-results .sr { border: 1px solid var(--border); border-left: 3px solid transparent; border-radius: 8px; background: var(--bg-soft); padding: 12px 14px; margin: 0 0 8px; transition: border-left-color 0.16s ease, - line 2081
transform 0.16s ease; } .search-results .sr:hover { border-left-color: var(--accent); transform: translateX(2px); } /* The entrance. 150ms and 5px -- enough to show the list changed, short * enough that it is finished before the eye - line 2081
settles on the first result. * `search.js` adds `.sr-stagger` only when the shape of the answer changes * (a sort, a filter, or the first results after an empty box), never on an * ordinary keystroke: replaying a cascade per character - line 2081
reads as flicker. */ .sr-stagger .sr { animation: sr-in 0.15s cubic-bezier(0.2, 0.8, 0.2, 1) both; animation-delay: calc(var(--i, 0) * 14ms); - line 2121
} @keyframes sr-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } } .sr-head { display: inline-block; color: var(--fg); font-weight: 700; border: 0; margin-bottom: 3px; } .sr-head:hover { color: - line 2121
var(--accent); } .sr-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--muted); } .sr-path { word-break: break-all; } .sr-line { flex: none; } .sr-kind { flex: none; border: 1px solid - line 2121
var(--border); border-radius: 999px; padding: 1px 8px; font-size: 11px; letter-spacing: 0.04em; color: var(--accent-2); } .sr-kind-doc { color: var(--ok); } .sr-kind-rust { color: var(--accent); } .sr-kind-test { color: var(--accent-2); } - line 2121
.sr-kind-web { color: var(--cyan, var(--accent)); } - line 2161
.sr-kind-legal { color: var(--muted); } .sr-x { color: var(--muted); font-size: 13px; margin: 7px 0 0; } .search-results mark { /* Fallback first, then the theme-aware form -- the order matters, and the * reverse silently means the themed - line 2161
colour never applies. */ background: rgba(122, 162, 247, 0.26); background: color-mix(in srgb, var(--accent) 26%, transparent); color: var(--fg); border-radius: 3px; padding: 0 1px; } .search-empty { color: var(--muted); } /* 760px is the - line 2161
header's own breakpoint, not a round number: below it the header * becomes two rows and grows past the offset these controls stick at, so a * sticky bar here would sit underneath it. Static is the honest answer on a * phone, where a second - line 2161
sticky row would cost screen the page needs (F-34). */ @media (max-width: 760px) { .search-controls { position: static; } .search-filters select { flex: 1 1 45%; } } /* ---------- the JavaScript edition toggle ---------- */ /* * A switch, - line 2161
not a link that looks like one, because the thing it controls is a * binary state the reader is in: which edition of this site they are being * served. `role="switch"` and `aria-checked` are on the anchor so assistive * technology reports - line 2161
that state rather than "link, no-js". * * It is honest about what it does. It does **not** turn JavaScript off in the * browser -- nothing on a page can do that -- it moves you between the full * site and the HTML-and-CSS edition. The - line 2161
label says "JavaScript" and the title * attribute says the rest. * * Three states: * .js + on the full site -> on, and clicking goes to the no-JS edition * .js + on the no-JS site -> off, and clicking comes back - line 2201
* no .js at all -> off and LOCKED: scripts are not running, so * there is no way back and the control says so. */ /* The two site controls, grouped and set apart from the navigation. * * The switch used to live among the nav links, where - line 2201
it read as one more * destination. It is not: it chooses which *edition* of the site you are * served, which is the same kind of choice as the colour scheme. Grouping them * says so without a word of explanation. */ .controls { display: - line 2201
flex; align-items: center; gap: 12px; margin-left: auto; padding-left: 14px; border-left: 1px solid var(--border); } @media (max-width: 760px) { /* On a phone the navigation already takes its own row, so the divider would * separate the - line 2201
controls from nothing. */ .controls { margin-left: auto; padding-left: 0; border-left: 0; } /* With scripts off the switch grows a note -- "(locked: scripts are not * running)" -- which is 257 px of `white-space: nowrap` beside a colour * - line 2201
picker. On a 390 px phone that pushed the header, and therefore every * page, 90 px sideways. Measured with * `tools/render/probe.py overflow --no-js --width 390`, which is a * combination nothing had rendered before: the site was checked - line 2201
with * scripts off, and separately on a phone, and never both at once. * * The note stays, in full, because it is the honest explanation of why the * switch cannot be moved. It simply takes its own line. */ .controls { flex-wrap: wrap; - line 2201
row-gap: 4px; } .js-toggle { white-space: normal; flex-wrap: wrap; } .js-toggle-lock { white-space: normal; } } .js-toggle { display: inline-flex; - line 2241
align-items: center; gap: 7px; border: 0; color: var(--muted); font-size: 13px; min-height: 24px; /* WCAG 2.5.8, same as the nav links */ white-space: nowrap; } .js-toggle:hover { color: var(--accent); } .js-toggle-track { position: - line 2241
relative; width: 30px; height: 16px; flex: none; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-inset); transition: background-color 0.16s ease, border-color 0.16s ease; } .js-toggle-knob { position: absolute; - line 2241
top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: var(--muted); /* transform only: the compositor moves it without laying the page out. */ transition: transform 0.16s cubic-bezier(0.2, 0.8, 0.2, 1), - line 2241
background-color 0.16s ease; } /* * ON is driven by `html.js`, not by the `aria-checked` attribute. * * The attribute is in the markup, and markup cannot know whether scripts run. * The first version hardcoded `aria-checked="true"`, so - line 2241
with JavaScript * disabled the switch cheerfully reported "on" *while* displaying "scripts are - line 2281
* not running" beside it -- a control contradicting itself in the one state it * exists to describe. * * So the honest default is off, in the markup, for everyone; `theme.js` adds * `html.js` and upgrades `aria-checked` when it actually - line 2281
runs. A reader with no * JavaScript is never told a script is running. */ html.js .js-toggle .js-toggle-track { background: var(--accent); border-color: var(--accent); } html.js .js-toggle .js-toggle-knob { transform: translateX(14px); - line 2281
background: var(--bg); } /* Which word is shown. Two spans rather than one, because CSS can hide an * element but cannot rewrite its text, and this has to be right with no script * available to rewrite anything. */ .js-when-on { display: - line 2281
none; } html.js .js-when-on { display: inline; } html.js .js-when-off { display: none; } .js-toggle-state { font-variant-numeric: tabular-nums; } /* * Locked. `html.js` is set by `theme.js` from a blocking head script, so its * absence - line 2281
means scripts are not running -- which is exactly when this control * cannot be used, because moving back to the full site would be moving to a * page that needs the thing that is switched off. * * Written as `html:not(.js)` rather than - line 2281
toggled by script for the obvious * reason: script is what is missing. */ html:not(.js) .js-toggle { cursor: not-allowed; opacity: 0.75; } html:not(.js) .js-toggle .js-toggle-track { border-style: dashed; - line 2321
} html:not(.js) .js-toggle-lock { display: inline; } .js-toggle-lock { display: none; color: var(--muted); } /* On the no-JavaScript edition the switch is off because that is the edition * the reader chose, not because scripts are broken - line 2321
-- so no lock note there. * That page carries its own copy of these rules and does not load this file. */ /* --- Looking at a screenshot properly ------------------------------------- * * The captures are 3840 across. In a two-column grid - line 2321
that is about a tenth of * their real size, and every word in them is unreadable -- which makes them * decoration rather than evidence, and evidence is the point of showing them. * * So a screenshot opens. Clicking one puts it on a dark - line 2321
backdrop at whatever * size the window allows, and clicking again, pressing Escape, or moving focus * away closes it. * * Built with `:target` and a link rather than script, so it works with * JavaScript off, which is the same reason the - line 2321
rest of this page is built the * way it is. The close control is a real link and the panel is reachable by * keyboard. */ .shot img { cursor: zoom-in; } .viewer { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: - line 2321
60; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, 0.88); - line 2361
overscroll-behavior: contain; } .viewer:target { display: flex; } .viewer img { max-width: 100%; max-height: 100%; width: auto; height: auto; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6); } /* The whole backdrop closes it, so a mis-aimed - line 2361
click is not a trap. */ .viewer .dismiss { position: absolute; top: 0; right: 0; bottom: 0; left: 0; inset: 0; cursor: zoom-out; } .viewer .shut { position: absolute; top: 18px; right: 22px; z-index: 1; padding: 6px 14px; border-radius: - line 2361
8px; font: inherit; color: var(--fg); background: var(--bg-soft); border: 1px solid var(--line); text-decoration: none; } - line 2401
.viewer .shut:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } @media (max-width: 700px) { .viewer { padding: 10px; } .viewer .shut { top: 10px; right: 12px; } } /* - line 2401
--------------------------------------------------------------------------- The walkthrough: photographs of every screen, and the command line as jobs. Deliberately plain. The pictures are the content, so the chrome around them stays out - line 2401
of the way: a row of tabs, one framed image, and a caption. The grid for the command cases collapses to a single column rather than shrinking, because a worked example is a sentence and a sentence in a 40-character column is worse than a - line 2401
taller page. --------------------------------------------------------------------------- */ .walk { margin-top: 46px; } .walk-h { font-size: 15px; letter-spacing: 0.08em; color: var(--accent-2); margin: 0 0 8px; } .walk-h-cli { margin-top: - line 2401
44px; } .walk-lede { color: var(--muted); font-size: 13.5px; line-height: 1.6; margin: 0 0 16px; max-width: 70ch; } .walk-tabs { display: flex; - line 2441
flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } .walk-tab { font: inherit; font-size: 12.5px; color: var(--muted); background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; padding: 6px 11px; cursor: pointer; - line 2441
transition: color 120ms ease, border-color 120ms ease, background 120ms ease; } .walk-tab:hover { color: var(--fg); border-color: var(--accent); } .walk-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } - line 2441
.walk-tab-on { color: var(--bg); background: var(--accent); border-color: var(--accent); } .walk-shot { margin: 0; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-inset); padding: 10px; } .walk-shot img { - line 2441
display: block; width: 100%; height: auto; border-radius: 6px; } .walk-shot figcaption { color: var(--muted); - line 2481
font-size: 13px; line-height: 1.6; margin-top: 10px; max-width: 70ch; } .walk-cases { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; } .walk-case { border: 1px solid var(--border); border-radius: - line 2481
8px; background: var(--bg-soft); padding: 13px 15px; } .walk-case-title { margin: 0 0 8px; font-size: 13.5px; color: var(--fg); } .walk-case-cmd { display: block; font-size: 12.5px; color: var(--cyan); background: var(--bg-inset); border: - line 2481
1px solid var(--border); border-radius: 5px; padding: 7px 9px; margin-bottom: 9px; /* A long command wraps rather than scrolling the page sideways. */ overflow-wrap: anywhere; } .walk-case-note { margin: 0; - line 2521
color: var(--muted); font-size: 12.5px; line-height: 1.6; } @media (max-width: 700px) { .walk-cases { grid-template-columns: 1fr; } .walk-shot { padding: 6px; } }
website/css/themes.css
- line 1
/* SPDX-License-Identifier: GPL-3.0-or-later * * Colour schemes. Every theme defines the same eleven tokens, so the rest of * the stylesheet never names a colour directly and adding a theme is a matter * of adding one block here. * * Tokyo - line 1
Night is the default and is declared on :root so the page has correct * colours before any JavaScript runs, including for readers who block it * entirely. The others are opt-in via [data-theme] on <html>. * * Each block also declares - line 1
`color-scheme`, beside the colours it has to agree * with. That is the one thing CSS custom properties cannot reach: the browser's * *native* controls -- the theme `<select>`'s dropdown list, the verifier's * `<progress>` bar, the file - line 1
picker -- are drawn by the platform, and without * this they are drawn light on a near-black page. Keeping it in the same block * as the palette means a new theme cannot be added with the wrong one. * * In plain words * * This is the list - line 1
of colour schemes -- nine of them, twelve colours each. * * Everything else on the site refers to colours by their job rather than by * name: "the background", "a warning", "secondary text". That is why adding * a whole new scheme means - line 1
adding a block here and changing nothing else, and * why the desktop application can offer exactly the same nine. */ :root, :root[data-theme="tokyo-night"] { color-scheme: dark; --bg: #1a1b26; --bg-soft: #1f2335; --bg-inset: #16161e; - line 1
--border: #2f3549; --fg: #c0caf5; --muted: #737aa2; --accent: #7aa2f7; /* primary */ --accent-2: #bb9af7; /* secondary */ --cyan: #7dcfff; --ok: #9ece6a; - line 41
--warn: #e0af68; --err: #f7768e; } :root[data-theme="gruvbox"] { color-scheme: dark; --bg: #282828; --bg-soft: #32302f; --bg-inset: #1d2021; --border: #504945; --fg: #ebdbb2; --muted: #928374; --accent: #83a598; --accent-2: #d3869b; - line 41
--cyan: #8ec07c; --ok: #b8bb26; --warn: #fabd2f; --err: #fb4934; } :root[data-theme="dracula"] { color-scheme: dark; --bg: #282a36; --bg-soft: #343746; --bg-inset: #21222c; --border: #44475a; --fg: #f8f8f2; --muted: #6272a4; --accent: - line 41
#bd93f9; --accent-2: #ff79c6; --cyan: #8be9fd; --ok: #50fa7b; --warn: #f1fa8c; --err: #ff5555; } :root[data-theme="nord"] { color-scheme: dark; --bg: #2e3440; --bg-soft: #3b4252; - line 81
--bg-inset: #272c36; --border: #4c566a; --fg: #eceff4; --muted: #7b88a1; --accent: #88c0d0; --accent-2: #b48ead; --cyan: #8fbcbb; --ok: #a3be8c; --warn: #ebcb8b; --err: #bf616a; } :root[data-theme="catppuccin"] { color-scheme: dark; --bg: - line 81
#1e1e2e; --bg-soft: #313244; --bg-inset: #181825; --border: #45475a; --fg: #cdd6f4; --muted: #7f849c; --accent: #89b4fa; --accent-2: #cba6f7; --cyan: #94e2d5; --ok: #a6e3a1; --warn: #f9e2af; --err: #f38ba8; } :root[data-theme="everforest"] - line 81
{ color-scheme: dark; --bg: #2d353b; --bg-soft: #343f44; --bg-inset: #272e33; --border: #475258; --fg: #d3c6aa; --muted: #859289; --accent: #a7c080; --accent-2: #d699b6; --cyan: #83c092; --ok: #a7c080; - line 121
--warn: #dbbc7f; --err: #e67e80; } :root[data-theme="solarized"] { color-scheme: dark; --bg: #002b36; --bg-soft: #073642; --bg-inset: #00212b; --border: #0f4b5c; --fg: #93a1a1; --muted: #657b83; --accent: #268bd2; --accent-2: #d33682; - line 121
--cyan: #2aa198; --ok: #859900; --warn: #b58900; --err: #dc322f; } :root[data-theme="rose-pine"] { color-scheme: dark; --bg: #191724; --bg-soft: #1f1d2e; --bg-inset: #14121f; --border: #33304a; --fg: #e0def4; --muted: #6e6a86; --accent: - line 121
#9ccfd8; --accent-2: #c4a7e7; --cyan: #31748f; --ok: #a6da95; --warn: #f6c177; --err: #eb6f92; } /* A light option, because a dark-only site is unreadable in bright sun and * some people simply prefer it. Same tokens, inverted - line 121
relationships. */ :root[data-theme="paper"] { color-scheme: light; - line 161
--bg: #faf4ed; --bg-soft: #f2e9e1; --bg-inset: #fffaf3; --border: #dfd8d0; --fg: #575279; --muted: #797593; --accent: #286983; --accent-2: #907aa9; --cyan: #56949f; --ok: #618774; --warn: #ea9d34; --err: #b4637a; }
website/download.html
- Download VeilVoice
Builds for ten platforms, signed, with verification instructions. This section is also part of the front page , where it sits in context with the rest. Latest release: see GitHub . Every binary is built twice in separate directories and - Download VeilVoice
verified byte-identical before it ships. all releases build from source - Files in the latest release
The current list of files is on the releases page . Always verify before you run it. A download can be corrupted in transit or replaced entirely. Two independent checks are published with every release: a SHA-256 for each file, and an - Files in the latest release
OpenPGP signature over that hash list. The in-browser verifier does the first one for you. VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio - Files in the latest release
routing on Windows is usually provided by VB-CABLE, which is proprietary donationware and is not bundled: install it separately if you want it. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built - Files in the latest release
and tested before release.
website/faq.html
- Questions people ask
Answers to what actually gets asked, including the ones where the answer is that it does not do that. The same answers as docs/FAQ.md , which is where they are written. What does VeilVoice actually do? Can the original voice be recovered - Questions people ask
from the output? Does it hide what I said? Does it send anything anywhere? Will other people still understand me? Can I use it on a call? Can it record, or do I need another program for that? Can it make a video, and why does that need - Questions people ask
ffmpeg? What operating systems does it run on? Is it free, and what licence? Who wrote it, and why a pseudonym? How do I know the download is the one that was published? Does the app lock protect my recordings? What is the decoy - Questions people ask
passphrase? Is it deniability? Can it work out who is speaking in a recording? Does it detect keyloggers? Does it hide its own window from screen recording? What is Failsafe, and does it prevent anything? Has it been audited? What does it - Questions people ask
not protect against? Do I need a GPU, or the internet? What happens if I forget a passphrase? Every claim here is also made somewhere it can be checked: the whitepaper, the audit, the user guide or the source. Where the answer is that - Questions people ask
VeilVoice does not do something, that is the answer rather than a gap. - What does VeilVoice actually do?
It destroys the biometric voiceprint of a speaker and keeps the words. Pitch, formants, timbre, micro timing and the melody of an accent go; what was said stays intelligible and transcribable. That is the whole claim, and the second half - What does VeilVoice actually do?
is deliberate. A scrambler you cannot understand protects nobody, because nobody uses it. - Can the original voice be recovered from the output?
No, and there are two separate reasons rather than one. The measured phase of every frame is discarded and never written anywhere . It is not encrypted or hidden, so there is no key that brings it back and nothing stored from which it - Can the original voice be recovered from the output?
could be reconstructed. And every speaker is mapped onto one canonical register and vocal tract. Many voices go in and one set of characteristics comes out, so several different people arrive at the same place. Even in principle there is - Can the original voice be recovered from the output?
nothing to invert, because the mapping is not one to one. - Does it hide what I said?
No, and it is important that it does not. The words survive on purpose. If the message itself is sensitive, that is a different problem with a different answer, which is encryption. - Does it send anything anywhere?
No. There is no networking code in the project, and the build fails if an HTTP client appears anywhere in the dependency graph. That is a CI job rather than a promise, and it is one of the claims you can check in about ten seconds. The one - Does it send anything anywhere?
exception is the desktop application's check-for-updates button, which you press or do not press. - Will other people still understand me?
Yes. Intelligibility is the design constraint the rest of the engine works around. What changes is who you sound like, not what you said. - Can I use it on a call?
Yes, through a virtual audio cable: VeilVoice takes your microphone and writes the veiled voice to the cable, and the calling program listens to the cable instead of the microphone. VB-CABLE on Windows, BlackHole on macOS, PipeWire on - Can I use it on a call?
Linux. VeilVoice detects them, never bundles them, and the Setup tab will install the ones that can be installed without accepting somebody else's licence on your behalf. Press preview to my headphones first. It runs the same engine and - Can I use it on a call?
sends the result to your own output and nowhere else, so you can hear what you sound like before an interview rather than during one. - Can it record, or do I need another program for that?
It records. The Recording Studio is a tab in the desktop application, and it is there for one reason: a recording made anywhere else is a plaintext file on your disk. That is not a small point, and it is the whole argument for building a - Can it record, or do I need another program for that?
recorder into a tool like this. Open Audacity, record an interview, save it, veil the result with VeilVoice, delete the original: the original was on the disk the whole time, and on flash storage deleting it does not reliably take it back. - Can it record, or do I need another program for that?
Every minute between pressing record and remembering to shred is a minute the unveiled voice exists in the clear, and the encryption VeilVoice does afterwards cannot reach backwards to cover it. So the Studio never writes one. The samples - Can it record, or do I need another program for that?
go from the audio callback into memory the operating system has been asked to keep out of the page file, the WAV is assembled inside that protected memory, and what leaves is already sealed. There is no point in the process at which an - Can it record, or do I need another program for that?
unencrypted recording exists as a file, and there is deliberately no route in the code that would produce one, because a route that existed would eventually be taken. What it records. Any input your system offers, chosen by name: a - Can it record, or do I need another program for that?
microphone, an interface, or a virtual cable carrying your computer's own audio, which is how you capture what is playing rather than what is spoken. Up to eight microphones at once, a person each, if you are recording a room. It keeps the - Can it record, or do I need another program for that?
veiled voice, the original, or both, and it says which before you start. What it keeps. Uncompressed PCM at whatever rate the device is actually running, so nothing is resampled and no lossy codec is involved. Nothing is resampled to a - Can it record, or do I need another program for that?
rate you did not ask for either: a mismatch between devices is refused with both numbers rather than quietly converted, because in a program about how a voice sounds, silently changing the pitch is a wrong answer rather than a small one. - Can it record, or do I need another program for that?
What it does not do. It is not an editor. There is no cutting, no fading, no multitrack arranging, and Audacity is recommended in the Setup tab for exactly that. This is not a comparison anybody has benchmarked, and this page is not going - Can it record, or do I need another program for that?
to claim it is faster or better than an editor that has had twenty years of work: it does one job, which is getting sound onto a disk without it ever being readable on the way. - Can it make a video, and why does that need ffmpeg?
Yes. A rendered conversation can be written as an MP4 whose picture is the same one the player page draws: a circle per speaker in their own colour, whoever is talking lit, a level under each name that moves with the sound, the waveform - Can it make a video, and why does that need ffmpeg?
and a playhead. VeilVoice draws every one of those pictures itself , frame by frame, with its own drawing code and its own typeface. What it does not do is encode the video, and it asks ffmpeg for that one step. Every usable video encoder - Can it make a video, and why does that need ffmpeg?
is a large piece of C, and carrying one would end the thing this project keeps saying about itself: that you can read the whole of it, and that cargo tree shows nothing large. So the last step belongs to a tool many people already have. - Can it make a video, and why does that need ffmpeg?
Without ffmpeg nothing fails. A render still writes the audio, the subtitles, the player page and every picture, then hands you the exact command that turns them into the file. The Setup tab lists ffmpeg under companion software with the - Can it make a video, and why does that need ffmpeg?
install command for your system, and veilvoice companions --install ffmpeg does the same from the command line. Nothing is downloaded by VeilVoice, there either. An install runs the package manager your machine already has, which is why - Can it make a video, and why does that need ffmpeg?
the promise that this program ships no network client survives having an install button at all. - What operating systems does it run on?
Eleven platforms have signed builds, including Windows, macOS on both architectures, Linux, FreeBSD and OpenBSD. The microphone and camera monitor works on Windows and Linux; macOS exposes no public interface for it, and the tab says so - What operating systems does it run on?
rather than showing an empty list as good news. - Is it free, and what licence?
Free software, GPL-3.0-or-later. The source is the whole of it: no separate paid version, no telemetry, nothing held back. - Who wrote it, and why a pseudonym?
It is published under the name tilas01. The pseudonym is deliberate, and it costs something concrete rather than nothing: kernel-level enforcement on Windows and macOS needs a certificate issued to a verified legal identity, and so does - Who wrote it, and why a pseudonym?
macOS notarisation. Those are unavailable here, and the roadmap says so rather than describing them as future work. tilas01 is the developer: the architecture, the decisions about what this does and refuses to do, and a great deal of the - Who wrote it, and why a pseudonym?
code are theirs directly. Some of the code, the documentation and this website were drafted with the help of Claude, Anthropic's assistant, working to that direction. Nothing reaches a release unread. Every change is reviewed, built and - Who wrote it, and why a pseudonym?
tested first, and the audit rounds in docs/AUDIT.md are the record of that review finding its own mistakes. - How do I know the download is the one that was published?
Run veilvoice verify in the folder you downloaded to, or drop the archive on the desktop application's verify tab. That is the whole instruction, and it does every check below in the right order. The checks, and the order is the point: The - How do I know the download is the one that was published?
signing key's fingerprint , compared against the one published in the README, on the website and in every release's notes. The signature over SHA256SUMS , verified with that key. The archive's hash , compared against the now trusted list. - How do I know the download is the one that was published?
Every file you extracted , compared against CONTENTS.sha256 , which the release publishes and SHA256SUMS covers. This is the one that tells you the program you are about to run is the published one, rather than only that the zip was. - How do I know the download is the one that was published?
Checking the hash first and the signature afterwards proves only that the file matches a list that might itself have been replaced. veilvoice verify does it in the right order, needs no GnuPG installed, and has no flag that skips a step, - How do I know the download is the one that was published?
because a verification with a skip switch is decorative. If you do have GnuPG it uses that too: it adds the key to your keyring, tells you it did and how to remove it, runs gpg --verify , and fails if the two implementations disagree. It - How do I know the download is the one that was published?
also prints the commands so you can run them yourself, which is the part no program can do for you. Releases before v0.1.15 carry no CONTENTS.sha256 and stop at check 3, which the tool says at the time. You can also build the repository - How do I know the download is the one that was published?
yourself and compare what comes out against the published hashes for your platform. - Does the app lock protect my recordings?
No. The app lock guards the application: it stops somebody who walks up to your unlocked computer from opening VeilVoice and reading what is in it. A recording is protected by its own encryption, which is on by default and is a separate - Does the app lock protect my recordings?
thing. Somebody who can read your disk can read the lock file. The lock is worth having and it is not a substitute for encrypting the recording, and the application says so where you set it. - What is the decoy passphrase? Is it deniability?
A second passphrase that opens VeilVoice with nothing in it, so you can comply with somebody standing over you without handing over your recordings. It does not give you deniability. VeilVoice is open source and this feature is documented, - What is the decoy passphrase? Is it deniability?
so anybody who recognises the program knows the decoy exists and can ask for the other passphrase. It buys you a way to hand something over. It does not buy you an argument that there is nothing more, and that sentence is the first thing - What is the decoy passphrase? Is it deniability?
the feature prints. There is no destructive duress passphrase and there will not be one. On flash storage a write does not overwrite: the controller puts new data in a fresh page and leaves the old one until it is collected, which may be - What is the decoy passphrase? Is it deniability?
never. A passphrase that claimed to destroy your recordings would be believed at exactly the moment being wrong costs the most. - Can it work out who is speaking in a recording?
No. Group mode gives each speaker a different voice, and it works from a plan you write saying who speaks when. VeilVoice does not guess, because a program that guessed would sometimes put one person's words in another person's voice and - Can it work out who is speaking in a recording?
you would not find out by listening: the result would sound perfectly fine. Telling voices apart automatically is diarisation, it means shipping a trained model, and this project ships none. - Does it detect keyloggers?
No, and nothing can. The mechanisms a logger uses are the mechanisms accessibility software, password managers and remote support tools use, and software written to hide is written to hide from a process list. What veilvoice input does is - Does it detect keyloggers?
name the programs currently running that are able to see your keyboard and mouse, and say what each is for. It prints, with every result, that a clean answer proves nothing. Somebody who reads "nothing found" as "nothing there" has been - Does it detect keyloggers?
made less safe by running it. - Does it hide its own window from screen recording?
No. Excluding a window from capture needs a foreign function call, and every crate here carries #![forbid(unsafe_code)] , which is on the front page and is one of the things you can check quickly. veilvoice capture says so rather than - Does it hide its own window from screen recording?
implying otherwise. Worth noting that it would not buy much: a window excluded from capture is still visible to a camera pointed at the screen, and the thing VeilVoice protects is a file rather than a picture of a window. - What is Failsafe, and does it prevent anything?
It notices the moment another program picks up a real microphone while you are being veiled, and by default it closes that program. The accident it exists for: you are talking through VeilVoice, you plug in a headset, the operating system - What is Failsafe, and does it prevent anything?
offers the new microphone, the calling program takes it, and from that moment your real voice is going out with the veiled window still open in front of you and the meters still moving. Nobody notices, because there is nothing to notice. - What is Failsafe, and does it prevent anything?
It notices; it does not prevent, and the difference is printed every time. Stopping the operating system handing over a microphone needs exclusive capture of every input device or a driver, and this project ships neither. Failsafe sees it - What is Failsafe, and does it prevent anything?
within about a second and acts. That moment is short and it is not zero. - Has it been audited?
Nine rounds, eighty-three defects found and fixed, all written up individually in docs/AUDIT.md with what each one was and how it was found. By the author. There has been no outside review, and that is recorded at the top of the audit - Has it been audited?
rather than left for you to wonder about. Three rounds of wider tools and wider scope have each found real defects in code a previous round called clean, which is a measurement of what author-only review is worth. - What does it not protect against?
The honest list, which is also in the whitepaper: Anyone who has the original recording. VeilVoice changes a copy. What you say. Names, places and details identify you regardless of voice. Everything around the audio. Metadata is stripped - What does it not protect against?
from files VeilVoice writes; it cannot strip the metadata of a platform you upload to. A compromised machine. Software that is already running as you can read the microphone before VeilVoice does. Being the only person it could be. If - What does it not protect against?
three people were in the room, changing the voice does not change that. - Do I need a GPU, or the internet?
Neither. It runs on the processor, offline, and the hardware report says what it found and why the engine does not use it: the work per frame is small and sending it to a graphics card and back costs more than doing it. - What happens if I forget a passphrase?
The recording stays sealed. There is no recovery, no backdoor and no reset, because any of those would be a way in for somebody else too. The app lock can be removed by deleting its file, which does not affect encrypted recordings. - What happens if I forget a passphrase?
VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is usually provided by VB-CABLE, which is proprietary donationware and is - What happens if I forget a passphrase?
not bundled: install it separately if you want it. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
website/guide.html
- How to use VeilVoice
A walkthrough: anonymise a file, scramble a microphone, verify a download. This section is also part of the front page , where it sits in context with the rest. There are two programs in the archive: veilvoice-gui , the desktop app, and - How to use VeilVoice
veilvoice , the command line. They share one engine, so anything one can do the other can. Nothing installs a service, writes to a registry, or phones home. Delete the folder and it is gone. - Give it a recording
wav, mp3, flac, ogg, m4a and friends. Open the desktop app on the anonymise file tab and choose one, or point the command line at it. Roughly 90× faster than real time, so an hour of audio takes well under a minute. veilvoice anonymise - Give it a recording
interview.mp3 -o clean.wav - The voiceprint is destroyed, the words are kept
Each frame's measured phase is thrown away and resynthesised, and pitch register, vocal-tract length and spectral tilt are each collapsed onto one canonical value, so a whole population of speakers lands on the same output and there is - The voiceprint is destroyed, the words are kept
nothing left to invert. What comes out is understandable, transcribable, and belongs to nobody. The same energy, and no shared structure. Left, a voice as it was recorded; right, what is left after the phase relationship that identified - The voiceprint is destroyed, the words are kept
the speaker has been destroyed. The words survive the journey; the speaker does not. - It is encrypted before it reaches the disk
The result is sealed into a .veil container as it is written, so -o clean.wav produces clean.wav.veil . The WAV is built in memory and encrypted there, so the plaintext never exists on disk, not even for a moment, because a file that is - It is encrypted before it reaches the disk
written and then deleted cannot be reliably taken back on flash storage. veilvoice decrypt clean.wav.veil -o clean.wav # when you want it back - Or scramble your microphone as you speak
The Studio tab routes your veiled voice into a virtual audio cable. Every application on the machine, whether a call, a stream or a recorder, then receives that instead of you, with no per-app setup. The same tab keeps a take of it, sealed - Or scramble your microphone as you speak
into a vault, when you ask for one. - Check nothing else is listening
De-identifying your voice on a call achieves little if a second program is recording the raw microphone at the same time. The monitor tab names what is holding your microphone and camera and warns the moment something starts. - Lock the app behind you
Set a password on the lock tab and VeilVoice will not open without it. The lock button in the header locks it immediately and clears the session passphrase with it. - The two passwords, and why there are two
- The app lock
Decides whether VeilVoice opens at all. Argon2id verifier, rate limited, three attempts free and then a doubling wait. - The recording passphrase
Encrypts the files it writes. Argon2id at 256 MiB, or seal to a post-quantum hybrid public key instead. They are deliberately different secrets. If one password did both, then opening the app would be the same act as unsealing everything - The recording passphrase
it had ever written, which is the opposite of what a lock is for. VeilVoice keeps the two derivations domain-separated, so typing the same passphrase in both places still does not produce two copies of one value. Use two anyway: one guess - The recording passphrase
that opens both defeats the point regardless of the maths. The app lock is not tamper-proof, and cannot be. A program running on your computer has nowhere to hide a secret from that computer: anyone who can write to your files can delete - The recording passphrase
the lock, and anyone holding the disk can attack the stored password hash offline. It protects against casual access , meaning the person who sits down at your unlocked session, which is a real and common threat, and is exactly what the - The recording passphrase
unlock screen says it is for. If someone taking your disk is the threat, encrypt the whole volume. read the wiki the app lock in detail how it works VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key - The recording passphrase
8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is usually provided by VB-CABLE, which is proprietary donationware and is not bundled: install it separately if you want it. Written and maintained by tilas01 , who - The recording passphrase
holds the copyright. Every change is reviewed, built and tested before release.
website/index.html
- below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
VeilVoice destroys the biometric voiceprint of a speaker, meaning pitch, formants, timbre, micro-timing and the melody of an accent, so that neither software nor a human can re-identify them or reconstruct the original voice, while the - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
words stay clean and transcribable . download verify a download source No network code in the dependency graph CI fails the build if an HTTP client appears No unsafe code, in any of the 13 crates 1659 tests, and 20 more suites for the - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
website Reproducible builds: each target built twice, and compared Releases signed; the fingerprint is published everywhere Measured phase is discarded every frame, and never stored Every speaker is mapped onto one canonical voice - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
Many-to-one, so there is no inverse to compute Modulation seeded by ChaCha20; the seed never leaves the process A forward-secure ratchet, every two seconds Recordings are encrypted at rest by default X25519 + ML-KEM-768: hybrid, - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
post-quantum XChaCha20-Poly1305, and Argon2id The app lock is a verifier, not disk encryption Tamper detection detects; it does not prevent Secure erase is unreliable on flash, and the docs say so Accent removal is partial, and that limit - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
is documented Metadata stripped: tags, EXIF, GPS No telemetry. No accounts. No automatic update check Ten platforms, from one reproducible build The artwork is generated by a script you can read Every crate and every source file has a - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
generated page 196 defects found and fixed across thirty-three audit rounds A complete edition of this site that runs no scripts Free software, GPL-3.0-or-later , and the same point is repeated in the welcome dialog. That is not - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
duplication for its own sake: the dialog is drawn by `js/legal.js`, so a reader with JavaScript off never sees it. A notice about having JavaScript off has to be somewhere that works with JavaScript off. --> JavaScript is off, so this site - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
will not be displayed with it. You are being served the HTML and CSS only. This page still reads fine that way, but the hash verifier and the live repository panel need scripts and will not appear. The JavaScript-free edition is built for - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
this and has everything in the page, including a complete search index your browser's own find-in-page can search. The JavaScript switch in the header shows off and is locked, because nothing on a page can turn scripts back on -- only your - below states the same thing once, and a reader using a screen reader should hear it once. The picture this replaces was
browser can. - WHAT HAPPENS TO YOUR RECORDING
interview.wav voiceprint present words yours what you recorded veilvoice phase discarded on your machine, offline interview-veiled.wav voiceprint destroyed words unchanged the same words, in a voice that is not yours nothing leaves this - WHAT HAPPENS TO YOUR RECORDING
machine What goes in is a file; what comes out is a file. There is no account, no upload and no queue: the whole of it happens on the machine you are sitting at, and CI fails the build if a network client appears anywhere in the dependency - WHAT HAPPENS TO YOUR RECORDING
graph. What this picture does not show is the limit. The voiceprint goes; what you said stays, because the output is meant to be listened to and transcribed. If the words themselves identify you: a name, a place, a story only you could - WHAT HAPPENS TO YOUR RECORDING
tell. VeilVoice has not touched that and does not claim to. Segmental accent cues, which phonemes you actually produced, survive for the same reason. See security and cryptography for the full scope. - WHAT IT ACTUALLY DOES
- Anonymise a recording
wav, mp3, flac, ogg, m4a in, a clean WAV out, with metadata stripped. Roughly 90× faster than real time. - Scramble a microphone live
Route the veiled voice into a virtual audio cable and every application on the machine, whether calls, streams or recorders, receives it instead of you. - Encrypt at rest, by default
Every recording is sealed as it is written, using an X25519 + ML-KEM-768 hybrid , so one captured today is not readable by a quantum adversary tomorrow. Turning that off makes you read why first. - Lock the app
A separate password gates the desktop app, rate limited and Argon2id -derived. It stops someone who picks up your unlocked computer. It is not tamper-proof, and the unlock screen says so. - Strip metadata
Audio tags, image EXIF and GPS. A de-identified voice is worthless if the file still says who recorded it, where, and on what. - Work as a Rust library
Every crate is a normal dependency. The engine is allocation-free and safe to call from inside an audio callback. - Transcribe without giving up your voice
Speech-to-text needs the words, not the voiceprint. Anonymise first and the service gets speech it can transcribe and a voice belonging to nobody. - See what is listening
Which applications are holding your microphone or camera, right now, with an alert the moment one starts. De-identifying a call achieves little if a second program is recording the raw microphone beside it. macOS exposes no interface for - See what is listening
this, so nothing is reported there rather than something guessed. - Detect tampering with its own files
A signed manifest of what VeilVoice should be, and a check that reports what changed. Where the system's own auditing allows it, it names the program responsible, and says plainly when it cannot see, rather than implying nothing happened. - Erase a recording
Overwrite and unlink, with an honest account of what that is worth. On flash storage the controller may have written the data somewhere the filesystem can no longer reach, so this is not a guarantee and is not described as one. - Verify a download without GnuPG
veilvoice verify is part of the program you downloaded, and the desktop application's Verify tab runs the same code. The signing key is compiled in, so it checks the signature over the hash list and then the file on a machine with no GnuPG - Verify a download without GnuPG
and no network. It distinguishes a download being intact from a build being reproducible , because those are different claims. Honest scope. “Fill the spectrogram with noise” and “stay understandable” are mutually exclusive, because noise - Verify a download without GnuPG
that covers the voice covers the words. VeilVoice targets the achievable goal: irreversible speaker de-identification with intelligibility preserved on purpose . If the message must also be secret, encrypt it; that is a separate problem - Verify a download without GnuPG
with a separate answer. The same applies to accent . Its melody and colour do not survive. What no signal-level transform can change is which phonemes you produced , and at that level the accent and the words are the same thing, so a - Verify a download without GnuPG
strong regional accent may still be audible. - DOWNLOAD
Latest release: see GitHub . Every binary is built twice in separate directories and verified byte-identical before it ships. all releases build from source - Files in the latest release
The current list of files is on the releases page . Always verify before you run it. A download can be corrupted in transit or replaced entirely. Two independent checks are published with every release: a SHA-256 for each file, and an - Files in the latest release
OpenPGP signature over that hash list. The in-browser verifier does the first one for you. - SO YOU HAVE DOWNLOADED IT, NOW WHAT
There are two programs in the archive: veilvoice-gui , the desktop app, and veilvoice , the command line. They share one engine, so anything one can do the other can. Nothing installs a service, writes to a registry, or phones home. Delete - SO YOU HAVE DOWNLOADED IT, NOW WHAT
the folder and it is gone. - Give it a recording
wav, mp3, flac, ogg, m4a and friends. Open the desktop app on the anonymise file tab and choose one, or point the command line at it. Roughly 90× faster than real time, so an hour of audio takes well under a minute. veilvoice anonymise - Give it a recording
interview.mp3 -o clean.wav - The voiceprint is destroyed, the words are kept
Each frame's measured phase is thrown away and resynthesised, and pitch register, vocal-tract length and spectral tilt are each collapsed onto one canonical value, so a whole population of speakers lands on the same output and there is - The voiceprint is destroyed, the words are kept
nothing left to invert. What comes out is understandable, transcribable, and belongs to nobody. The same energy, and no shared structure. Left, a voice as it was recorded; right, what is left after the phase relationship that identified - The voiceprint is destroyed, the words are kept
the speaker has been destroyed. The words survive the journey; the speaker does not. - It is encrypted before it reaches the disk
The result is sealed into a .veil container as it is written, so -o clean.wav produces clean.wav.veil . The WAV is built in memory and encrypted there, so the plaintext never exists on disk, not even for a moment, because a file that is - It is encrypted before it reaches the disk
written and then deleted cannot be reliably taken back on flash storage. veilvoice decrypt clean.wav.veil -o clean.wav # when you want it back - Or scramble your microphone as you speak
The Studio tab routes your veiled voice into a virtual audio cable. Every application on the machine, whether a call, a stream or a recorder, then receives that instead of you, with no per-app setup. The same tab keeps a take of it, sealed - Or scramble your microphone as you speak
into a vault, when you ask for one. - Check nothing else is listening
De-identifying your voice on a call achieves little if a second program is recording the raw microphone at the same time. The monitor tab names what is holding your microphone and camera and warns the moment something starts. - Lock the app behind you
Set a password on the lock tab and VeilVoice will not open without it. The lock button in the header locks it immediately and clears the session passphrase with it. - The two passwords, and why there are two
- The app lock
Decides whether VeilVoice opens at all. Argon2id verifier, rate limited, three attempts free and then a doubling wait. - The recording passphrase
Encrypts the files it writes. Argon2id at 256 MiB, or seal to a post-quantum hybrid public key instead. They are deliberately different secrets. If one password did both, then opening the app would be the same act as unsealing everything - The recording passphrase
it had ever written, which is the opposite of what a lock is for. VeilVoice keeps the two derivations domain-separated, so typing the same passphrase in both places still does not produce two copies of one value. Use two anyway: one guess - The recording passphrase
that opens both defeats the point regardless of the maths. The app lock is not tamper-proof, and cannot be. A program running on your computer has nowhere to hide a secret from that computer: anyone who can write to your files can delete - The recording passphrase
the lock, and anyone holding the disk can attack the stored password hash offline. It protects against casual access , meaning the person who sits down at your unlocked session, which is a real and common threat, and is exactly what the - The recording passphrase
unlock screen says it is for. If someone taking your disk is the threat, encrypt the whole volume. read the wiki the app lock in detail how it works - WHAT IT LOOKS LIKE
Every picture here is of the current build. The window captures are taken by a script that drives the release build and photographs each tab, and the terminal drawings are generated from the command output committed beside them, so a - WHAT IT LOOKS LIKE
picture that disagrees with the program fails the build rather than sitting here saying something untrue. Every one of these is below in the demonstration too, one at a time and larger. Go there to watch the command line type itself out - WHAT IT LOOKS LIKE
first. Anonymise a file One recording in, a voice nobody owns out. Encrypted at rest by default. Group mode Several people in one recording, a name and a colour each, a different voice each. Recording Studio A microphone, scrambled as it - WHAT IT LOOKS LIKE
runs, into a virtual cable anything else can hear, and a take kept straight into a locked vault if you want one. The vault opens with both passphrases at once, and with neither on its own. Recording Browser What is in the vault, listed - WHAT IT LOOKS LIKE
without opening any of it. Play one out of locked memory, take it out as a page or a video, or fill the folder with decoys so which vault is yours stops being visible. Monitor Which programs are using the microphone and the camera, and - WHAT IT LOOKS LIKE
what this cannot see. Lock The app lock, and a plain account of what it is and is not worth. Verify a download Drop the download, the SHA256SUMS and its signature on the window. Nothing is fetched, and the key is compiled in. Settings Nine - WHAT IT LOOKS LIKE
palettes, your own if you write one, motion, and which tabs are shown. Install Offered only to a portable copy. An installed one does not show this tab. About Versions, scope, and the update check that happens when you press it. - The command line
Everything the window does, and some things it does not. These are drawings rather than photographs: they follow your palette, and the text in them can be selected and searched. veilvoice --help veilvoice conversation --help veilvoice - The command line
anonymise --help veilvoice conversation render --help veilvoice conversation preview --help veilvoice companions --help - WHAT IT SOUNDS LIKE, IN ONE PICTURE
Normal voice framed, 1024 samples phase measured phase discarded pitch and formants moved modulation seed rolled words left alone Anonymised voice The bars marked “normal voice” are in step with one another. That shared phase relationship - WHAT IT SOUNDS LIKE, IN ONE PICTURE
is most of what makes a voice recognisable as yours : the precise waveform, the micro-timing, the way your vocal tract shapes every harmonic. The bars marked “anonymised voice” carry the same energy and none of the relationship. The list - WHAT IT SOUNDS LIKE, IN ONE PICTURE
between them is what actually happens, in order, and it is six steps rather than one: Framed. The audio is cut into overlapping frames of 1024 samples and each one is turned into frequencies. Everything below happens per frame, which at 48 - WHAT IT SOUNDS LIKE, IN ONE PICTURE
kHz is about 187 times a second. Phase measured. A frame has a magnitude, which is how much of each frequency there is, and a phase, which is how those frequencies line up in time. The second one is a great deal of what makes a voice - WHAT IT SOUNDS LIKE, IN ONE PICTURE
recognisably yours. Phase discarded. Not scrambled, not encrypted, not hidden: thrown away and replaced, and never written anywhere. That is the step with no inverse. Nothing is left from which the original timing could be recovered, by us - WHAT IT SOUNDS LIKE, IN ONE PICTURE
or by anybody else. Pitch and formants moved. Every speaker is mapped onto one canonical register and one vocal-tract scale. Many voices in, one set of characteristics out, which is the other reason there is nothing to undo: several - WHAT IT SOUNDS LIKE, IN ONE PICTURE
different people arrive at the same place. Modulation seed rolled. How far things move is drawn from a cryptographic random stream, and that stream rolls forward at an interval drawn fresh at every launch. Audio from before a roll is - WHAT IT SOUNDS LIKE, IN ONE PICTURE
sealed off behind it, so there is no fixed period to observe. Words left alone. None of it touches which words were said. The words survive all of it. The output is meant to be listened to, shared, understood and transcribed. That is the - WHAT IT SOUNDS LIKE, IN ONE PICTURE
whole design: a scrambler you cannot understand protects nobody, because nobody uses it. - THE COMMAND LINE, TYPED OUT
Five sessions, recorded from the real programs on a real terminal, prompts and passphrases included. Pick one and it types itself out. The only invented thing is the speed: a session that arrives all at once is a paste rather than a - THE COMMAND LINE, TYPED OUT
session. These play with scripts on. With scripts off, the drawings of each help screen are above , and the same transcripts are in assets/screenshots/ in the repository. - THE COMMAND LINE, ONE JOB AT A TIME
Each of these is a real command. Every one is checked against this build's own --help when the page is generated, so a command here that no longer exists stops the build rather than teaching you something that fails. - EVERY SCREEN, AND WHAT IT IS FOR
Photographs of the real window, one per tab. Pick a tab and its picture is below. These are captures of the program running, not drawings of it, and the build re-takes them so a screen that changes cannot leave a stale picture here. - VERIFY A DOWNLOAD
Drop the file you downloaded here. It is hashed locally, in your browser , using the built-in WebCrypto API, so there is no upload and no server that could receive it. Read js/verify.js ; that file is the whole implementation. click or - VERIFY A DOWNLOAD
drop a release archive here no file hashed yet Paste the expected hash, or a whole line from SHA256SUMS : - The stronger check: the signature
A hash proves the file matches a list. The signature proves the list came from the maintainer. Browsers cannot verify OpenPGP, so this part runs on your machine: gpg --import veilvoice-signing-key.asc gpg --verify SHA256SUMS.asc SHA256SUMS - The stronger check: the signature
sha256sum -c SHA256SUMS --ignore-missing Signing key fingerprint. Check that gpg --verify names this exact key, not merely “a good signature”: 8101 FB3B B28D 02FB 239E 0CDF 9CC1 C7E7 A9B5 833A download the public key . The user ID is - The stronger check: the signature
exactly tilas01 , with no e-mail address attached. - Or let it do all of that for you
veilvoice verify is built into the program itself: it ships in every release because veilvoice does, and it needs no installer and no separate download. Open a terminal in the folder you downloaded to and run it, or open the desktop - Or let it do all of that for you
application's verify tab and drop the archive on the window. One press does all four steps: the signature over SHA256SUMS ; the archive, against SHA256SUMS ; CONTENTS.sha256 , against SHA256SUMS ; every file you extracted , against - Or let it do all of that for you
CONTENTS.sha256 , and it names anything in that folder the release never published. Step 4 is the one worth having. A hash over the archive tells you the zip is genuine; this tells you the program you are about to run is. Releases before - Or let it do all of that for you
v0.1.15 carry no contents list and are checked as far as step 2, which it says at the time. If GnuPG is on your machine it is used as well: the key is added to your keyring, gpg --verify is run, and what GnuPG said is shown. The signature - Or let it do all of that for you
is then checked by two independent implementations. The commands above are still printed for you to run yourself, because a program telling you a download is genuine came out of that download, only you typing them makes the answer - Or let it do all of that for you
independent of it. Every one of those commands, written out with what each answer proves , sits with the release you are downloading: the whole check in one line, the signature over the hash list on its own, one file against that list, one - Or let it do all of that for you
file against a hash with no list at all, the desktop application's three slots, and the build that answers the harder question. Every command there is checked against the program's own help when the page is generated. - SECURITY, IN FULL
- Why the transform cannot be undone
Three independent mechanisms, each individually lossy. Reversing the output means defeating all three. Mechanism What it destroys Phase discard Every frame's measured phase is thrown away and a synthetic one generated. Phase encodes the - Why the transform cannot be undone
exact waveform and the speaker's micro-timing. It is never stored, and infinitely many waveforms share any given magnitude spectrogram. Many-to-one normalisation Pitch register, vocal-tract length and long-term spectral tilt are each - Why the transform cannot be undone
collapsed onto a single canonical value. A whole population of speakers maps to the same output, so there is nothing to invert. CSPRNG modulation The residual transform changes every frame from a ChaCha20 stream whose seed comes from the - Why the transform cannot be undone
OS CSPRNG, lives only in page-locked RAM, and is zeroized on drop. There is no fixed transform to undo. Rolling seed Every two seconds by default the stream draws a fresh seed from its own output and restarts. ChaCha20 does not run - Why the transform cannot be undone
backwards, so each roll permanently seals off the audio before it: a long recording is a chain of short streams, not one. Configurable, and inaudible: parameters glide across a roll and phase offsets ease over half a second. - At-rest encryption
Layer Primitive Why Password → key Argon2id (RFC 9106) Memory-hard, so GPU and ASIC cracking gains little. Cost parameters travel with the file so old files still open. Public-key X25519 + ML-KEM-768 hybrid An attacker must break both . - At-rest encryption
Guards against harvest-now-decrypt-later: a recording stored today may be attacked decades from now. Payload XChaCha20-Poly1305 192-bit random nonces remove the counter-management failure mode entirely. Header Authenticated as associated - At-rest encryption
data An attacker cannot downgrade the stored KDF cost to make cracking cheap, because tampering makes decryption fail. Keys in memory Page-locked, zeroized, constant-time Keys stay out of the swap file and are wiped on drop. Comparison - At-rest encryption
leaks no timing. Stated plainly: page-locking keeps keys off disk, not away from an attacker who can already read this process's memory, and hibernation writes RAM to disk wholesale and defeats it. A passphrase still sitting in a text - At-rest encryption
field has not reached that protection yet, which is why it is wiped the moment it is used. Recordings are sealed in memory and written once. An encrypted recording never exists on disk in the clear, because a plaintext file that is written - At-rest encryption
and then deleted cannot be reliably taken back on flash storage. - The app lock, and exactly what it is worth
VeilVoice can sit behind a password of its own, separate from the one that encrypts recordings, so that opening the app is not the same act as unsealing everything it has written. What it is What that means An Argon2id verifier, not a key - The app lock, and exactly what it is worth
A password hash is stored and compared in constant time. It encrypts nothing, because there is nothing local it could usefully encrypt. Rate limited, and the limit persists Three attempts are free; then the wait doubles from 5 s to a - The app lock, and exactly what it is worth
15-minute cap. The count is written to disk after every attempt, so killing the app does not hand an attacker a fresh budget. Domain separated Type the same passphrase in both places and you still do not end up with two copies of one - The app lock, and exactly what it is worth
value. Not tamper-proof, and it cannot be. A local application has nowhere to hide a secret from the machine it runs on. Anyone who can write to your files can delete the lock; anyone holding the disk can edit the attempt counter, move the - The app lock, and exactly what it is worth
clock, or attack the stored hash offline. This protects against casual access , meaning the person who sits down at your unlocked session. If the disk is the threat, the answers are full-volume encryption and the at-rest encryption above, - The app lock, and exactly what it is worth
not this. - Libre, and what that buys you
GPL-3.0-or-later. You may use, study, modify and redistribute it; derivatives stay free under the same terms. No unsafe anywhere. Every crate carries #![forbid(unsafe_code)] , including the page-locking path. Whole classes of - Libre, and what that buys you
memory-corruption bugs are impossible by construction. Offline by construction. No telemetry and no accounts, and CI fails the build if an HTTP client so much as enters the dependency graph. One thing reaches the network and only when you - Libre, and what that buys you
press it: the desktop app's check for updates button, which runs then and at no other time, sends nothing about you, and downloads nothing. It borrows your system's own transfer tool, exactly as the release verifier does. Reproducible. - Libre, and what that buys you
Pinned toolchain, committed lockfile, path-remapped builds. Rebuild a release and confirm it matches, byte for byte. Artwork generated from source. Every icon and the banner come out of a readable script, not a committed binary blob. This - Libre, and what that buys you
website too. No CDN, no web fonts, no analytics, no cookies. The only third-party request is the optional repository panel, and it is a button you press. Audited by tilas01 , the author, who wrote and reviewed it. Be clear about what that - Libre, and what that buys you
is worth: a maintainer audit catches what the author can see, and no external firm or independent researcher has reviewed this code . The cryptography uses standard, well-reviewed primitives rather than anything invented here, and the - Libre, and what that buys you
de-identification argument is verifiable by reading two source files. Until an independent review exists, the source is the strongest verification available to you. - THE REPOSITORY, LIVE
This panel fetches from api.github.com , which learns your IP address. GitHub already serves this page, so for most visitors that changes nothing, but it is your call, so nothing loads until you ask. load live repository data … stars … - THE REPOSITORY, LIVE
forks … open issues GPL-3.0 licence The README renders here once loaded. VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is - THE REPOSITORY, LIVE
usually provided by VB-CABLE, which is proprietary donationware and is not bundled: install it separately if you want it. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before - THE REPOSITORY, LIVE
release. so `tools/site/split.py` can see which pages actually carry the markup: `demo-data.js` is the largest script on the site and eight section pages used to download it to do nothing with. --> close close close close close close close - THE REPOSITORY, LIVE
close close close
website/js/legal.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The welcome dialog: licence terms, liability waiver, and the disclosure that // this project was built with AI assistance. // // # Shown once per session, and never phoned home // // - line 1
Acceptance is recorded in sessionStorage, so it survives navigation between // pages of this site and is gone when the tab closes. It is not a cookie, so it // is never attached to a request; there is no server here to receive it and no // - line 1
analytics to correlate it with. That is also why there is no "remember me // forever" option -- a permanent record would be more data about you than this // site has any business keeping. // // # Why it is a real gate and not a banner // - line 1
// The waiver's section 4 is the part that matters: it says plainly that this // software hides *who said it*, not *what was said*. Someone who assumes the // opposite could send a recording believing its contents are protected. A // - line 1
dismissible strip at the bottom of the page does not carry that. // // The page underneath is inert while the dialog is open -- focus is trapped, and // the content is hidden from assistive technology -- so the gate cannot be // stepped - line 1
around by tabbing past it. // // In plain words // // This is the notice you see the first time you open the site: the licence, // what this project does not promise, and the fact that it was built with AI // assistance. // // It remembers - line 1
that you read it for as long as the tab is open, and forgets // when you close it. Nothing about that is sent anywhere. There is no // "remember me forever" option because keeping a permanent note about you // would be more than a privacy - line 1
tool's website has any business keeping. (function () { "use strict"; var KEY = "veilvoice-accepted-v1"; - line 41
function accepted() { try { return sessionStorage.getItem(KEY) === "yes"; } catch (e) { return false; } } function remember() { try { sessionStorage.setItem(KEY, "yes"); } catch (e) { /* private mode */ } } function build() { var overlay = - line 41
document.createElement("div"); overlay.className = "legal-overlay"; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-labelledby", "legal-title"); overlay.innerHTML = [ // The - line 41
box takes focus when the gate opens, so `tabindex="-1"`: reachable // by script, never by the Tab key. See `show`. '<div class="legal-box" tabindex="-1">', ' <h2 id="legal-title">BEFORE YOU USE THIS</h2>', // Deliberately not the AI - line 41
notice: the first thing a reader sees is the // one misunderstanding that could actually harm them. ' <p>VeilVoice destroys the <b>biometric voiceprint</b> in a recording --', ' pitch, formants, timbre, and the melody of an accent -- so - line 41
the speaker', ' cannot be identified or reconstructed. It is free software under the', ' <b>GNU General Public License v3 or later</b>, and it is provided with', ' non-commercially -- and it is provided with', ' <b>absolutely no - line 41
warranty</b>.</p>', ' <p class="legal-warn"><b>It does not hide what you said.</b>', ' Intelligibility is preserved on purpose -- the words remain in the output', ' and can be transcribed. If the message itself is sensitive, encrypt - line 41
it.</p>', ' <p>This project was developed with <b>AI assistance (Claude, by', ' Anthropic)</b> and has been reviewed and audited by <b>tilas01</b>. That', ' is a maintainer audit: no external firm or independent researcher has', ' reviewed - line 41
this code. It is disclosed so you can judge for yourself how', ' much to verify before relying on it -- the whole project is published', - line 81
' under the GPL precisely so that you can read it.</p>', ' <details>', ' <summary>The rest of the terms, in short</summary>', ' <ul>', ' <li><b>You may</b> run it for any purpose, study it, change it,', ' and redistribute it -- including - line 81
commercially. There is no', ' NonCommercial clause.</li>', ' <li><b>You must</b> pass on the source, keep the licence notices,', ' state your changes, and license derivatives under the GPL too.</li>', ' <li><b>No warranty, no - line 81
liability.</b> The author disclaims all', ' liability for data loss, for a key or passphrase you destroy, and', ' for consequences of being identified despite using this.</li>', ' <li><b>Limits.</b> It does not remove a strong accent - line 81
entirely, does', ' not sanitise background audio, and does not help against an attacker', ' already running code on your machine.</li>', ' <li><b>Destructive features are irreversible by design</b> and are', ' gated behind an explicit - line 81
confirmation.</li>', ' <li><b>Privacy.</b> This site sets no cookies, runs no analytics and', ' loads nothing from a third party. Your choices stay in your browser.</li>', ' </ul>', ' <p>Full texts: ', ' <a - line 81
href="user-agreements/LEGAL-WAIVER.txt">LEGAL-WAIVER.txt</a>, ', ' <a href="user-agreements/LICENCE-PLAIN-ENGLISH.txt">the licence in plain English</a>, ', ' <a href="user-agreements/LICENSE.txt">LICENSE.txt</a>.</p>', ' </details>', ' - line 81
<label class="legal-check">', ' <input type="checkbox" id="legal-waiver">', ' <span>I have read and understood the disclaimer and liability waiver,', ' including what this software does <b>not</b> do.</span>', ' </label>', ' <label - line 81
class="legal-check">', ' <input type="checkbox" id="legal-licence">', ' <span>I have read the licence (GPL-3.0-or-later) and will comply with it.</span>', ' </label>', // Stated here as well as in the page's <noscript>, because the two // - line 81
reach different readers: this dialog is drawn by script, so somebody // with JavaScript off never sees a word of it. - line 121
' <p>There are <b>two editions</b> of this site. This one runs scripts.', ' The <b>JavaScript</b> switch in the header serves you the other:', ' <b>HTML and CSS only</b>, with no script running at all -- including a', " complete search - line 121
index your browser's own find-in-page can search.", ' The switch changes which edition you are sent, not any setting in your', ' browser; if you turn JavaScript off yourself it shows <b>off</b> and', ' locks, because a page cannot turn - line 121
scripts back on.</p>', ' <p class="legal-fine">Using this website, the repository, the released', ' binaries or any output they produce constitutes your binding agreement', ' to these terms in full.</p>', ' <button class="btn primary" - line 121
id="legal-go" disabled>continue</button>', '</div>' ].join(""); return overlay; } function show() { var overlay = build(); document.body.appendChild(overlay); document.body.classList.add("legal-locked"); var main = - line 121
document.querySelector("main"); var header = document.querySelector("header.top"); [main, header].forEach(function (el) { if (el) { el.setAttribute("aria-hidden", "true"); } }); var waiver = overlay.querySelector("#legal-waiver"); var - line 121
licence = overlay.querySelector("#legal-licence"); var go = overlay.querySelector("#legal-go"); function sync() { go.disabled = !(waiver.checked && licence.checked); } waiver.addEventListener("change", sync); - line 121
licence.addEventListener("change", sync); go.addEventListener("click", function () { remember(); - line 161
overlay.remove(); document.body.classList.remove("legal-locked"); [main, header].forEach(function (el) { if (el) { el.removeAttribute("aria-hidden"); } }); }); // Keep focus inside the dialog: the page behind it is not usable yet, and // - line 161
tabbing into it would be a way around the gate. overlay.addEventListener("keydown", function (event) { if (event.key !== "Tab") { return; } var focusable = overlay.querySelectorAll( 'a[href], button:not([disabled]), input, summary, - line 161
[tabindex]:not([tabindex="-1"])' ); if (!focusable.length) { return; } var first = focusable[0]; var last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } - line 161
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); // Focus goes to the box, not to the first checkbox. Script-driven focus // counts as keyboard focus in every engine, so focusing - line 161
the checkbox drew // its focus ring before the reader had touched anything: the page opened // with a box that looked selected for no reason (finding F-173). The // dialog container is where a modal's focus is meant to land anyway. A // - line 161
screen reader announces the title from there, Tab reaches the first // control, and the trap above still holds. overlay.querySelector(".legal-box").focus(); } document.addEventListener("DOMContentLoaded", function () { if (!accepted()) { - line 161
show(); } }); })();
website/js/markdown.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // A small Markdown renderer and syntax highlighter. // // # Why not a library // // Pulling `marked` and `highlight.js` off a CDN would be three lines. It would // also mean every visitor to - line 1
a privacy tool's website makes requests to a third // party that learns their IP address and what they were reading, and it would // mean trusting code nobody here has read. This is a few hundred lines that // does what this page needs and - line 1
nothing else. // // # Escaping // // Everything is HTML-escaped *first*, and only the tags this renderer itself // emits are ever introduced. Raw HTML in the source Markdown is shown as text // rather than injected, so the README cannot - line 1
inject script into this page even // if it were altered upstream. // // In plain words // // This turns the project's plain-text documents into the formatted pages you // read, including the colours in the code examples. // // Most sites - line 1
borrow somebody else's code from another company's server to do // this. That would tell that company your address and what you were reading. // This is a few hundred lines written here instead, so nothing about your // visit leaves this - line 1
site. window.MD = (function () { "use strict"; // Placeholders are single characters from the Unicode private-use area. See // `parker` below for why, and why the source is stripped of them first. var PARK_BASE = 0xe000; var PARK_LIMIT = - line 1
0xf8ff; var PARK_RE = new RegExp("[\uE000-\uF8FF]", "g"); /** * Characters that are stripped outright rather than escaped, because they - line 41
* have no legitimate place in rendered prose and are invisible when they go * wrong: * * - **C0 and C1 control characters**, apart from tab, newline and carriage * return. A NUL reaching the output is not exploitable -- the HTML parser * - line 41
turns it into U+FFFD -- but it is a stray invisible character in a * document, which is its own kind of wrong. * - **Bidirectional overrides and isolates** (U+202A-U+202E, U+2066-U+2069). * These reorder how text is *displayed* without - line 41
changing what it says, so * a README could be made to read differently on the page than in the * repository. That is the Trojan Source class of trick, and a site whose * whole argument is "the rendered README is the real README" has a * - line 41
specific reason to care. * * Ordinary right-to-left text is untouched: it does not need these controls * to display correctly, only to be *overridden*. */ var STRIP_RE = new RegExp( "[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F" + - line 41
"\u202A-\u202E\u2066-\u2069]", "g" ); function escapeHtml(text) { return String(text) .replace(PARK_RE, "") .replace(STRIP_RE, "") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } /** * Set - line 41
finished markup aside so later passes cannot chew on it. * * The placeholder has to satisfy three things at once, and the obvious * choice -- `" " + index + " "` -- satisfies none of them: * * 1. **Later passes must not match it.** The - line 41
number highlighter - line 81
* (`\b\d+\b`) matched the index inside a space-delimited placeholder and * wrapped it in a span. The un-parking pass then no longer recognised * it, so the parked content was *discarded*: every string literal in * every code block rendered - line 81
as a stray highlighted digit. A private-use * character is not a word character, not a digit and not a quote, so no * pass in this file matches it. * 2. **The source must not be able to forge one.** `escapeHtml` strips the * private-use - line 81
range before anything else runs, so a placeholder in the * working string can only be one this code put there. * 3. **Adjacent placeholders must both survive.** The old form ate its own * delimiters -- the space closing one placeholder was - line 81
the space opening * the next, so alternate items silently failed to come back. A single * character has no delimiters to share. */ function parker() { var parked = []; return { park: function (markup) { if (parked.length > PARK_LIMIT - - line 81
PARK_BASE) { return markup; } parked.push(markup); return String.fromCharCode(PARK_BASE + parked.length - 1); }, /** * Put every parked fragment back, including the ones nested inside * others. * * Parked markup can itself contain a - line 81
placeholder: `[`code`](url)` parks * the inline code first, and then parks an anchor whose label *is* that * placeholder. `String.replace` does not rescan its own replacement * text, so a single pass emitted `<a href="url">` wrapped around - line 81
a bare * private-use character -- which browsers draw as nothing. Every link in * the README whose label was inline code rendered as an empty link, so * "see [`docs/AUDIT.md`](docs/AUDIT.md)." came out as "see .". * * Looping until the - line 81
string stops changing fixes it. The bound is a * guard, not a limit: nesting here is two deep at most, and comparing * the result is a more honest termination condition than trusting that. */ unpark: function (html) { for (var depth = 0; - line 81
depth < 8; depth++) { - line 121
var next = html.replace(PARK_RE, function (ch) { var index = ch.charCodeAt(0) - PARK_BASE; return index < parked.length ? parked[index] : ""; }); if (next === html) { return next; } html = next; } return html; } }; } // --- syntax - line 121
highlighting ------------------------------------------------- /** * Keyword patterns by language. * * A null-prototype object, and looked up through `hasOwnProperty` below, * because the key comes from the source document: the fence info - line 121
string is * matched with `\w*`, and `constructor` and `__proto__` are both `\w*`. On a * plain object literal, ```` ```constructor ```` made `KEYWORDS[lang]` * resolve to `Object` through the prototype chain, and ```` ```__proto__ ```` * - line 121
resolved to `Object.prototype`. * * Neither did any harm as the code stood -- `String.replace` stringifies a * non-regex search value, so it looked for the literal text * `function Object() { [native code] }` and found nothing. But "an - line 121
attacker * can steer this lookup onto `Object.prototype` and the result happens to be * inert" is a description of a bug that has not gone off yet, not of a safe * lookup. A document must not be able to reach the prototype chain at all. */ - line 121
var KEYWORDS = Object.assign(Object.create(null), { rust: /\b(fn|let|mut|pub|use|mod|struct|enum|impl|trait|for|in|if|else|match|return|const|static|crate|self|super|where|as|dyn|move|async|await|ref|type|unsafe)\b/g, bash: - line 121
/\b(if|then|else|fi|for|while|do|done|case|esac|function|return|export|local|source|cd|echo|cat|sudo|git|cargo|gpg|sha256sum|python|python3|tar|unzip|curl|wget)\b/g, toml: /^\s*\[[^\]]+\]/gm, json: /"(?:[^"\\]|\\.)*"(?=\s*:)/g }); /** - line 121
Own-property lookup, so a document cannot reach `Object.prototype`. */ function keywordsFor(lang) { - line 161
return Object.prototype.hasOwnProperty.call(KEYWORDS, lang) ? KEYWORDS[lang] : null; } function highlight(code, language) { var html = escapeHtml(code); var lang = (language || "").toLowerCase(); // Strings and comments first, then protect - line 161
them from later passes by // parking the markup behind placeholders. Highlighting a keyword that // happens to sit inside a string is the classic way these break. var store = parker(); var park = store.park; html = - line 161
html.replace(/("(?:[^&]|&(?!quot;))*"|'[^']*')/g, function (m) { return park('<span class="tok-str">' + m + "</span>"); }); if (lang === "rust" || lang === "toml" || lang === "js" || lang === "javascript") { html = - line 161
html.replace(/(\/\/[^\n]*|#[^\n]*)/g, function (m) { return park('<span class="tok-com">' + m + "</span>"); }); } else if (lang === "bash" || lang === "sh" || lang === "shell" || lang === "yaml" || lang === "") { html = - line 161
html.replace(/(#[^\n]*)/g, function (m) { return park('<span class="tok-com">' + m + "</span>"); }); } var keywords = keywordsFor(lang); if (keywords) { html = html.replace(keywords, '<span class="tok-kw">$&</span>'); } if (lang === "rust" - line 161
|| lang === "js" || lang === "javascript") { html = html.replace(/\b([a-z_][a-z0-9_]*)\s*\(/gi, '<span class="tok-fn">$1</span>('); } html = html.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="tok-num">$1</span>'); return - line 161
store.unpark(html); } // --- URLs ---------------------------------------------------------------- - line 201
/** * Whether a link or image target may be emitted at all. * * The rule is: if the target names a scheme, it must be http or https; * if it names no scheme, it is a relative path and is fine. That is an * allowlist over schemes -- - line 201
`javascript:`, `data:`, `vbscript:` and anything * invented after this was written are all refused by default, because they * are not on the list rather than because someone remembered to name them. * * Two things are excluded that look - line 201
relative and are not: * * - `//host/path`, which is protocol-relative and goes off-site. Treating it * as internal is exactly how a link ends up without * `rel="noopener noreferrer"` and leaks a referrer. * - a leading backslash, which - line 201
several browsers normalise to `/` -- so * `\\host` becomes protocol-relative by the back door. * * The previous version tested `^(?:https?:|[./#])`, which refused any path * not starting with `.`, `/` or `#`. That is most ordinary Markdown - line 201
links: * `[whitepaper](docs/WHITEPAPER.md)` silently rendered as plain text. Safe, * but wrong, and quietly wrong -- the worst combination for a page whose * whole argument is "go and read the source". */ function safeUrl(url) { if - line 201
(/^[\\/]{2}/.test(url) || /^\\/.test(url)) { return false; } var scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url); return scheme ? /^https?$/i.test(scheme[1]) : true; } function isExternal(url) { return /^https?:/i.test(url); } /** * Link - line 201
and image targets, written so the engine cannot backtrack over them. * * The previous pair were `\(([^)\s]+)[^)]*\)`. Those two runs **overlap**: a * character that is neither `)` nor whitespace can be taken by either one, so * for a `(` - line 201
that never closes, the engine tries every way of splitting the - line 241
* text between them. That is quadratic, and it was measured rather than * guessed at -- `\\]" + "\\(([^)\\s]{1," + MAX_TARGET + "})(?:\\s[^)]{0," + MAX_TITLE + "})?\\)", "g" ); var TARGET_LINK = new RegExp( "\\[([^\\]]{1," + MAX_LABEL + "})\\]" + "\\(([^)\\s]{1," + MAX_TARGET + "})(?:\\s[^)]{0," + MAX_TITLE + - line 281
"})?\\)", "g" ); /** * Inline code, bounded for the same reason: an unclosed backtick followed by * a long run makes every later backtick a fresh start position. */ var INLINE_CODE = new RegExp("`([^`]{1,4096})`", "g"); // --- inline - line 281
-------------------------------------------------------------- function inline(text) { var out = escapeHtml(text); var store = parker(); var park = store.park; // Inline code first: its contents must not be interpreted as emphasis. out = - line 281
out.replace(INLINE_CODE, function (_, code) { return park("<code>" + code + "</code>"); }); // Images and links share one scheme allowlist. They did not always: the // image branch used to interpolate whatever was in the parentheses, so // - line 281
`` produced `src="javascript:..."`. No current // browser executes a `javascript:` image source, which is why it went - line 321
// unnoticed, but "no browser we tested still honours this" is not a // security argument. One rule, applied in both places. out = out.replace(TARGET_IMAGE, function (_, alt, src) { if (!safeUrl(src)) { return alt; } return park('<img - line 321
src="' + encodeURI(src) + '" alt="' + alt + '">'); }); out = out.replace(TARGET_LINK, function (_, label, href) { if (!safeUrl(href)) { return label; } return park( '<a href="' + encodeURI(href) + '"' + (isExternal(href) ? ' rel="noopener - line 321
noreferrer"' : "") + ">" + label + "</a>" ); }); out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); out = out.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>"); out = out.replace(/(^|[\s(])_([^_\n]+)_/g, "$1<em>$2</em>"); - line 321
return store.unpark(out); } // --- block --------------------------------------------------------------- /** * How deeply blockquotes may nest before the rest is rendered as plain text. * * A blockquote strips one `>` and calls `render` - line 321
again, so nesting depth is * recursion depth and the document chooses it. A line of five thousand `>` * characters overflowed the JavaScript stack and threw a `RangeError` -- and * because `repo.js` reports any rejection from the README - line 321
fetch as "could not * reach api.github.com", the user was given a confidently wrong explanation * for a page that had loaded fine and then broken while rendering. * * Sixteen is far past any real document; the deepest quote in this - line 321
project's * own Markdown is two. */ var MAX_QUOTE_DEPTH = 16; function render(source, depth) { - line 361
depth = depth || 0; var lines = String(source).replace(/\r\n?/g, "\n").split("\n"); var html = []; var i = 0; function tableRow(line) { return line.trim().indexOf("|") === 0 || /\s\|\s/.test(line); } while (i < lines.length) { var line = - line 361
lines[i]; // fenced code var fence = line.match(/^```(\w*)/); if (fence) { var lang = fence[1]; var body = []; i++; while (i < lines.length && !/^```/.test(lines[i])) { body.push(lines[i]); i++; } i++; html.push("<pre><code>" + - line 361
highlight(body.join("\n"), lang) + "</code></pre>"); continue; } // headings -- h1 is skipped because the page supplies its own title var heading = line.match(/^(#{1,6})\s+(.*)$/); if (heading) { var level = heading[1].length; - line 361
html.push("<h" + level + ">" + inline(heading[2]) + "</h" + level + ">"); i++; continue; } if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) { html.push("<hr>"); i++; continue; } // blockquote if (/^>\s?/.test(line)) { var quote = []; while - line 361
(i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, "")); - line 401
i++; } if (depth >= MAX_QUOTE_DEPTH) { // Past the limit the remaining `>` are shown as the text they are, // rather than recursed into. Escaped, so this is still only ever the // renderer's own markup reaching the page. html.push("<p>" + - line 401
inline(quote.join(" ")) + "</p>"); } else { html.push( "<blockquote>" + render(quote.join("\n"), depth + 1) + "</blockquote>" ); } continue; } // table: a header row followed by a --- separator if (tableRow(line) && i + 1 < lines.length && - line 401
/^[\s|:-]+$/.test(lines[i + 1]) && lines[i + 1].indexOf("-") !== -1) { var cells = function (row) { return row.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|") .map(function (c) { return inline(c.trim()); }); }; var table = - line 401
["<table><thead><tr>"]; cells(line).forEach(function (c) { table.push("<th>" + c + "</th>"); }); table.push("</tr></thead><tbody>"); i += 2; while (i < lines.length && tableRow(lines[i]) && lines[i].trim() !== "") { table.push("<tr>"); - line 401
cells(lines[i]).forEach(function (c) { table.push("<td>" + c + "</td>"); }); table.push("</tr>"); i++; } table.push("</tbody></table>"); html.push(table.join("")); continue; } // lists if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) { var ordered - line 401
= /^\s*\d+\./.test(line); - line 441
var items = []; while (i < lines.length && /^\s*(?:[-*+]|\d+\.)\s+/.test(lines[i])) { items.push(inline(lines[i].replace(/^\s*(?:[-*+]|\d+\.)\s+/, ""))); i++; // continuation lines belong to the item above while (i < lines.length && - line 441
/^\s{2,}\S/.test(lines[i]) && !/^\s*(?:[-*+]|\d+\.)\s+/.test(lines[i])) { items[items.length - 1] += " " + inline(lines[i].trim()); i++; } } var tag = ordered ? "ol" : "ul"; html.push("<" + tag + ">" + items.map(function (t) { return - line 441
"<li>" + t + "</li>"; }).join("") + "</" + tag + ">"); continue; } // HTML comments and raw block tags in the source are dropped rather than // passed through, so nothing upstream can inject markup into this page. if - line 441
(/^\s*<!--/.test(line)) { while (i < lines.length && lines[i].indexOf("-->") === -1) { i++; } i++; continue; } if (/^\s*<\/?(p|div|img|br|hr|center|table|h[1-6])\b/i.test(line)) { i++; continue; } if (line.trim() === "") { i++; continue; } - line 441
// paragraph var para = []; while (i < lines.length && lines[i].trim() !== "" && !/^(#{1,6}\s|```|>|\s*(?:[-*+]|\d+\.)\s)/.test(lines[i])) { para.push(lines[i]); i++; } if (para.length) { html.push("<p>" + inline(para.join(" ")) + "</p>"); - line 441
} } return html.join("\n"); - line 481
} // `safeUrl` is exported so that `repo.js` can apply the *same* rule to the // URLs it takes from the GitHub API. Two independently written scheme checks // on one page is two things to keep in step, and the one that gets forgotten // is - line 481
the one that matters. return { render: render, highlight: highlight, escape: escapeHtml, safeUrl: safeUrl }; })();
website/js/prefetch.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Fetch, quietly and in the background, the few things a reader is most likely // to open next -- so that clicking them is instant instead of a wait. // // # Why any of this is in JavaScript - line 1
at all // // Most of it is not. The pages carry `<link rel="prefetch">` in their markup, // which is declarative, costs no script, and works in the JavaScript-free // edition exactly as it does here. That is deliberate: a reader who runs - line 1
no // scripts should not get a slower site as a punishment. // // This file exists for the one thing that should *not* be declared in markup: // `search-index.json` is about a megabyte. A `<link rel="prefetch">` for it // would be fetched - line 1
by every visitor on every page, including somebody on a // metered phone connection who never opens the search. So it is fetched from // script, where the conditions below can be checked first. // // # The conditions, and why each one // - line 1
// - `Save-Data`. If the reader has asked their browser to use less data, // downloading a megabyte they did not ask for is precisely the thing they // asked not to happen. // - `prefers-reduced-data`. The same request expressed as a media - line 1
query, // which is what Safari implements. // - `effectiveType`. On 2G, a megabyte in the background competes with the // page the reader is actually trying to read. // - Idle time. `requestIdleCallback` means this never runs while the - line 1
browser // has something better to do. Without it a prefetch can delay the very // page it was meant to make faster. // // # Same origin only // // Every URL here is a path on this site. This is stated because a prefetch is // a real - line 1
network request, and a privacy tool that quietly reached a third party // to make itself feel fast would be undermining its own argument. There is no // third-party host in this file and there is nothing to configure. // // In plain words - line 1
// - line 41
// This quietly fetches the one or two pages you are most likely to click // next, so that when you do, they are already there. // // Almost all of it is done by the pages themselves without any code at all. // This file exists for the - line 41
search index, which is about a megabyte -- too // much to pull down on a phone on a slow connection for something you might // never open. So it asks the browser how good the connection is, and skips it // when the answer is "not very". - line 41
(function () { "use strict"; // Small documents worth having ready, by page. Keep these short: a prefetch // list that includes everything is a download of the whole site. var LIKELY = { "index.html": ["wiki.html", "search.html"], - line 41
"wiki.html": ["search.html", "index.html"], "search.html": [] }; // Fetched only from the pages where search is one click away, and only when // the connection looks willing. About a megabyte. var INDEX = "search-index.json"; function - line 41
pageName() { var path = window.location.pathname; var last = path.slice(path.lastIndexOf("/") + 1); return last || "index.html"; } /** Has the reader asked, in any of the available ways, for less data? */ function wantsLessData() { var - line 41
connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (connection) { if (connection.saveData) { return true; } var type = connection.effectiveType || ""; if (type === "slow-2g" || type === "2g") { - line 41
return true; } } if (window.matchMedia && window.matchMedia("(prefers-reduced-data: reduce)").matches) { - line 81
return true; } return false; } /** Already declared in the markup, or already added by an earlier call? */ function alreadyQueued(url) { var existing = document.querySelectorAll('link[rel="prefetch"]'); for (var i = 0; i < existing.length; - line 81
i++) { if (existing[i].getAttribute("href") === url) { return true; } } return false; } function prefetch(url, as) { // The pages already carry `<link rel="prefetch">` for the small documents, // because that works with no script at all. - line 81
Adding them again from here // asks the browser for the same file twice -- which it may well collapse, // but "the browser probably deduplicates it" is not a reason to send it. if (alreadyQueued(url)) { return; } var link = - line 81
document.createElement("link"); // `prefetch` rather than `preload`: this is for a *later* navigation, and // `preload` would tell the browser the current page needs it, which is // false and produces a console warning saying so. link.rel - line 81
= "prefetch"; link.href = url; if (as) { link.as = as; } document.head.appendChild(link); } function whenIdle(fn) { if (window.requestIdleCallback) { window.requestIdleCallback(fn, { timeout: 4000 }); } else { // Safari has no - line 81
requestIdleCallback. A timeout well after load is a // poor imitation, but it keeps the work off the critical path, which is // the part that matters. window.setTimeout(fn, 2500); } - line 121
} function start() { if (wantsLessData()) { return; } var here = pageName(); var pages = LIKELY[here]; if (!pages) { return; } whenIdle(function () { for (var i = 0; i < pages.length; i++) { prefetch(pages[i], "document"); } // The index, - line 121
only where search is the obvious next click, and only after // the small pages are queued. if (here === "index.html" || here === "wiki.html") { whenIdle(function () { prefetch(INDEX, "fetch"); }); } }); } // `load`, not `DOMContentLoaded`: - line 121
prefetching before the current page has // finished fetching its own assets is competing with itself. if (document.readyState === "complete") { start(); } else { window.addEventListener("load", start, { once: true }); } })();
website/js/repo.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Live repository data: stars, description, latest release, and the README // rendered with syntax highlighting. // // # The one third-party request on this site, and why it is opt-in // // - line 1
Everything else here is served from the same origin. This module talks to // api.github.com, which learns your IP address and that you looked at this // project. GitHub already knows both -- it is serving the page you are reading -- // so - line 1
the marginal cost is nil for most visitors. But someone reading over Tor // or a mirror is in a different position, so the fetch is announced in the page // and can be skipped: the panel degrades to static text and a plain link, and // - line 1
nothing else on the site depends on it. // // No token, no cookies, no credentials. Unauthenticated GitHub API requests are // rate-limited by IP to 60/hour, which a documentation page will never approach. // // In plain words // // This - line 1
is the panel showing the project's stars, its latest release and its // README, read from GitHub as you look at it. // // It is the only thing on this site that talks to anybody else's server, and // the page says so before it does. GitHub - line 1
learns your address and that you // looked at this project -- which it already knows, because it is serving you // this page. If you would rather it did not happen at all, the panel turns // into a plain link and nothing else on the site - line 1
notices. (function () { "use strict"; var OWNER = "tilas01"; var REPO = "veilvoice"; var API = "https://api.github.com/repos/" + OWNER + "/" + REPO; var RAW = "https://raw.githubusercontent.com/" + OWNER + "/" + REPO + "/main/README.md"; - line 1
function text(id, value) { var node = document.getElementById(id); if (node) { node.textContent = value; } - line 41
} function number(value) { return typeof value === "number" ? value.toLocaleString() : "--"; } /** Whether the reader has asked the system for less movement. */ function still() { return window.matchMedia && - line 41
window.matchMedia("(prefers-reduced-motion: reduce)").matches; } /** * Give a piece of the panel its entrance. * * `--i` staggers the four stat tiles so they arrive in sequence rather than * snapping in together, which is the difference - line 41
between "the data loaded" * and "the data arrived". */ function arrive(node, index) { if (!node) { return; } node.style.setProperty("--i", index || 0); // Restarting the animation needs the class gone for a frame, or a second // load after - line 41
a failed one would not replay it. node.classList.remove("repo-in"); void node.offsetWidth; node.classList.add("repo-in"); } /** * Count a figure up to its value instead of snapping to it. * * Eased, and capped at a fixed duration whatever - line 41
the number, so a repository * with four stars and one with forty thousand both take the same short * moment. Driven by requestAnimationFrame and finished by writing the exact * value, so the number on screen at the end is the real one - line 41
rather than * wherever the easing happened to land. */ function countTo(id, value) { var node = document.getElementById(id); if (!node) { return; } - line 81
if (typeof value !== "number" || !isFinite(value)) { node.textContent = "--"; return; } if (still() || value === 0) { node.textContent = value.toLocaleString(); return; } var DURATION = 650; var started = null; function frame(now) { if - line 81
(started === null) { started = now; } var t = Math.min(1, (now - started) / DURATION); // Ease out cubic: quick at first, settling rather than stopping dead. var eased = 1 - Math.pow(1 - t, 3); node.textContent = Math.round(value * - line 81
eased).toLocaleString(); if (t < 1) { window.requestAnimationFrame(frame); } else { node.textContent = value.toLocaleString(); } } window.requestAnimationFrame(frame); } function loadMeta() { return fetch(API, { headers: { Accept: - line 81
"application/vnd.github+json" } }) .then(function (r) { if (!r.ok) { throw new Error("GitHub returned " + r.status); } return r.json(); }) .then(function (data) { countTo("stars", data.stargazers_count); countTo("forks", data.forks_count); - line 81
countTo("issues", data.open_issues_count); text("repo-desc", data.description || ""); if (data.license && data.license.spdx_id) { text("repo-license", data.license.spdx_id); } var tiles = document.querySelectorAll(".stats .stat"); for (var - line 81
i = 0; i < tiles.length; i++) { arrive(tiles[i], i); } arrive(document.getElementById("repo-desc"), tiles.length); - line 121
}); } /** * The most release assets that will be turned into DOM nodes. * * The count comes from the API response rather than from this page, and a * release currently has nine binaries plus their checksums. A hundred is far * past that - line 121
and stops a malformed or hostile response from being turned into * an unbounded number of elements. */ var MAX_ASSETS = 100; /** * Whether a download URL may be put in an `href` at all. * * The rule is the renderer's own `safeUrl`, - line 121
deliberately shared rather than * re-implemented, plus a requirement that the scheme actually be present and * `https:` -- a release asset is always an absolute GitHub URL, so anything * else is wrong whatever else it might be. * * This - line 121
value comes from the GitHub API for this repository, so it is not a * live route today; the audit recorded it as "trusted by omission", which is * a different thing from trusted. A response is a response: an API that * returned - line 121
`javascript:...` here would have had it assigned straight into a * clickable link on a page whose entire subject is not trusting remote code. * The fallback when a URL is refused is to show the asset name as plain text, * so a reader still - line 121
learns the file exists and can find it on GitHub. */ function safeAssetUrl(url) { if (typeof url !== "string" || url === "") { return false; } if (window.MD && typeof window.MD.safeUrl === "function" && !window.MD.safeUrl(url)) { return - line 121
false; } return /^https:\/\//i.test(url); } function loadRelease() { return fetch(API + "/releases/latest", { headers: { Accept: "application/vnd.github+json" } }) .then(function (r) { return r.ok ? r.json() : null; }) - line 161
.then(function (data) { if (!data) { return; } text("latest-tag", typeof data.tag_name === "string" ? data.tag_name : "--"); var list = document.getElementById("asset-list"); if (!list || !Array.isArray(data.assets)) { return; } - line 161
list.innerHTML = ""; data.assets .slice(0, MAX_ASSETS) // `String(...)` before comparing: `localeCompare` on a value that is // not a string throws, and one bad entry would reject the whole // promise and be reported to the reader as a - line 161
network failure. .sort(function (a, b) { return String(a && a.name).localeCompare(String(b && b.name)); }) .forEach(function (asset) { if (!asset || typeof asset.name !== "string") { return; } var li = document.createElement("li"); var - line 161
label; if (safeAssetUrl(asset.browser_download_url)) { label = document.createElement("a"); label.href = asset.browser_download_url; label.rel = "noopener noreferrer"; } else { // Named, but not made clickable. label = - line 161
document.createElement("span"); label.title = "this download URL was not an https link and is not linked"; } label.textContent = asset.name; var size = document.createElement("span"); size.style.color = "var(--muted)"; size.textContent = - line 161
typeof asset.size === "number" && isFinite(asset.size) ? " (" + (asset.size / 1048576).toFixed(1) + " MB)" : ""; li.appendChild(label); li.appendChild(size); list.appendChild(li); }); }); } - line 201
/** * The largest README this page will render, in characters. * * This project's own is about thirteen kilobytes. A megabyte is seventy times * that and still renders in well under a frame; past it the document is not * a README, and - line 201
rendering happens on the main thread, so the honest answer is * to say so and link to GitHub rather than to spend an unbounded amount of * the reader's time on it. * * The renderer itself is linear in its input now -- see the two - line 201
quadratics * fixed in `markdown.js` -- so this is defence in depth against the next one * rather than the mitigation for those. */ var MAX_README_CHARS = 1024 * 1024; /** * Remove block-level raw HTML from a Markdown document. * * - line 201
`markdown.js` escapes raw HTML rather than emitting it, which is the * property that makes it safe to hand its output to `innerHTML`. The * consequence is that a README opening with a centred banner -- * `<p - line 201
align="center"><picture>...</picture></p>`, which is how GitHub wants * one written -- rendered as a paragraph of escaped tag soup above the * project's own name. That was live on the site. * * Neither half of that is a bug on its own. - line 201
Together they are, and the fix * belongs here rather than in the renderer: block-level markup in somebody * else's README is presentation, and this panel is showing the prose. The * renderer keeps escaping everything, exactly as before. * - line 201
* The rule is CommonMark's, simplified to the two block kinds that actually * occur: a comment runs to `-->`, and any other HTML block runs to the next * blank line. Fenced code is left alone -- a fence full of markup is an * example being - line 201
shown deliberately, which is the distinction * `links.test.js` and the hostile-input suite both had to learn. * * One pass, no backtracking. Every regular expression here is anchored and * runs against a single line, because this text - line 201
arrives over the network and * two quadratics in this file's neighbourhood already froze a reader's tab * for eight seconds (F-22, F-23). - line 241
*/ var HTML_BLOCK_START = /^<(?:!--|\/?[a-zA-Z][a-zA-Z0-9-]*)/; var FENCE = /^(?:```|~~~)/; function stripHtmlBlocks(markdown) { var lines = markdown.split("\n"); var out = []; var i = 0; var inFence = false; var fence = ""; while (i < - line 241
lines.length) { var line = lines[i]; var trimmed = line.replace(/^[ \t]+/, ""); if (inFence) { if (trimmed.indexOf(fence) === 0) { inFence = false; } out.push(line); i++; continue; } if (FENCE.test(trimmed)) { inFence = true; fence = - line 241
trimmed.slice(0, 3); out.push(line); i++; continue; } if (HTML_BLOCK_START.test(trimmed)) { if (trimmed.indexOf("<!--") === 0) { while (i < lines.length && lines[i].indexOf("-->") === -1) { i++; } i++; // the line carrying the terminator } - line 241
else { while (i < lines.length && lines[i].trim() !== "") { i++; } } continue; } out.push(line); i++; } return out.join("\n"); } - line 281
function loadReadme() { return fetch(RAW) .then(function (r) { if (!r.ok) { throw new Error("README unavailable (" + r.status + ")"); } return r.text(); }) .then(function (markdown) { var target = document.getElementById("readme"); if - line 281
(!target) { return; } if (typeof markdown !== "string") { return; } if (markdown.length > MAX_README_CHARS) { target.textContent = "The README is unusually large (" + Math.round(markdown.length / 1024) + " KB) and has not been rendered - line 281
here. Read it on GitHub."; return; } target.innerHTML = window.MD.render(stripHtmlBlocks(markdown)); // The README's own banner is already the page hero; showing it twice // just pushes the content down. var first = - line 281
target.querySelector("img"); if (first && /banner/.test(first.getAttribute("src") || "")) { first.remove(); } // Repo-relative links must resolve against GitHub, not this site. // // Built through `URL` against a fixed base rather than by - line 281
pasting // strings together. String concatenation left `..` segments in the // result for the browser to resolve afterwards, so a link written as // `[x](../../../elsewhere)` produced a URL that normalised to a // different part of - line 281
github.com than the one it appeared to point at. // The host was never in doubt, so this was misdirection rather than // escape -- but a page asking people to click through to source they // are being told to read should send them where - line 281
the link says. // // `Array.prototype.slice.call` rather than `NodeList.forEach`: the // latter is missing on older WebKit, and this file has to run there. var base = "https://github.com/" + OWNER + "/" + REPO + "/blob/main/"; - line 321
var anchors = Array.prototype.slice.call(target.querySelectorAll("a[href]")); anchors.forEach(function (a) { var href = a.getAttribute("href"); if (!href || /^https?:/i.test(href) || href.charAt(0) === "#") { return; } var resolved; try { - line 321
resolved = new URL(href.replace(/^\.?\//, ""), base); } catch (e) { return; // Not resolvable: leave it exactly as the renderer emitted it. } // Refuse anything that climbed out of the repository, or that a // scheme in the target steered - line 321
off github.com altogether. if (resolved.origin !== "https://github.com" || resolved.href.indexOf(base) !== 0) { return; } a.setAttribute("href", resolved.href); a.setAttribute("rel", "noopener noreferrer"); }); arrive(target, 0); }); } /** - line 321
Put the button back in a state the reader can act on. */ function settleButton(button, failedEverything) { if (!button) { return; } button.textContent = failedEverything ? "try again" : "reload live data"; button.disabled = false; } - line 321
function start(button) { var status = document.getElementById("repo-status"); var panel = document.getElementById("repo"); if (status) { status.textContent = "loading from api.github.com ..."; } // Drives the pulse on the figures, so the - line 321
panel looks busy rather than // looking like four em dashes are the answer. if (panel) { panel.classList.add("repo-loading"); } Promise.allSettled([loadMeta(), loadRelease(), loadReadme()]).then(function (results) { if (panel) { - line 321
panel.classList.remove("repo-loading"); } - line 361
var failed = results.filter(function (r) { return r.status === "rejected"; }); var allFailed = failed.length === results.length; settleButton(button, allFailed); if (!status) { return; } if (allFailed) { status.textContent = "could not - line 361
reach api.github.com -- the project page on GitHub has the same information."; } else { status.textContent = ""; } }); } document.addEventListener("DOMContentLoaded", function () { var button = document.getElementById("load-repo"); if - line 361
(!button) { return; } // The fetch stays opt-in: this panel is the one third-party request on the // site, and whether to make it is the reader's call, not ours. button.addEventListener("click", function () { button.disabled = true; // A - line 361
spinner on the control that was pressed, rather than a message // somewhere else on the page. button.textContent = ""; var spinner = document.createElement("span"); spinner.className = "spin"; button.appendChild(spinner); - line 361
button.appendChild(document.createTextNode("loading")); start(button); }); }); })();
website/js/reveal.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Reveal-on-scroll, with one rule that outranks every other consideration: // **content must never stay invisible.** // // The first version of this file broke that rule, and it took - line 1
rendering the // page to notice. An IntersectionObserver fires when an element's intersection // ratio crosses a threshold. If the viewport *jumps* -- an anchor link from the // nav, a browser restoring your scroll position when you come - line 1
back, a // find-in-page hit -- an element can go from below the viewport to above it // between two frames. It was not intersecting before and is not intersecting // after, the ratio never left zero, and no callback ever runs. Because a - line 1
reveal // that re-hides on scroll-up is a gimmick, nothing ever showed it again. // // Three paragraphs of the walkthrough were invisible that way, and one of them // was the box explaining that the app lock is not tamper-proof. A page - line 1
whose // entire argument is that it states its limits had made a limit unreadable. // // So there are two mechanisms here, and they are not redundant: // // 1. The observer, which does the animation and costs nothing while idle. // 2. A - line 1
sweep, which asks a much simpler question -- "is this element at or // above the bottom of the viewport?" -- and reveals anything that is, // whether or not the observer ever saw it cross. It runs on scroll and // resize, coalesced into - line 1
one animation frame, and **both listeners and the // observer detach the moment the last element is revealed.** On an // ordinary read that is a fraction of a second of work in total, and none // at all thereafter. // // The other - line 1
constraints, unchanged: // // - Never hide what cannot then be shown. The `.reveal` rule is scoped to // `html.js`, set by `theme.js` from a blocking head script, so a reader // without JavaScript sees everything immediately. // - Animate - line 1
only `opacity` and `transform`, which the compositor handles // without laying out the page again. // - Obey `prefers-reduced-motion`: someone who asked the system for less // movement gets the content with no movement at all. // // In - line 1
plain words - line 41
// // This is the gentle fade as sections of the page come into view. // // It is written around one rule that matters more than the effect: text must // never end up invisible. An earlier version could leave whole paragraphs // hidden if - line 41
you jumped down the page, which was found by looking at the page // rather than by any test. If anything at all goes wrong now, everything // simply shows. // // If you have asked your computer for less movement, there is no fade at all. - line 41
(function () { "use strict"; function showAll(nodes) { for (var i = 0; i < nodes.length; i++) { nodes[i].classList.add("in"); } } document.addEventListener("DOMContentLoaded", function () { var nodes = document.querySelectorAll(".reveal"); - line 41
if (!nodes.length) { return; } var still = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (still || !("IntersectionObserver" in window)) { showAll(nodes); return; } // Everything not yet shown. - line 41
Emptying this is what tears the whole thing // down, so it is the single source of truth for "is there work left". var pending = Array.prototype.slice.call(nodes); var scheduled = false; function reveal(node) { node.classList.add("in"); - line 41
var at = pending.indexOf(node); if (at !== -1) { pending.splice(at, 1); } if (observer) { observer.unobserve(node); } if (!pending.length) { stop(); } } - line 81
function stop() { if (observer) { observer.disconnect(); observer = null; } window.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); } // The safety net. Anything whose top has reached the bottom of - line 81
the viewport // has been scrolled to, however the viewport got there. function sweep() { scheduled = false; var limit = window.innerHeight || document.documentElement.clientHeight; for (var i = pending.length - 1; i >= 0; i--) { if - line 81
(pending[i].getBoundingClientRect().top <= limit) { reveal(pending[i]); } } } function schedule() { if (scheduled) { return; } scheduled = true; window.requestAnimationFrame(sweep); } var observer = new IntersectionObserver(function - line 81
(entries) { for (var i = 0; i < entries.length; i++) { if (entries[i].isIntersecting) { reveal(entries[i].target); } } }, { // Start the transition slightly before the element reaches the viewport, // so it has finished by the time it is - line 81
properly in view. rootMargin: "0px 0px -12% 0px", threshold: 0.05 }); for (var i = 0; i < pending.length; i++) { observer.observe(pending[i]); } window.addEventListener("scroll", schedule, { passive: true }); - line 81
window.addEventListener("resize", schedule, { passive: true }); // And once now, because the page may already have been restored to a - line 121
// position halfway down it before a single scroll event fires. schedule(); }); })();
website/js/search.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Search across the whole repository and this website. // // The index is built by `tools/search-index/generate.py` and committed, so this // file only has to read it. CI regenerates and - line 1
compares, which means a stale // index fails the build instead of quietly answering questions about code that // no longer looks like that. // // # This file is pure ASCII, on purpose // // `website/js/*.js` is served raw and people are - line 1
invited to open it. A viewer // that guesses CP1252 turns an em dash into mojibake in the middle of a // sentence promising the code is honest, so anything non-ASCII is written as a // `\uXXXX` escape. `tools/site-tests/characters.test.js` - line 1
fails the build // otherwise. // // # Nothing from the index is ever HTML // // The index contains text taken verbatim from source files -- including, by // construction, this project's own tests for hostile markup, which contain // - line 1
`<script>` and `onerror=` as ordinary content. Every value out of the index // therefore reaches the page through `textContent` or `createTextNode` and // never through `innerHTML`. Match highlighting is done by splitting a string // and - line 1
appending text nodes and `<mark>` elements built with `createElement`, // which is why it looks more long-winded than a `replace` would. // // # Bounded work // // F-22 and F-23 were quadratic blow-ups in the Markdown renderer on text // - line 1
fetched over the network, and the lesson generalises: this file bounds the // query, the number of terms, the number of results scored and the number // rendered, so no input makes the tab do an unbounded amount of work. Scoring // is a - line 1
linear pass with a plain `indexOf` -- no regular expression is ever // built from user input, so there is no pattern for a query to blow up. // // In plain words // // This is the search box. It looks through every file in the project -- - line 1
the // code, the documents and this website -- and shows you the lines that match. - line 41
// // The searching happens in your browser, on a list that ships with the site. // Nothing you type is sent anywhere, and there is nothing here that could // collect it. (function () { "use strict"; var INDEX_URL = "search-index.json"; - line 41
var MAX_QUERY = 128; // characters accepted from the box var MAX_TERMS = 8; // terms scored from one query var MAX_RESULTS = 200; // results kept after scoring var MAX_RENDER = 60; // results put in the page at once var MAX_STAGGER = 12; - line 41
// how many results get an entrance delay var index = null; var state = { q: "", sort: "relevance", kind: "", area: "" }; var els = {}; // --- small helpers -------------------------------------------------------- function byId(id) { - line 41
return document.getElementById(id); } function text(tag, value, className) { var node = document.createElement(tag); if (value) { node.appendChild(document.createTextNode(value)); } if (className) { node.className = className; } return - line 41
node; } function clear(node) { while (node.firstChild) { node.removeChild(node.firstChild); } } /** * Append `value` to `parent`, wrapping each occurrence of each term in a * `<mark>`. Built from text nodes and elements, never from a - line 41
string of HTML. */ - line 81
function appendHighlighted(parent, value, terms) { if (!value) { return; } if (!terms.length) { parent.appendChild(document.createTextNode(value)); return; } var lower = value.toLowerCase(); var at = 0; var guard = 0; while (at < - line 81
value.length && guard++ < 400) { // The earliest match of any term from here. var bestAt = -1; var bestLen = 0; for (var i = 0; i < terms.length; i++) { var found = lower.indexOf(terms[i], at); if (found !== -1 && (bestAt === -1 || found < - line 81
bestAt)) { bestAt = found; bestLen = terms[i].length; } } if (bestAt === -1) { break; } if (bestAt > at) { parent.appendChild(document.createTextNode(value.slice(at, bestAt))); } parent.appendChild(text("mark", value.slice(bestAt, bestAt + - line 81
bestLen))); at = bestAt + bestLen; } if (at < value.length) { parent.appendChild(document.createTextNode(value.slice(at))); } } // --- scoring -------------------------------------------------------------- function parseQuery(raw) { var q - line 81
= String(raw || "").slice(0, MAX_QUERY).toLowerCase(); var parts = q.split(/[^a-z0-9_.:/-]+/); var out = []; for (var i = 0; i < parts.length && out.length < MAX_TERMS; i++) { if (parts[i]) { out.push(parts[i]); } - line 121
} return out; } /** * How well one section answers the query. * * Deliberately simple and explainable: a heading match is worth more than a * body match, a path match is worth something, and every term must appear * somewhere or the - line 121
section does not match at all. Returning 0 means "not a * result", never "a weak result". */ function score(section, doc, terms) { // Read from the folded copies made once at load. Folding here instead -- // which is what this did first -- - line 121
meant three `toLowerCase()` calls per // section per keystroke: about fifteen thousand new strings for every // character typed, all of them thrown away immediately. // // Measured properly, minimum of 25 runs over 5,061 sections, because - line 121
// timing noise is one-sided and the fastest run is the closest estimate of // the work actually done: // // query folded folding per call // "en" 0.7 ms 1.1 ms // "encrypt" 1.0 ms 1.4 ms // "the voiceprint" 0.8 ms 1.2 ms // // Folding - line 121
once at load costs 0.9 ms. So this is worth having and it is // *not* the expensive part of a keystroke -- scoring the whole corpus is // about a millisecond either way. The cost that matters is building the // result rows, which is why - line 121
`render` uses a fragment. var heading = section._h; var body = section._x; var path = doc._p; var total = 0; for (var i = 0; i < terms.length; i++) { var term = terms[i]; var here = 0; if (heading.indexOf(term) !== -1) { - line 161
here += heading === term ? 120 : 60; if (heading.indexOf(term) === 0) { here += 15; } } if (path.indexOf(term) !== -1) { here += 25; // The file's own name is a stronger signal than a directory in its path. if (doc._t.indexOf(term) !== -1) - line 161
{ here += 20; } } if (body.indexOf(term) !== -1) { here += 12; } if (!here) { return 0; } total += here; } // Documentation is what most people are looking for when they search prose; // a test file matching the same word usually is not. A - line 161
small nudge, not a // filter -- the kind filter is there for when someone wants only one kind. if (doc.k === "doc") { total += 8; } if (doc.k === "rust") { total += 4; } return total; } function search() { if (!index) { return []; } var - line 161
terms = parseQuery(state.q); var results = []; if (terms.length) { var secs = index.secs; for (var i = 0; i < secs.length; i++) { var section = secs[i]; var doc = index.docs[section.d]; if (state.kind && doc.k !== state.kind) { continue; } - line 161
if (state.area && doc.r !== state.area) { continue; } var value = score(section, doc, terms); if (value > 0) { results.push({ s: section, d: doc, v: value }); } } } else { // No query: show the corpus, filtered. This is what makes the page - line 161
// useful before anyone has typed anything. for (var j = 0; j < index.docs.length; j++) { - line 201
var d = index.docs[j]; if (state.kind && d.k !== state.kind) { continue; } if (state.area && d.r !== state.area) { continue; } results.push({ s: null, d: d, v: 0 }); } } sortResults(results); return results.slice(0, MAX_RESULTS); } - line 201
function sortResults(results) { var how = state.sort; results.sort(function (a, b) { if (how === "path") { return a.d.p < b.d.p ? -1 : a.d.p > b.d.p ? 1 : (a.s ? a.s.l : 0) - (b.s ? b.s.l : 0); } if (how === "title") { // Folded copies - line 201
again: a comparator runs O(n log n) times, so folding // inside it is the same waste as folding inside `score`, spread over // more calls. var at = a.d._t; var bt = b.d._t; return at < bt ? -1 : at > bt ? 1 : 0; } if (how === "size") { - line 201
return b.d.n - a.d.n; } // Relevance, with a stable tie-break so equal scores do not reshuffle // between keystrokes -- a list that jitters is hard to read. if (b.v !== a.v) { return b.v - a.v; } if (a.d.p !== b.d.p) { return a.d.p < b.d.p - line 201
? -1 : 1; } return (a.s ? a.s.l : 0) - (b.s ? b.s.l : 0); }); } // --- rendering ------------------------------------------------------------ function resultUrl(doc, section) { if (doc.u.indexOf("https://") === 0) { - line 241
// A repository file: link to the line. return section && section.l > 1 ? doc.u + "#L" + section.l : doc.u; } return section && section.a ? doc.u + "#" + section.a : doc.u; } function renderResult(item, position, terms) { var row = - line 241
document.createElement("li"); row.className = "sr"; if (position < MAX_STAGGER) { row.style.setProperty("--i", String(position)); } var link = document.createElement("a"); link.className = "sr-head"; link.href = resultUrl(item.d, item.s); - line 241
if (item.d.u.indexOf("https://") === 0) { link.rel = "noopener noreferrer"; } appendHighlighted(link, (item.s && item.s.h) || item.d.t, terms); row.appendChild(link); var meta = text("div", null, "sr-meta"); var kind = text("span", - line 241
item.d.k, "sr-kind sr-kind-" + item.d.k); meta.appendChild(kind); var path = text("span", null, "sr-path"); appendHighlighted(path, item.d.p, terms); meta.appendChild(path); if (item.s && item.s.l > 1) { meta.appendChild(text("span", "line - line 241
" + item.s.l, "sr-line")); } row.appendChild(meta); if (item.s && item.s.x) { var snippet = text("p", null, "sr-x"); appendHighlighted(snippet, item.s.x, terms); row.appendChild(snippet); } return row; } - line 281
// The staggered entrance is right once, and wrong on every keystroke. // // The list is rebuilt on each render, so animating every item every time // would replay the whole cascade for each character typed -- which reads as // flicker, - line 281
not polish, and is the opposite of what a fast search should feel // like. So the stagger runs only when the shape of the answer changes: the // first results after an empty box, a sort, or a filter. Refining a query // just swaps the - line 281
text, which is why narrowing a search feels still. var lastShape = null; function shapeOf() { return state.sort + "\n" + state.kind + "\n" + state.area + "\n" + (state.q ? "q" : "-"); } function render() { var results = search(); var terms - line 281
= parseQuery(state.q); var shape = shapeOf(); var stagger = shape !== lastShape; lastShape = shape; var list = els.results; clear(list); list.classList.toggle("sr-stagger", stagger); // Build the rows off-document and attach them in one - line 281
go. // // Appending each row to the live list makes the browser consider layout up // to sixty times per keystroke; a `DocumentFragment` is not in the // document, so nothing is laid out until the single `appendChild` at the // end. This - line 281
is the part of a keystroke that actually costs something -- // scoring the whole corpus is about a millisecond, and building rows is // most of the rest. var shown = results.slice(0, MAX_RENDER); var fragment = - line 281
document.createDocumentFragment(); for (var i = 0; i < shown.length; i++) { fragment.appendChild(renderResult(shown[i], i, terms)); - line 321
} list.appendChild(fragment); // The count is the honest number, not the number drawn. var summary; if (!state.q) { summary = results.length + " file" + (results.length === 1 ? "" : "s") + " in the index"; } else if (!results.length) { - line 321
summary = "nothing matched " + JSON.stringify(state.q); } else { summary = results.length + " result" + (results.length === 1 ? "" : "s"); if (results.length > shown.length) { summary += ", showing the first " + shown.length; } if - line 321
(results.length === MAX_RESULTS) { summary = "more than " + MAX_RESULTS + " results, showing the best " + shown.length; } } clear(els.count); els.count.appendChild(document.createTextNode(summary)); els.empty.hidden = results.length !== 0; - line 321
} // Coalesce keystrokes into one render per frame. Typing quickly should not // queue a render per character. var frame = null; function scheduleRender() { if (frame !== null) { return; } frame = window.requestAnimationFrame(function () { - line 321
frame = null; render(); }); } // --- wiring --------------------------------------------------------------- function fillSelect(node, pairs, allLabel) { - line 361
clear(node); var first = document.createElement("option"); first.value = ""; first.appendChild(document.createTextNode(allLabel)); node.appendChild(first); for (var i = 0; i < pairs.length; i++) { var option = - line 361
document.createElement("option"); option.value = pairs[i][0]; option.appendChild(document.createTextNode(pairs[i][1])); node.appendChild(option); } } function readQueryFromUrl() { try { var q = new - line 361
URL(window.location.href).searchParams.get("q"); if (q) { return String(q).slice(0, MAX_QUERY); } } catch (e) { /* older engine: no deep link, which is not fatal */ } return ""; } function fail(message) { clear(els.count); - line 361
els.count.appendChild(document.createTextNode(message)); els.empty.hidden = true; } /** * Fold every searchable string to lower case, once. * * Search is case-insensitive and JavaScript has no case-insensitive * `indexOf`, so the choice is - line 361
to fold on every comparison or to fold once * and keep the result. Folding once costs one pass at load and roughly the * size of the index again in memory; folding per keystroke costs an * allocation per section per character, for ever. * - line 361
* The folded fields are prefixed with `_` and are never rendered -- what the * reader sees is always the original text, so a heading still shows its * capitals. */ - line 401
function fold(loaded) { var docs = loaded.docs; for (var i = 0; i < docs.length; i++) { docs[i]._p = String(docs[i].p || "").toLowerCase(); docs[i]._t = String(docs[i].t || "").toLowerCase(); } var secs = loaded.secs; for (var j = 0; j < - line 401
secs.length; j++) { secs[j]._h = String(secs[j].h || "").toLowerCase(); secs[j]._x = String(secs[j].x || "").toLowerCase(); } } function start(loaded) { fold(loaded); index = loaded; fillSelect(els.kind, index.kinds, "every kind"); - line 401
fillSelect(els.area, index.areas.map(function (a) { return [a, a]; }), "everywhere"); els.q.disabled = false; els.q.placeholder = "search " + index.docs.length + " files"; var initial = readQueryFromUrl(); if (initial) { els.q.value = - line 401
initial; state.q = initial; } els.q.addEventListener("input", function () { state.q = els.q.value.slice(0, MAX_QUERY); scheduleRender(); }); els.sort.addEventListener("change", function () { state.sort = els.sort.value; scheduleRender(); - line 401
}); els.kind.addEventListener("change", function () { state.kind = els.kind.value; scheduleRender(); }); els.area.addEventListener("change", function () { - line 441
state.area = els.area.value; scheduleRender(); }); els.form.addEventListener("submit", function (event) { // There is no server to submit to; everything happens here. event.preventDefault(); scheduleRender(); }); - line 441
document.documentElement.classList.add("search-live"); render(); if (!initial) { els.q.focus(); } } document.addEventListener("DOMContentLoaded", function () { els.form = byId("search-form"); els.q = byId("q"); els.sort = byId("sort"); - line 441
els.kind = byId("kind"); els.area = byId("area"); els.results = byId("results"); els.count = byId("result-count"); els.empty = byId("no-results"); if (!els.form || !els.q || !els.results) { return; } els.q.disabled = true; if - line 441
(!window.fetch) { fail("This browser cannot load the index. The complete static index is " + "linked below and needs nothing but your browser's find-in-page."); return; } window.fetch(INDEX_URL, { credentials: "omit" }) .then(function - line 441
(response) { if (!response.ok) { throw new Error("HTTP " + response.status); } return response.json(); }) .then(function (data) { // Treat the index as data of unknown shape rather than as something - line 481
// that must be well-formed. A truncated deploy should say so, not throw // a TypeError somewhere further in and blame the network. if (!data || typeof data !== "object" || !Array.isArray(data.docs) || !Array.isArray(data.secs) || - line 481
!Array.isArray(data.kinds) || !Array.isArray(data.areas)) { throw new Error("the index is not in the expected shape"); } start(data); }) .catch(function (error) { fail("Could not load the search index (" + error.message + "). The " + - line 481
"complete static index is linked below and needs no JavaScript."); }); }); })();
website/js/sessions.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The command line, on the page, typed out. // // # What this plays, and why it is not a drawing // // Five recordings of the real programs: the bytes `veilvoice` wrote on a real // - line 1
terminal, with the passphrases typed at the real prompts. `tools/shots/ // sessions.py` records them into `assets/screenshots/session-*.txt` and // `tools/site/demo.py` turns those into `window.VEILVOICE_DEMO.sessions`, which // CI - line 1
regenerates and compares. Nothing here is written by hand, so nothing here // can quietly stop matching the program. // // The one invented thing is the pacing. A session that arrives all at once is a // paste rather than a session, so the - line 1
typing is played at about the speed // somebody types and the output at about the speed a terminal fills. That is // the whole of the fiction and it is stated here rather than implied. // // # What used to be here instead // // A - line 1
hand-built model of the desktop application, drawn in CSS, opened from a // button as an overlay over whichever page the reader was on. It responded to // clicks and it was a drawing: the device names, the levels and the panels were // all - line 1
written by hand, and it veiled no audio. It was labelled as a drawing, // and a label is a weaker thing than not needing one. // // It was also redundant. The real window is photographed on every build, nine // captures, one per screen, - line 1
and those are on this page too. Offering a reader a // drawing of an interface *and* photographs of the same interface asks them to // work out which one to believe, on a site whose argument is that they should // not have to take - line 1
anybody's word for anything. // // So the drawing is gone and this is what replaced it: the recordings, on the // page rather than behind a button, above the photographs of the window. // // In plain words // // Plays back real terminal - line 1
sessions at typing speed, so you can see what the // program actually prints without installing it. - line 41
(function () { "use strict"; // Milliseconds. Slow enough to read a command as it appears, quick enough // that a forty-line help screen does not outlast the reader's patience. var PER_CHARACTER = 24; var AFTER_PROMPT = 200; var PER_LINE = - line 41
30; var PER_BLANK = 80; function el(name, className, text) { var node = document.createElement(name); if (className) { node.className = className; } if (text !== undefined) { node.textContent = text; } return node; } function still() { - line 41
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); } document.addEventListener("DOMContentLoaded", function () { var root = document.getElementById("cli-demo"); if (!root) { return; } var data = - line 41
window.VEILVOICE_DEMO || {}; var sessions = data.sessions || []; if (!sessions.length) { return; } var bar = el("div", "term-bar"); ["#f7768e", "#e0af68", "#9ece6a"].forEach(function (colour) { var dot = el("i", "term-dot"); - line 41
dot.style.background = colour; bar.appendChild(dot); }); var name = el("span", "term-name", "veilvoice"); bar.appendChild(name); var picks = el("div", "term-picks"); - line 81
picks.setAttribute("role", "tablist"); picks.setAttribute("aria-label", "Recorded sessions"); var note = el("p", "term-note", ""); var out = el("pre", "term-out"); // A live region, so somebody using a screen reader is told the output // - line 81
arrived rather than being left with a box that silently fills. out.setAttribute("aria-live", "polite"); out.setAttribute("tabindex", "0"); var replay = el("button", "term-btn term-btn-do", "play it again"); replay.type = "button"; var skip - line 81
= el("button", "term-btn term-btn-do", "show all of it"); skip.type = "button"; root.appendChild(bar); root.appendChild(picks); root.appendChild(note); root.appendChild(out); var timer = null; var current = sessions[0]; var started = - line 81
false; function stop() { if (timer) { window.clearTimeout(timer); timer = null; } } function whole(entry) { return entry.steps.map(function (step) { return "$ " + step.typed + "\n" + step.output; }).join("\n\n"); } function mark(entry) { - line 81
Array.prototype.forEach.call(picks.children, function (button) { if (!button.dataset.name) { return; } var on = button.dataset.name === entry.name; button.className = on ? "term-btn term-btn-on" : "term-btn"; - line 81
button.setAttribute("aria-selected", on ? "true" : "false"); button.setAttribute("tabindex", on ? "0" : "-1"); - line 121
}); } function play(entry, animate) { stop(); current = entry; name.textContent = entry.programme; note.textContent = entry.note; mark(entry); if (!animate || still()) { out.textContent = whole(entry); return; } out.textContent = ""; var - line 121
queue = []; entry.steps.forEach(function (step, index) { if (index) { queue.push(["line", ""]); } queue.push(["prompt", ""]); step.typed.split("").forEach(function (character) { queue.push(["type", character]); }); queue.push(["line", - line 121
""]); step.output.split("\n").forEach(function (line) { queue.push(["line", line]); }); }); var at = 0; (function tick() { if (at >= queue.length) { timer = null; return; } var kind = queue[at][0]; var text = queue[at][1]; at += 1; var - line 121
wait; if (kind === "prompt") { out.textContent += "$ "; wait = AFTER_PROMPT; } else if (kind === "type") { out.textContent += text; wait = PER_CHARACTER; } else { out.textContent += "\n" + text; wait = text ? PER_LINE : PER_BLANK; } - line 121
out.scrollTop = out.scrollHeight; - line 161
timer = window.setTimeout(tick, wait); })(); } sessions.forEach(function (entry, index) { var button = el("button", "term-btn", entry.title); button.type = "button"; button.setAttribute("role", "tab"); button.dataset.name = entry.name; - line 161
button.title = entry.note; button.addEventListener("click", function () { started = true; play(entry, true); }); if (index === 0) { button.setAttribute("tabindex", "0"); } picks.appendChild(button); }); replay.addEventListener("click", - line 161
function () { play(current, true); }); skip.addEventListener("click", function () { stop(); out.textContent = whole(current); }); picks.appendChild(replay); picks.appendChild(skip); // Arrow keys move within the strip, as a tablist is - line 161
meant to: one tab stop // for the whole row rather than seven for somebody who wants none of them. picks.addEventListener("keydown", function (event) { var order = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"]; var step = - line 161
order.indexOf(event.key); if (step < 0) { return; } event.preventDefault(); var at = sessions.indexOf(current); at = (at + (step < 2 ? 1 : -1) + sessions.length) % sessions.length; started = true; play(sessions[at], true); var button = - line 161
picks.querySelector('[data-name="' + sessions[at].name + '"]'); if (button) { button.focus(); } }); - line 201
// The first one is shown straight away, and starts typing when it is on // screen. Animating a terminal nobody has scrolled to spends the reader's // battery on something they never saw, and finishing before they arrive // leaves them - line 201
looking at the end of a session they did not watch. play(current, false); if (!window.IntersectionObserver || still()) { return; } var watcher = new window.IntersectionObserver(function (entries) { entries.forEach(function (entry) { if - line 201
(entry.isIntersecting && !started) { started = true; play(current, true); } }); }, { threshold: 0.25 }); watcher.observe(root); }); })();
website/js/teleport.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Following a link to a section of the page lands on that section's heading, // with the heading visible. // // # The defect this replaces // // The header is `position: sticky`, so the top - line 1
of the viewport is covered by // it. `scroll-margin-top` is the property for that, and this site set it to a // flat `90px`. The header is not 90px tall. Measured in a browser it is 133px // at desktop widths, 171px where the navigation - line 1
wraps to three rows, 115px on // a phone, 143px at 320px, and 81px on the reference pages. Every one of those // is a landing that is wrong, and at the common desktop width it is 43px // wrong in the direction that hides the heading behind - line 1
the header. // // It was also set on `section`, `h2` and `h3` only, so an `h4` or a list item // with an id -- which is most of the releases page and every roadmap entry -- // had no offset at all and landed a full header height underneath - line 1
it. // // The edition of this site that runs no scripts has never had this problem, // because it has no sticky header. That is the standard being matched here. // // # How the offset is decided // // By measuring the header, not by - line 1
writing a number down. `--anchor-offset` is // set from the header's own height and kept current by a ResizeObserver, so a // header that grows a row, a font that loads late and a phone that is turned // sideways all correct themselves. - line 1
The stylesheet carries a starting value for // the moment before this runs, and that value is never the one that matters. // // # Why the scroll is repeated // // A fragment jump happens once, at the moment the browser reads the hash, and - line 1
// the page is not finished at that moment. An image without intrinsic size // finishes loading, a web font replaces the fallback, a reveal transition // takes its transform off, and the heading that was in the right place is now // - line 1
somewhere else. So the position is checked over the second that follows and // corrected if it drifts, and the checking stops the instant the reader // scrolls: catching up with a moving page is the job, fighting the reader is // not. - line 41
// // Reveal transitions are settled up front rather than corrected afterwards. // `.reveal` holds an element 18px below where it belongs, and an element // scrolled to is an element that has been reached, so the target and anything // - line 41
holding it are shown before the browser scrolls, while the click is still // being handled. // // # What is deliberately left alone // // The navigation itself. Clicks are not prevented and no history entry is // written here, because the - line 41
full-size screenshot viewers are opened by // `:target`, which follows a real fragment navigation and not a `pushState`. // The back button, the middle button and copying a link therefore behave // exactly as they do with this file absent, - line 41
which is also what happens if it // fails to load: the stylesheet still clears the header, just less exactly. // // In plain words // // Clicking a link that points further down the same page takes you to exactly // that spot, with the - line 41
heading you asked for on screen rather than hidden under // the bar at the top, and a brief highlight so you can see where you landed. (function () { "use strict"; var doc = document.documentElement; // Clear of the header, plus enough - line 41
that the heading is not touching it. var BREATHING_ROOM = 14; // How long the page is given to stop moving after a landing. var SETTLE_MS = 1000; // How long the landing stays highlighted. var HIGHLIGHT_MS = 2000; var settleUntil = 0; var - line 41
settling = false; var touched = false; var wanted = null; - line 81
var highlighted = null; var highlightTimer = 0; function header() { return document.querySelector("header.top"); } /** Publish the header's real height, so the stylesheet can clear it. */ function measure() { var bar = header(); if (!bar) - line 81
{ return; } // A header that is not sticky covers nothing: the reference pages and any // page narrow enough for the browser to drop stickiness need no offset. var stuck = window.getComputedStyle(bar).position === "sticky"; var height = - line 81
stuck ? Math.ceil(bar.getBoundingClientRect().height) : 0; doc.style.setProperty("--anchor-offset", (height + BREATHING_ROOM) + "px"); } /** Where the top of `el` should end up, in document coordinates. */ function restingPlace(el) { var - line 81
bar = header(); var stuck = bar && window.getComputedStyle(bar).position === "sticky"; var clearance = stuck ? bar.getBoundingClientRect().height + BREATHING_ROOM : 0; var top = el.getBoundingClientRect().top + window.pageYOffset - - line 81
clearance; var floor = Math.max(0, doc.scrollHeight - window.innerHeight); return Math.max(0, Math.min(Math.round(top), floor)); } /** * Scroll there now, whatever `scroll-behavior` the stylesheet asks for. * * Not `scrollTo({ behavior: - line 81
"auto" })`: in the specification `auto` means * "whatever the CSS property says", which here is `smooth`, so that form * animates. `instant` is the value that means instant, and it is newer than * some of the browsers this site supports. - line 81
Turning the property off for the * one call is older than both and does the same thing. */ function snapTo(place) { var was = doc.style.scrollBehavior; doc.style.scrollBehavior = "auto"; - line 121
window.scrollTo(0, place); doc.style.scrollBehavior = was; } /** * Take the reveal transition off the target and everything holding it. * * Without this the element is measured 18px below where it will settle, and * the landing is 18px out - line 121
by the time the transition finishes. */ function settleReveal(el) { for (var node = el; node && node !== document.body; node = node.parentElement) { if (node.classList && node.classList.contains("reveal")) { node.classList.add("in"); } } } - line 121
/** * What to outline. Outlining a whole section outlines most of the screen, * which points at nothing; its heading is the thing the reader came for. */ function cueFor(el) { if (/^(SECTION|ARTICLE|DIV|MAIN)$/.test(el.tagName)) { var - line 121
heading = el.querySelector("h1, h2, h3, h4"); if (heading) { return heading; } } return el; } /** Mark where the reader landed, briefly. */ function highlight(el) { el = cueFor(el); if (highlighted) { - line 121
highlighted.classList.remove("landed"); } if (highlightTimer) { window.clearTimeout(highlightTimer); } // Restarting the animation needs the class gone for a frame. highlighted = el; window.requestAnimationFrame(function () { - line 121
el.classList.add("landed"); highlightTimer = window.setTimeout(function () { - line 161
el.classList.remove("landed"); if (highlighted === el) { highlighted = null; } }, HIGHLIGHT_MS); }); } /** * Continue the landing for as long as the page is still moving underneath * it. Cancelled by the reader touching the page at all. * - line 161
* The stylesheet asks for `scroll-behavior: smooth`, so the jump is an * animation rather than an event, and a correction issued while it is * running restarts it from wherever it had got to. That is what a first * version of this did on - line 161
every frame, and the page crept towards the target * over more than a second instead of gliding to it. So corrections wait for * the scrolling to stop, and when they are made they are made instantly: by * then it is a few pixels, and a few - line 161
pixels do not need an animation. */ function settle() { if (settling) { return; } settling = true; var previous = null; window.requestAnimationFrame(function step() { if (!wanted || Date.now() > settleUntil) { settling = false; wanted = - line 161
null; return; } var here = window.pageYOffset; if (here === previous) { var place = restingPlace(wanted); if (Math.abs(here - place) > 1) { snapTo(place); } } previous = here; window.requestAnimationFrame(step); }); } function - line 161
stopSettling() { touched = true; settleUntil = 0; wanted = null; } - line 201
/** Put `id` on screen properly. `cue` moves focus and marks the landing. */ function teleport(id, cue) { var el = document.getElementById(id); if (!el) { return; } // The full-size screenshot viewers are fixed overlays opened by - line 201
`:target`. // They cover the page rather than sitting in it, so there is nothing to // scroll to and scrolling the page underneath one is a change the reader // sees when they close it. if (el.classList.contains("viewer")) { return; } - line 201
settleReveal(el); // A short hop glides, because watching the page move the height of a // screen or two is what tells somebody they have gone down rather than // sideways. A long one does not: the stylesheet asks for // `scroll-behavior: - line 201
smooth`, and smooth over twenty thousand pixels is a // second and a half of everything on the page rushing past, which orients // nobody. Past two screens this goes straight there and the outline below // says where "there" is. var place - line 201
= restingPlace(el); if (Math.abs(window.pageYOffset - place) > 2 * window.innerHeight) { snapTo(place); } else { window.scrollTo(0, place); } if (cue) { // Focus follows the landing, or a keyboard reader carries on from // wherever they - line 201
were rather than from what they asked for. if (!el.hasAttribute("tabindex")) { el.setAttribute("tabindex", "-1"); } try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); } highlight(el); } touched = false; wanted = el; - line 201
settleUntil = Date.now() + SETTLE_MS; settle(); } - line 241
/** The id in the address bar, if it has one. */ function named() { var hash = window.location.hash; if (hash.length < 2) { return null; } try { return decodeURIComponent(hash.slice(1)); } catch (e) { // A hash that is not valid - line 241
percent-encoding is not an id here either, // but `decodeURIComponent` throws rather than saying so. return hash.slice(1); } } function fromHash() { var id = named(); if (id) { teleport(id, true); } } - line 241
document.addEventListener("DOMContentLoaded", function () { measure(); if (window.ResizeObserver) { var bar = header(); if (bar) { new window.ResizeObserver(measure).observe(bar); } } window.addEventListener("resize", measure, { passive: - line 241
true }); window.addEventListener("orientationchange", measure, { passive: true }); if (document.fonts && document.fonts.ready && document.fonts.ready.then) { document.fonts.ready.then(measure); } // A click on a link into this page settles - line 241
the target's reveal transition // before the browser scrolls, and then lets the browser scroll. Nothing is // prevented and no history entry is written here: the screenshot viewers // are opened by `:target`, which follows real fragment - line 241
navigation and not // a pushState, and the back button, the middle button and copying a link // all keep working because none of them has been taken over. document.addEventListener("click", function (event) { if (event.defaultPrevented || - line 241
event.button !== 0) { return; } - line 281
var link = event.target.closest ? event.target.closest('a[href^="#"]') : null; if (!link) { return; } var id = decodeURIComponent(link.getAttribute("href").slice(1)); var el = id && document.getElementById(id); if (el) { settleReveal(el); - line 281
} }, true); // The browser has already jumped by the time this runs, using whatever the // stylesheet's starting offset was. Landing again with the measured one is // the same frame, so there is nothing to see. fromHash(); // Everything - line 281
below the fold finishes loading after this, and some of it // changes the height of what is above it. Landing once more when it is all // in, quietly: no second highlight, and nothing at all if the reader has // started reading in the - line 281
meantime. window.addEventListener("load", function () { var id = named(); if (id && !touched) { teleport(id, false); } }); }); window.addEventListener("hashchange", fromHash); window.addEventListener("popstate", fromHash); for (var i = 0, - line 281
events = ["wheel", "touchstart", "keydown", "mousedown"]; i < events.length; i++) { window.addEventListener(events[i], stopSettling, { passive: true }); } })();
website/js/theme.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Theme switching. Nine palettes, Tokyo Night default. // // The choice is kept in localStorage, which never leaves the browser. No // cookie, so nothing is attached to a request and there - line 1
is nothing to consent // to -- a preference the server never sees is not tracking. // // In plain words // // This is the colour-scheme menu in the corner of the page. Pick a theme and // every page on this site changes to it, and stays - line 1
that way next time you // come back. // // Your choice is kept in your own browser. It is not a cookie, so it is never // sent anywhere: this site has no server that could receive it and nothing // that could match it to you. (function () - line 1
{ "use strict"; var THEMES = [ ["tokyo-night", "Tokyo Night"], ["gruvbox", "Gruvbox"], ["dracula", "Dracula"], ["nord", "Nord"], ["catppuccin", "Catppuccin Mocha"], ["everforest", "Everforest"], ["solarized", "Solarized Dark"], // Written - line 1
as an escape, not a literal: this file is source the site // explicitly invites people to open and read, and a reader whose viewer // guesses the wrong encoding would see mojibake instead of a theme name. // The escape is ASCII on disk and - line 1
the correct character on screen. ["rose-pine", "Ros\u00e9 Pine"], ["paper", "Paper (light)"] ]; var KEY = "veilvoice-theme"; var DEFAULT = "tokyo-night"; - line 41
function valid(name) { return THEMES.some(function (t) { return t[0] === name; }); } function stored() { try { var v = localStorage.getItem(KEY); return valid(v) ? v : DEFAULT; } catch (e) { // Private browsing can throw on access rather - line 41
than return null. return DEFAULT; } } function apply(name) { document.documentElement.setAttribute("data-theme", name); try { localStorage.setItem(KEY, name); } catch (e) { /* not fatal */ } } function build(select) { var current = - line 41
stored(); THEMES.forEach(function (t) { var opt = document.createElement("option"); opt.value = t[0]; opt.textContent = t[1]; if (t[0] === current) { opt.selected = true; } select.appendChild(opt); }); select.addEventListener("change", - line 41
function () { apply(select.value); }); } // Applied before DOMContentLoaded so the page never flashes the default // palette before switching to the reader's choice. apply(stored()); // Marks the document as scripted, from a *blocking* - line 41
head script, so the // class is set before the first paint. Scroll reveals hide themselves only // under `html.js`: without JavaScript the content is simply visible, rather // than transparent for ever waiting for an observer that will - line 41
never run. document.documentElement.classList.add("js"); - line 81
// Upgrade the JavaScript switch from its honest default. // // The markup says `aria-checked="false"` because markup cannot know whether // scripts run, and a switch that claims "on" when nothing is running is // simply lying to whoever - line 81
most needs the truth. Reaching this line proves // scripts run, so the attribute is corrected here -- the visual state is // handled by CSS through `html.js`, but assistive technology reads the // attribute and it has to agree. - line 81
document.addEventListener("DOMContentLoaded", function () { var toggle = document.querySelector(".js-toggle[role=\"switch\"]"); // Only on the full site: the no-JavaScript edition's switch is genuinely // off, and it does not load this - line 81
file anyway. if (toggle && toggle.getAttribute("href") !== "../index.html") { toggle.setAttribute("aria-checked", "true"); } }); document.addEventListener("DOMContentLoaded", function () { var select = document.getElementById("theme"); if - line 81
(select) { build(select); } }); })();
website/js/verify.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // In-browser SHA-256 verification for downloaded release archives. // // # The file never leaves your machine // // Hashing happens locally through WebCrypto (`crypto.subtle.digest`), which - line 1
is // built into the browser. The file is read with FileReader, hashed in memory, // and discarded. There is no upload, no fetch, no XHR, and no server that // could receive it -- you can confirm that by reading this file, which is the // - line 1
whole of the implementation. // // # Why it streams // // A release archive can be tens of megabytes and WebCrypto has no incremental // digest API, so the whole file must be in memory at once for `digest()`. // Reading it in chunks first - line 1
lets the progress bar move and keeps the tab // responsive, rather than freezing until the browser finishes. // // In plain words // // This is the box on the verify page where you drop a file you have // downloaded, and it tells you - line 1
whether it is the one that was published. // // Your file never leaves your computer. The browser does the arithmetic // itself, on the file sitting on your disk, and this file is the whole of // how -- there is no upload in it, and you - line 1
can read it and see that. // // It reads a big file in pieces rather than all at once, so a large download // does not make the page freeze while it works. (function () { "use strict"; var CHUNK = 4 * 1024 * 1024; function hex(buffer) { - line 1
var bytes = new Uint8Array(buffer); var out = ""; for (var i = 0; i < bytes.length; i++) { - line 41
out += bytes[i].toString(16).padStart(2, "0"); } return out; } /** * Read the whole file into one buffer, in chunks, reporting progress. * * The destination is allocated **once**, up front, and each chunk is written * straight into it. The - line 41
previous version collected the chunks in an array and * then copied them into a second buffer, so peak memory was twice the file * size before WebCrypto had even been handed it -- and a release archive is * tens of megabytes. On a phone - line 41
that is the difference between hashing a * download and having the tab killed by the system, which is a poor outcome * for the one tool on this page whose entire purpose is checking that a * download is genuine. */ function readFile(file, - line 41
onProgress) { return new Promise(function (resolve, reject) { if (file.size === 0) { resolve(new Uint8Array(0)); return; } var all; try { all = new Uint8Array(file.size); } catch (e) { // A file too large to hold in one buffer at all. Say - line 41
so plainly rather // than failing somewhere deeper with a less useful message. reject(new Error( "this file is too large for the browser to hash in memory (" + Math.round(file.size / 1048576) + " MB). Use sha256sum, " + "certutil - line 41
-hashfile, or shasum -a 256 instead." )); return; } var offset = 0; var reader = new FileReader(); reader.onerror = function () { reject(new Error("could not read the file")); }; reader.onload = function (event) { - line 81
var part = new Uint8Array(event.target.result); all.set(part, offset); offset += part.length; onProgress(Math.min(1, offset / file.size)); if (offset < file.size) { next(); } else { resolve(all); } }; function next() { - line 81
reader.readAsArrayBuffer(file.slice(offset, offset + CHUNK)); } next(); }); } /** * Whether the browser will let this page hash anything at all. * * `crypto.subtle` is only defined in a **secure context**: HTTPS, or * localhost. Over plain - line 81
`http://` to a LAN address -- which is exactly how * somebody serving this folder to test it reaches it from a phone -- it is * `undefined`, and the old code walked straight into * "Cannot read properties of undefined (reading 'digest')". - line 81
That is a true * message and a useless one. The published site is HTTPS and unaffected; * this is about not lying to the person who self-hosts. */ function digestAvailable() { return typeof crypto !== "undefined" && crypto.subtle && typeof - line 81
crypto.subtle.digest === "function"; } /** Normalise whatever the user pasted down to a bare hex digest. * Accepts a raw hash, or a whole `SHA256SUMS` line like * "<hash> veilvoice-v0.1.1-linux-x86_64.tar.gz". */ function - line 81
expectedFrom(text) { var match = String(text).toLowerCase().match(/\b[0-9a-f]{64}\b/); return match ? match[0] : ""; } document.addEventListener("DOMContentLoaded", function () { - line 121
var drop = document.getElementById("drop"); var picker = document.getElementById("file"); var expected = document.getElementById("expected"); var output = document.getElementById("digest"); var verdict = document.getElementById("verdict"); - line 121
var bar = document.getElementById("progress"); if (!drop || !picker) { return; } function compare() { var want = expectedFrom(expected.value); var got = (output.dataset.hash || "").toLowerCase(); verdict.className = "verdict"; if (!got || - line 121
!want) { verdict.textContent = ""; verdict.style.display = "none"; return; } verdict.style.display = "block"; if (want === got) { verdict.classList.add("match"); verdict.textContent = "MATCH -- this file is byte-for-byte what the hash - line 121
describes."; } else { verdict.classList.add("fail"); verdict.textContent = "NO MATCH -- do not run this file. It is not the release it claims to be, " + "or the download was corrupted."; } } function handle(file) { if (!file) { return; } - line 121
if (!digestAvailable()) { output.textContent = "this browser will not hash a file on an insecure page. Open this over " + "https, or over http://localhost, and it will work. Nothing is uploaded " + "either way -- the hashing is done here - line 121
in the page."; return; } verdict.style.display = "none"; output.dataset.hash = ""; output.textContent = "reading " + file.name + " ..."; bar.style.display = "block"; bar.value = 0; - line 161
readFile(file, function (fraction) { bar.value = fraction * 0.8; }) .then(function (bytes) { output.textContent = "hashing ..."; bar.value = 0.9; return crypto.subtle.digest("SHA-256", bytes); }) .then(function (digest) { var value = - line 161
hex(digest); output.dataset.hash = value; output.textContent = value + " (" + file.name + ")"; bar.value = 1; setTimeout(function () { bar.style.display = "none"; }, 400); compare(); }) .catch(function (error) { bar.style.display = "none"; - line 161
output.textContent = "error: " + error.message; }); } drop.addEventListener("click", function () { picker.click(); }); // A drop zone that can only be reached with a mouse is not reachable at // all for someone using a keyboard or a screen - line 161
reader. The element carries // `tabindex` and a `button` role in the markup; this is the other half. drop.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") { e.preventDefault(); - line 161
picker.click(); } }); picker.addEventListener("change", function () { handle(picker.files[0]); }); expected.addEventListener("input", compare); ["dragenter", "dragover"].forEach(function (name) { drop.addEventListener(name, function (e) { - line 161
e.preventDefault(); drop.classList.add("hot"); }); }); ["dragleave", "drop"].forEach(function (name) { - line 201
drop.addEventListener(name, function (e) { e.preventDefault(); drop.classList.remove("hot"); }); }); drop.addEventListener("drop", function (e) { if (e.dataTransfer && e.dataTransfer.files) { handle(e.dataTransfer.files[0]); } }); }); })();
website/js/walkthrough.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Every screen of the application as a photograph you pick between, and the // command line as a list of jobs rather than a list of flags. // // # What this is, and what it deliberately is - line 1
not // // Nothing here pretends to run. The pictures are captures of the real window, // taken by the build on every commit, and the only thing the reader drives is // which one they are looking at. That is a smaller claim than an - line 1
interactive // model of the program, and it is one the page can actually keep. // // There used to be such a model, drawn in CSS and opened from a button. It is // gone: a drawing of an interface is exactly the thing a reader cannot check, - line 1
// and offering it beside photographs of the same interface asked somebody to // decide which of the two to believe. `js/sessions.js` replaced it with the // recorded terminal sessions, which are the real programs' real output. // // # - line 1
Where the content comes from // // All of it is in `window.VEILVOICE_DEMO`, which `tools/site/demo.py` writes // from the source: the tab list out of `app.rs`, the pictures out of // `assets/screenshots`, and every worked command checked - line 1
against the program's // own `--help`. Nothing is typed here, so nothing here can drift. // // In plain words // // Lets you click through screenshots of the real app, and read what each // command line job actually does, without - line 1
downloading anything. (function () { "use strict"; var data = window.VEILVOICE_DEMO || {}; var shots = data.shots || []; var cases = data.usecases || []; var tabsEl = null; var imgEl = null; var noteEl = null; - line 41
var casesEl = null; var current = 0; function select(index, focus) { if (!shots.length) { return; } if (index < 0) { index = shots.length - 1; } if (index >= shots.length) { index = 0; } current = index; var shot = shots[index]; - line 41
imgEl.setAttribute("src", shot.image); // The label already names the screen, so the alt text says what the // picture is rather than repeating the button next to it. imgEl.setAttribute("alt", "The " + shot.label + " screen of VeilVoice"); - line 41
noteEl.textContent = shot.note; var buttons = tabsEl.querySelectorAll("button"); Array.prototype.forEach.call(buttons, function (button, at) { var on = at === index; button.setAttribute("aria-selected", on ? "true" : "false"); // Only the - line 41
selected tab is in the tab order: a tablist is one stop, and // arrow keys move within it. Nine tab stops in a row would be nine // things to get past for somebody who does not want any of them. button.setAttribute("tabindex", on ? "0" : - line 41
"-1"); button.className = on ? "walk-tab walk-tab-on" : "walk-tab"; if (on && focus) { button.focus(); } }); } function buildTabs() { if (!shots.length) { return; } shots.forEach(function (shot, index) { var button = - line 41
document.createElement("button"); button.type = "button"; button.className = "walk-tab"; button.setAttribute("role", "tab"); button.textContent = shot.label; button.addEventListener("click", function () { select(index, false); }); - line 41
tabsEl.appendChild(button); - line 81
}); tabsEl.addEventListener("keydown", function (event) { var key = event.key; if (key === "ArrowRight" || key === "ArrowDown") { event.preventDefault(); select(current + 1, true); } else if (key === "ArrowLeft" || key === "ArrowUp") { - line 81
event.preventDefault(); select(current - 1, true); } else if (key === "Home") { event.preventDefault(); select(0, true); } else if (key === "End") { event.preventDefault(); select(shots.length - 1, true); } }); select(0, false); } function - line 81
buildCases() { if (!cases.length) { return; } cases.forEach(function (item) { var row = document.createElement("div"); row.className = "walk-case"; var title = document.createElement("h4"); title.className = "walk-case-title"; - line 81
title.textContent = item.title; var code = document.createElement("code"); code.className = "walk-case-cmd"; code.textContent = item.typed; var note = document.createElement("p"); note.className = "walk-case-note"; note.textContent = - line 81
item.note; - line 121
row.appendChild(title); row.appendChild(code); row.appendChild(note); casesEl.appendChild(row); }); } // The Demo link in the header lands on the section. Somebody who followed it // came to look at the program, so the first screen is - line 121
already shown by the // time they arrive rather than waiting behind another click. function fromFragment() { var hash = window.location.hash; if (hash !== "#demo" && hash !== "#walkthrough") { return; } var target = - line 121
document.getElementById("walkthrough"); if (!target) { return; } select(current, false); if (hash === "#walkthrough") { target.scrollIntoView({ block: "start" }); } } // The elements are looked up here rather than at load, and the handler - line 121
stops // if the ones it cannot work without are missing. `tools/site/split.py` reads // this guard to decide which of the section pages need this file at all, so a // page that carries none of this markup does not download it. - line 121
document.addEventListener("DOMContentLoaded", function () { var imgNode = document.getElementById("walk-img"); var noteNode = document.getElementById("walk-note"); if (!imgNode || !noteNode) { return; } imgEl = imgNode; noteEl = noteNode; - line 121
tabsEl = document.querySelector(".walk-tabs"); casesEl = document.querySelector(".walk-cases"); if (!tabsEl || !casesEl) { return; } buildTabs(); buildCases(); fromFragment(); }); window.addEventListener("hashchange", fromFragment); - line 161
})();
website/nojs/index.html
- VeilVoice
Irreversible voice de-identification, fully offline. VeilVoice destroys the biometric voiceprint of a speaker, meaning pitch, formants, timbre, micro-timing and the melody of an accent, so that neither software nor a human can re-identify - VeilVoice
them or reconstruct the original voice, while the words stay clean and transcribable. - ABOUT THIS PAGE
This is the JavaScript-free edition. It exists because refusing to run unknown code is a reasonable position, particularly on a site about not being identified, and “turn scripts on or leave” is a rude answer to it. The main site is - ABOUT THIS PAGE
better, and here is the honest reason why. Two features need JavaScript and cannot be faked without it: The hash verifier. Checking a download's SHA-256 inside your browser requires computation, and computation requires a script. On this - ABOUT THIS PAGE
page you verify from a terminal instead, which is no worse, and arguably better, since sha256sum is a program you already trust. The live repository panel : stars, latest release, the README rendered inline. Here you get plain links to - ABOUT THIS PAGE
GitHub. If your concern is malicious script, note that the main site loads no third-party code at all : no CDN, no analytics, no web fonts, no tag manager. Every script is served from the same origin and is short enough to read in a few - ABOUT THIS PAGE
minutes: verify.js , theme.js , markdown.js , repo.js , legal.js . Read them, then decide. Or stay here, because this page is complete, and it is not a lesser tier of information. The colour schemes above work without JavaScript, using - ABOUT THIS PAGE
radio inputs and a CSS :has() selector. If your browser does not support it, you keep Tokyo Night and nothing else changes. - WHAT IT DOES
Anonymise a recording wav, mp3, flac, ogg, m4a in, a clean WAV out, metadata stripped. Around 90× faster than real time. Scramble live Route the veiled voice into a virtual audio cable; every application on the machine receives it instead - WHAT IT DOES
of you. Encrypt at rest, by default Every recording is sealed as it is written, using an X25519 + ML-KEM-768 hybrid, so one captured today is not readable by a quantum adversary tomorrow. Turning it off makes you read why first. Lock the - WHAT IT DOES
app A separate, rate-limited password gates the desktop app. It stops someone who picks up your unlocked computer; it is not tamper-proof, and the unlock screen says so. Strip metadata Audio tags, image EXIF and GPS. Rust library Every - WHAT IT DOES
crate is a normal dependency; the engine is allocation-free and callback-safe. - DOWNLOAD
Latest release , with builds for Windows, macOS (Apple Silicon and Intel) and Linux. Or build it; a fresh clone needs no secrets: git clone https://github.com/tilas01/veilvoice cd veilvoice cargo build --release - SO YOU HAVE DOWNLOADED IT, NOW WHAT
Two programs are in the archive: veilvoice-gui , the desktop app, and veilvoice , the command line. They share one engine, so anything one can do the other can. Nothing installs a service, writes to a registry, or phones home. Delete the - SO YOU HAVE DOWNLOADED IT, NOW WHAT
folder and it is gone. Give it a recording. wav, mp3, flac, ogg, m4a and friends. Around 90× faster than real time, so an hour of audio takes well under a minute. veilvoice anonymise interview.mp3 -o clean.wav The voiceprint is destroyed, - SO YOU HAVE DOWNLOADED IT, NOW WHAT
the words are kept. Phase is discarded and resynthesised; pitch register, vocal-tract length and spectral tilt are each collapsed onto one canonical value, so a whole population of speakers lands on the same output and there is nothing - SO YOU HAVE DOWNLOADED IT, NOW WHAT
left to invert. It is encrypted before it reaches the disk. The result is sealed as it is written, so -o clean.wav produces clean.wav.veil . The WAV is built in memory and encrypted there, so the plaintext never exists on disk, because a - SO YOU HAVE DOWNLOADED IT, NOW WHAT
file that is written and then deleted cannot be reliably taken back on flash storage. veilvoice decrypt clean.wav.veil -o clean.wav Or scramble your microphone as you speak , routed into a virtual audio cable so every application on the - SO YOU HAVE DOWNLOADED IT, NOW WHAT
machine receives that instead of you. Check nothing else is listening. The monitor names what is holding your microphone and camera and warns the moment something starts. Lock the app behind you. veilvoice lock set , or the lock tab in the - SO YOU HAVE DOWNLOADED IT, NOW WHAT
desktop app. - The two passwords, and why there are two
The app lock Decides whether VeilVoice opens at all. Argon2id verifier, rate limited: three attempts free, then a doubling wait. The recording passphrase Encrypts the files it writes. Argon2id at 256 MiB, or seal to a post-quantum hybrid - The two passwords, and why there are two
public key instead. They are deliberately different secrets. If one password did both, opening the app would be the same act as unsealing everything it had ever written, and the opposite of what a lock is for. The two derivations are - The two passwords, and why there are two
domain separated, so typing the same passphrase in both places still does not produce two copies of one value. Use two anyway: one guess that opens both defeats the point regardless of the maths. The app lock is not tamper-proof, and - The two passwords, and why there are two
cannot be. A program running on your computer has nowhere to hide a secret from that computer. It protects against casual access, meaning the person who sits down at your unlocked session. If someone taking your disk is the threat, encrypt - The two passwords, and why there are two
the whole volume. - VERIFY WHAT YOU DOWNLOADED
Do this. A download can be corrupted in transit or replaced entirely. - 1. Check the hash
sha256sum -c SHA256SUMS --ignore-missing On macOS: shasum -a 256 -c SHA256SUMS --ignore-missing . On Windows PowerShell: Get-FileHash .\veilvoice-*.zip -Algorithm SHA256 and compare by eye against SHA256SUMS . - 2. Check the signature
gpg --import veilvoice-signing-key.asc gpg --verify SHA256SUMS.asc SHA256SUMS The signature must name this exact key. “Good signature” from some other key means nothing at all: 8101 FB3B B28D 02FB 239E 0CDF 9CC1 C7E7 A9B5 833A The key's - 2. Check the signature
user ID is exactly tilas01 , with no e-mail address attached. Public key . - 3. Rebuild it yourself: the strongest check
Releases are bit-for-bit reproducible. Build the tagged commit and compare: git checkout v0.1.5 export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) cargo build --release --locked sha256sum target/release/veilvoice Signatures are detached - 3. Rebuild it yourself: the strongest check
and cover only the hash list, and no binary is ever modified by signing, which is what lets it stay reproducible. - SECURITY
- Why the transform cannot be undone
Phase discard Every frame's measured phase is thrown away and a synthetic one generated. Phase encodes the exact waveform and micro-timing. Infinitely many waveforms share any magnitude spectrogram. Many-to-one normalisation Pitch - Why the transform cannot be undone
register, vocal-tract length and spectral tilt are each collapsed onto one canonical value. A whole population maps to the same output, so there is nothing to invert. CSPRNG modulation The residual transform changes every frame from a - Why the transform cannot be undone
ChaCha20 stream seeded by the OS CSPRNG, held in page-locked memory and zeroized on drop. - Encryption
Argon2id Memory-hard password hashing (RFC 9106). Cost parameters travel with the file so old files still open. X25519 + ML-KEM-768 Hybrid: an attacker must break both. Guards against harvest-now-decrypt-later. XChaCha20-Poly1305 192-bit - Encryption
random nonces remove the counter-management failure mode entirely. Authenticated header The stored KDF cost cannot be downgraded to make cracking cheap. Amnesic memory Keys are page-locked out of swap, zeroized on drop, compared in - Encryption
constant time. Sealed in memory A recording that is going to be encrypted is never written to disk in the clear first, because on flash storage, a plaintext file that is written and then deleted cannot be reliably taken back. - The app lock, and what it is worth
An Argon2id verifier A password hash, stored and compared in constant time. It is not a key and encrypts nothing, because there is nothing local it could usefully encrypt. Rate limited Three attempts free, then the wait doubles from 5 s to - The app lock, and what it is worth
a 15-minute cap. The count is persisted after every attempt, so restarting the app does not reset it. A separate password Different from the recording passphrase, and domain separated, so unlocking the app is not the same act as unsealing - The app lock, and what it is worth
everything it has written. Not tamper-proof Anyone who can write to your files can delete the lock; anyone holding the disk can edit the counter, move the clock, or attack the hash offline. This protects against casual access, not against - The app lock, and what it is worth
the disk. Encrypt the volume if that is the threat. - Libre
GPL-3.0-or-later. Run, study, change and redistribute it, including commercially; derivatives stay free. No unsafe anywhere: every crate carries #![forbid(unsafe_code)] . No network code. CI fails the build if an HTTP client enters the - Libre
dependency graph. Reproducible builds: pinned toolchain, committed lockfile, path-remapped compilation. Artwork generated from a readable script, not committed as a binary blob. This page: no scripts, no cookies, no third-party requests of - Libre
any kind. - WHAT IT WILL NOT DO
It does not hide what you said. Intelligibility is preserved on purpose; the words are in the output and can be transcribed. If the message itself is sensitive, encrypt it, or do not send it. It does not fully remove a strong regional - WHAT IT WILL NOT DO
accent: the melody goes, the phonemes stay. It does not sanitise background audio: room acoustics, other voices, a passing siren. It does not hide your speaking rate or rhythm. It does not help against an attacker already running code on - WHAT IT WILL NOT DO
your machine. It does not clean filenames, filesystem timestamps, or the channel you send it over. Page-locking keeps keys out of swap; it does not survive hibernation. - LICENCE, WARRANTY AND DISCLOSURE
VeilVoice is free software under the GNU General Public License v3 or later . You may run it for any purpose, study it, change it and redistribute it, including commercially. If you distribute a modified version you must publish your - LICENCE, WARRANTY AND DISCLOSURE
changes under the same licence. It is provided with absolutely no warranty . This project was developed with AI assistance (Claude, by Anthropic) and has been reviewed and audited by tilas01 . That is a maintainer audit: no external firm - LICENCE, WARRANTY AND DISCLOSURE
or independent researcher has reviewed this code. It is disclosed so you can judge how much to verify before relying on it. The source is published under the GPL precisely so that you can. Using this website, the repository, the released - LICENCE, WARRANTY AND DISCLOSURE
binaries or any output they produce constitutes your binding agreement to these terms in full. There is no tick box on this page, because a tick box needs JavaScript; reading this section is the same agreement. Full texts: disclaimer and - LICENCE, WARRANTY AND DISCLOSURE
liability waiver · the licence in plain English · GPL-3.0 full text Privacy of this page. Static HTML on GitHub Pages. No cookies, no storage, no analytics, no fonts, no scripts, nothing fetched from anywhere else. Your theme choice is not - LICENCE, WARRANTY AND DISCLOSURE
even remembered, because there is no JavaScript to remember it with, and that is the trade. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release. VeilVoice · - LICENCE, WARRANTY AND DISCLOSURE
GPL-3.0-or-later · source · full site · wiki Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A
website/releases.html
- Releases
Every version of VeilVoice, what changed in it, and where to get it. The same notes as CHANGELOG.md . Every release, newest first. Open one to read what changed in it; its files are at the end of the notes. The notes are CHANGELOG.md , - Releases
which is where they are written. Check a download the signature, the hashes, and the files inside the archive, from the command line or the window Every archive below is signed, and every release publishes the two files that let you check - Releases
it: SHA256SUMS , a list of hashes, and SHA256SUMS.asc , the detached OpenPGP signature over that list. From v0.1.15 there is a third, CONTENTS.sha256 , which is itself covered by SHA256SUMS and lists every file inside each archive, so the - Releases
binary you are about to run can be checked and not merely the zip it arrived in. The signing key's fingerprint is: 8101 FB3B B28D 02FB 239E 0CDF 9CC1 C7E7 A9B5 833A Comparing that against the copy in README.md and the copy on the front - Releases
page is the one step no program can do for you, because a program that came out of the download cannot vouch for the download. The chain, end to end. Every check below is one link in this, and each link is only worth anything if the one - Releases
above it held: SHA256SUMS.asc is a detached OpenPGP signature, made with the key above, over SHA256SUMS . Verifying it is what ties everything under it to a person rather than to whoever served you the files. Skip it and the hash list is - Releases
numbers of unknown origin, and comparing your file against numbers of unknown origin proves nothing at all. SHA256SUMS holds the hash of each archive and the hash of CONTENTS.sha256 , so the contents list is signed as well, by being - Releases
covered by the list the signature is over. CONTENTS.sha256 holds the hash of every file inside each archive : veilvoice , veilvoice-gui , and everything shipped beside them. So the hash of the binary you are about to run traces back, link - Releases
by link, to that one signature. Checking the archive stops at step 2 and tells you the zip was published; going on to step 3 tells you the program was, which is the question somebody actually has. Releases before v0.1.15 publish no - Releases
CONTENTS.sha256 and stop at step 2, and every command here says so at the time rather than quietly checking less. - With VeilVoice itself, no GnuPG required
The verifier is part of veilvoice : it is not a separate download, and it carries the signing key compiled in, so it needs no GnuPG, no keyring and no network. The whole check, in one command veilvoice verify Run it in the folder you - With VeilVoice itself, no GnuPG required
downloaded to. It finds the release, checks the OpenPGP signature over SHA256SUMS , checks every archive against that list, checks CONTENTS.sha256 against it as well, and then checks every file you extracted against CONTENTS.sha256 . Each - With VeilVoice itself, no GnuPG required
step runs only if the one before it passed. Entirely offline. The same, pointed at a folder veilvoice verify auto ~/Downloads A named directory is checked and nothing else is searched. A path that is not there is refused by name rather - With VeilVoice itself, no GnuPG required
than answered with a verdict about somewhere else. The signature over the hash list, on its own veilvoice verify sums SHA256SUMS SHA256SUMS.asc This is the step everything else rests on. SHA256SUMS is a list of numbers; SHA256SUMS.asc is - With VeilVoice itself, no GnuPG required
the detached OpenPGP signature over it. Until that signature checks out against the signing key, the list is just numbers somebody sent you. One file, against the signed list veilvoice verify file veilvoice-v0.1.22-linux-x86_64.tar.gz - With VeilVoice itself, no GnuPG required
--sums SHA256SUMS --sig SHA256SUMS.asc The signature is verified first , and the file is compared against the list only after that passes. A pass means the download is INTACT : byte for byte the file that was published, signed by the - With VeilVoice itself, no GnuPG required
VeilVoice key. One file, with no hash list at all veilvoice verify file veilvoice-v0.1.22-linux-x86_64.tar.gz --sha256 THE_HASH_YOU_WERE_GIVEN For when you have a hash rather than a signed list. What a match proves depends entirely on - With VeilVoice itself, no GnuPG required
where that hash came from, and only you know that, so the tool says so instead of guessing. If it came from somebody else's independent build of the same tag, a match means the release is REPRODUCIBLE , which is the stronger claim. Just - With VeilVoice itself, no GnuPG required
the hash, verifying nothing veilvoice verify hash veilvoice-v0.1.22-linux-x86_64.tar.gz Prints the SHA-256 and checks nothing, for when you want to compare it against something yourself. The same check through your own GnuPG veilvoice - With VeilVoice itself, no GnuPG required
verify gnupg The one thing no program can do for itself: the tool telling you a download is genuine came out of that download. This runs the gpg on your machine over the same signature, says it added the public key and how to remove it - With VeilVoice itself, no GnuPG required
again, and prints the commands so you can run them yourself. The key it is checking against veilvoice verify key Prints the signing key compiled into the program, and its fingerprint. Compare it against the fingerprint at the top of this - With VeilVoice itself, no GnuPG required
section, against README.md , and against the website. It is the one step nothing can do for you. What a pass actually proves veilvoice verify --explain INTACT and REPRODUCIBLE are different claims, and this is the difference written out in - With VeilVoice itself, no GnuPG required
full. - The same thing, with your own GnuPG and nothing of ours
Three commands, in the folder you downloaded to. This is what veilvoice verify gnupg runs and prints, and it is worth typing yourself: the second opinion is the whole point. gpg --import veilvoice-signing-key.asc gpg --verify - The same thing, with your own GnuPG and nothing of ours
SHA256SUMS.asc SHA256SUMS sha256sum -c SHA256SUMS --ignore-missing gpg --verify answers whether the hash list is the one that was signed with the key above. sha256sum -c answers whether your files match that list. Both have to pass, and in - The same thing, with your own GnuPG and nothing of ours
that order: a hash list nobody checked the signature of proves nothing at all. On macOS and the BSDs the last command is shasum -a 256 -c SHA256SUMS or sha256 -c SHA256SUMS ; the first two are the same everywhere. - In the desktop application
veilvoice-gui runs the same code with the same key, so the answer is the same one; only the typing is different. Open Verify in the desktop application. Three slots are shown before you drop anything, because verifying needs three files - In the desktop application
and an interface that discovers that after the drop teaches people it is fiddly. Drop the archive you downloaded, the SHA256SUMS and the SHA256SUMS.asc on the window. A drop fills whichever slot the file's name says it is, in any order. - In the desktop application
Press the button once. It checks the signature over the hash list, then the archive against that list, then every file extracted out of the archive against the signed contents list, and then runs the GnuPG on this machine over the same - In the desktop application
signature. Read the three answers, which are drawn separately on purpose so a pass on one is never mistaken for a pass on another. A release published before v0.1.15 carries no contents list and simply has no such row. A GnuPG that will - In the desktop application
not run on this machine is drawn quietly: that is a fact about the computer and says nothing about the download. The Verify tab. All three slots are shown before anything is dropped, and each of the three answers is drawn on its own. - Checking the source rather than the download
What a build needs on this machine veilvoice verify deps What is required to build VeilVoice here and which of it is already present. --install offers the missing pieces one at a time with the exact command shown before each question. - Checking the source rather than the download
Build it, and compare against the published hashes veilvoice verify reproduce . --sums SHA256SUMS --sig SHA256SUMS.asc A signature says who made a file. Only a build says what it is made of. This compiles the workspace here and compares - Checking the source rather than the download
what came out against the published hashes for this platform, with the signature verified before any hash from the list is read. A difference is a finding , not an accusation: both hashes are printed and the exit status is deliberately not - Checking the source rather than the download
the one that means tampering. - What each answer is worth
INTACT means your file is byte for byte the one that was published: not truncated, not corrupted in transit, not swapped by whoever served it to you. The hash came from the signed list. REPRODUCIBLE is the stronger claim and needs a hash - What each answer is worth
from somewhere else: somebody else's independent build of the same tagged source. It says the published binary corresponds to the published source, which a signature alone cannot. A GnuPG that will not run is a fact about your computer. It - What each answer is worth
is not a failed check and nothing here treats it as one. Full instructions per platform, including Windows and the BSDs, are in docs/INSTALL.md (https://github.com/tilas01/veilvoice/blob/main/docs/INSTALL.md). The documentation every - What each answer is worth
guide, on GitHub and on this site, and in the archive you just downloaded Each of these is a file in docs/ . Every release archive carries the whole folder, so once you have unpacked one you have all of it offline. The first link is the - What each answer is worth
file as it is written; the second, where there is one, is the same ground covered as a page of this site. Document What it covers On this site USER_GUIDE.md The whole application, screen by screen guide GUIDE_CLI.md Every command line - What each answer is worth
command, with worked examples — GUIDE_GUI.md The desktop application on its own — GUIDE_VERIFY.md Checking a download, at length verify INSTALL.md Installing and verifying, per operating system download FAQ.md The questions people actually - What each answer is worth
ask faq WHITEPAPER.md What the veiling does and why it cannot be undone crypto REPRODUCIBLE_BUILDS.md Building it yourself and comparing — SELF_SIGNING.md The code-signing certificate, and importing it — PACKAGING.md The deb, the RPM, the - What each answer is worth
AUR recipe and the rest — USING_THE_CRATES.md Using VeilVoice as a library — AUDIT.md Every defect found and fixed, in order — SECURITY.md Reporting a vulnerability, and what counts as one — CONTRIBUTING.md Building it, the standing rules, - What each answer is worth
and the house style — WEBSITE.md Both editions of this site, and what generates each page — The generated reference for every crate and every source file is under the reference , and the same pages are in the wiki . v0.1.22 VeilVoice was - What each answer is worth
split into twenty-seven separate libraries. - Files
Windows, 64-bit veilvoice-v0.1.22-windows-x86_64.zip macOS, Apple silicon veilvoice-v0.1.22-macos-arm64.tar.gz macOS, Intel veilvoice-v0.1.22-macos-x86_64.tar.gz Linux, 64-bit veilvoice-v0.1.22-linux-x86_64.tar.gz Linux, ARM 64-bit - Files
veilvoice-v0.1.22-linux-arm64.tar.gz Raspberry Pi OS, 32-bit (command line only) veilvoice-v0.1.22-linux-armv7-pi.tar.gz Linux, 64-bit, static (command line only) veilvoice-v0.1.22-linux-x86_64-musl-static.tar.gz Linux, ARM 64-bit, static - Files
(command line only) veilvoice-v0.1.22-linux-arm64-musl-static.tar.gz FreeBSD, 64-bit (command line only) veilvoice-v0.1.22-freebsd-x86_64.tar.gz OpenBSD, 64-bit (command line only) veilvoice-v0.1.22-openbsd-x86_64.tar.gz NetBSD, 64-bit - Files
(command line only) veilvoice-v0.1.22-netbsd-x86_64.tar.gz SHA256SUMS the hash of every file above CONTENTS.sha256 the hash of every file inside those archives SHA256SUMS.asc the signature over that list veilvoice-signing-key.asc the - Files
public key it was signed with These links are worked out from the version number rather than fetched, so this page builds with no network. A release that did not build for a platform answers with a not-found; the release page is the - Files
definitive list. Release notes everything that changed in v0.1.22, in full Twenty-seven crates became thirteen VeilVoice was split into twenty-seven separate libraries. Seven of them were under seven hundred lines and four were used by - Files
exactly one thing. Every one of those splits is a published surface, a manifest, a README, a page of the reference and a row in every list of what this project is made of, and somebody deciding what to depend on had to read twenty-seven - Files
descriptions to find the two they wanted. The line is drawn at what a person would realistically take on its own. The six that watch what else the machine is doing are one library now. The two that raise an alarm and the safety catch that - Files
acts on one are another. The download verifier absorbed both of its halves, the video renderer absorbed the graphics probe that exists to answer one question for it, the decoy passphrase joined the cryptography it is part of, the update - Files
check joined the installer, and saved profiles joined the settings. Nothing was deleted and nothing behaves differently. Every module kept its name, its documentation and its tests, and the whole suite passed before and after. If you were - Files
using one of these as a library, the code is in the same shape at a shorter path. Found while doing it: the documentation generator wrote pages for what exists and never removed pages for what does not, because nothing had ever been - Files
removed before. It would have left 218 pages describing crates that are no longer there. It takes them away now, and says how many. A link to this site now shows a picture, and search engines are told they may read it Every page told a - Files
link preview to use assets/banner.png . That tag is read by crawlers, not by browsers, and a crawler does not work out what a relative address means. So every link to this site, posted anywhere, had always shown no picture. Nothing looked - Files
wrong: the tag was there and the file exists. The address is absolute now, and every page also says which address is its own, which stops the same page being counted twice. Two pages had no preview tags at all and now have them, and the - Files
no-JavaScript page's preview title no longer reads VeilVoice &middot; no-JavaScript edition . None of it is typed any more. It is built from what each page already says, by one piece of code every generator calls, so a new page cannot - Files
arrive without it. The site also had no robots.txt and no sitemap, so the only way in was a link from somewhere else. Both are there now, and the sitemap is produced by walking the site: all 398 pages, updated whenever one is added or - Files
removed. The banner moves on the no-JavaScript page too That edition showed the still picture while the main site animated the same banner. It shows the animation now, which is the same file the main site already serves to anyone with - Files
scripts off. Anyone who has asked their system for less movement still gets the still, and that choice is made by the markup rather than by a script, so it works on a page that runs none. Links into a page land on the thing they name The - Files
site's header stays at the top of the screen as you scroll, so anything a link jumps to needs to be pushed down clear of it. The amount it was pushed down by was a single number, and the header is five different heights depending on the - Files
page and the width of the window. At the commonest desktop width the heading you had asked for ended up behind the bar, so the section looked as though it started halfway through a sentence. Worse for two whole pages: the rule only covered - Files
sections and the larger headings, so every entry on the releases page and every entry on the roadmap got no push at all and landed a full header height underneath. Driven in a browser, 425 of the 430 links into a page on this site landed - Files
in the wrong place. The edition of the site that runs no scripts had none of this, because it has no bar that follows you down, and it is the standard the rest of the site is now held to. The header is measured instead of guessed, and - Files
re-measured when the window changes shape or a font arrives late. The landing is held for a second afterwards, because pictures and fonts below the fold can still move the page under you, and it lets go the moment you scroll. Where you - Files
landed is outlined briefly so it is obvious which heading you asked for, and if you have asked your system for less movement the outline simply sits there rather than fading. Jumping a long way down the page no longer takes a second and a - Files
half to get there. The page glides when the distance is a screen or two, which is what tells you that you went down rather than sideways, and goes straight there when it is further, which is what a link into a page is for. Seventeen images - Files
on the site did not say how big they were, so everything below them moved down when they arrived. That is why a section you had just landed on could be somewhere else a moment later. They all say now, and a check refuses an image that does - Files
not. The back button, the middle button and copying a link all behave exactly as before: nothing about the navigation itself was taken over. The list of crates on the front page was short by fifteen The website's front page shows the - Files
README, and the README lists what this project is made of twice. Both lists, and a third in the library documentation, had stopped being added to. They showed twelve and thirteen crates; there are twenty-seven. The missing ones are not - Files
plumbing: the two halves of the download verifier, the failsafe, the video renderer and the workspace store are all things the rest of the documentation describes at length. All three lists are complete, and a check now reads the workspace - Files
and the three lists on every build, so the next crate cannot be added without them. The website no longer opens on a ticked-looking box The first thing the site shows is the notice a reader has to accept, and the first checkbox in it - Files
opened already wearing its focus ring, as if somebody had just tabbed onto it. Nobody had. The script moved focus to that checkbox when the dialog opened, and focus moved by a script is drawn the same way as focus moved by a keyboard, in - Files
every engine. Focus now lands on the dialog itself, which is where a modal's focus belongs: a screen reader announces its title from there, the first Tab reaches the first control, and the trap that keeps focus inside the notice still - Files
holds. Nothing looks pressed until the reader presses it. Checked by the website's own suite, which reads the script and the stylesheet and fails if either goes back. The numbers this project states about itself are written by the tool - Files
that measures them The README, the front page and one row of the audit state how many tests this tree has, how many crates, how many website suites and how many functional lines of Rust. Ten sentences in all, and every one of them was - Files
typed by hand and compared against the tree by the website suite. The comparison was doing its job. The typing was not: every change to any Rust file moves the line count, so a commit that changed code and not those ten places failed a - Files
check that had nothing to do with what the commit was for. It happened four times in a single round. The generator that already takes those numbers now writes the sentences from them too, using the same patterns the suite checks with, so - Files
the tool that writes a claim and the check that reads it cannot disagree about where the claim is. The check is unchanged and still runs. Nothing a reader sees is different, and that is the point: the numbers were right before and are - Files
right now without anybody having to remember them. A size calculation that could wrap on 32-bit machines Records in the encrypted store are padded to a fixed set of sizes so that how big a file is says as little as possible about what is - Files
in it. Working out that size multiplies the record's length by eight. On the 32-bit builds, a record over about half a gigabyte would have wrapped that sum around. Nothing was at risk: the next line caught it and reported a failure, and - Files
the store only ever holds settings and measurements a few kilobytes in size. But it was the wrong way to write it. The arithmetic is checked now, and refuses cleanly instead of wrapping. Two places that asked the system to find a program, - Files
rather than saying where it was Naming a program without its full path means the system searches for it, and whatever it finds first is what runs. VeilVoice already says full paths everywhere it starts something, and a test enforces that, - Files
but the test only reads one file. Listing your graphics hardware on Linux, and looking up where an installed tool lives, were both still asking the system to search. Both now say exactly where they expect to find what they are running, and - Files
the graphics code has its own copy of the test that keeps it that way. The reproducible-build check still searches for your Rust toolchain and git, deliberately: it is checking a build against the tools you have, so finding yours is the - Files
point. A one in 65,536 chance that the voice scrambler settled into a fixed rhythm VeilVoice re-draws the seed behind its voice scrambling at an interval, and draws that interval's range fresh at every launch, so the rhythm is a property - Files
of your session rather than of the program. Two numbers are drawn to set the range. About one launch in 65,536, the two came out identical, and the range had no width. That means a fixed interval, which is precisely what drawing the range - Files
is meant to avoid. Nothing looked wrong: the settings were valid and a panel showing them would have shown two matching numbers. A drawn range is now always at least one frame wide, widened around the numbers that were drawn so a slow - Files
rhythm stays slow. It turned up as a test failing once after passing eleven times the same day, and two hundred repeats afterwards did not bring it back. The arithmetic is a separate function now, so the case can be handed to it directly - Files
rather than waited for. The metadata cleaner, put through the same test The part that reads a sound file somebody else made, and the part that replaces its metadata, were changed a line at a time the same way. Of 58 changes worth trying, - Files
four went unnoticed. One let the reader look one byte past the end of a file whose last section has an odd length and no padding, which is what a cut-off recording looks like. The other three were in the bland tags this writes in place of - Files
the ones it removes. Removing metadata is itself a signal, so plausible ordinary tags go in instead, and that only works if they are shaped like the ones any other program writes. Nothing had ever read back what it writes, so three ways of - Files
getting that shape wrong all passed. All four are fixed and the whole thing was run again: nothing survives now. These two files are covered by the weekly check from here on. Eighty-one changes to the encryption code that every test - Files
accepted One way to find out whether tests are worth anything is to change the code a line at a time and see whether any of them complains. Run over the app lock, the vault, the chunked store and the reversible encodings, that found 674 - Files
changes worth trying and 81 that the whole suite let through. Forty-four were the same gap. The encodings were only ever checked by encoding something and decoding it back, which tests the pair rather than the encoder: any change the - Files
decoder still reverses passes. Each of the thirty-one encodings now has its exact output written down for one fixed input, so a change to what they emit has to be deliberate. Three were in the app lock and none of them harmless. Two locks - Files
sharing half their identity could have compared as the same lock. A tamper warning could have been cleared without the passphrase. The limit on how long the lock makes somebody wait was checked against the setting that defines it, so - Files
changing fifteen minutes to seventy-five seconds passed every check. Two were not missing tests but dead code: a condition that could only act where the next line already acts, and a branch that cannot be reached at all. Both are gone. - Files
Four rounds of writing tests and re-measuring took 81 down to 15, and each of those 15 is now recorded with the reason no test could ever catch it: some compute exactly the same answer as the original, and some depend on the machine rather - Files
than on the code. This runs every week from now on, and compares itself against that recorded list. Anything new fails the build and is named. Fifteen gigabytes of build output that was living inside the repository The tool that counts how - Files
many tests this project has does it by running them. It sent that build to a folder under the user's profile on Windows, which is right, and through a fallback nobody had thought about it sent it to a folder inside the repository on every - Files
other system. So every machine that is not Windows had a second complete copy of the build sitting in the source tree, rebuilt from scratch each time the numbers were regenerated. Nothing reported it: the rule that hides build output from - Files
version control hides it at any depth, which is correct and also means this never appeared in any status, any diff, or any clean. It was found because something else broke. A long test run ran out of disk, several steps removed from the - Files
cause. The build now goes where builds go, and a check fails if build output ever appears anywhere in the repository except the one place at the root. It names the folder and how large it has grown. The undefined-behaviour checker can now - Files
look at the cryptography VeilVoice keeps every key and passphrase in a type that locks its pages out of swap, so the operating system cannot write them to disk. That lock is made with a system call the interpreter used to check for - Files
undefined behaviour cannot make, so the first key any test created stopped the check dead, and the part of the program that does the encrypting was the one part that could not be checked this way at all. The lock is now skipped when - Files
running under that interpreter, which is the same thing that already happens on a machine with no budget for it or a platform without the call. The program reports honestly that the pages are not locked, and nothing about what it stores or - Files
wipes changes. Ordinary runs are untouched and still lock. The checker then read the reversible encodings, the authenticated encryption, the protected-memory type itself, the chunked store, the file shredder and the private-file helper, - Files
and found nothing wrong in any of them. Two tests keep it that way: one fails if the lock is ever taken outside the one place that knows about this, and one proves a secret holds and wipes the same bytes whether the lock happened or not. - Files
Code that nothing reached, taken out, and a check so it cannot come back Five public items were compiled into every binary on every platform, documented, published into the generated reference and the wiki, and called by nothing: not by - Files
the programs, not by a test, not by anything. Each had been superseded by a path that does the same job, and none of them was wrong, which is why nobody had noticed. One of them was costing something. An accessor for the last vault audit - Files
kept alive a private field, and that field was filled by a copy of the audit result made every time the vault was opened, for a value no line of code ever read. The accessor, the field and the copy are all gone. The compiler cannot report - Files
this. Its dead-code warning stops at the edge of a library, because a public item might be called by a program the compiler cannot see, and an accessor that reads a private field keeps that field looking used while it does. So a check now - Files
asks it: every public item has to be named somewhere other than the line that declares it. An item used only by its tests passes, which is a normal thing for a reader kept beside a writer. An item used by nothing fails the build, naming - Files
the file and the line. It runs in CI and before every commit, and it was proved able to fail before it was trusted. The audit, round thirty-three: the whole tree read again The round after 0.1.21, run against the brief written before it - Files
began: every class of defect the earlier rounds established and every check the repository runs, asked of each file. What it found is written up in docs/AUDIT.md ; what changed because of it is below. Licences, sources and duplicate - Files
versions are checked now. cargo audit answers whether anything in the graph has an advisory against it and nothing asked the other three questions: whether every licence can ship under GPL-3.0-or-later, whether every crate comes from - Files
crates.io, and whether one crate is compiled at two versions. deny.toml answers them, cargo deny runs in CI beside the advisory check, and the two policy files are checked against each other so their advisory exceptions cannot drift apart. - Files
Five checks that ran only when somebody ran them are in CI. The guard for a state file written one place and read from another, the per-program guides, the questions page, the app-manifest self-test and the local site host were all in - Files
tools/verify.py and none was in a workflow, so each caught drift only when somebody remembered to look. The seven coverage-guided fuzz targets run weekly , two minutes each from the committed corpus, in a workflow of their own that can - Files
also be dispatched before a release. They had only ever run by hand. The hybrid key exchange's documentation said both public keys went into the combiner. The recipient's X25519 key does; the recipient's ML-KEM key does not, and does not - Files
need to, because FIPS 203 binds it through the shared secret itself. The doc comment now says exactly what is bound, why the omission is not a weakness, and why the transcript is not changed: every key and container already made would - Files
change with it, for no gain. The command line's offline claim was proved again on the built binary, four ways, and each guard was shown to fail when given something to catch. Undefined-behaviour checking, run here for the first time The - Files
whole workspace forbids unsafe code, so anything an interpreter found would belong to a dependency rather than to VeilVoice. It found nothing, in the three crates it got through: the release-signature and contents readers, the WAV and - Files
metadata crate, and the speaker plan and its edits. Said precisely, because the alternative is a claim that sounds larger than it is: four tests in the metadata crate could not run, because they write tags to a real file and the - Files
interpreter's filesystem stands in the way. No undefined behaviour and no unsupported operation was reported for them, and they pass in seconds outside it, so the tag writer is uncovered rather than broken. The engine and the cryptography - Files
were not reached at all, and that is said rather than left to be assumed. A promise about allocation, kept The frame-pacing code carried a note saying it allocates nothing once a frame, and then took its median with a sort that takes a - Files
scratch buffer for a slice that long. Nothing looked wrong and nothing was slow; what was wrong is that a sentence in this repository was not true about the code fourteen lines beneath it. It now selects the middle element in place - Files
instead, which allocates nothing and is all a median needs. The tests now stand at the edge of every boundary in the cryptography Mutation testing over the four files that matter most, which changes the code a line at a time and asks - Files
whether any test objects: 127 changes, 91 objected to, 21 that do not compile, and fifteen that the whole suite accepted . Five were boundaries in the parser that reads a file somebody sends you : the length tests and both checks on what a - Files
header claims to carry. Each is now tested from both sides, at exactly the length in question rather than near it. Four were the ceilings that stop a file from choosing how much memory this program allocates. Each now accepts its own value - Files
and refuses one past it, and a header asking for zero lanes is refused on its own rather than only in company. Three were the randomness adapter, and that is the one worth saying plainly. Replacing it with something that returns a - Files
constant, or that claims success without writing a byte, passed every test this project had. A key drawn from a buffer left untouched is a key somebody else already knows. It is now asked, in both forms, whether bytes actually arrive. Run - Files
again afterwards , which is what makes it a result: 104 objected to, 21 that do not compile, and two left, both of them the same line. That line cannot be killed and the reason is now written where it lives: a ceiling on parallelism that - Files
the memory ceiling always reaches first. It stays, because a guard that is only redundant today is not a guard to delete, and the next person who changes it and sees nothing happen will find the answer in the comment rather than concluding - Files
the line is dead. The command it prints and the command it runs cannot drift apart veilvoice conversation prints an ffmpeg command for you to run yourself, under a line saying VeilVoice never runs it for you. The window runs a different - Files
one, because it feeds the pictures in by a list rather than by a numbered pattern. Everything after the inputs was the same decision made separately in three places, with only the frame size compared across two of them. They agreed, and - Files
now they have to: a test compares the codec, the quality, the scale filter, the pixel format and the audio settings across all three, so an instruction this program gives cannot quietly stop being the thing this program does. Dependencies, - Files
reviewed one by one egui and eframe 0.36 , and this one is a security change as much as an upgrade: 0.36 rasterises text without ttf-parser , so that crate has left the dependency graph and its advisory leaves the exception list with it. - Files
Two accepted advisories remain where there were three. The port was real work: the application now draws into a root panel rather than taking the context, the OpenGL choices moved to where the OpenGL backend is configured, panels and - Files
styles are set differently, a dropped file reports its path through a trait, and every headless test had to learn that a frame's texture uploads must be accounted for. 1611 tests pass on it. symphonia 0.6 , the decoder for everything that - Files
is not a WAV. The 0.6 API changed how a stream is probed, how a decoder is made and how decoded audio is read; the port keeps one interleaved buffer across packets rather than one per packet, and every audio test passes on it. The GitHub - Files
Actions the workflows use , nine of them, to their current majors. The cryptographic line is held, on purpose, and the reason is written where it will be read. sha2 , hkdf , chacha20poly1305 , argon2 , x25519-dalek , ml-kem , rand , - Files
rand_core , rand_chacha and getrandom all have a newer major. pgp , which verifies release signatures, pins the generation this project uses, and taking the newer one would compile two copies of every primitive into both binaries. None of - Files
the newer versions fixes a vulnerability. The line moves together the day pgp moves, or the day the signature check stops needing it, which is roadmap item 149 on the roadmap: a reader for exactly what a detached signature over a text file - Files
is, with nothing else in it. Dependabot is told not to reopen the same ten pull requests every week. Every compatible update in the lock file taken. The meters can sit above a call or a stream The live monitor had two places to be and both - Files
were inside the VeilVoice window. On a call or while streaming, that window is behind the thing you are talking into, so the only picture of what your microphone is doing was covered exactly when it mattered. A third choice: a small window - Files
of its own, kept above other windows. Off the task bar, draggable, resizable, showing the same two levels and the same sentence about what a level is not. It is not the default, because a window that puts itself above everything is - Files
something to ask for rather than to be given. Closing it brings the strip back rather than turning the meters off. Its close button belongs to the window manager, so pressing it means "not in my way", and reading that as "never show me my - Files
microphone again" would take the meters away from under a call without being asked. Where a platform will not give a second window, it falls back to the floating card, which is the same thing inside the window. Offered where the session - Files
starts , not only in Settings: while the voice is being veiled, a keep the meters on top button sits beside the live indicator. That is the moment somebody is about to put a call in front of this window, and a setting they have to go - Files
looking for afterwards is one they find after the call. The window draws at the display's rate The animations ran at twenty frames a second, by design: a constant in the mark, a fifty-millisecond cadence for anything busy, and sixteen for - Files
veiling. On a display faster than sixty that is judder, and the sixteen was wrong even at sixty, because a display at sixty shows a frame every 16.67 ms and a request for one "within sixteen" misses the frame it wanted and lands on the - Files
next. Thirty a second, asked for as sixty. The fix is not a bigger number. While anything is moving, the window asks for the next frame now and lets the screen space it: a window that waits for the display cannot draw faster than the - Files
display shows, so this is one frame per refresh and no more, at whatever rate the screen runs. That is also what makes the display measurable. Nothing in the libraries this window is built on will say what the refresh rate is, so it is - Files
measured: frames paced that way are the display's own, and the middle value of the last thirty-two is a figure a single slow frame cannot move. Settings can lower it , to 30, 60, 90, 120, 144, 165 or 240, which is a choice to make for a - Files
battery rather than for smoothness. A live readout in the header when you turn it on, and the About tab now carries what the window is aiming at, what the display measured, the rate as drawn and how many frames arrived late. It says when - Files
it is struggling. A frame more than half again later than it should have been is counted late; two seconds of that shows a notice once, naming the rate, the target and what the window is drawing with, because software rendering and a slow - Files
GPU are different problems. One bad second is not enough, since every launch costs one. Idle is unchanged: a window with nothing moving asks for no frames and draws none. The lock button and the theme picker agree on a height The manual - Files
lock button in the header did not line up with the picker beside it. Measured properly, in a frame laid out the way the header lays it out: the two share a centre exactly and the picker is one pixel taller, because each control worked its - Files
own height out from padding and nothing said the two should match. The button now takes its height from the picker's own rectangle rather than from a number written down twice, so they cannot drift apart at a font size or on a platform - Files
nobody here has tried. Two tests hold it, one of which proves the other can fail. No screenshot can show this: the captures photograph a window with no app lock set, and the button is only drawn when there is one. Two roadmap items on the - Files
roadmap Roadmap item 148, the window drawing at the display's rate and saying so: the animations ran at twenty frames a second by design, and what to build instead is specified in full. Roadmap item 149, the signature check that needs only - Files
a signature check, which is what unblocks the cryptographic line above. The site can be claimed in Search Console The ownership tag Google asks for sits in the head of every page, so the site can be verified and indexed. It is a string - Files
with no behaviour: nothing is loaded, sent or run because of it. The Studio is in the documentation, and so is the reason for it The companion list recommended Audacity as being "useful for recording a file and for trimming one before - Files
veiling it". The first half stopped being true when the Recording Studio landed, and it was worse than merely out of date: it pointed a reader at exactly the thing the Studio exists to avoid. A recording made in another program is a - Files
plaintext file on your disk. Open an editor, record an interview, save it, veil the result, delete the original: the original was on the disk the whole time, and on flash storage deleting it does not reliably take it back. The encryption - Files
that happens afterwards cannot reach backwards to cover it. So the Studio never writes one. The samples go from the audio callback into memory the operating system has been asked to keep out of the page file, the WAV is assembled inside - Files
that protected memory, and what leaves is already sealed. There is deliberately no route in the code that would produce a plain copy, because a route that existed would eventually be taken. Audacity is still recommended, for what it is - Files
actually for here: editing a file you already have. The install guide said the same thing twice and now says this instead. The questions page gains the question a reader actually arrives with, which is whether it can record at all. It says - Files
what it captures (any input the system offers, chosen by name, so a microphone or a virtual cable carrying the computer's own audio, up to eight at once for a room), and what it keeps (uncompressed PCM at whatever rate the device is really - Files
running, nothing resampled, no lossy codec). It does not claim to be an editor, or to be better than one. There is no cutting, fading or arranging, and the page says so rather than implying otherwise. No comparison with any other program - Files
has been benchmarked, so none is made. A doc comment that had moved to the wrong function Factoring one loop out of three copies left a documentation block behind, and the next function down inherited it. The function that looks for GnuPG - Files
was documented as "the route to Audacity differs per platform"; the function that installs Audacity was documented as nothing at all. The generated wiki repeated it , which is the part worth recording. Every check passed, because every - Files
check compares the generated pages to the source, and the source said this. This is the second time in one release that an insertion has taken a block from the item below it, after the missing feature gate earlier. A test now reads the - Files
module and fails if a probe or an offer carries no documentation, which is the mechanical half of the mistake and the half a losing item always shows. The same notes are in CHANGELOG.md , where they are written, and at the top of this - Files
release on GitHub . v0.1.21 Every screenshot the same size, and in the face they were meant to be in - Files
Windows, 64-bit veilvoice-v0.1.21-windows-x86_64.zip macOS, Apple silicon veilvoice-v0.1.21-macos-arm64.tar.gz macOS, Intel veilvoice-v0.1.21-macos-x86_64.tar.gz Linux, 64-bit veilvoice-v0.1.21-linux-x86_64.tar.gz Linux, ARM 64-bit - Files
veilvoice-v0.1.21-linux-arm64.tar.gz Raspberry Pi OS, 32-bit (command line only) veilvoice-v0.1.21-linux-armv7-pi.tar.gz Linux, 64-bit, static (command line only) veilvoice-v0.1.21-linux-x86_64-musl-static.tar.gz Linux, ARM 64-bit, static - Files
(command line only) veilvoice-v0.1.21-linux-arm64-musl-static.tar.gz FreeBSD, 64-bit (command line only) veilvoice-v0.1.21-freebsd-x86_64.tar.gz OpenBSD, 64-bit (command line only) veilvoice-v0.1.21-openbsd-x86_64.tar.gz NetBSD, 64-bit - Files
(command line only) veilvoice-v0.1.21-netbsd-x86_64.tar.gz SHA256SUMS the hash of every file above CONTENTS.sha256 the hash of every file inside those archives SHA256SUMS.asc the signature over that list veilvoice-signing-key.asc the - Files
public key it was signed with These links are worked out from the version number rather than fetched, so this page builds with no network. A release that did not build for a platform answers with a not-found; the release page is the - Files
definitive list. Release notes everything that changed in v0.1.21, in full Every screenshot the same size, and in the face they were meant to be in The captures were trimmed to each tab's own content, with a floor. That gave eight pictures - Files
1000 tall, one 1095 and one 1315, which is right for one picture and wrong for the grid the README and the website show them in: the row holding the group tab sat lower than the rows either side of it. One height now, and it is the tallest - Files
one's content , measured across the set on every run rather than written down. It is the only shared height that crops nothing: trimming to the shortest would cut the group panel off, and scaling would make the text in one picture a - Files
different size from the next. The cost is real and is paid on purpose: the short tabs carry background below their content. Empty space in a picture reads as the window having room; a stepped grid reads as a mistake. The captures are in - Files
JetBrains Mono, and that is now proved rather than hoped. The window prefers it and falls back to the built-in monospace when it is absent, which is right for somebody running the program and wrong for a capture: half a set in the wrong - Files
face looks subtly off and nothing about the run says so. veilvoice-gui --typeface answers which face the window would draw with, without opening one, and the capture script asks first and refuses to photograph anything if the answer is the - Files
fallback, naming the package for each platform. All ten retaken, so they show what this release actually contains. More room between them on the website: 40px across and 48px down, more vertically because each picture has a caption under - Files
it and a matching row gap put the next picture as close to a caption as the caption is to its own picture. The README's grid is a table GitHub renders and strips styles from, so there the equal heights are the whole of the fix. ffmpeg - Files
joins the companion list, and the render points at it The Setup tab lists the software VeilVoice works with, says who makes each and under what licence, and installs one on an explicit yes. ffmpeg was not in it , which is the whole of - Files
this: a render told you ffmpeg was missing and the tab that installs things had never heard of the name, so the message pointed at nothing. It is there now, first in the list, with what it is for, what still works without it, and the - Files
install command for this system: winget on Windows, Homebrew on macOS, and whichever package manager is actually on PATH elsewhere. Every message about a missing ffmpeg now names the tab, and a test fails if one stops doing so. Both front - Files
ends read the same table, so veilvoice companions gained the entry at the same time and cannot disagree with the window about what exists. The loop that asks each package manager in turn existed twice and would have become three copies. It - Files
is written once, which is the point at which copies start disagreeing about which managers this project recognises. "Look again" no longer freezes the window. It ran a command per companion inside the frame it was drawing, which on a - Files
machine with several of them is the window going white. The search moved to a worker and the button says it is working while it runs. Nothing here downloads anything. An install runs the package manager the machine already has, so the - Files
claim that VeilVoice ships no network client is unchanged and still checked three ways on every commit. The video has a picture in it (roadmap item 139, finished) A render produced veiled audio over a black rectangle. It now produces the - Files
same picture the preview page shows: a circle per speaker in their colour, whoever is talking lit, the level under each name, the waveform and a playhead. One layout function draws both, so the two cannot drift into being pictures of - Files
different recordings. The frames are drawn here, as pixels. A canvas with rectangles, antialiased circles and a PNG writer, and a five-by-seven monospace face of ninety-five glyphs written out in the file. The drawing is SVG and no build - Files
of ffmpeg can be assumed to read SVG; converting it would have meant an SVG rasteriser, which is exactly the large library this project spends a page explaining why it will not carry. One dependency, miniz_oxide , for the deflate that PNG - Files
is made of, and it was already in this tree under flate2 . A picture is written when the picture changes , not once per frame of video. The playhead moves a pixel at a time rather than a frame at a time, so ten minutes at thirty frames - Files
writes hundreds of files instead of eighteen thousand. The saving is measured and reported rather than asserted, and a short recording holds nothing, which is correct: the playhead crosses the whole waveform however long the recording is. - Files
That is why the ffmpeg command is a concat list with a duration per picture . Held frames handed to the numbered-sequence reader would play an hour of conversation in the few seconds its distinct pictures cover, which is a video that is - Files
wrong rather than one that refuses to encode. Names the face cannot draw are named. Printable ASCII only, so a name in another alphabet comes out as open boxes, and the render says which names before somebody watches an hour of video to - Files
find out. The preview page does not have this limit, because it is markup and uses the reader's own fonts. Video is the one output off by default , because it is the one that needs a tool VeilVoice does not ship. Without ffmpeg the - Files
pictures and the list are still written and the exact command is handed over. A score for what a recording gives away about who was speaking Beside the voice controls in the Group tab there is now a percentage. A hundred means the finished - Files
recording says nothing about which of the people in it was talking. It falls as the group grows in "a voice each" mode: two people is 70 per cent, four is 40, eight is 10. One voice for everybody is a hundred at any size. It is not a - Files
measure of how well a voice is disguised. That is the engine's and it does not get weaker because somebody else joined the call: eight people are each hidden exactly as well as one. It is not cryptography either, and the module, the guide - Files
and the interface all say so rather than letting an information count be read as a claim about strength. What it counts is the other leak, the one that does grow with the group: how much of the conversation's shape a listener gets free. - Files
Eight tellable-apart voices let anybody count the participants, follow who said what, and align two recordings of the same group by voice. log2(classes) bits per turn, measured against the widest the engine goes. Two speakers whose voices - Files
are too close to separate count as one. So crowding the table makes a recording give less away while making it harder to follow. That runs backwards from the obvious reading, so it is shown on its own line rather than folded into the - Files
score, and a test pins it down. Fixed while building it: a solo recording was reported as "crowded", because the closest-pair measure answers 1.0 when there is nothing to compare and that is below the separation floor. One voice has - Files
nothing to be confused with. A level under every speaker's name (roadmap item 139, the first of its two halves) The preview page lit whoever had the turn and said nothing more. A lit circle cannot say whether that person is mid-sentence or - Files
mid-pause, and those look identical for as long as the turn lasts, so under each name there is now a bar that moves with the sound. Drawn from the same envelope as the waveform beneath it , so the two cannot disagree: one array, two things - Files
drawn from it, and the page is animated from the numbers it drew rather than from a second copy. It is the mix, given to whoever is speaking , and the guide says so. A render produces one mixed track, so there is no separate signal per - Files
person to measure. That is the same thing while one person talks; where two turns overlap both show the same bar, which is what a listener hears and is not a claim that each was that loud. Somebody whose turn it is not shows nothing , - Files
rather than a small amount. A bar moving for a person who is not speaking would be the one thing on the picture actively saying something untrue. The track is always drawn and only the filled part moves, so the layout does not shift under - Files
the reader every time somebody stops talking. What is left is drawing the frames. The video file is still veiled audio over a black picture. That needs a rasteriser and a font, neither of which is a small addition, and the roadmap row says - Files
what they are and why a full SVG rasteriser is the wrong way to get them. Dependabot, configured, and a check that keeps it true There was no .github/dependabot.yml . Nothing had ever raised a version update or an alert against this tree. - Files
There is one now: the Cargo workspace, fuzz/ separately (it is outside the workspace, so an entry on the root does not reach it), and the actions the workflows run, which hold a token that can publish signed artefacts and are monitored on - Files
the same terms as the code. The configuration is checked rather than remembered. tools/audit/dependabot.py reads it against the tree and fails on a manifest no entry covers, or an entry naming a directory that has gone. It runs in CI - Files
beside the check that every dependency says what it is for, which is the other half of the same question: saying what a dependency is for does not make anything watch it. Two ecosystems, and the file says why there are only two. The - Files
website's JavaScript and the site tests use Node's own built-in modules and nothing else, and every script under tools/ and assets/ is standard-library Python, so a package.json or a Gemfile here would declare no dependencies and would be - Files
one more file to keep true. Cargo.toml is this project's package manifest, and there are 28 of them. One demonstration, the command line first and the window under it The demonstration was in three parts in the order: the command line - Files
typed out, the window's screens, the command line one job at a time. The two halves of the same thing had the other thing wedged between them, so a reader who wanted to know what the commands are read about them, looked at pictures of a - Files
window, and then read about the commands again. It is one section now. Both command-line parts are together and first, and the window is below them. The command line goes first because it is the half that can be shown rather than depicted: - Files
those are recordings of the real programs replayed at typing speed, and every byte in them is what the program wrote. Somebody who reaches the photographs has already watched it run. Nothing else about the page moved, and nothing went - Files
behind a button. The terminal drawings share one width Each drawing was exactly as wide as its own longest line, which gave eleven pictures at five widths: 651, 800, 817, 825 and 834. The README stacks all eleven vertically and the website - Files
shows the same set, so what a reader saw was a column of terminal windows whose edges did not line up. They share one width now, measured across the set on every run, so a command whose help grows moves all of them together instead of - Files
becoming the one exception. Height still varies, because height is the content: a longer help screen is a taller picture, which is the axis a page can afford. These draw a terminal window , and a terminal window does not shrink to fit - Files
whichever command printed the least. The shared width is the widest one's content, which is the only shared width that re-wraps nothing. Checked rather than asserted: the ten window captures are all 1400 by 1537 and the eleven drawings are - Files
all 834 wide, and both are produced by tools that measure the set rather than by a number written down somewhere. The tag a release publishes now names the commit it built The release workflow ends by creating a GitHub release for a tag. - Files
Creating a release for a tag that does not exist yet makes GitHub create the tag, and with no target it creates it at the default branch rather than at the commit the run compiled. Both of the last two releases were tagged that way. - Files
v0.1.21's tag landed on a tree whose Cargo.toml still said 0.1.20; v0.1.20's landed thirteen minutes ahead of what it published, on a commit that does not compile on nine of the twelve targets. This goes to the whole of what a release - Files
claims. Every binary is built twice in different directories and compared byte for byte, the hashes are signed, and the reproducible-builds guide tells a reader to check out the tag and rebuild. That is all machinery for one sentence, and - Files
the sentence is false when the tag names a different tree: the reader who does the work gets different bytes and correctly concludes the release does not reproduce. Nothing was tampered with, and the binaries are what the run built. What - Files
was wrong was the pointer, and it is one line: the release step names the commit now, and tools/audit/publishing.py fails a build if it stops doing so or starts naming something else. A release published saying it had no release notes - Files
v0.1.21's notes on GitHub read, in full, that no changelog section could be found for it. The workflow matches ## v<version> as a whole line, the entry had been written with a date after it and no v in front, and the match failed, so seven - Files
hundred lines of notes were dropped. It then published anyway. That is the defect rather than the heading: a note saying the notes are missing is not a smaller version of the notes, it is a release whose contents nobody can tell. The - Files
heading is the shape every other entry uses. The workflow refuses to publish without a section now, and because that refusal only fires after twelve jobs have built and compared every binary, the same question is asked first where it costs - Files
nothing: tools/release/version.py --check requires the changelog to carry a heading the workflow's reader can find, and says which heading is actually there when it is the wrong shape. Two readers of one file disagreeing is why this - Files
survived. The website's releases page is built from the same headings and tolerates both forms, so every local check passed while the one that mattered found nothing. The feature nine release jobs turn off, that nothing built - Files
veilvoice-audio 's live capture is an optional feature, off on nine of the twelve targets a release builds: cpal has no backend for the BSDs, cannot be linked into a static musl binary, and has no cross-architecture ALSA to build against. - Files
Those nine build the command line with default features off. Nothing in CI built that. Every cargo line took the default features, so a module whose #[cfg(feature = "live")] had gone missing compiled on all three platforms, passed every - Files
check twice over two days, and then failed nine release jobs at once. The attribute had been taken by a playback declaration inserted directly above it, because an attribute attaches to the item that follows it. The gate is back, and the - Files
gap it fell through is closed: tools/audit/features.py reads the build arguments out of the release workflow and compiles each selection, in CI beside the release build. Reading the workflow rather than copying its arguments is what makes - Files
a target added to that matrix built on every push without anybody remembering, and the tool refuses to pass if it can read fewer than two selections, so a workflow rewritten into a shape it cannot parse fails rather than checking nothing. - Files
The existing platform guard could not have caught this. It varies the operating system; this varies the features, and a guard covering one axis reads as covering the other. Several microphones at once, veiled and metered each (roadmap item - Files
147) veilvoice_audio::room opens one input per guest, veils each with its own engine, seed and destination voice, and mixes the results into the one output a call or a recorder hears. One microphone carrying four people is one signal, and - Files
whatever it is turned into, everybody in it is turned into the same thing. The cost is a number rather than a promise. The output callback runs every guest's engine before it returns, so they share one deadline of a few milliseconds and - Files
the cost is the sum. RoomStats::load is that sum measured against that deadline. At 1.0 the engines have used the whole block and what follows is dropouts. The guest limit is a bound on the arithmetic, not a claim about any machine. The - Files
mix is summed and clipped, and never limited. Two people talking at once is two signals added, which can pass full scale; a render fixes that afterwards with one factor and a live path cannot see the rest of the conversation. So the peak - Files
before clipping is reported, the blocks that clipped are counted, and there is deliberately no limiter: a limiter is a dynamics processor, it changes the voice, and this program's whole claim is about what changes a voice. Each guest has - Files
their own ring, so a microphone whose clock runs fast drops that guest's samples rather than everybody's, and a slow one starves and is padded. A rate mismatch is not absorbed: it is refused before anything opens, which is the fix in this - Files
same release. A recorder per guest, veiled or unveiled, and one for the mix. Roadmap item 131's warning about the unveiled side applies once per guest. The Studio drives it. Ticking "several microphones, a guest each" in the Studio turns - Files
the input picker into a guest list: a name and a microphone per person, up to eight. Starting it opens all of them, and each guest is veiled into a voice of their own, from the same table a group render hands out and in the same order. Two - Files
bars per guest , what went into their microphone and what their engine produced from it, with the mix under them and the load beside them. A room drawn as one pair of bars cannot say which microphone is dead, which is the reading somebody - Files
actually needs. The load is said as a percentage of the block every engine shares, and past 80 per cent it says what to do about it. A take stores every guest separately, beside the mix. One name, and the recordings under it are the mix, - Files
called "(everybody)", and one or two per guest called after them. The choice of which side to keep is the one the single microphone already has and applies to every guest at once, so an unveiled room is eight recordings of eight real - Files
voices and says so before it starts. The Studio holds one session or the other and never both , as one field rather than two: two of them would be two streams on one output with every guest's voice arriving twice, and a field that has to - Files
be remembered is a field that gets forgotten. Two guests on one microphone is refused by name before anything opens. One microphone carrying two people is one signal, so veiling it would give both of them the same voice, which is what a - Files
microphone each was for. Two guests on the default device are the same refusal: None is a device, not an absence. Every recording of a take is named in one function, checked by a test that reads this module. A take now produces up to - Files
seventeen recordings from three loops, and a suffix added in two of them would leave an entry that is somebody's real voice looking exactly like the veiled one beside it. A microphone and an output that never compared their rates The live - Files
path built the engine and the ring between its callbacks from the output device's sample rate, and the input stream from the microphone's, and never checked that the two were the same number. Where they are not, and a laptop with a 44.1 - Files
kHz microphone and 48 kHz speakers is an ordinary machine, the ring starves continuously and the veiled voice comes out about a semitone and a half sharp and stuttering. Invisible twice over: the starvation reads as "this machine is too - Files
slow", and the pitch is meant to change, because this is a voice de-identifier. Both devices are now put on one rate: the one they already share, the output's if the microphone will take it, or the microphone's if the output will. If - Files
neither will move, it refuses and names both rates and what to do, rather than running them together and quietly shifting the voice. The output's rate is preferred, because it is what the person hears through and what anything on a virtual - Files
cable expects. Nothing here resamples, and adding a resampler to paper over a mismatch would be a quality and latency decision taken to avoid saying something. The Studio has a failsafe of its own, and a running take says what it keeps - Files
Both are clauses of roadmap item 145 that nothing had built, found by reading that row rather than treating it as a sum of the roadmap items under it. A device that goes stops the Studio, not the recording. Since the last change the Studio - Files
knows when the device a take is being recorded from has stopped existing; knowing was as far as it went, and the take carried on recording silence until somebody looked at the screen. It now stores what was captured and stops. It does not - Files
discard, retry or switch device. Not discard, because everything up to the fault is a real recording of something somebody said. Not retry or switch, because the person chose that microphone and moving a recording onto another one is this - Files
program deciding that for them, which on most machines means a laptop's built-in microphone. Only a device that has gone. Anything else the platform reports is shown and left alone: an underrun is not a reason to end somebody's recording. - Files
Separate from the application's safety catch, which is about other programs taking the microphone. This one is about this tab. A running take now says which voice it is keeping. The choice is made on a form that disappears when recording - Files
starts, so a take of somebody's real voice looked exactly like one that is not for the whole of the recording. It is beside the clock, in yellow when the microphone is being kept. Roadmap item 145 is still planned, and its row now says - Files
what it is waiting for rather than reading as a sum of the roadmap items under it: group mode recording every guest with the same guarantees, which is roadmap item 147. A BSD reader was told to run a command their system does not have - Files
veilvoice verify --script writes a shell script that checks the signature and the hashes with the reader's own GnuPG. It knew two systems, Linux and macOS, and every mapping onto it ended in a catch-all meaning Linux, so a reader on - Files
FreeBSD, OpenBSD or NetBSD was handed sha256sum -c . The same release's other script, the one that reproduces the build, has always given the BSDs sha256 -c and has a test forbidding sha256sum there. Two scripts in one release, disagreeing - Files
about the reader's machine. The cause was a copy: both modules answered the same question and one of them was not kept current. The copy is gone. There is one public System::hash_check_command , the verification script asks it, and the two - Files
catch-all matches are now one exhaustive function that will not compile if a system is added to one enumeration and not the other. The guard is that no system's script may contain another system's hash command, which fails on exactly the - Files
fall-through this was. Checking a download, written up per system veilvoice verify on its own is the same command everywhere: it hashes and checks the signature itself, and needs no GnuPG, no network and none of the system's own tools. A - Files
BSD reader has nothing to translate, which is why the guide now says so first. The second opinion is where systems differ, and the guide carries a table of which script each system gets and what it runs. The table is checked against the - Files
program in the test suite, so a command in the documentation that the program no longer prints fails a build. Installing GnuPG on OpenBSD and NetBSD is the one thing left unsaid. This project has not run those package managers and does not - Files
print commands it has not run; FreeBSD's spelling is named because install/install.sh already uses it. Two bars per speaker, while a group render runs As the render walks the file, each person gets what went into the turn just finished and - Files
what the engine produced from it, with how far through their turns the render is. One bar answers "is something being written" and not "is this person being veiled", which is the question somebody rendering an interview is asking. Drawn - Files
with the shared meter the live path uses, so it is the same bar rather than a second one that would drift from it, and the limit is printed under them in the same words. The render reports through a small structure of atomics: nothing - Files
allocates, nothing locks, and the render threads write to it while the window reads it every frame. A watched render and an unwatched one produce byte-identical audio, which is a test rather than a claim. A progress made for fewer speakers - Files
than the plan holds drops what it cannot keep rather than failing the render. A bar with nowhere to go is not worth a refused render. This is the half of roadmap item 133 that could be built from what was here. The other half, several - Files
guests on several microphones at once, is now roadmap item 147: the live path opens one input and everything downstream assumes one. The audio path says when something interfered with it A device unplugged or swapped mid-session, and - Files
anything else the platform reports about either stream, is now shown where the person is looking. Both error callbacks used to be eprintln! and nothing else: on Windows the desktop application is built with no console at all, so a - Files
microphone taken away in the middle of a call was completely silent and a recording carried on being made of nothing. The report is the platform's own words, with the device-is-gone case named separately because it does not come back on - Files
its own. Detected by the platform telling us rather than by polling a device list. A list read once a second is a guess between reads, and enumerating devices from another thread on Windows is what F-163 and F-165 were. While a take is - Files
running, a program other than VeilVoice taking the microphone is named on the tab and again with the stored take. That program heard the real voice whatever was going to the cable. Independent of the safety catch's posture: that setting is - Files
about closing other programs, and somebody who turned it off did not ask to be told less about their own recording. The command line shows the same thing as INTERRUPTED on the meter line, and veilvoice record repeats it when the take is - Files
sealed, because the meter line is gone by the time somebody decides whether to keep what was recorded. The limit is stated beside the warning rather than after it: this is what VeilVoice's own path noticed, and it cannot vouch for a - Files
microphone that was already being intercepted before this opened it. The roadmap item asked for the samples reaching the recorder to be checked against the engine's output. They cannot differ, because the recorder is fed from inside the - Files
output callback from the same slice the engine has just written into, so the check would be a buffer compared with itself. That is a property worth keeping rather than measuring, and a test now reads the source for it: two sinks, two - Files
writes, each from the one place its samples exist. A check that failed once now says what it disagreed about The recorded-session check failed once on a loaded machine and passed on the twenty-two runs after it. It has not been reproduced - Files
and is not claimed to be fixed. What it said was that the transcript "is not what the program prints now", and nothing else. It now prints the differing lines, diffed on the same normalised text it compares, so the next occurrence explains - Files
itself instead of being re-run until it passes. Two roadmap rows corrected rather than built around Roadmap item 132's opening sentence described a check that would compare a buffer with itself, and the row now says what was actually - Files
missing. Roadmap item 133 asked for two live bars per speaker in group mode , and neither word survives reading the code: group mode is a panel for a recording that already exists and never opens a device, and the live path opens one - Files
input, so there is no per-guest live signal in this tree to draw. The row now says that this is two things: bars drawn during a render, which the plan already has the information for, and a multi-input capture path, which is the actual - Files
work. It stays planned. Optimisation stops being a pass and becomes how this is written The practices roadmap item 125's reading established are now in CLAUDE.md as the standing way this project is written, and three of the four are - Files
enforced by a build rather than by somebody remembering. No audio callback allocates, blocks or prints. A callback runs on the operating system's audio thread with a deadline of a few milliseconds; allocating takes a lock in the allocator, - Files
blocking on a mutex hands the thread away, and printing takes the lock on standard output. Each of the three callbacks already carried a comment saying its buffers are sized once. A test now reads the callbacks themselves and fails naming - Files
the line and what it would cost. Every dependency says what it is for, on the line that declares it , checked in CI. A dependency is code this project ships and does not review, build time on every machine that compiles this, and one more - Files
thing that has to work on the BSDs and the 32-bit targets. If there is no sentence to write, that is the answer. Writing those sentences found three dependencies no line of code referred to: sha2 in veilvoice-verify , hex in its tests and - Files
hex-literal in the crypto crate's. All three had been compiled by every build on every platform for as long as they had been there. They are gone. Live scramble is the Studio now, not a tab beside it Veiling as it runs has stopped being a - Files
separate tab. The Studio is where it happens, and the tab is the voice above and the take below: devices, engine settings, meters, performance figures and the preview button on top, the vault and the take under them. The voice half works - Files
with the vault shut. Veiling a call has never needed a recording vault, and requiring one would be a worse program. Both screens always ran the same engine through the same session, which is what made two of them wrong rather than merely - Files
redundant. The Studio recorded with whichever devices the other tab happened to be set to, and nothing on its screen said so. Worse, each tab started a session of its own: veiling on one and recording on the other opened the same - Files
microphone twice. There is one starter now, and a test that reads the desktop crate's source and fails if a second one appears. Ending a take leaves the veiling running. Somebody who has just stopped recording has not asked to be heard in - Files
their own voice again. stop , in the voice half, ends the veiling, and it stores a take still running rather than discarding it. Starting and ending a take each restart the audio, because a recorder cannot be attached to a stream that has - Files
already started. That costs a short gap in the outgoing voice, and it is said on screen and in the guide rather than hidden. veilvoice-gui --tab live still opens a window. It opens the Studio, because the name is in shortcuts and scripts - Files
written before the tab moved and the honest destination is the tab that does the job today. The monitor strip works during a take, which it did not before: it read the live tab's session, and a Studio recording was a different one, so the - Files
strip went blank over a recording that was running. A recording was written with the rate it asked for, not the rate it got record::start takes the sample rate for the WAV header, and its own documentation says that has to be the rate the - Files
device agreed to. Both callers passed config.sample_rate , which is the rate the engine was configured for and is 48 kHz by default, and the session then overwrites that field with what the hardware actually gave. On a machine whose output - Files
runs at 44.1 kHz the take was written 48 kHz over 44.1 kHz samples: about nine per cent fast and a semitone and a half sharp, on top of the veiling. Fixed by construction rather than by care. LiveSession::start_recording builds the - Files
recorders itself, after the device has answered, and hands them back: it takes a Keeping saying which sides to keep and returns a Kept holding them. The caller has no rate to get wrong. The property roadmap item 131 asked for survives: - Files
Keeping 's fields are named at the call site, so no caller reaches a recording of somebody's real voice without writing the word plain next to it. A test reads the workspace and fails if a recorder is built anywhere outside the crate that - Files
knows the rate. The Studio can keep the real voice, and asks before it does "What to keep": the veiled voice, both, or the microphone unveiled. Before the start button rather than after it, because a recording of somebody's real voice is - Files
not a thing to discover having made. The veiled voice is what is selected. The choice is not remembered between runs and locking the window puts it back, for the reason group mode is not remembered: a mode somebody forgets is on eventually - Files
records what they did not mean to record. Anything that keeps the microphone says what that costs in the same words the plaintext path uses. It is sealed in the vault exactly as strongly as a veiled take, and it is still a recording - Files
anybody who opens the vault can hear who was speaking in. Keeping both gives two takes, and the unveiled one's name ends in "(unveiled)". The name is the only thing telling them apart, which the panel says where the choice is made. The - Files
microphone is copied into its recorder from inside the input callback, after the downmix and before anything else sees it, through a buffer sized once at startup so nothing allocates in a realtime path. It is a separate argument to - Files
start_recording rather than a flag on the existing one, so no caller can reach it without naming it, and every path that was not asked for it passes nothing. veilvoice record on the command line keeps the veiled voice only and is - Files
unchanged. This is a Studio decision, made where the vault that receives it is. The panel that runs during a take drains both recorders every frame and takes the clock from whichever is running. Draining one of two would have made the - Files
second take quietly short, which is the failure the dropped-sample count exists to report; and reading the clock from the veiled recorder alone would have shown 0:00 for the whole of a microphone-only take. A guard for it, rather than a - Files
third one at a time No test in the desktop crate may open a device, a dialog or a window. The guard walks that crate's test code and fails naming any line that reaches one, with a single exception by test name for the one place that - Files
enumerates devices on purpose. Proved both ways: it passes on the tree as it stands, and planting one line that enumerates a device makes it fail naming the file, the line and the test. Its own first false positive is recorded too. - Files
dialog.rs 's guard searches the source for rfd::FileDialog , and the string it searches for is not an opened dialog, so a match inside a string literal is skipped. Windows, a second time, and the same mistake in a new place A test written - Files
for the new setup card asked the machine how many audio devices it has, twice, to check the answer was stable. The desktop crate's test binary already enumerates real devices once, deliberately, in one place; a second enumerator beside it - Files
killed the process on Windows with an access violation, exactly as the last one did. Gone rather than made conditional. It was checking that a call the machine answers does not fail, which is a fact about the machine. What is worth - Files
checking is that the card asks the machine rather than carrying a number, and a test reads the card's source for that. "One enumeration, in one place" is now written where the counting function is. Every platform is green again, and the - Files
crash is understood test / windows-latest was dying with an access violation after every test it printed had passed. A step that reran the desktop crate's tests on one thread named the culprit on its first run: a test that played a - Files
recording and then locked the window, on the stated assumption that a build machine has no audio device. The Windows runner has one. A stream started and tearing it down took the whole test binary with it. A test whose correctness depends - Files
on the machine not having a sound card is not testing the thing it names. It reads close 's own source now, the way its sibling reads play 's, and no test in the crate opens a device. That step was there to identify the defect and is gone, - Files
as it said it would be. All thirteen jobs pass: the offline proof runs all four of its steps for the first time, and macOS and Windows are green. A fifth setup card, and every number on it read from the machine First run ends on "What this - Files
machine says": where recordings will go and how much room is free there, how many devices there are to record from and play to, and what the window will ask the graphics driver for. Every figure is read at the moment the card is drawn. - Files
None of it is a default written into the program: a setup screen that asserts how much room there is, or that the graphics will be fine, is guessing on somebody else's hardware and sounding certain about it. The free space is said as an - Files
hour of veiled audio rather than as a number of bytes, because that is the question somebody about to record actually has. Where the machine will not answer, the card says so instead of printing a figure nobody measured. A system that does - Files
not say where an application keeps its files is told plainly what that costs, which is that nothing is kept between runs. Nothing on the card has to be answered, and like the four before it there is a way past it. The guard that proves no - Files
card is a gate read a fixed four thousand characters after each function's name, which is a length rather than a body. A card longer than that reported no way past it, and a shorter one was checked against the card after it as well. It - Files
ends where the function does now. Acceleration is a switch now, and the About tab shows both halves The window has always asked the platform for a hardware context and accepted a software one, which is why it opens in a virtual machine, - Files
over a remote desktop and on a server with no card. That was not settable. One tick in Settings turns the asking off. It is for the case the request cannot cover: a driver that accepts and then draws badly, which is a hybrid-graphics - Files
laptop handing over the wrong adapter or a black window on a broken OpenGL path. Nothing can detect that, because from inside the process it looks like success, so it is a switch rather than a measurement and the panel says so. It never - Files
becomes a demand. Required refuses to open where no hardware context exists, and a privacy tool that will not run is not more private. The About tab shows what was asked for beside what the driver actually gave. Either line alone answers - Files
half of "why is this slow". Portable first, and the About tab says exactly where things are A folder called veilvoice-data beside the program makes the settings, the vaults, the policies, the palettes and the app lock live in it. A copy on - Files
a memory stick now stays a copy on a stick. Remove the folder and it goes back to the platform's own configuration directory; neither switch moves anything that is already there. Opted into rather than detected. "Beside the program if that - Files
is writable" would have moved an ordinary installation's state the day somebody unpacked it somewhere writable, and the symptom would have been an empty vault. The About tab lists the exact folders in use, worked out on the machine rather - Files
than written down, with a line saying which of the two arrangements is in force. The app lock's file names are not among them: they are derived rather than fixed, on purpose, and printing them in a window would hand that back to anybody - Files
standing behind the reader. A test reads the crate's own source for every place it keeps something and fails naming any the tab does not show, so a location added tomorrow cannot quietly stop being reported. Installing a portable copy now - Files
asks what should happen to that folder, and the button waits for the answer. There is no default because the two right answers point in opposite directions: carrying over is right when moving onto your own machine, and leaving is right - Files
when installing on somebody else's. Carried means copied, never moved, and nothing already at the destination is replaced. The build was red, and the offline proof had never run The offline-runtime job proves the front page's claim four - Files
ways. Its third step ran the command line inside an empty network namespace with unshare -rn , which asks for an unprivileged user namespace, and Ubuntu 24.04 refuses those by default. It failed on its first run and on every run since, and - Files
because a failed step ends a job, the two steps after it never ran : the syscall trace and the window's socket families. The job existed, looked like it proved four things, and proved one. The namespace is now taken whichever way the - Files
kernel allows, with the program dropped back to the ordinary account inside it, and the step fails saying the claim is unproved rather than passing quietly if neither way works. The fourth step, never reached, had one quotation mark too - Files
many on its last line and would have failed the job the first time it ran. The desktop tests were opening a real file panel. On macOS that panics outright, and on Windows the dialog thread outlived the harness and took the process down - Files
with an access violation after every test had passed. Both platforms were red. A file panel is not opened where there is no window to open it on, and on macOS an ask from any thread but the main one now reads as a cancel rather than - Files
crashing the application. Three counts said on the front page and in the README had drifted from the tree: the tests, the functional lines and the defects. They are checked against it, and now agree with it. Decoy vaults, and a real vault - Files
that is not found by its name The Browser can now fill the vault folder with decoys: vaults whose contents never existed, sealed under a key made and dropped inside the call that writes them. Nobody holds that key, so there is nothing to - Files
find, to leak, or to be compelled to hand over, and cracking one yields bytes that parse as nothing. make_decoy and Shape::of had been written, documented and tested since 0.1.20 and were reached by nothing. This is the half that was - Files
missing. How many is worked out from the room actually free where the vaults live, read from the operating system rather than guessed: one twentieth of it, up to a stated ceiling of thirty-two. Where the system will not say how much is - Files
free, the panel says that instead of showing an invented figure as though it had been measured. The real vault used to sit at a fixed name, studio , which would have made every decoy beside it pointless: the one directory called studio is - Files
the one worth attacking. Vaults now live in directories with opaque names and the real one is found by trying each in turn until one opens, which only the pair of passphrases does. A vault written the old way is moved down into a directory - Files
of its own on the next unlock, index last, so an interrupted move finishes on the following one rather than splitting the vault in two. A wrong pair of passphrases finds nothing and makes nothing. Creating a fresh vault there would show - Files
somebody who mistyped an empty vault, which reads exactly like their recordings having been lost. A decoy's index is padded to the length the real one measured. Without it, every decoy in the folder would be the one with the smallest index - Files
file, and the sizes were the thing this was meant to make identical. The limit is stated where the button is: this raises the cost of a search. It does not hide the real vault from somebody watching you open it, from something already - Files
running inside the computer, or from a backup taken before the decoys were made. The pictures of the window showed nine tabs, and there are eleven Both the README's table and the website's "what it looks like" grid were hand-written lists. - Files
The Studio and the Browser shipped in 0.1.20 and appeared in neither, so the whole section quietly described a different application from the one released. Both now show all eleven, and a test reads the tab keys out of the window's own - Files
source and fails if either page is missing one. The count was already checked and the list was not, which is why this got through: knowing there are eleven tabs does not notice that nine of them have pictures. The README also said the - Files
captures were taken on Windows 11 with gui.ps1 and PrintWindow . That stopped being true when they were recaptured headlessly on Linux for this release. It now says which script took the committed ones and what the other is for, and keeps - Files
the Windows 11 note as what it actually is: the platform a person has run the application on, rather than the one a script photographed it on. The two lists of command line screens became one tools/shots/terminal.py takes the pictures and - Files
tools/site/demo.py names them for the walkthrough, and each held its own copy of the same eleven entries. The comment in the second justified this by saying the first holds the command "in a form no page can read", which is a list of - Files
arguments, and joining a list of arguments with spaces is not difficult. They had already drifted: the note under render said "a plan, a recording and a page" in one and "a plan, a recording, and a page" in the other. Adding a screen to - Files
one of them left the other unable to draw it, which is how this was found. veilvoice conversation fix --help is now in the walkthrough, drawn from what the built program prints. The verifier transcript, recorded against the published - Files
0.1.20 It is the one recording that cannot be made before a release exists, because it verifies a real download: it fetches the published archive, the hash list and the signature, checks the embedded key's fingerprint, checks the signature - Files
over the list, checks the archive against the list, and then does the whole thing again through the reader's own GnuPG. The hash in the transcript is the hash GitHub reports for that asset. Nothing in it is staged. The Studio meters what - Files
it is doing, and the Browser can play Two bars while a take records, input and output. One output meter answers "is something being recorded" and not "is it being veiled", which is the question somebody at that tab is actually asking; - Files
seeing the two move differently is the only thing on screen that shows the engine is between them. The bar itself moved out of the window's private module into monitor , so there is one of it rather than a second that would slowly stop - Files
looking like the first. A take in the Browser plays, straight out of locked memory. Nothing is written to the disk , so there is no copy to remember to shred: the obvious way to hear a WAV is to put it somewhere and hand over the path, and - Files
that would leave an unencrypted recording lying about, which is what the vault exists to prevent. Said as built rather than as first imagined: the take is decrypted whole , not in blocks. The container is sealed and authenticated as one - Files
piece, and an encryption that let you open the first second without the rest would not be authenticating anything. What that buys is no plaintext file at any point. What it does not buy is a footprint smaller than the recording, and the - Files
roadmap entry now says that instead of the other thing. One take at a time, and starting a second releases the first: two decrypted recordings in memory at once is twice as much of somebody's voice as the reason for it. Locking the window - Files
releases whatever is playing, along with the vault. Playing at the wrong sample rate is refused rather than done. It would make a voice sound higher or lower, and in a program whose whole point is that a voice cannot be traced back, that - Files
is a wrong answer rather than a small one. The same notes are in CHANGELOG.md , where they are written, and at the top of this release on GitHub . v0.1.20 The offline claim is now proved by a machine, four ways - Files
Windows, 64-bit veilvoice-v0.1.20-windows-x86_64.zip macOS, Apple silicon veilvoice-v0.1.20-macos-arm64.tar.gz macOS, Intel veilvoice-v0.1.20-macos-x86_64.tar.gz Linux, 64-bit veilvoice-v0.1.20-linux-x86_64.tar.gz Linux, ARM 64-bit - Files
veilvoice-v0.1.20-linux-arm64.tar.gz Raspberry Pi OS, 32-bit (command line only) veilvoice-v0.1.20-linux-armv7-pi.tar.gz Linux, 64-bit, static (command line only) veilvoice-v0.1.20-linux-x86_64-musl-static.tar.gz Linux, ARM 64-bit, static - Files
(command line only) veilvoice-v0.1.20-linux-arm64-musl-static.tar.gz FreeBSD, 64-bit (command line only) veilvoice-v0.1.20-freebsd-x86_64.tar.gz OpenBSD, 64-bit (command line only) veilvoice-v0.1.20-openbsd-x86_64.tar.gz NetBSD, 64-bit - Files
(command line only) veilvoice-v0.1.20-netbsd-x86_64.tar.gz SHA256SUMS the hash of every file above CONTENTS.sha256 the hash of every file inside those archives SHA256SUMS.asc the signature over that list veilvoice-signing-key.asc the - Files
public key it was signed with These links are worked out from the version number rather than fetched, so this page builds with no network. A release that did not build for a platform answers with a not-found; the release page is the - Files
definitive list. Release notes everything that changed in v0.1.20, in full The offline claim is now proved by a machine, four ways The front page says VeilVoice never touches the network. CI checked the dependency graph for HTTP clients, - Files
and then a comment in that job said the source names no network API anywhere. True, and a sentence a reader was asked to believe, inside a job whose purpose is to replace belief with a check. Four independent layers now, each re-runnable - Files
by a stranger from the workflow file: the dependency graph as before; the source , which names no network API; the built binary , which imports no network function at all; and the running program , which de-identifies a recording inside an - Files
empty network namespace with no interfaces and not even loopback, and makes zero network syscalls under strace . The window is claimed separately, because it is a different program and the honest statement differs. It talks to the display - Files
server and the desktop portal. Traced, it opens exactly two sockets, both local, and asks for no internet socket at any point. Each guard was tested against a case that should fail it. A guard that has never failed is a guard nobody has - Files
tested. Ten drift checks that existed and did not fail a build Every one has a --check , every one was run by hand before a release, and none was in CI. The rule they enforce is written down: drift fails a build rather than being noticed - Files
later by a reader. They were the half of that sentence nobody wired up. Three caught real staleness in this round, which is how the gap was noticed: the walkthrough had two new tabs with no picture and no caption, generated source pages
website/roadmap.html
- Roadmap
What is built, what is coming, and roughly when. Generated from ROADMAP.md. The same information as ROADMAP.md , which is where it is written and where the reasoning behind each one lives. - Roadmap
.rm-square{transform-box:fill-box;transform-origin:center;animation:rm-in .45s cubic-bezier(.2,.8,.3,1) backwards;transition:filter .15s ease}.rm-square:hover{filter:brightness(1.35)}@keyframes - Roadmap
rm-in{from{opacity:0;transform:scale(.4)}to{opacity:1;transform:none}}@media (prefers-reduced-motion:reduce){.rm-square{animation:none}} 154 items 144 done, 10 planned Shipped DSP engine: phase discard, many-to-one normalisation, CSPRNG - Roadmap
modulation (done) Accent neutralisation, on by default (done) Cryptography: Argon2id, X25519+ML-KEM-768, XChaCha20-Poly1305 (done) Encryption at rest, by default, plaintext never touching disk (done) App lock: Argon2id verifier, persisted - Roadmap
rate limit (done) Audio: device enumeration, live path, decode, WAV write (done) Metadata stripping: tags, EXIF/GPS, chunk-level RIFF cleaner (done) Microphone and camera monitor (Windows, Linux) (done) Tamper detection, unprivileged half - Roadmap
(veilvoice-guard) (done) Secure erase, with an honest account of flash storage (done) CLI and desktop app (done) Website, wiki, no-JavaScript edition, legal gate (done) Search over the whole repository and website, with a static fallback - Roadmap
(done) Portable release verifier needing no GnuPG (veilvoice-verify) (done) Install scripts for Windows, Linux and macOS (done) Reproducible signed releases on ten platforms (done) Four audit rounds: 47 defects found and fixed (done) In - Roadmap
progress Documentation generator: a page, flowchart and banner for every crate and every .rs file, (done) Repository panel no longer shows a README's own markup as text (done) Write the missing module documentation for the 14 files that - Roadmap
had almost none (done) Website split into a page per section, every published link still working (done) Motion and polish: smooth loading and scrolling, hover, CSS-first tooltips (done) Demonstration animation: a voice going in, the mark - Roadmap
lighting up, an unidentifiable wave co (done) Cycling line of project facts, slow enough to read: CSS rather than an image, so it follow (done) Every website theme in the app, plus user-defined palettes with contrast computed rather t - Roadmap
(done) Interactive workflow diagrams that open the relevant source, highlighted, in the site's pa (done) Randomised, user-configurable ratchet interval, with invalid input refused rather than cla (done) Installer with a window: Tokyo - Roadmap
Night, animated, and portable described as the normal case (done) Optional companion setup: VB-CABLE on Windows, PipeWire on Linux, BlackHole on macOS, and (done) The site's search presented as an index, and animated (done) Security and - Roadmap
monitoring features Screen-capture detection: which recorders are running, muted per program by an allowlist (done) Keyboard and mouse activity monitoring, reported as the heuristic it is (done) Ransomware canaries and mass-change rate - Roadmap
detection, now veilvoice_guard::sentry (done) Learn what runs, then allowlist it, with time-limited grants and a log, now veilvoice_watc (done) veilvoice-policy: settings sealed with the existing post-quantum cryptography, and shaped - Roadmap
(done) Privileged mode: an opt-in service, and an elevated no-service mode, with the difference v (done) Alert on driver and kernel-module installation; cross-view checks (done) Failsafe: on by default: notice the moment another program - Roadmap
picks up a *real* microphone wh (done) Notification overlay: rounded, translucent, contrast computed, or an alert, or off (done) Duress and decoy passwords (done) Conversations, subtitles and video Conversation mode: tell the engine a - Roadmap
recording holds more than one speaker, and give each (done) Up to ten speakers, each with a name, carried into the audio and into subtitles (done) A rolling seed per speaker, at a randomised interval inside a range the user sets, with no - Roadmap
(done) Video output: the waveform, a circle per speaker in their palette colour or their own pict (done) A preview of the video and of the voices before anything is generated (done) An asynchronous pipeline, every speaker rendering at once - Roadmap
rather than in sequence (done) Every crate and every .rs file explained: the technical workflow in a paragraph, then the (done) The website on mobile, and on every engine: not only the one it was written in (done) Seventh audit round - Roadmap
across the whole tree, then the production deploy (done) Building it yourself, and proving the download matches Build the whole repository from source, from the tool itself: find or install a toolchain, (done) Reproducibility check: build - Roadmap
here, hash what came out, and compare it against the publishe (done) The hashes are trusted only after the signature is: verify the detached signature over SHA (done) Set the machine up per platform: the build dependencies each operating - Roadmap
system actually nee (done) Custom install: CLI, desktop app, or both, from a build you just made or from a download y (done) Four verbosity levels: nothing, minimal, normal (the default) and everything, applied to e (done) Group mode, - Roadmap
where you can see it Group mode in the desktop app, shown as a mode rather than hidden in a flag: off by defaul (done) A name and a colour per speaker in the app: the colour chosen automatically to be as disti (done) Live levels while a - Roadmap
recording is running, in the app and in the terminal (done) Seeing it before you install it The live monitor: what is going in and what is coming out, on every tab, on by default, an (done) An interactive demonstration on the website: the - Roadmap
inside of the application and of the comm (done) A frequently asked questions page, answering what gets asked rather than what is convenien (done) A drawn graphic for every workflow chart: coloured arrows, an explanation inside the pictu - Roadmap
(done) This roadmap, published as a page, with a picture of what is done and what is not, generat (done) A video of the roadmap, scrolling what is finished, with a short pause and a countdown bef (done) The front page animation, in more - Roadmap
depth: the same picture, saying what the engine actually (done) A full security and functionality audit, and an optimisation pass, before the next deploy: (done) The lock, the guard, and a window that does not stutter The lock screen tells - Roadmap
an attacker nothing: no explanation of what the lock is or is not wo (done) veilvoice-guard inside the desktop application: the integrity record taken at the first la (done) The app lock, hardened as far as it honestly goes: an - Roadmap
authentication tag under the passphr (done) What the lock is worth, written down properly: one account, in the documentation, separati (done) Every website palette in the application, chosen from the interface (done) A window that does not - Roadmap
stutter: the interface measured rather than described, every task o (done) Finally Fifth audit round: every vulnerability class across the tree, twelve findings written up i (done) v0.1.10 released: ten platforms, signed, and verified by - Roadmap
hand after publication (done) Ready for the release audit: the RPM built, lintian run and its findings fixed, manual pag (done) Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath Find the encrypted volumes this machine - Roadmap
already has: detect an installed Cryptomator or Ve (done) Write veiled output into a chosen volume: a destination that is a Cryptomator vault or a m (done) The hidden-volume question, asked properly: asked before the first write, three - Roadmap
answers, a (done) The guided path, for when detection fails: plain instructions, a folder chosen by hand, an (done) What full-disk encryption is for, said once and said properly: BitLocker, FileVault, LUKS (done) The app lock as a key, not - Roadmap
only a verifier: the app-lock passphrase seals everything VeilV (done) Asked for after the encrypted volumes A video of a veiled recording: a black frame and the audio, so a recording can be posted w (done) Import from every format OBS - Roadmap
writes: bring in a recording made elsewhere, video or audio, (done) Veil the other person afterwards: the interviewee given their own voice in post, through t (done) GnuPG verification inside the window: in the verify tab, beside the hash - Roadmap
check, using the (done) veilvoice-verify finds the release itself: GnuPG arguments where wanted, and an auto that (done) An autolock timeout: on at half an hour, from five minutes to forty eight hours, chosen fr (done) Group mode explained - Roadmap
where it is used: how to build a plan, what each field does, and what (done) Release notes people can actually read: every release listed newest first, its notes openi (done) One version per release, in order, enforced: the tag, the - Roadmap
workspace and every package defi (done) v0.1.15 released: the audit run over everything since v0.1.14, CI green, and the release p (done) A verifier anybody can use, checking everything: one press or one command checks the signa (done) The - Roadmap
window, and the things that were marked done and were not A window that is genuinely idle: the cause of the frames found by asking the toolkit rathe (done) The window toolkit brought forward: eframe and egui from 0.29 to 0.32, which is - Roadmap
what made (done) What drew the window, reported: the graphics choices named in the source with their reason (done) Crash reports about the failure they are about: the closing note chosen from the panic rat (done) One version, in one file: - Roadmap
every other copy derived from Cargo.toml or checked against it, (done) v0.1.17 released: the idle window fixed at the cause, the toolkit upgraded, and the releas (done) Asked for while v0.1.17 was being built A demonstration that is the - Roadmap
program: five recorded sessions of the real binaries replayed (done) A crash report offered rather than buried: the report already written to disk surfaced abo (done) A first run that explains itself: one card per tab saying what it is - Roadmap
for, skippable, and a (done) An obfuscated program folder: VeilVoice's own files encrypted under names derived from the (done) A first-run setup and tour: the app lock, the recording passphrase and the autolock explai (done) Host the - Roadmap
website locally: a script and an nginx config that serve website/ exactly as GitH (done) A self-signed code certificate beside the OpenPGP key: a detached, signed APPMANIFEST.json (done) Asked for after v0.1.18 Memory that reads as - Roadmap
ciphertext from outside the process: the app-lock key holds the secre (planned) A layout no two copies share: the in-memory shape of that protected state varied per build (planned) Tamper noticed, and the source named: an attempt to read - Roadmap
or write VeilVoice's memory from (planned) One author in the history, and every commit verified: the commit history rewritten so the (done) A demonstration you navigate rather than watch: the website's demo driven by the reader, a (done) - Roadmap
Told when anything touches VeilVoice at all: every attempt to open, read, write or attach (planned) A verdict, and then a choice: something reaching into VeilVoice is reported with what it d (planned) The allowlist that cannot be worn as a - Roadmap
disguise: a signed antivirus reading VeilVoice's me (planned) A studio vault that needs both keys: every recording the studio makes, and every preview i (done) Recording Studio: the place recording happens, with the vault already open - Roadmap
because both lo (done) Recording Browser: everything in the vault, listed with what it is and when it was made, p (done) Neither lock alone, proven rather than asserted: tests that open the vault with each secre (done) A pass for size and - Roadmap
speed, changing no behaviour: the tree read for the things that accumu (done) Written so the next pass finds nothing: the practices that pass established are now the st (done) The BSDs built twice, like everywhere else: FreeBSD, OpenBSD - Roadmap
and NetBSD were the three pla (done) A BSD reader can check their own copy either way: all three routes written up per system r (done) The live scramble moved into the Studio: veiling as it runs has stopped being a separate t (done) Both - Roadmap
sides of the glass, kept or discarded: the Studio asks which of the two to keep befor (done) Audio that says when it was interfered with: a device swapped or unplugged mid-session, an (done) Every guest, veiled and plain, side by side: two - Roadmap
bars per speaker in group mode, what went (done) Decoy vaults, as many as you choose: the Browser asks whether to make dummy vaults and how (done) Setup inside the app, with defaults it worked out: first run ends on a card that reads thi - Roadmap
(done) Portable first, and it tells you where it is: a folder called veilvoice-data beside the pr (done) Acceleration on unless you turned it off: the window asks the platform for a hardware cont (done) A video of who said what, from the - Roadmap
Studio: done. The preview page and the video file are n (done) Resolutions somebody would actually pick: 1080p as the default, with 1440p, 2160p and a cu (done) How much code this actually is, counted one way and said once: a functional - Roadmap
line count in (done) v0.1.19, the audited release: the full audit of roadmap item 127 run over the whole reposi (done) The full final audit, and what counts as complete: no part of this repository is signed of (done) The demonstration on - Roadmap
the page, and the drawing of it gone: the recorded terminal sessions (done) veilvoice verify, said that way everywhere: nine places still described a veilvoice-verify (done) Staleness as an invariant rather than a habit: CLAUDE.md now - Roadmap
states that a change is not f (done) The Recording Studio, in full: everything roadmap items 121 to 137 specify, built and work (done) The Studio release: 145 with the Browser, the decoys, the setup and the BSD reproducibilit (planned) - Roadmap
Several microphones at once, veiled and metered each: one input per guest, each veiled by (done) The window draws at the display's rate, and says so: the animations ran at twenty frames a (done) A signature check that needs only a - Roadmap
signature check: veilvoice verify and the Verify tab c (planned) The meters above a call: the live monitor had two places to sit and both were inside the V (done) Mutation testing, as a check rather than as an afternoon: changing the code - Roadmap
a line at a ti (done) The numbers the README states, written by the tool that measures them: the functional line (done) A link into a page lands on what it names: the site's header is sticky, so a fragment jump (done) What this project - Roadmap
says it is made of, checked against what it is: the front page renders t (done) Companion software installed for you, wherever that can honestly be done: today VeilVoice (planned) Every page says where it lives, and a crawler is told it - Roadmap
may read the site: og:image was a (done) The banner moves on the no-JavaScript page too: that edition showed the still while the ma (done) Fewer crates, chosen by what somebody would actually take: twenty-seven crates for one app (done) - Roadmap
The wiki becomes the documentation, and publishes itself: the wiki held the reference, a p (done) The lock and the unlock are one control: the button that locks the window and the button t (done) VeilVoice on a phone: an Android package a - Roadmap
person installs without developer tools, signed (planned) done: 144 next: 0 planned: 10 blocked: 0 144 of 154 are built, tested, documented and in main . Area is not progress. Every square is the same size and one item is not one day: the - Roadmap
first is the whole signal engine, and another is a page of questions. The estimates below are the closest thing to an answer, and they are estimates. - Everything that is finished, in one go
A little under half a minute. It scrolls what is done, waits four seconds with a countdown in the corner, and starts again. .film-list { animation: film-scroll 289.76s linear infinite; } .film-ring { animation: film-count 289.76s linear - Everything that is finished, in one go
infinite; } @keyframes film-scroll { 0%% { transform: translateY(0); } 98.62%, 100% { transform: translateY(-12002.0px); } } @keyframes film-count { 0%, 98.62% { opacity: 0; stroke-dashoffset: 56.55; } 98.63% { opacity: 1; - Everything that is finished, in one go
stroke-dashoffset: 56.55; } 100%% { opacity: 1; stroke-dashoffset: 0; } } @media (prefers-reduced-motion: reduce) { .film-list, .film-ring { animation: none; } .film-ring { opacity: 0; } } 144 items finished 1 DSP engine: phase discard, - Everything that is finished, in one go
many-to-one normalisation, CSPRNG modulation 2 Accent neutralisation, on by default 3 Cryptography: Argon2id, X25519+ML-KEM-768, XChaCha20-Poly1305 4 Encryption at rest, by default, plaintext never touching disk 5 App lock: Argon2id - Everything that is finished, in one go
verifier, persisted rate limit 6 Audio: device enumeration, live path, decode, WAV write 7 Metadata stripping: tags, EXIF/GPS, chunk-level RIFF cleaner 8 Microphone and camera monitor (Windows, Linux) 9 Tamper detection, unprivileged half - Everything that is finished, in one go
(veilvoice-guard) 10 Secure erase, with an honest account of flash storage 11 CLI and desktop app 12 Website, wiki, no-JavaScript edition, legal gate 13 Search over the whole repository and website, with a static fallback 14 Portable - Everything that is finished, in one go
release verifier needing no GnuPG (veilvoice-verify) 15 Install scripts for Windows, Linux and macOS 16 Reproducible signed releases on ten platforms 17 Four audit rounds: 47 defects found and fixed 18 Documentation generator: a page, - Everything that is finished, in one go
flowchart and banner for every crate and every .rs file, mirrored to the website and the GitHub wiki 20 Repository panel no longer shows a README's own markup as text 21 Write the missing module documentation for the 14 files that had - Everything that is finished, in one go
almost none 22 Website split into a page per section, every published link still working 23 Motion and polish: smooth loading and scrolling, hover, CSS-first tooltips 24 Demonstration animation: a voice going in, the mark lighting up, an - Everything that is finished, in one go
unidentifiable wave coming out 25 Cycling line of project facts, slow enough to read: CSS rather than an image, so it follows the reader's theme and needs no script 26 Every website theme in the app, plus user-defined palettes with - Everything that is finished, in one go
contrast computed rather than assumed 27 Interactive workflow diagrams that open the relevant source, highlighted, in the site's palette 28 Randomised, user-configurable ratchet interval, with invalid input refused rather than clamped 30 - Everything that is finished, in one go
Installer with a window: Tokyo Night, animated, and portable described as the normal case rather than as something missing 31 Optional companion setup: VB-CABLE on Windows, PipeWire on Linux, BlackHole on macOS, and Audacity everywhere, - Everything that is finished, in one go
detected if present and installed only if confirmed 32 The site's search presented as an index, and animated 33 Screen-capture detection: which recorders are running, muted per program by an allowlist 35 Keyboard and mouse activity - Everything that is finished, in one go
monitoring, reported as the heuristic it is 36 Ransomware canaries and mass-change rate detection, now veilvoice_guard::sentry 37 Learn what runs, then allowlist it, with time-limited grants and a log, now veilvoice_watch::appctl 38 - Everything that is finished, in one go
veilvoice-policy: settings sealed with the existing post-quantum cryptography, and shaped so they can only be tightened 39 Privileged mode: an opt-in service, and an elevated no-service mode, with the difference visible to the user 40 - Everything that is finished, in one go
Alert on driver and kernel-module installation; cross-view checks 65 Failsafe: on by default: notice the moment another program picks up a *real* microphone while you are being veiled, warn, and close it 41 Notification overlay: rounded, - Everything that is finished, in one go
translucent, contrast computed, or an alert, or off 42 Duress and decoy passwords 46 Conversation mode: tell the engine a recording holds more than one speaker, and give each a distinct voice while destroying every voiceprint 47 Up to ten - Everything that is finished, in one go
speakers, each with a name, carried into the audio and into subtitles 48 A rolling seed per speaker, at a randomised interval inside a range the user sets, with no interval hardcoded and a fresh one at every launch 49 Video output: the - Everything that is finished, in one go
waveform, a circle per speaker in their palette colour or their own picture inside a coloured ring, a title, and a black or image background with padding 50 A preview of the video and of the voices before anything is generated 51 An - Everything that is finished, in one go
asynchronous pipeline, every speaker rendering at once rather than in sequence 52 Every crate and every .rs file explained: the technical workflow in a paragraph, then the same thing in plain words 53 The website on mobile, and on every - Everything that is finished, in one go
engine: not only the one it was written in 54 Seventh audit round across the whole tree, then the production deploy 55 Build the whole repository from source, from the tool itself: find or install a toolchain, pin it to - Everything that is finished, in one go
rust-toolchain.toml, and run the same build the release does 56 Reproducibility check: build here, hash what came out, and compare it against the published SHA256SUMS entry for this platform, saying which files matched and which did not 57 - Everything that is finished, in one go
The hashes are trusted only after the signature is: verify the detached signature over SHA256SUMS against the project key *before* any hash from it is compared, and refuse rather than warn if it does not verify 58 Set the machine up per - Everything that is finished, in one go
platform: the build dependencies each operating system actually needs, detected, named with who ships them, and installed only on an explicit yes 59 Custom install: CLI, desktop app, or both, from a build you just made or from a download - Everything that is finished, in one go
you just verified 60 Four verbosity levels: nothing, minimal, normal (the default) and everything, applied to every one of the above, with the exit status carrying the answer when the output carries nothing 61 Group mode in the desktop - Everything that is finished, in one go
app, shown as a mode rather than hidden in a flag: off by default, a toggle that does not persist, and a separate tick for "always start in group mode" 62 A name and a colour per speaker in the app: the colour chosen automatically to be as - Everything that is finished, in one go
distinct as the number of speakers allows, overridable per speaker, and drawn from every palette the website offers 63 Live levels while a recording is running, in the app and in the terminal 66 The live monitor: what is going in and what - Everything that is finished, in one go
is coming out, on every tab, on by default, and a preview that lets you hear yourself veiled before anybody else does 67 An interactive demonstration on the website: the inside of the application and of the command line, laid out in the - Everything that is finished, in one go
site's own colours, that a reader can click through before downloading anything 68 A frequently asked questions page, answering what gets asked rather than what is convenient to answer 69 A drawn graphic for every workflow chart: coloured - Everything that is finished, in one go
arrows, an explanation inside the picture, and every word wrapped rather than running off the edge 70 This roadmap, published as a page, with a picture of what is done and what is not, generated from this file so the two cannot disagree 71 - Everything that is finished, in one go
A video of the roadmap, scrolling what is finished, with a short pause and a countdown before it repeats 72 The front page animation, in more depth: the same picture, saying what the engine actually does to the signal rather than one word - Everything that is finished, in one go
73 A full security and functionality audit, and an optimisation pass, before the next deploy: the whole tree, both halves, and the last thing that happens 74 The lock screen tells an attacker nothing: no explanation of what the lock is or - Everything that is finished, in one go
is not worth while it is locked, the account of that moved to the documentation and to the unlocked application, and a small animation in its place 75 veilvoice-guard inside the desktop application: the integrity record taken at the first - Everything that is finished, in one go
launch and checked at every one after, sealed under the app-lock passphrase where there is one 76 The app lock, hardened as far as it honestly goes: an authentication tag under the passphrase, two copies with the spare administrator-owned - Everything that is finished, in one go
where the platform allows it, restoration when one goes, randomised names and masked contents, and a report that only the passphrase can clear 77 What the lock is worth, written down properly: one account, in the documentation, separating - Everything that is finished, in one go
the parts that are real from the parts that are only obscurity 78 Every website palette in the application, chosen from the interface 79 A window that does not stutter: the interface measured rather than described, every task off the - Everything that is finished, in one go
drawing thread, and the smallest amount of code that does it 44 Fifth audit round: every vulnerability class across the tree, twelve findings written up individually (F-48 to F-59) 45 v0.1.10 released: ten platforms, signed, and verified - Everything that is finished, in one go
by hand after publication 80 Ready for the release audit: the RPM built, lintian run and its findings fixed, manual pages generated from the binaries, 32-bit re-run over the new code, and the parser campaign run over all six targets with a - Everything that is finished, in one go
seed corpus kept 81 Find the encrypted volumes this machine already has: detect an installed Cryptomator or VeraCrypt, and the vaults and mounted volumes each is offering, without asking either to do anything 82 Write veiled output into a - Everything that is finished, in one go
chosen volume: a destination that is a Cryptomator vault or a mounted VeraCrypt volume, remembered, and used for every export 83 The hidden-volume question, asked properly: asked before the first write, three answers, and a job that will - Everything that is finished, in one go
not start until one is given 84 The guided path, for when detection fails: plain instructions, a folder chosen by hand, and the same confirmation a detected one gets 85 What full-disk encryption is for, said once and said properly: - Everything that is finished, in one go
BitLocker, FileVault, LUKS and LUKS2, and the OpenBSD and FreeBSD equivalents, single-sourced and shown in both 86 The app lock as a key, not only a verifier: the app-lock passphrase seals everything VeilVoice veils, automatically, as an - Everything that is finished, in one go
option that says what it costs 87 A video of a veiled recording: a black frame and the audio, so a recording can be posted where only video is accepted 88 Import from every format OBS writes: bring in a recording made elsewhere, video or - Everything that is finished, in one go
audio, and take the sound out of it 89 Veil the other person afterwards: the interviewee given their own voice in post, through the group plan that already exists 90 GnuPG verification inside the window: in the verify tab, beside the hash - Everything that is finished, in one go
check, using the GnuPG somebody already has 91 veilvoice-verify finds the release itself: GnuPG arguments where wanted, and an auto that looks in Downloads, checks the archive, and checks what came out of it 92 An autolock timeout: on at - Everything that is finished, in one go
half an hour, from five minutes to forty eight hours, chosen from a list or typed, with the range itself adjustable, and offered during first-run setup 93 Group mode explained where it is used: how to build a plan, what each field does, - Everything that is finished, in one go
and what happens without one 94 Release notes people can actually read: every release listed newest first, its notes opening in place, and every file one click away 95 One version per release, in order, enforced: the tag, the workspace and - Everything that is finished, in one go
every package definition checked against each other before a release can go out 96 v0.1.15 released: the audit run over everything since v0.1.14, CI green, and the release published 97 A verifier anybody can use, checking everything: one - Everything that is finished, in one go
press or one command checks the signature, the archive, every file you extracted, and then asks your own GnuPG the same question 98 A window that is genuinely idle: the cause of the frames found by asking the toolkit rather than by - Everything that is finished, in one go
reasoning, the animation stopped when the window is unfocused or being dragged, and the result measured to nothing 99 The window toolkit brought forward: eframe and egui from 0.29 to 0.32, which is what made the frames answerable, and the - Everything that is finished, in one go
runtime dependency it added declared everywhere a package can declare it. Brought forward again to 0.36 with roadmap item 148, which took ttf-parser out of the dependency graph and one advisory out of the exception list with it 100 What - Everything that is finished, in one go
drew the window, reported: the graphics choices named in the source with their reasoning, and the driver actually obtained shown on the About tab, read from the driver 101 Crash reports about the failure they are about: the closing note - Everything that is finished, in one go
chosen from the panic rather than the same guess every time, and a missing system library named along with the package that carries it 102 One version, in one file: every other copy derived from Cargo.toml or checked against it, with the - Everything that is finished, in one go
files that keep a history added to rather than rewritten 103 v0.1.17 released: the idle window fixed at the cause, the toolkit upgraded, and the release published 104 A demonstration that is the program: five recorded sessions of the real - Everything that is finished, in one go
binaries replayed on the website, re-run and compared on every build, sitting with the screenshots 105 A crash report offered rather than buried: the report already written to disk surfaced above whatever tab you land on, with what it - Everything that is finished, in one go
contains listed and readable in full before anything leaves your machine, and the filing left to the person 106 A first run that explains itself: one card per tab saying what it is for, skippable, and after an upgrade only the tabs that - Everything that is finished, in one go
are new, with portable and installed said plainly on the last card 109 An obfuscated program folder: VeilVoice's own files encrypted under names derived from the app-lock passphrase, with decoys among them, so the lock protects data rather - Everything that is finished, in one go
than only a window 110 A first-run setup and tour: the app lock, the recording passphrase and the autolock explained and offered once, skipping whichever is already set 111 Host the website locally: a script and an nginx config that serve - Everything that is finished, in one go
website/ exactly as GitHub Pages does, so the site survives the repo or Pages going down, and is the audit surface during development 112 A self-signed code certificate beside the OpenPGP key: a detached, signed APPMANIFEST.json describing - Everything that is finished, in one go
each binary, verify scripts for Unix and Windows, and an import tutorial, for organisations that want a known publisher without breaking reproducible builds 116 One author in the history, and every commit verified: the commit history - Everything that is finished, in one go
rewritten so the whole of it matches the attribution rule this project already states, with the assistance credit staying exactly where a reader looks for it (the README, and the footer of every page) rather than in a trailer that makes a - Everything that is finished, in one go
second contributor of it, and every commit in the history signed rather than only the recent ones. Trees are unchanged by this, so reproducible builds still verify 117 A demonstration you navigate rather than watch: the website's demo - Everything that is finished, in one go
driven by the reader, a header per part of the program, and the screenshots for whichever they pick shown under it. Not an imitation of the interface: the real captures, the ones the build already regenerates and compares, so the demo - Everything that is finished, in one go
cannot drift from the program. The command line gets the same treatment, a worked case per thing somebody actually wants to do, explained rather than listed, and the site's own Demo link lands on the section and opens it 121 A studio vault - Everything that is finished, in one go
that needs both keys: every recording the studio makes, and every preview it holds, sealed into one vault whose key exists only when the app lock *and* the at-rest passphrase have both been given. Neither alone derives it, so a stolen - Everything that is finished, in one go
laptop with the app unlocked opens nothing and a known recording passphrase without the app opens nothing. The same post-quantum sealing used everywhere else, held in the same page-locked, zeroizing memory, with the same honest report of - Everything that is finished, in one go
how much the operating system actually agreed to lock 122 Recording Studio: the place recording happens, with the vault already open because both locks were answered on the way in. Capture, monitor, preview and re-take without a plaintext - Everything that is finished, in one go
file existing at any point, and a session that locks itself back up when the app does rather than staying open behind a screensaver 123 Recording Browser: everything in the vault, listed with what it is and when it was made, played back by - Everything that is finished, in one go
decrypting into page-locked memory rather than writing a temporary file somebody would have to remember to shred, whole rather than in blocks because the container is sealed and authenticated as one piece. Export is a deliberate act with - Everything that is finished, in one go
its own warning, because leaving the vault is the moment the protection ends 124 Neither lock alone, proven rather than asserted: tests that open the vault with each secret in turn and get nothing, and a stated account of what the pair - Everything that is finished, in one go
does and does not buy. It raises the cost of a stolen machine and of a guessed passphrase; it does not defeat somebody watching the process while both are entered, and that is written beside the feature rather than left implied 125 A pass - Everything that is finished, in one go
for size and speed, changing no behaviour: the tree read for the things that accumulate rather than the things that break. Work repeated that could be done once, allocations in paths that run per frame or per sample, generic code - Everything that is finished, in one go
instantiated more times than it needs to be, dependencies pulled in for one function, and anything the compiler is doing twice. Measured before and after, with the binary size and the timings recorded, and not one behavioural change: every - Everything that is finished, in one go
test that passed before passes after, or the change is reverted rather than argued for 126 Written so the next pass finds nothing: the practices that pass established are now the standing way this project is written, in CLAUDE.md, and - Everything that is finished, in one go
three of the four are enforced by a build rather than by somebody remembering. No audio callback may allocate, block or print, checked by a test that reads the callbacks themselves rather than by the comments that said so. Every dependency - Everything that is finished, in one go
says what it is for on the line that declares it, checked in CI, which found three on its first run that no line of code referred to. Work that can be done once is done once, and the comment says what made it constant. The fourth is a - Everything that is finished, in one go
habit and is written as one: the reading happens before the push rather than to the tree once a year 128 The BSDs built twice, like everywhere else: FreeBSD, OpenBSD and NetBSD were the three platforms whose archives said not-verified - Everything that is finished, in one go
(built once, in a VM), which was honest and was the only gap in the reproducibility claim. The second build now happens in the same VM, with the same path remapping and the same SOURCE_DATE_EPOCH the other ten platforms use, and the - Everything that is finished, in one go
verdict is published in the release notes beside theirs. At v0.1.19 all three reported reproducible, so every one of the eleven targets is now verified rather than eight of them. A BSD that stops reproducing says so in those words rather - Everything that is finished, in one go
than quietly dropping the line 129 A BSD reader can check their own copy either way: all three routes written up per system rather than left as a Linux instruction somebody has to translate. veilvoice verify on its own is the same command - Everything that is finished, in one go
everywhere and needs no GnuPG, no network and none of the system's own tools, which is why it is offered first and why a BSD reader has nothing to translate. The second opinion is where systems differ, and the script now has a spelling for - Everything that is finished, in one go
the BSDs: it had two, Linux and macOS, and a BSD reader fell through to the Linux one and was told to run a command this project's *other* script says they do not have. That is F-167. The guide's table of which system runs what is checked - Everything that is finished, in one go
against the program rather than typed beside it, and the one command this project has not run on a BSD, installing GnuPG on OpenBSD and NetBSD, is left unsaid rather than guessed 130 The live scramble moved into the Studio: veiling as it - Everything that is finished, in one go
runs has stopped being a separate tab and is what the Studio does. It was already the same engine, the same ratchet and the same virtual-cable routing on both screens, which is what made two of them wrong rather than merely redundant: the - Everything that is finished, in one go
Studio recorded with whichever devices the other tab happened to be set to, and each tab started a session of its own, so veiling on one and recording on the other opened the same microphone twice. There is one starter now. The tab is the - Everything that is finished, in one go
voice above and the take below, the voice half works with the vault shut because veiling a call has never needed a vault, and ending a take leaves the veiling running 131 Both sides of the glass, kept or discarded: the Studio asks which of - Everything that is finished, in one go
the two to keep before the button rather than after it, because a recording of somebody's real voice is not a thing to discover having made. The veiled voice is selected, both is offered, and the microphone on its own is offered last. - Everything that is finished, in one go
Anything that keeps the microphone says so in the same words the plaintext path uses: it is sealed in the vault as strongly as anything else, and it is still a recording anybody who opens the vault can hear who was speaking in. Never - Everything that is finished, in one go
remembered between runs and reset when the window locks, for the reason group mode is not remembered. Both kept means two takes, the unveiled one named for it. The command line's record keeps the veiled voice only and is unchanged: this is - Everything that is finished, in one go
a Studio decision, made where the vault that receives it is 132 Audio that says when it was interfered with: a device swapped or unplugged mid-session, and another program taking the microphone while a take is running, are noticed and - Everything that is finished, in one go
shown rather than recorded silently. This row used to open by asking for the samples reaching the recorder to be *checked* against what the engine produced. They cannot differ: the recorder is fed from inside the output callback, from the - Everything that is finished, in one go
same slice the engine has just written into, so that check is a buffer compared with itself. It is a property to keep rather than one to measure, and a test reads the source for it. What was actually missing was the report: the platform - Everything that is finished, in one go
announces a stream error on a callback of its own, and both of them were eprintln! and nothing else, so on Windows, where the window has no console, a microphone unplugged mid-call was silent. Stated limit up front, beside the report - Everything that is finished, in one go
rather than after it: this notices interference with VeilVoice's own path and cannot vouch for a microphone that was already lying 133 Every guest, veiled and plain, side by side: two bars per speaker in group mode, what went into each of - Everything that is finished, in one go
their turns and what the engine produced from it, drawn as the render walks the file. One bar answers "is something being written" and not "is this person being veiled", which is the question somebody rendering an interview is asking; two - Everything that is finished, in one go
that move differently are the only thing on screen showing the engine is between them. Under them, in the same words the live meters use, what they cannot show. The row asked for this live, and that half is roadmap item 147: group mode - Everything that is finished, in one go
works on a recording that already exists and never opens a device, the live path opens one input, and there is no per-guest live signal in this tree to draw. The one-microphone live case already has its two bars, in the Studio 134 Decoy - Everything that is finished, in one go
vaults, as many as you choose: the Browser asks whether to make dummy vaults and how many, sizes them from the real one so they are indistinguishable by size, and fills them with encrypted nonsense under keys that are thrown away. Cracked, - Everything that is finished, in one go
one yields bytes that will not parse as anything: a decoy is not a vault with weak contents, it is a vault whose contents never existed. Asked there rather than at first run, because a decoy is sized from the real vault and at first run - Everything that is finished, in one go
there is not one yet. The real vault is found by trying each directory rather than by name, so a decoy is not told apart by reading the folder either. The count defaults from the free space actually detected rather than from a number - Everything that is finished, in one go
picked here, and the honest limit is stated: this raises the cost of a search, it does not make the real vault unfindable to somebody who watches you open it 135 Setup inside the app, with defaults it worked out: first run ends on a card - Everything that is finished, in one go
that reads this machine rather than asserting anything about it. Where recordings will go and how much room is free there, read from the operating system and said as an hour of veiled audio rather than as a number of bytes. How many - Everything that is finished, in one go
devices there are to record from and play to. What the window will ask the graphics driver for, with the tick that turns it off. Every one says what it costs, and where the machine will not answer the card says that instead of printing a - Everything that is finished, in one go
figure nobody measured. Nothing on it has to be answered and nothing is a gate 136 Portable first, and it tells you where it is: a folder called veilvoice-data beside the program makes the settings, the vaults and the lock live in it, so a - Everything that is finished, in one go
copy on a stick stays a copy on a stick. Opted into rather than detected, because "beside the program if that is writable" would move an ordinary installation's state the day somebody unpacked it somewhere writable, and the symptom would - Everything that is finished, in one go
be an empty vault. The About tab prints the exact paths it is using and says which of the two arrangements is in force, rather than leaving somebody to guess. Installing later can carry those settings over or leave them, and that is a - Everything that is finished, in one go
question the install button waits for rather than a default, because the answer differs for a shared machine and a private one. Carried means copied, never moved, and nothing already at the destination is replaced 137 Acceleration on - Everything that is finished, in one go
unless you turned it off: the window asks the platform for a hardware context and accepts a software one, so a virtual machine, a remote desktop or a server with no card still opens. One tick in Settings turns the asking off, for the - Everything that is finished, in one go
machines where a driver accepts and then draws badly: a hybrid-graphics laptop handing over the wrong adapter, or a black window on a broken OpenGL path. That case cannot be detected, because from inside the process it looks like success, - Everything that is finished, in one go
so it is a switch rather than a measurement and the honest limit is said where the tick is. The About tab shows what was asked for beside what the driver actually gave, which is the pair that answers "why is this slow" 139 A video of who - Everything that is finished, in one go
said what, from the Studio: done. The preview page and the video file are now two drawings of one recording rather than a picture and a black rectangle. A circle per speaker in their own colour, whoever is talking lit, a level under each - Everything that is finished, in one go
name that moves with the sound, the waveform and a playhead. The frames are drawn here, as pixels: raster is a canvas with rectangles, antialiased circles and a PNG writer, and font is a five-by-seven monospace face of ninety-five glyphs - Everything that is finished, in one go
written out in the file. Neither borrows anything, because the drawing is SVG and no ffmpeg can be assumed to read SVG, and pulling in a rasteriser to convert it would have undone the argument the ffmpeg module already makes about large C - Everything that is finished, in one go
libraries. The one dependency is miniz_oxide for the deflate PNG needs, which was already in this tree under flate2. A picture is written when the picture changes, not thirty times a second: the playhead moves a pixel at a time rather than - Everything that is finished, in one go
a frame at a time, so ten minutes at thirty writes hundreds of files rather than eighteen thousand, and the saving is reported rather than claimed. A short recording holds nothing and should not, because the playhead crosses the whole - Everything that is finished, in one go
waveform however long the recording is. That is why the ffmpeg command is a concat list with a duration per picture rather than a numbered sequence, which would have played an hour of conversation in a few seconds. Names outside printable - Everything that is finished, in one go
ASCII cannot be drawn by a face this size and come out as boxes; the render names them rather than letting somebody find out by watching. Single-person recordings take the same path as a group of eight 140 Resolutions somebody would - Everything that is finished, in one go
actually pick: 1080p as the default, with 1440p, 2160p and a custom size offered, rather than the 1280x720 the renderer starts at today. The size the display is actually running at is offered as a preset where the platform will say, and - Everything that is finished, in one go
where it will not the list is simply the fixed ones: a guess about somebody's monitor is worse than a menu 141 How much code this actually is, counted one way and said once: a functional line count in the README, and the same count per - Everything that is finished, in one go
crate in each crate's own documentation, with the definition stated where it is used: a line holding code, not a blank line and not a comment. Deliberately a new number rather than a redefinition of an existing one. The per-file line - Everything that is finished, in one go
counts already printed on the generated pages, in the artwork and in the reference links are a different measure, counted differently, and are left exactly as they are: changing what an existing number means, everywhere it appears, to - Everything that is finished, in one go
match a new definition would silently alter every page and link that carries one 138 v0.1.19, the audited release: the full audit of roadmap item 127 run over the whole repository, everything it finds fixed, and the tag cut on what came - Everything that is finished, in one go
out. Not the code alone: the documentation, the website, the packaging, the scripts and the reproducibility, and the optimisation pass, because a release audited in part is a release described inaccurately. The Studio is deliberately not - Everything that is finished, in one go
in it. A release named after a feature that is still being written is the thing this roadmap exists to prevent, so 0.1.19 is the release that says the tree is sound and 0.1.20 is the one that says what was built on it 127 The full final - Everything that is finished, in one go
audit, and what counts as complete: no part of this repository is signed off until every part of it has been. Security, memory safety and the post-quantum surface; correctness and QA over every crate; reproducibility of the build and of - Everything that is finished, in one go
every generated artefact; the documentation, the website, the packaging and the scripts; and the optimisation pass above, because bloat that nobody measured is a claim nobody checked. A round that covers the code and skips the - Everything that is finished, in one go
documentation, or covers both and skips whether the thing still builds byte for byte, is not a final audit and is not to be described as one 142 The demonstration on the page, and the drawing of it gone: the recorded terminal sessions and - Everything that is finished, in one go
the photographs of the window both on the front page in the order somebody meets them, neither behind a button. The hand-drawn CSS model of the application is removed rather than relabelled: it sat beside photographs of the same interface - Everything that is finished, in one go
and asked a reader to work out which to believe, on a site whose argument is that they should not have to take anybody's word for anything 143 veilvoice verify, said that way everywhere: nine places still described a veilvoice-verify - Everything that is finished, in one go
executable that stopped being published at 0.1.18, three of them lists the program itself reads. The lists are now taken from the job that publishes the binaries, and a test reads the repository the way a reader does and fails on anything - Everything that is finished, in one go
telling somebody to run a program that is not there 144 Staleness as an invariant rather than a habit: CLAUDE.md now states that a change is not finished until everything describing it has changed with it, that a fact appearing twice is - Everything that is finished, in one go
derived or checked rather than repeated, and that the only exception is a record of the past. Written down because the two roadmap items above were the same failure found twice, in different files, months apart 145 The Recording Studio, in - Everything that is finished, in one go
full: everything roadmap items 121 to 137 specify, built and working together rather than as parts. Recording in an environment that never lets a plaintext sample reach the disk, sealed post-quantum into a vault that needs both the app - Everything that is finished, in one go
lock and the at-rest passphrase, held in page-locked zeroizing memory. It shows, at all times and both live and on replay, whether the voice being recorded is veiled or not: done, and the part that was missing was the running take, which - Everything that is finished, in one go
said the clock and not which voice it was keeping, so a take of somebody's real voice looked exactly like one that was not for the whole of the recording. It has its own failsafe, separate from the application's, so a fault in the Studio - Everything that is finished, in one go
stops the Studio rather than the recording: done. The device a take is being recorded from going away is the fault it catches, and it stores what was captured and stops, rather than discarding it, retrying, or moving the recording onto a - Everything that is finished, in one go
microphone the person did not choose. Group mode records every guest with the same guarantees: done, and it is roadmap item 147, because group mode never opens a device and the live path opened one input. A room in the Studio opens one per - Everything that is finished, in one go
guest, veils each into a voice of their own, and stores a take per guest beside the mix, in the same vault, under the same lock and with the same choice of which side to keep. The output is rendered inside the vault, viewed and listened to - Everything that is finished, in one go
inside the vault, and leaving it is an export: a deliberate act, warned about, because that is the moment the protection ends. Something like a recording studio somebody already knows how to use, that happens to be secure, rather than a - Everything that is finished, in one go
security tool somebody has to learn to record with. No longer carries a version number: it was aimed at v0.1.20, that release shipped with the parts named in their own entries, and a target date a release has already passed is worse than - Everything that is finished, in one go
none 147 Several microphones at once, veiled and metered each: one input per guest, each veiled by its own engine with its own seed and destination voice, mixed into the one output a call or a recorder hears. The capture path is - Everything that is finished, in one go
veilvoice_audio::room: a recorder per guest and one for the mix, one sample rate agreed across every device before anything opens, a ring per guest so one microphone's clock drifting is that guest's dropped samples rather than everybody's, - Everything that is finished, in one go
and the honest account the row asked for as a number rather than a promise, since the output callback runs every engine before it returns and the load is the sum of their realtime factors against the deadline they share. The mix is summed - Everything that is finished, in one go
and clipped and never limited, because a limiter is a dynamics processor and this program's whole claim is about what changes a voice. The Studio drives it: a guest list with a name and a microphone each, two bars per guest with the load - Everything that is finished, in one go
and the clipping count beside them, and a take that stores the mix and every guest separately, veiled or unveiled on the same choice the single microphone has. The Studio holds one session or the other and never both, which is one field - Everything that is finished, in one go
rather than two so it cannot be got wrong. Two guests on one microphone is refused by name before anything opens, the default device included, because one microphone carrying two people is one signal and veiling it gives both of them the - Everything that is finished, in one go
same voice 148 The window draws at the display's rate, and says so: the animations ran at twenty frames a second by design, from a constant in the mark and a fifty-millisecond cadence in the busy path, and a reader with a 144 Hz display - Everything that is finished, in one go
saw that as judder. The sixteen-millisecond veiling path was worse than it looks: a display at sixty shows a frame every 16.67 ms, so asking for one "within sixteen" misses the frame it wanted and lands on the next, which is thirty asked - Everything that is finished, in one go
for as sixty. Done, and not by choosing a bigger number: while anything is moving the window asks for the next frame now and vsync spaces it, so it draws once per refresh at whatever the display runs at. That is also what makes the display - Everything that is finished, in one go
measurable, since neither egui nor eframe exposes a refresh rate: frames paced that way are the display's own, and the median of the last thirty-two is a figure one slow frame cannot move, clamped between 30 and 240. Settings offers 30, - Everything that is finished, in one go
60, 90, 120, 144, 165 and 240 for somebody who wants fewer frames on a battery, and a live readout in the header. The About tab carries what it is aiming at, what the display measured, the rate as drawn and how many frames arrived late; a - Everything that is finished, in one go
frame more than half again late is late, and two seconds of that says so once, naming what the window is drawing with. Idle still draws nothing. Nothing in the measurement allocates, locks or prints per frame 150 The meters above a call: - Everything that is finished, in one go
the live monitor had two places to sit and both were inside the VeilVoice window, which on a call or while streaming is behind the thing being talked into, so the one picture of what a microphone is doing was covered exactly when it - Everything that is finished, in one go
mattered. A third choice now: a small window of its own, kept above other windows, off the task bar, draggable and resizable, drawing the same two levels and the same sentence about what a level cannot tell you. Not the default, because a - Everything that is finished, in one go
window that puts itself above everything is a thing to ask for. Closing it brings the strip back rather than turning the meters off, since the close button belongs to the window manager and pressing it means "not in my way". Where a - Everything that is finished, in one go
platform will not give a second window it falls back to the floating card, which is the same thing inside the window 151 Mutation testing, as a check rather than as an afternoon: changing the code a line at a time and asking whether any - Everything that is finished, in one go
test objects is the only thing this project runs that asks whether an assertion exists for what came back, as opposed to whether an input was explored. Its first run over four cryptographic files found fifteen changes the whole suite - Everything that is finished, in one go
accepted, including one that replaced the randomness adapter with a constant. All three parts are in. mutants.yml runs the campaign weekly over the eight cryptography files and dispatches before a release, and its verdict is not a number: - Everything that is finished, in one go
tools/mutants/check.py compares the run against tools/mutants/survivors.txt, a committed list where every entry carries the argument for why that mutant cannot be killed, so a new survivor is a diff somebody reads and a survivor that has - Everything that is finished, in one go
been killed fails too, because the list would then claim something untrue. The campaign was extended to the app lock, the studio vault, the obfuscated store and the reversible encodings: 674 mutants, 81 survivors, and four rounds of - Everything that is finished, in one go
writing tests and re-measuring brought that to fifteen, every one of them argued. And tools/audit/build_output.py fails the build on any build output outside the root target/, which is F-185, because the fifteen gigabytes the first attempt - Everything that is finished, in one go
died on turned out to be written by this project's own tooling on every machine that is not Windows 152 The numbers the README states, written by the tool that measures them: the functional line count and the test count appear in - Everything that is finished, in one go
README.md, on the front page and in one row of docs/AUDIT.md, and all four are typed by hand and compared against the tree by the website suite. The comparison is right and the typing is the problem: every change to any Rust file moves the - Everything that is finished, in one go
line count, so a commit that touches code and does not touch those four places fails a check that has nothing to do with what the commit was for. Four times in one round was the measurement. tools/measured/generate.py now writes all ten of - Everything that is finished, in one go
them from the numbers it already takes, using the same anchors the suite checks, so the tool that writes a claim and the suite that checks it cannot disagree about where the claim is. The comparison stays exactly as it was: it is the - Everything that is finished, in one go
guard, and it no longer doubles as the thing that catches a person on every push 153 A link into a page lands on what it names: the site's header is sticky, so a fragment jump puts the heading underneath it unless the target is pushed down - Everything that is finished, in one go
clear of the bar. The amount it was pushed by was one number, 90px, and the header is 133px at desktop widths, 171px where the navigation wraps to three rows, 115px on a phone, 143px at 320px and 81px on the reference pages. The rule also - Everything that is finished, in one go
covered sections and the two larger headings only, so every entry on the releases page and every roadmap entry got nothing at all. Driven in a browser, 425 of 430 fragment links on this site landed wrong. The offset is measured from the - Everything that is finished, in one go
header now and re-measured when the window changes shape or a font arrives late, the landing is held for a second afterwards because pictures below the fold can still move the page and it lets go the moment the reader scrolls, and where - Everything that is finished, in one go
somebody landed is outlined briefly, which under reduced motion is an outline that sits there rather than fading. Navigation itself is untouched: no click is prevented and no history entry written, because the full-size screenshot viewers - Everything that is finished, in one go
open through :target. F-191 154 What this project says it is made of, checked against what it is: the front page renders the README, the README lists the crates twice and the library guide lists them a third time, and the three had drifted - Everything that is finished, in one go
to thirteen, twelve and thirteen rows against a workspace of twenty-seven. The missing fifteen included both halves of the download verifier, the failsafe, the video renderer and the workspace store. tools/audit/crate_tables.py reads the - Everything that is finished, in one go
workspace and all three tables on every build and fails naming each crate a table has missed, so the next crate cannot be added without them. The sentences stay hand-written, because one assembled from a crate name is padding. F-192 156 - Everything that is finished, in one go
Every page says where it lives, and a crawler is told it may read the site: og:image was assets/banner.png on every page, a relative address, and the crawlers that read that tag do not resolve one. Every link to this site posted anywhere - Everything that is finished, in one go
had always shown no picture, and nothing about the page looked wrong. There was no canonical address either, so one page reachable two ways was two pages to an index, and two pages carried no preview tags at all. tools/site/seo.py builds - Everything that is finished, in one go
all of it from what each page already says, every generator that writes a page calls it, and robots.txt and a sitemap covering all 398 pages exist where there were none. Checked twice on purpose: once that each page is what the generator - Everything that is finished, in one go
would write, once that what the generator writes is correct. F-194 157 The banner moves on the no-JavaScript page too: that edition showed the still while the main site animated the same artwork, both drawn by assets/generate.py from the - Everything that is finished, in one go
same source. It shows the animation now, with the still still served to anybody who has asked their system for less movement, chosen by markup rather than by a script because that page runs none 158 Fewer crates, chosen by what somebody - Everything that is finished, in one go
would actually take: twenty-seven crates for one application, seven of them under seven hundred lines and four used by exactly one caller. Full modularity is not free: every split is a published surface, a Cargo.toml, a README, a banner, a - Everything that is finished, in one go
page of the reference and a row in every table that lists them, and a reader deciding what to depend on had to read twenty-seven descriptions to find the two they wanted. Thirteen now, drawn at what somebody would realistically take on its - Everything that is finished, in one go
own. The six observers became veilvoice-watch, the two alarms and the safety catch became veilvoice-guard, the verifier absorbed both of its backends, the renderer absorbed the graphics probe that answers one question for it, the decoy - Everything that is finished, in one go
passphrase joined the cryptography it is part of, the update check joined the installer, and the saved profiles joined the settings. Nothing was deleted and no behaviour changed: every module kept its name, its tests and its page, and the - Everything that is finished, in one go
whole suite passed before and after 159 The wiki becomes the documentation, and publishes itself: the wiki held the reference, a page for each of the thirteen crates and each source file in them, and three guides, and nothing else. No - Everything that is finished, in one go
Home, so a reader arrived at whichever page GitHub picked; no _Sidebar, so there was no list of the rest; and none of the prose that answers what somebody actually arrives asking, how to build it, how to check a download reproduces, how to - Everything that is finished, in one go
contribute, what the website is made of. Those are pages now, converted from the documents rather than written again, so --check fails on a difference and there is no second copy to go stale. Two documents that did not exist were written - Everything that is finished, in one go
to be converted: docs/CONTRIBUTING.md and docs/WEBSITE.md, the second covering both editions of the site and all twelve scripts. A guard walks every [[link]] in all 202 pages, because a wiki link to a missing page renders as an invitation - Everything that is finished, in one go
to create it rather than as an error, so nothing else would ever notice. And a workflow pushes the lot to the wiki repository, which is where a reader looks and where none of it was 160 The lock and the unlock are one control: the button - Everything that is finished, in one go
that locks the window and the button that unlocks it were written where each was needed and sized by whatever its own text happened to measure, so one was 46 points wide beside a 132-point dropdown and the other was the word unlock padded - Everything that is finished, in one go
with literal spaces, eight points taller than the password field beside it and four points off its middle. Roadmap item 123 fixed the height of the first and said the width was a different complaint; it was the same complaint. One width - Everything that is finished, in one go
for the picker and both buttons, each button's height taken from whatever it stands beside, and the field grown to the button rather than the button squashed to the field. Measured in a headless frame rather than read off a photograph, on - Everything that is finished, in one go
the rows that ship rather than on replicas of them, with a test that fails if controls left to themselves happen to agree anyway. F-196 An animation rather than a video file, and that is a decision rather than a shortcut. This project - Everything that is finished, in one go
ships no codec and does not bundle ffmpeg , and the rule it already follows for video is to render here and always produce something that needs nothing else installed. An encoded file would also be a committed binary whose bytes depend on - Everything that is finished, in one go
which build of which encoder made it, so it could not be regenerated and compared the way every other picture here is. If you want a file, ffmpeg -i roadmap-film.svg roadmap.mp4 will make you one from this. - What is left, and roughly what it takes
Working days, one person, no interruptions, so the calendar will be longer than the sum. In the order it is expected to be done. # Marker State Estimate 113 Memory that reads as ciphertext from outside the process the app-lock key holds - What is left, and roughly what it takes
the secrets and the veiling state encrypted in RAM with the same post-quantum sealing used at rest, each value decrypted only for the moment it is used and re-sealed straight after, so a scan of the process's memory finds no passphrase and - What is left, and roughly what it takes
no voiceprint planned 20 114 A layout no two copies share the in-memory shape of that protected state varied per build and per run, so a scanner tuned to one copy of VeilVoice does not recognise the next, and no fixed offset survives from - What is left, and roughly what it takes
one binary to another. No junk and no decoy RAM: the footprint is unchanged, the protection is in the arrangement rather than in bulk planned 10 115 Tamper noticed, and the source named an attempt to read or write VeilVoice's memory from - What is left, and roughly what it takes
another process detected where each operating system allows it, the app locked and the person told, and the reaching process identified as far as the platform permits, on Windows, macOS, Linux and the BSDs. Where a platform cannot say, it - What is left, and roughly what it takes
says that rather than guessing planned 20 118 Told when anything touches VeilVoice at all every attempt to open, read, write or attach to one of VeilVoice's processes surfaced to the person, not only the ones that succeed, with what - What is left, and roughly what it takes
reached in named as far as the platform will say. Built on roadmap items 113 to 115 rather than beside them: the memory is already sealed, this is the part that says somebody tried planned 15 119 A verdict, and then a choice something - What is left, and roughly what it takes
reaching into VeilVoice is reported with what it did and what VeilVoice can prove about it, and the person decides: remove it, quarantine it, or mark it a false positive that is remembered. The decision is the person's, always, because a - What is left, and roughly what it takes
program that silently uninstalls another program on a heuristic is a worse problem than the one it set out to solve planned 12 120 The allowlist that cannot be worn as a disguise a signed antivirus reading VeilVoice's memory is what - What is left, and roughly what it takes
antivirus does, and flagging Defender as a rootkit would train somebody to ignore the warnings. So known-good is recognised by a verified code signature checked at the moment of the access, never by a process name, a path or a hash - What is left, and roughly what it takes
somebody can copy. An allowlist entry permits the read and still records it, still shows it in the log, and never widens to the file storage or the app lock: nothing on it can turn into a way through the protection roadmap items 113 to 115 - What is left, and roughly what it takes
provide. Stated limit, as everywhere: an attacker who already holds the kernel can forge what the kernel is asked, and this says so rather than promising otherwise planned 18 146 The Studio release 145 with the Browser, the decoys, the - What is left, and roughly what it takes
setup and the BSD reproducibility, tagged after its own audit round rather than on the strength of an earlier one. An audit covers the tree it was run on and no other. v0.1.20 carried the vault, both tabs, the render settings, the - What is left, and roughly what it takes
corrections, the player and the BSD double build, and was audited in its own round; the decoys, the machine card, the portable arrangement, the acceleration switch and the choice of which side to keep landed after it. So this describes the - What is left, and roughly what it takes
release after 0.1.20, and says so rather than keeping a number that has gone planned 8 149 A signature check that needs only a signature check veilvoice verify and the Verify tab check an RSA-4096 OpenPGP signature over SHA256SUMS , and do - What is left, and roughly what it takes
it through pgp , which brings an entire OpenPGP implementation, rsa , aes , aes-gcm , aes-kw and the whole RustCrypto generation it was written against. That last part is why every cryptographic crate in this tree is held a generation - What is left, and roughly what it takes
back: moving them while pgp stays would compile two copies of each primitive into both binaries. The job is a reader for exactly what a detached signature over a text file is, the packet framing, the hashed subpackets, the RSA-PKCS1-v1.5 - What is left, and roughly what it takes
verify over SHA-256 and SHA-512, with nothing else in it, tested against the published signatures of every release and against the fixtures the fuzz targets already hold. When it lands, pgp and rsa leave the graph, RUSTSEC-2023-0071 leaves - What is left, and roughly what it takes
the exceptions with them, and the ten held majors move in one commit planned 20 155 Companion software installed for you, wherever that can honestly be done today VeilVoice detects ffmpeg, Audacity, GnuPG and the audio routing drivers, and - What is left, and roughly what it takes
offers a package-manager line where the platform has one and its own page where it does not. That leaves the Windows reader, who has no package manager by default, doing the most work on the platform where the routing driver matters most. - What is left, and roughly what it takes
This finishes the job per operating system and per architecture: the release that matches the machine, fetched and checked against its published hash before anything is run, then installed. Where the software has an installer of its own, - What is left, and roughly what it takes
that installer is run and the install path is its question to ask, because a program that answers it on the installer's behalf is a program guessing at somebody else's layout. Where there is no installer, the default location for the - What is left, and roughly what it takes
platform is used unless the person names another, and where a package manager already governs the machine it stays in charge rather than being worked around. Nothing proprietary is ever installed silently: its licence is still accepted by - What is left, and roughly what it takes
the person it binds. The same routes from veilvoice companions and from the About tab, one list, one implementation, with progress, cancel and resume as the existing installs already have planned 25 107 VeilVoice on a phone an Android - What is left, and roughly what it takes
package a person installs without developer tools, signed, published beside the desktop archives and verifiable the same way. Roadmap item 52 established that the code compiles for Android; what is missing is the NDK in the release build, - What is left, and roughly what it takes
a capture path that uses the platform's own audio rather than ALSA, a window that works at a phone's size, and a signing key that does not require a legal identity, all four set out in VeilVoice on a phone above along with why iOS is a - What is left, and roughly what it takes
separate question. It sits last because it is worth more once there is a Studio to put on the phone, and because none of the desktop work is waiting on it planned 10 - Blocked, and why that is not the same as late
Nothing is blocked. Five items were, some of them for months, each waiting on a decision rather than on effort, and they have been taken off rather than left to make the list look busy. The roadmap says what each one was, what it was - Blocked, and why that is not the same as late
waiting for, and why the reasoning is kept even though the item is gone. - Everything that is finished
In the order the roadmap lists it, under the heading it was asked for. Every item links to itself, so one can be sent to somebody on its own. - Shipped #
1 DSP engine: phase discard, many-to-one normalisation, CSPRNG modulation 2 Accent neutralisation, on by default 3 Cryptography: Argon2id, X25519+ML-KEM-768, XChaCha20-Poly1305 4 Encryption at rest, by default, plaintext never touching - Shipped #
disk 5 App lock: Argon2id verifier, persisted rate limit 6 Audio: device enumeration, live path, decode, WAV write 7 Metadata stripping: tags, EXIF/GPS, chunk-level RIFF cleaner 8 Microphone and camera monitor (Windows, Linux) 9 Tamper - Shipped #
detection, unprivileged half ( veilvoice-guard ) 10 Secure erase, with an honest account of flash storage 11 CLI and desktop app 12 Website, wiki, no-JavaScript edition, legal gate 13 Search over the whole repository and website, with a - Shipped #
static fallback 14 Portable release verifier needing no GnuPG ( veilvoice-verify ) 15 Install scripts for Windows, Linux and macOS 16 Reproducible signed releases on ten platforms 17 Four audit rounds: 47 defects found and fixed - In progress #
18 Documentation generator: a page, flowchart and banner for every crate and every .rs file, mirrored to the website and the GitHub wiki 20 Repository panel no longer shows a README's own markup as text 21 Write the missing module - In progress #
documentation for the 14 files that had almost none 22 Website split into a page per section, every published link still working 23 Motion and polish: smooth loading and scrolling, hover, CSS-first tooltips 24 Demonstration animation: a - In progress #
voice going in, the mark lighting up, an unidentifiable wave coming out 25 Cycling line of project facts, slow enough to read: CSS rather than an image, so it follows the reader's theme and needs no script 26 Every website theme in the - In progress #
app, plus user-defined palettes with contrast computed rather than assumed 27 Interactive workflow diagrams that open the relevant source, highlighted, in the site's palette 28 Randomised, user-configurable ratchet interval, with invalid - In progress #
input refused rather than clamped 30 Installer with a window: Tokyo Night, animated, and portable described as the normal case rather than as something missing 31 Optional companion setup: VB-CABLE on Windows, PipeWire on Linux, BlackHole - In progress #
on macOS, and Audacity everywhere, detected if present and installed only if confirmed 32 The site's search presented as an index , and animated - Security and monitoring features #
Each of these is a crate of its own, so that another project can depend on one without taking all of them. Every one is opt-in , and every one states what it cannot do as plainly as what it can. 33 Screen-capture detection: which recorders - Security and monitoring features #
are running, muted per program by an allowlist 35 Keyboard and mouse activity monitoring, reported as the heuristic it is 36 Ransomware canaries and mass-change rate detection, now veilvoice_guard::sentry 37 Learn what runs, then allowlist - Security and monitoring features #
it, with time-limited grants and a log, now veilvoice_watch::appctl 38 veilvoice-policy : settings sealed with the existing post-quantum cryptography, and shaped so they can only be tightened 39 Privileged mode: an opt-in service, and an - Security and monitoring features #
elevated no-service mode, with the difference visible to the user 40 Alert on driver and kernel-module installation; cross-view checks 65 Failsafe on by default: notice the moment another program picks up a real microphone while you are - Security and monitoring features #
being veiled, warn, and close it 41 Notification overlay: rounded, translucent, contrast computed, or an alert, or off 42 Duress and decoy passwords - Conversations, subtitles and video #
Asked for after v0.1.12. One recording, several speakers, each given a different voice and each voiceprint destroyed just as thoroughly; names and subtitles; and an optional video of the result. 46 Conversation mode tell the engine a - Conversations, subtitles and video #
recording holds more than one speaker, and give each a distinct voice while destroying every voiceprint 47 Up to ten speakers, each with a name, carried into the audio and into subtitles 48 A rolling seed per speaker , at a randomised - Conversations, subtitles and video #
interval inside a range the user sets, with no interval hardcoded and a fresh one at every launch 49 Video output the waveform, a circle per speaker in their palette colour or their own picture inside a coloured ring, a title, and a black - Conversations, subtitles and video #
or image background with padding 50 A preview of the video and of the voices before anything is generated 51 An asynchronous pipeline , every speaker rendering at once rather than in sequence 52 Every crate and every .rs file explained: - Conversations, subtitles and video #
the technical workflow in a paragraph, then the same thing in plain words 53 The website on mobile, and on every engine: not only the one it was written in 54 Seventh audit round across the whole tree, then the production deploy - Building it yourself, and proving the download matches #
Asked for after the conversation work. Today veilvoice-verify answers one question, is this download the one that was published , and answers it without GnuPG, without a network client of its own, and without ever holding a private key. - Building it yourself, and proving the download matches #
The request is to make the same program answer the harder question: is the published build the one this source produces , and to have it set your machine up so you can find out. The program is renamed veilvoice-setup-tools , because - Building it yourself, and proving the download matches #
checking a signature is then the smallest thing it does. 55 Build the whole repository from source , from the tool itself: find or install a toolchain, pin it to rust-toolchain.toml , and run the same build the release does 56 - Building it yourself, and proving the download matches #
Reproducibility check build here, hash what came out, and compare it against the published SHA256SUMS entry for this platform, saying which files matched and which did not 57 The hashes are trusted only after the signature is verify the - Building it yourself, and proving the download matches #
detached signature over SHA256SUMS against the project key before any hash from it is compared, and refuse rather than warn if it does not verify 58 Set the machine up per platform the build dependencies each operating system actually - Building it yourself, and proving the download matches #
needs, detected, named with who ships them, and installed only on an explicit yes 59 Custom install CLI, desktop app, or both, from a build you just made or from a download you just verified 60 Four verbosity levels nothing, minimal, - Building it yourself, and proving the download matches #
normal (the default) and everything, applied to every one of the above, with the exit status carrying the answer when the output carries nothing - Group mode, where you can see it #
The engine has handled several speakers since roadmap item 46. The desktop app has never shown it. These are about making the thing visible and usable rather than about the signal, which is already done. 61 Group mode in the desktop app , - Group mode, where you can see it #
shown as a mode rather than hidden in a flag: off by default, a toggle that does not persist, and a separate tick for "always start in group mode" 62 A name and a colour per speaker in the app the colour chosen automatically to be as - Group mode, where you can see it #
distinct as the number of speakers allows, overridable per speaker, and drawn from every palette the website offers 63 Live levels while a recording is running , in the app and in the terminal - Seeing it before you install it #
Asked for after v0.1.14. Everything here is about the same problem from two sides: somebody deciding whether to trust this, and somebody using it and not being sure it is working. Neither is answered by more features. 66 The live monitor - Seeing it before you install it #
what is going in and what is coming out, on every tab, on by default, and a preview that lets you hear yourself veiled before anybody else does 67 An interactive demonstration on the website the inside of the application and of the command - Seeing it before you install it #
line, laid out in the site's own colours, that a reader can click through before downloading anything 68 A frequently asked questions page , answering what gets asked rather than what is convenient to answer 69 A drawn graphic for every - Seeing it before you install it #
workflow chart coloured arrows, an explanation inside the picture, and every word wrapped rather than running off the edge 70 This roadmap, published as a page , with a picture of what is done and what is not, generated from this file so - Seeing it before you install it #
the two cannot disagree 71 A video of the roadmap , scrolling what is finished, with a short pause and a countdown before it repeats 72 The front page animation, in more depth the same picture, saying what the engine actually does to the - Seeing it before you install it #
signal rather than one word 73 A full security and functionality audit, and an optimisation pass, before the next deploy the whole tree, both halves, and the last thing that happens - The lock, the guard, and a window that does not stutter #
Asked for after the tenth audit round. Four of these are one subject seen from different sides: the app lock is the weakest control this project ships, it is described as such in several places, and the request is to make it as strong as - The lock, the guard, and a window that does not stutter #
it can honestly be made rather than to keep apologising for it. 74 The lock screen tells an attacker nothing no explanation of what the lock is or is not worth while it is locked, the account of that moved to the documentation and to the - The lock, the guard, and a window that does not stutter #
unlocked application, and a small animation in its place 75 veilvoice-guard inside the desktop application the integrity record taken at the first launch and checked at every one after, sealed under the app-lock passphrase where there is - The lock, the guard, and a window that does not stutter #
one 76 The app lock, hardened as far as it honestly goes an authentication tag under the passphrase, two copies with the spare administrator-owned where the platform allows it, restoration when one goes, randomised names and masked - The lock, the guard, and a window that does not stutter #
contents, and a report that only the passphrase can clear 77 What the lock is worth, written down properly one account, in the documentation, separating the parts that are real from the parts that are only obscurity 78 Every website - The lock, the guard, and a window that does not stutter #
palette in the application, chosen from the interface 79 A window that does not stutter the interface measured rather than described, every task off the drawing thread, and the smallest amount of code that does it - Finally #
44 Fifth audit round: every vulnerability class across the tree, twelve findings written up individually (F-48 to F-59) 45 v0.1.10 released ten platforms, signed, and verified by hand after publication 80 Ready for the release audit the - Finally #
RPM built, lintian run and its findings fixed, manual pages generated from the binaries, 32-bit re-run over the new code, and the parser campaign run over all six targets with a seed corpus kept - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath #
81 Find the encrypted volumes this machine already has detect an installed Cryptomator or VeraCrypt, and the vaults and mounted volumes each is offering, without asking either to do anything 82 Write veiled output into a chosen volume a - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath #
destination that is a Cryptomator vault or a mounted VeraCrypt volume, remembered, and used for every export 83 The hidden-volume question, asked properly asked before the first write, three answers, and a job that will not start until one - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath #
is given 84 The guided path, for when detection fails plain instructions, a folder chosen by hand, and the same confirmation a detected one gets 85 What full-disk encryption is for, said once and said properly BitLocker, FileVault, LUKS - Encrypted volumes: Cryptomator, VeraCrypt, and the disk underneath #
and LUKS2, and the OpenBSD and FreeBSD equivalents, single-sourced and shown in both 86 The app lock as a key, not only a verifier the app-lock passphrase seals everything VeilVoice veils, automatically, as an option that says what it costs - Asked for after the encrypted volumes #
87 A video of a veiled recording a black frame and the audio, so a recording can be posted where only video is accepted 88 Import from every format OBS writes bring in a recording made elsewhere, video or audio, and take the sound out of - Asked for after the encrypted volumes #
it 89 Veil the other person afterwards the interviewee given their own voice in post, through the group plan that already exists 90 GnuPG verification inside the window in the verify tab, beside the hash check, using the GnuPG somebody - Asked for after the encrypted volumes #
already has 91 veilvoice-verify finds the release itself GnuPG arguments where wanted, and an auto that looks in Downloads, checks the archive, and checks what came out of it 92 An autolock timeout on at half an hour, from five minutes to - Asked for after the encrypted volumes #
forty eight hours, chosen from a list or typed, with the range itself adjustable, and offered during first-run setup 93 Group mode explained where it is used how to build a plan, what each field does, and what happens without one 94 - Asked for after the encrypted volumes #
Release notes people can actually read every release listed newest first, its notes opening in place, and every file one click away 95 One version per release, in order, enforced the tag, the workspace and every package definition checked - Asked for after the encrypted volumes #
against each other before a release can go out 96 v0.1.15 released the audit run over everything since v0.1.14, CI green, and the release published 97 A verifier anybody can use, checking everything one press or one command checks the - Asked for after the encrypted volumes #
signature, the archive, every file you extracted, and then asks your own GnuPG the same question - The window, and the things that were marked done and were not #
Two rows above were marked done before they were, and this section exists partly to say so. Roadmap item 79 declared a window that does not stutter, twice: once when the drawing thread was cleared, and again when a repaint timer was - The window, and the things that were marked done and were not #
removed and the improvement measured. Both changes were real and neither reached the cause, which was the animated logo asking for another frame thirty times a second whether or not anybody could see it. Roadmap item 95 declared one - The window, and the things that were marked done and were not #
version per release enforced, and the enforcement covered the package definitions and not the twelve other places, the README's install block among them, that repeat the version by hand. Neither is being un-marked. What was done was done. - The window, and the things that were marked done and were not #
The correction is that a roadmap item means the work described happened, not that the symptom is gone, and this list is more useful if the difference is visible. 98 A window that is genuinely idle the cause of the frames found by asking - The window, and the things that were marked done and were not #
the toolkit rather than by reasoning, the animation stopped when the window is unfocused or being dragged, and the result measured to nothing 99 The window toolkit brought forward eframe and egui from 0.29 to 0.32, which is what made the - The window, and the things that were marked done and were not #
frames answerable, and the runtime dependency it added declared everywhere a package can declare it. Brought forward again to 0.36 with roadmap item 148, which took ttf-parser out of the dependency graph and one advisory out of the - The window, and the things that were marked done and were not #
exception list with it 100 What drew the window, reported the graphics choices named in the source with their reasoning, and the driver actually obtained shown on the About tab, read from the driver 101 Crash reports about the failure they - The window, and the things that were marked done and were not #
are about the closing note chosen from the panic rather than the same guess every time, and a missing system library named along with the package that carries it 102 One version, in one file every other copy derived from Cargo.toml or - The window, and the things that were marked done and were not #
checked against it, with the files that keep a history added to rather than rewritten 103 v0.1.17 released the idle window fixed at the cause, the toolkit upgraded, and the release published - Asked for while v0.1.17 was being built #
Everything here came from one message and they belong together: somebody arriving at this project for the first time, deciding whether to trust it, installing it, and finding their way around it without reading a manual. 104 A - Asked for while v0.1.17 was being built #
demonstration that is the program five recorded sessions of the real binaries replayed on the website, re-run and compared on every build, sitting with the screenshots 105 A crash report offered rather than buried the report already - Asked for while v0.1.17 was being built #
written to disk surfaced above whatever tab you land on, with what it contains listed and readable in full before anything leaves your machine, and the filing left to the person 106 A first run that explains itself one card per tab saying - Asked for while v0.1.17 was being built #
what it is for, skippable, and after an upgrade only the tabs that are new, with portable and installed said plainly on the last card 109 An obfuscated program folder VeilVoice's own files encrypted under names derived from the app-lock - Asked for while v0.1.17 was being built #
passphrase, with decoys among them, so the lock protects data rather than only a window 110 A first-run setup and tour the app lock, the recording passphrase and the autolock explained and offered once, skipping whichever is already set - Asked for while v0.1.17 was being built #
111 Host the website locally a script and an nginx config that serve website/ exactly as GitHub Pages does, so the site survives the repo or Pages going down, and is the audit surface during development 112 A self-signed code certificate - Asked for while v0.1.17 was being built #
beside the OpenPGP key: a detached, signed APPMANIFEST.json describing each binary, verify scripts for Unix and Windows, and an import tutorial, for organisations that want a known publisher without breaking reproducible builds - Asked for after v0.1.18 #
The app lock already turns the app-lock passphrase into a key that seals everything VeilVoice writes (roadmap item 86). This asks for the same protection one layer in: the secrets and the veiling state while they are in memory, so that a - Asked for after v0.1.18 #
program which can read another process's RAM, a rootkit or a debugger, reads ciphertext rather than a passphrase or a voiceprint. The honest limit is stated with the feature rather than after it. Data the CPU is actively working on has to - Asked for after v0.1.18 #
be plaintext for the instant it is used, and an attacker already running as the kernel can wait for that instant. This raises the cost of an external read and narrows the window to nearly nothing; it does not claim to beat an adversary who - Asked for after v0.1.18 #
already owns the machine. 116 One author in the history, and every commit verified the commit history rewritten so the whole of it matches the attribution rule this project already states, with the assistance credit staying exactly where a - Asked for after v0.1.18 #
reader looks for it (the README, and the footer of every page) rather than in a trailer that makes a second contributor of it, and every commit in the history signed rather than only the recent ones. Trees are unchanged by this, so - Asked for after v0.1.18 #
reproducible builds still verify 117 A demonstration you navigate rather than watch the website's demo driven by the reader, a header per part of the program, and the screenshots for whichever they pick shown under it. Not an imitation of - Asked for after v0.1.18 #
the interface: the real captures, the ones the build already regenerates and compares, so the demo cannot drift from the program. The command line gets the same treatment, a worked case per thing somebody actually wants to do, explained - Asked for after v0.1.18 #
rather than listed, and the site's own Demo link lands on the section and opens it 121 A studio vault that needs both keys every recording the studio makes, and every preview it holds, sealed into one vault whose key exists only when the - Asked for after v0.1.18 #
app lock and the at-rest passphrase have both been given. Neither alone derives it, so a stolen laptop with the app unlocked opens nothing and a known recording passphrase without the app opens nothing. The same post-quantum sealing used - Asked for after v0.1.18 #
everywhere else, held in the same page-locked, zeroizing memory, with the same honest report of how much the operating system actually agreed to lock 122 Recording Studio the place recording happens, with the vault already open because - Asked for after v0.1.18 #
both locks were answered on the way in. Capture, monitor, preview and re-take without a plaintext file existing at any point, and a session that locks itself back up when the app does rather than staying open behind a screensaver 123 - Asked for after v0.1.18 #
Recording Browser everything in the vault, listed with what it is and when it was made, played back by decrypting into page-locked memory rather than writing a temporary file somebody would have to remember to shred, whole rather than in - Asked for after v0.1.18 #
blocks because the container is sealed and authenticated as one piece. Export is a deliberate act with its own warning, because leaving the vault is the moment the protection ends 124 Neither lock alone, proven rather than asserted tests - Asked for after v0.1.18 #
that open the vault with each secret in turn and get nothing, and a stated account of what the pair does and does not buy. It raises the cost of a stolen machine and of a guessed passphrase; it does not defeat somebody watching the process - Asked for after v0.1.18 #
while both are entered, and that is written beside the feature rather than left implied 125 A pass for size and speed, changing no behaviour the tree read for the things that accumulate rather than the things that break. Work repeated that - Asked for after v0.1.18 #
could be done once, allocations in paths that run per frame or per sample, generic code instantiated more times than it needs to be, dependencies pulled in for one function, and anything the compiler is doing twice. Measured before and - Asked for after v0.1.18 #
after, with the binary size and the timings recorded, and not one behavioural change : every test that passed before passes after, or the change is reverted rather than argued for 126 Written so the next pass finds nothing the practices - Asked for after v0.1.18 #
that pass established are now the standing way this project is written, in CLAUDE.md , and three of the four are enforced by a build rather than by somebody remembering. No audio callback may allocate, block or print, checked by a test - Asked for after v0.1.18 #
that reads the callbacks themselves rather than by the comments that said so. Every dependency says what it is for on the line that declares it, checked in CI, which found three on its first run that no line of code referred to. Work that - Asked for after v0.1.18 #
can be done once is done once, and the comment says what made it constant. The fourth is a habit and is written as one: the reading happens before the push rather than to the tree once a year 128 The BSDs built twice, like everywhere else - Asked for after v0.1.18 #
FreeBSD, OpenBSD and NetBSD were the three platforms whose archives said not-verified (built once, in a VM) , which was honest and was the only gap in the reproducibility claim. The second build now happens in the same VM, with the same - Asked for after v0.1.18 #
path remapping and the same SOURCE_DATE_EPOCH the other ten platforms use, and the verdict is published in the release notes beside theirs. At v0.1.19 all three reported reproducible , so every one of the eleven targets is now verified - Asked for after v0.1.18 #
rather than eight of them. A BSD that stops reproducing says so in those words rather than quietly dropping the line 129 A BSD reader can check their own copy either way all three routes written up per system rather than left as a Linux - Asked for after v0.1.18 #
instruction somebody has to translate. veilvoice verify on its own is the same command everywhere and needs no GnuPG, no network and none of the system's own tools, which is why it is offered first and why a BSD reader has nothing to - Asked for after v0.1.18 #
translate. The second opinion is where systems differ, and the script now has a spelling for the BSDs: it had two, Linux and macOS, and a BSD reader fell through to the Linux one and was told to run a command this project's other script - Asked for after v0.1.18 #
says they do not have. That is F-167. The guide's table of which system runs what is checked against the program rather than typed beside it, and the one command this project has not run on a BSD, installing GnuPG on OpenBSD and NetBSD, is - Asked for after v0.1.18 #
left unsaid rather than guessed 130 The live scramble moved into the Studio veiling as it runs has stopped being a separate tab and is what the Studio does. It was already the same engine, the same ratchet and the same virtual-cable - Asked for after v0.1.18 #
routing on both screens, which is what made two of them wrong rather than merely redundant: the Studio recorded with whichever devices the other tab happened to be set to, and each tab started a session of its own, so veiling on one and - Asked for after v0.1.18 #
recording on the other opened the same microphone twice. There is one starter now. The tab is the voice above and the take below, the voice half works with the vault shut because veiling a call has never needed a vault, and ending a take - Asked for after v0.1.18 #
leaves the veiling running 131 Both sides of the glass, kept or discarded the Studio asks which of the two to keep before the button rather than after it, because a recording of somebody's real voice is not a thing to discover having made. - Asked for after v0.1.18 #
The veiled voice is selected, both is offered, and the microphone on its own is offered last. Anything that keeps the microphone says so in the same words the plaintext path uses: it is sealed in the vault as strongly as anything else, and - Asked for after v0.1.18 #
it is still a recording anybody who opens the vault can hear who was speaking in. Never remembered between runs and reset when the window locks, for the reason group mode is not remembered. Both kept means two takes, the unveiled one named - Asked for after v0.1.18 #
for it. The command line's record keeps the veiled voice only and is unchanged: this is a Studio decision, made where the vault that receives it is 132 Audio that says when it was interfered with a device swapped or unplugged mid-session, - Asked for after v0.1.18 #
and another program taking the microphone while a take is running, are noticed and shown rather than recorded silently. This row used to open by asking for the samples reaching the recorder to be checked against what the engine produced. - Asked for after v0.1.18 #
They cannot differ: the recorder is fed from inside the output callback, from the same slice the engine has just written into, so that check is a buffer compared with itself. It is a property to keep rather than one to measure, and a test - Asked for after v0.1.18 #
reads the source for it. What was actually missing was the report: the platform announces a stream error on a callback of its own, and both of them were eprintln! and nothing else, so on Windows, where the window has no console, a - Asked for after v0.1.18 #
microphone unplugged mid-call was silent. Stated limit up front, beside the report rather than after it: this notices interference with VeilVoice's own path and cannot vouch for a microphone that was already lying 133 Every guest, veiled - Asked for after v0.1.18 #
and plain, side by side two bars per speaker in group mode, what went into each of their turns and what the engine produced from it, drawn as the render walks the file. One bar answers "is something being written" and not "is this person - Asked for after v0.1.18 #
being veiled", which is the question somebody rendering an interview is asking; two that move differently are the only thing on screen showing the engine is between them. Under them, in the same words the live meters use, what they cannot - Asked for after v0.1.18 #
show. The row asked for this live , and that half is roadmap item 147: group mode works on a recording that already exists and never opens a device, the live path opens one input, and there is no per-guest live signal in this tree to draw.
website/robots.txt
- line 1
# VeilVoice. Everything here is meant to be read. User-agent: * Allow: / Sitemap: https://tilas01.github.io/veilvoice/sitemap.xml
website/search.html
- The index
Every file this repository tracks, meaning the Rust source and its doc comments, the documentation, this website, the tests, the build and the licence texts split into sections and indexed. Results link to the exact line on GitHub or the - The index
exact section on this site. Search VeilVoice Sort results by relevance by path by file name by file length Filter by kind Filter by area Loading the index… Nothing matched. Try a shorter word, or clear the filters, because the index holds - The index
headings, doc comments and section text, so a phrase from the middle of a paragraph usually finds it. This page's live search needs JavaScript, and you do not have it. That is a supported way to read this site, so the search is not simply - The index
missing: the complete static index lists every file and every section in the project, in full, on one page. Nothing loads and nothing runs, and your browser's own find-in-page (Ctrl+F, or Cmd+F on a Mac) searches it. It is generated from - The index
the same walk of the repository as the index this page would have used, by tools/search-index/generate.py , so the two cannot disagree about what is in the project. - Without JavaScript, and why it is a real answer
The complete static index carries the same entries as this page, rendered as ordinary HTML: every file, grouped by kind, with each file's sections and the opening text of each one. It is the same index, not a summary of it, because both - Without JavaScript, and why it is a real answer
come out of tools/search-index/generate.py in the same pass, which is what stops them drifting apart. A search box that quietly does nothing without JavaScript is the kind of silent degradation this project audits itself against, so it is - Without JavaScript, and why it is a real answer
worth being plain about the trade: the static page cannot rank or filter, and it is a large page because the whole corpus is in it. What it can do is find things, with no code running at all. - What is actually indexed
Worth stating precisely rather than rounding up to "everything", because a search box that quietly does not look at most of the corpus answers "no results" with exactly the same confidence as one that does. Documentation and this website : - What is actually indexed
every heading, and all of the prose under it. Complete. JavaScript, CSS, the build files and the licence texts the whole file. Complete. Rust : every item ( fn , struct , enum , trait , mod …) by name, together with its doc comment. Not - What is actually indexed
the function bodies. The doc comments in this codebase are the argument for the code, and are the part worth searching; searching for a local variable will not find it. This is the one deliberate gap. Where a section is longer than one - What is actually indexed
result can show, it is split into several sections rather than cut short, because the limit is on how much a result displays , not on how much is searched. - How the ranking works
Deliberately simple enough to describe in a sentence, because a search that cannot be explained cannot be checked. Every term you type has to appear somewhere in a section for it to be a result at all, so there are no partial matches that - How the ranking works
quietly rank low. Beyond that, a match in a heading counts for more than one in the body, a match in the file's own name counts for more than one in a directory along its path, and documentation gets a small nudge above tests. Ties break - How the ranking works
on path and line, so the list does not reshuffle itself between keystrokes. The index is committed to the repository and regenerated in CI, which compares the result byte for byte. An index that has drifted away from the tree fails the - How the ranking works
build rather than shipping and answering questions about code that no longer looks like that. VeilVoice · GPL-3.0-or-later · by tilas01 on GitHub . home · wiki · static index Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Written and - How the ranking works
maintained by tilas01 , who holds the copyright. Drafted with help from Claude, Anthropic's assistant, and reviewed, built and tested before release.
website/sitemap.xml
- line 1
<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url><loc>https://tilas01.github.io/veilvoice/404.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/crypto.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/download.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/faq.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/guide.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/</loc></url> <url><loc>https://tilas01.github.io/veilvoice/nojs/</loc></url> <url><loc>https://tilas01.github.io/veilvoice/nojs/search.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-container_header.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-container_header.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-guard_manifest.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-guard_manifest.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-hybrid_keys.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-hybrid_keys.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-lock_file.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-lock_file.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-release_contents.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-release_contents.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-wav_chunks.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-wav_chunks.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-wav_preflight.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/fuzz/fuzz_targets-wav_preflight.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-css-main-css.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-css-themes-css.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-demo-data-js.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-legal-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-markdown-js.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-prefetch-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-repo-js.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-reveal-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-search-js.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-sessions-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-teleport-js.html</loc></url> - line 1
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-theme-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-verify-js.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/source/website-js-walkthrough-js.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/devices.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/devices.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/io.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/io.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/lib.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/live.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/live.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/meter.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/meter.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/playback.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/playback.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/record.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/record.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/room.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-audio/room.src.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/accel.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/accel.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/appctl.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/appctl.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/atrest.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/atrest.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/capture.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/capture.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/conversation.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/conversation.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/decoy.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/decoy.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/failsafe.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/failsafe.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/guard.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/guard.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/gui.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/gui.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/input.html</loc></url> - line 41
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/input.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/lock.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/lock.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/main.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/main.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/mandate.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/mandate.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/meter.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/meter.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/policy.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/policy.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/priv_mode.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/priv_mode.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/record.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/record.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/sentry.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/sentry.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/theme.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-cli/theme.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/edit.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/edit.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/lib.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/mode.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/mode.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/plan.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/plan.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/render.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/render.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/subtitles.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-conversation/subtitles.src.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/accent.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/accent.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/chain.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/chain.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/effects.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/effects.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/examples-spectrum_report.html</loc></url> - line 81
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/examples-spectrum_report.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/examples-veil_a_buffer.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/examples-veil_a_buffer.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/lib.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/lib.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/modulation.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/modulation.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/pitch.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/pitch.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/spectral.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/spectral.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/stft.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/stft.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/tests-hostile_audio.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/tests-hostile_audio.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/voices.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/voices.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/window.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-core/window.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/aead.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/aead.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/amnesia.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/amnesia.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/container.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/container.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/decoy.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/decoy.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/examples-seal_and_open.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/examples-seal_and_open.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/hoard.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/hoard.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/hybrid.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/hybrid.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/kdf.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/kdf.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/lib.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/lock.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/lock.src.html</loc></url> - line 121
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/privatefile.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/privatefile.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/shred.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/shred.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/studio.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/studio.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tape.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tape.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tests-parser_fuzz.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tests-parser_fuzz.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tests-timing.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/tests-timing.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/vault.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/vault.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/weave.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-crypto/weave.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/blame.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/blame.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/lib.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/lib.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/manifest.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-guard/manifest.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/app.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/app.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/autolock.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/autolock.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/avnotice.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/avnotice.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/crashlog.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/crashlog.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/crashreport.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/crashreport.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/decoys.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/decoys.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/dialog.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/dialog.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/firstrun.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/firstrun.src.html</loc></url> - line 161
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/graphics.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/graphics.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/group.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/group.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/integrity.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/integrity.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/layout.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/layout.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/lib.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/main.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/main.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/monitor.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/monitor.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/notify.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/notify.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/pace.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/pace.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/palettes.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/palettes.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/paths.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/paths.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/policy.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/policy.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/prefs.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/prefs.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/reduced_motion.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/reduced_motion.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/security.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/security.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/settings.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/settings.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/setup.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/setup.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/soundbar.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/soundbar.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/storage.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/storage.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/studio.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/studio.src.html</loc></url> - line 201
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/theme.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/theme.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/tour.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/tour.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/updates.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/updates.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/vault_store.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/vault_store.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/verify.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/verify.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/watchfeed.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/watchfeed.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/window.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-gui/window.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/audio.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/audio.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/image.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/image.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/lib.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/lib.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/tests-wav_fuzz.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/tests-wav_fuzz.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/wav.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-meta/wav.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/lib.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/mandate.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/mandate.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/policy.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/policy.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/workspace.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-policy/workspace.src.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/companions.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/companions.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/install.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/install.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/lib.html</loc></url> - line 241
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/lib.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/space.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/space.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/update.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/update.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/volumes.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-setup/volumes.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/builder.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/builder.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/deps.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/deps.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/discover.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/discover.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/extracted.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/extracted.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/fetch.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/fetch.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/lib.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/report.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/report.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/tests-release_manifest.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/tests-release_manifest.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/tests.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-verify/tests.src.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/accel.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/accel.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/ffmpeg.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/ffmpeg.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/font.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/font.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/frames.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/frames.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/lib.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/lib.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/page.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/page.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/palette.html</loc></url> - line 281
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/palette.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/raster.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/raster.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/size.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/size.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/waveform.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-video/waveform.src.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/appctl.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/appctl.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/examples-scan_once.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/examples-scan_once.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/input.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/input.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/lib.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/lib.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/linux.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/linux.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/privilege.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/privilege.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/proc.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/proc.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/windows.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/reference/veilvoice-watch/windows.src.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/releases.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/roadmap.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/search.html</loc></url> - line 321
<url><loc>https://tilas01.github.io/veilvoice/verify.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/what.html</loc></url> <url><loc>https://tilas01.github.io/veilvoice/wiki.html</loc></url> </urlset>
website/verify.html
- Verify a download
Check that what you downloaded is what was published, in your browser or with the portable verifier. This section is also part of the front page , where it sits in context with the rest. Drop the file you downloaded here. It is hashed - Verify a download
locally, in your browser , using the built-in WebCrypto API, so there is no upload and no server that could receive it. Read js/verify.js ; that file is the whole implementation. click or drop a release archive here no file hashed yet - Verify a download
Paste the expected hash, or a whole line from SHA256SUMS : - The stronger check: the signature
A hash proves the file matches a list. The signature proves the list came from the maintainer. Browsers cannot verify OpenPGP, so this part runs on your machine: gpg --import veilvoice-signing-key.asc gpg --verify SHA256SUMS.asc SHA256SUMS - The stronger check: the signature
sha256sum -c SHA256SUMS --ignore-missing Signing key fingerprint. Check that gpg --verify names this exact key, not merely “a good signature”: 8101 FB3B B28D 02FB 239E 0CDF 9CC1 C7E7 A9B5 833A download the public key . The user ID is - The stronger check: the signature
exactly tilas01 , with no e-mail address attached. - Or let it do all of that for you
veilvoice verify is built into the program itself: it ships in every release because veilvoice does, and it needs no installer and no separate download. Open a terminal in the folder you downloaded to and run it, or open the desktop - Or let it do all of that for you
application's verify tab and drop the archive on the window. One press does all four steps: the signature over SHA256SUMS ; the archive, against SHA256SUMS ; CONTENTS.sha256 , against SHA256SUMS ; every file you extracted , against - Or let it do all of that for you
CONTENTS.sha256 , and it names anything in that folder the release never published. Step 4 is the one worth having. A hash over the archive tells you the zip is genuine; this tells you the program you are about to run is. Releases before - Or let it do all of that for you
v0.1.15 carry no contents list and are checked as far as step 2, which it says at the time. If GnuPG is on your machine it is used as well: the key is added to your keyring, gpg --verify is run, and what GnuPG said is shown. The signature - Or let it do all of that for you
is then checked by two independent implementations. The commands above are still printed for you to run yourself, because a program telling you a download is genuine came out of that download, only you typing them makes the answer - Or let it do all of that for you
independent of it. Every one of those commands, written out with what each answer proves , sits with the release you are downloading: the whole check in one line, the signature over the hash list on its own, one file against that list, one - Or let it do all of that for you
file against a hash with no list at all, the desktop application's three slots, and the build that answers the harder question. Every command there is checked against the program's own help when the page is generated. VeilVoice · - Or let it do all of that for you
GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is usually provided by VB-CABLE, which is proprietary donationware and is not bundled: - Or let it do all of that for you
install it separately if you want it. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
website/what.html
- What VeilVoice does
The eight things it does, and the honest scope of each. This section is also part of the front page , where it sits in context with the rest. - Anonymise a recording
wav, mp3, flac, ogg, m4a in, a clean WAV out, with metadata stripped. Roughly 90× faster than real time. - Scramble a microphone live
Route the veiled voice into a virtual audio cable and every application on the machine, whether calls, streams or recorders, receives it instead of you. - Encrypt at rest, by default
Every recording is sealed as it is written, using an X25519 + ML-KEM-768 hybrid , so one captured today is not readable by a quantum adversary tomorrow. Turning that off makes you read why first. - Lock the app
A separate password gates the desktop app, rate limited and Argon2id -derived. It stops someone who picks up your unlocked computer. It is not tamper-proof, and the unlock screen says so. - Strip metadata
Audio tags, image EXIF and GPS. A de-identified voice is worthless if the file still says who recorded it, where, and on what. - Work as a Rust library
Every crate is a normal dependency. The engine is allocation-free and safe to call from inside an audio callback. - Transcribe without giving up your voice
Speech-to-text needs the words, not the voiceprint. Anonymise first and the service gets speech it can transcribe and a voice belonging to nobody. - See what is listening
Which applications are holding your microphone or camera, right now, with an alert the moment one starts. De-identifying a call achieves little if a second program is recording the raw microphone beside it. macOS exposes no interface for - See what is listening
this, so nothing is reported there rather than something guessed. - Detect tampering with its own files
A signed manifest of what VeilVoice should be, and a check that reports what changed. Where the system's own auditing allows it, it names the program responsible, and says plainly when it cannot see, rather than implying nothing happened. - Erase a recording
Overwrite and unlink, with an honest account of what that is worth. On flash storage the controller may have written the data somewhere the filesystem can no longer reach, so this is not a guarantee and is not described as one. - Verify a download without GnuPG
veilvoice verify is part of the program you downloaded, and the desktop application's Verify tab runs the same code. The signing key is compiled in, so it checks the signature over the hash list and then the file on a machine with no GnuPG - Verify a download without GnuPG
and no network. It distinguishes a download being intact from a build being reproducible , because those are different claims. Honest scope. “Fill the spectrogram with noise” and “stay understandable” are mutually exclusive, because noise - Verify a download without GnuPG
that covers the voice covers the words. VeilVoice targets the achievable goal: irreversible speaker de-identification with intelligibility preserved on purpose . If the message must also be secret, encrypt it; that is a separate problem - Verify a download without GnuPG
with a separate answer. The same applies to accent . Its melody and colour do not survive. What no signal-level transform can change is which phonemes you produced , and at that level the accent and the words are the same thing, so a - Verify a download without GnuPG
strong regional accent may still be audible. VeilVoice · GPL-3.0-or-later · source · wiki · legal · no-javascript version Signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Virtual audio routing on Windows is usually provided by - Verify a download without GnuPG
VB-CABLE, which is proprietary donationware and is not bundled: install it separately if you want it. Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
website/wiki.html
- Wiki
Everything worth knowing, in the order you will want it. Getting started The desktop app Command line Live scrambling Virtual audio cables Recording, use Audacity Private transcription Encryption The app lock Self-destruct Verifying a - Wiki
download How it works What it will not do Building from source Using it as a library FAQ - GETTING STARTED
Download a build for your platform, or compile it, since a fresh clone needs no secrets and no configuration. Get the archive from the releases page . Verify it . This takes ten seconds and is the difference between running the software - GETTING STARTED
and running whatever arrived. Unpack it. There are two programs: veilvoice (command line) and veilvoice-gui (desktop app). Nothing installs a service, writes to a registry, or phones home. Delete the folder and it is gone. - THE DESKTOP APP
veilvoice-gui is the whole tool in a window. Five tabs, no menus, no settings file to find. - anonymise file
Choose a recording and press anonymise . Everything on this tab is a choice you can see: Control What it does intensity How far pitch and formants are pushed from the original, 0.0–1.0. Default 1.0, which is full normalisation. neutralise - anonymise file
accent and intonation On by default. Maps every speaker onto one canonical register and vocal tract. Turning it off is weaker de-identification, so do it only if you know why. seed roll (s) How often the modulation stream ratchets forward. - anonymise file
Default 2 s; 0 keeps one stream for the whole session. Inaudible by construction. strip metadata from the result On by default. Removes tags from the written file. encrypt the result at rest On by default. See below. - At-rest encryption
The result is sealed as it is written, so a file you name clean.wav lands as clean.wav.veil . Two ways to seal it: passphrase : Argon2id at 256 MiB. Set it once and it is held for the session; the change button clears it. Locking the app - At-rest encryption
clears it too. public key : X25519 + ML-KEM-768 hybrid, to a .pub file from veilvoice keygen . No passphrase to type and nothing to forget; only the matching private key opens it. The anonymise button stays disabled until there is - At-rest encryption
something to encrypt with. That is deliberate: a tool that quietly wrote plaintext because you had not filled a field in yet would make the default worthless. Unticking the box opens a dialogue that has to be answered before anything - At-rest encryption
changes. The result is still a recording of every word that was said, and on flash storage deleting it afterwards is not a reliable fix, so the question is asked once, plainly, rather than hidden behind a preference. - live scramble
Pick an input and an output device and press start . A virtual audio cable is preselected as the output when one is installed, because routing there is what lets other applications hear the veiled voice at all; if none is found the page - live scramble
warns you rather than silently sending it to your speakers. Level meters, processing time per block, engine latency and a glitch counter are shown live. - monitor
Which applications are holding your microphone and camera, with a running log of starts and stops. The indicator rides the header on every tab, because a warning you have to go looking for is not doing its job. On a platform that cannot - monitor
see this, because macOS exposes no public interface, the tab says so explicitly. An empty list from a blind monitor is a false reassurance, and is never presented as good news. - lock
Set, change or remove the app lock, and lock the app immediately. See the app lock below for what it is worth. The header also carries a lock button, which locks and clears the session passphrase in one action. - about
Version of every crate, the licence, the typeface actually in use, and two short paragraphs stating what VeilVoice protects and what it does not. The limits are in the app, not only in the documentation. - COMMAND LINE
veilvoice anonymise recording.mp3 -o clean.wav # writes clean.wav.veil, sealed veilvoice live --output "CABLE Input (VB-Audio Virtual Cable)" veilvoice devices veilvoice clean photo.jpg veilvoice encrypt secret.wav veilvoice decrypt - COMMAND LINE
secret.wav.veil -o secret.wav veilvoice keygen veilvoice lock set veilvoice info Every command takes --help . Useful flags on anonymise and live : Flag Effect --intensity 0.0–1.0 How far pitch and formants move from the original. Default - COMMAND LINE
1.0. --keep-accent Leaves intonation, accent and vocal tract intact. Weaker de-identification; use only if you know why. --clean-metadata false Keeps the tags on the written file. On by default. --encrypt false Writes the recording in the - COMMAND LINE
clear. On by default; turning it off prints what you are giving up and waits for you to type UNENCRYPTED . --encrypt-to key.pub Seals to a recipient's hybrid public key instead of a passphrase. Where did my WAV go? anonymise seals its - COMMAND LINE
result, so -o clean.wav produces clean.wav.veil . Open it with veilvoice decrypt clean.wav.veil -o clean.wav . The words survive de-identification on purpose, so an unencrypted result is still a recording of everything that was said, which - COMMAND LINE
is why this is the default rather than an option you have to find. - LIVE SCRAMBLING
Live mode reads a microphone, de-identifies in real time, and writes to an output device. Latency is about 21 ms of algorithmic delay plus your hardware buffers, and processing costs roughly 1 % of one core. veilvoice devices # see what is - LIVE SCRAMBLING
available veilvoice live # picks a virtual cable automatically if one exists Point another application at the same cable as its microphone and it receives the veiled voice. If you select your speakers as the output instead of a cable, you - LIVE SCRAMBLING
will hear yourself and so will nobody else. That is useful for testing and useless for a call. - VIRTUAL AUDIO CABLES
A virtual cable is a loopback device: what one program plays into it, another can record from. VeilVoice does not bundle one, because they are separate projects with their own licences. Platform Usual choice Licence Windows VB-CABLE, from - VIRTUAL AUDIO CABLES
vb-audio.com Proprietary donationware macOS BlackHole GPL-3.0 Linux PipeWire or a PulseAudio null sink, already installed Free software On Linux nothing extra is needed: pactl load-module module-null-sink sink_name=veilvoice VeilVoice - VIRTUAL AUDIO CABLES
detects a cable by name and offers it first. If the installer ever offers to fetch VB-CABLE for you, it is an opt-in tick box that you must actively agree to, because it is proprietary software from a third party. - RECORDING, USE AUDACITY
VeilVoice deliberately does not include a recording studio. Audacity already is one, it is free software, it is mature, and it is audited by far more people than this project will ever be. Writing a worse one would help nobody. The - RECORDING, USE AUDACITY
workflow: Record and edit in Audacity . Export as WAV. Run it through VeilVoice: veilvoice anonymise take.wav -o safe.wav . Or record through VeilVoice live, by setting a virtual cable as Audacity's input. The installer may offer to fetch - RECORDING, USE AUDACITY
the current Audacity for your platform. Like the cable, it is an opt-in tick box. VeilVoice will not install third-party software you did not ask for. A licensing note for contributors. Audacity is GPL-2.0-or-later, which is not compatible - RECORDING, USE AUDACITY
with this project's GPL-3.0-or-later in the direction of copying code in . Recommend it, integrate with it, do not lift source from it. - PRIVATE TRANSCRIPTION
Speech-to-text needs the words. It does not need your voiceprint, but every cloud transcription service receives one anyway, and a voiceprint is as durable an identifier as a fingerprint. Anonymise first, then upload: veilvoice anonymise - PRIVATE TRANSCRIPTION
dictation.wav -o safe-to-upload.wav The service gets speech it can transcribe and a voice belonging to nobody. Two caveats. Accuracy drops somewhat, because recognisers are trained on natural speech and the output is synthetic. And the - PRIVATE TRANSCRIPTION
words still go to the provider . This protects your identity, not the content. If the content is sensitive, transcribe locally instead; whisper.cpp reads the WAV VeilVoice writes with no extra work. - ENCRYPTION
De-identification and secrecy are different problems. VeilVoice hides who spoke; encryption hides what was said. veilvoice encrypt notes.wav # password, Argon2id veilvoice keygen # hybrid post-quantum key pair veilvoice encrypt notes.wav - ENCRYPTION
--to recipient.pub veilvoice decrypt notes.wav.veil -o notes.wav --key veilvoice.key Private keys generated by keygen are themselves stored encrypted, under a passphrase, in the same container format: a stolen key file is worth nothing - ENCRYPTION
without it. Since anonymise now seals its own output, encrypt is for everything else: a file you already have, a note, a key you want to move. - THE APP LOCK
VeilVoice can sit behind a password of its own, so that someone who picks up your unlocked computer cannot open it, see which files you have processed, or start a live scramble. veilvoice lock set # choose a password veilvoice lock status - THE APP LOCK
# is one set, and is it rate limited right now? veilvoice lock change veilvoice lock remove The desktop app has the same controls under its lock tab, and a lock button in the header that locks it immediately and clears the session - THE APP LOCK
passphrase with it. Use a different password from the one you use for recordings. They are separate on purpose: opening the app should not be the same act as unsealing everything it has written. VeilVoice domain-separates the two - THE APP LOCK
derivations, so even typing the same string in both places does not produce two copies of one value, but it does mean one guess opens both, so do not. This is not tamper-proof, and cannot be. A local application has nowhere to hide a - THE APP LOCK
secret from the machine it runs on. Anyone who can write to your files can delete the lock file; anyone holding the disk can edit the attempt counter, move the clock to defeat the wait, or attack the stored password hash offline. What it - THE APP LOCK
does buy is real: it stops casual access . Three attempts are free, then the wait doubles: 5 s, 10 s, 20 s, up to fifteen minutes. The count is written to disk, so killing the app does not reset it. Argon2id at 256 MiB makes each offline - THE APP LOCK
guess expensive, which helps a good passphrase and does not save a bad one. If the threat is someone with your disk, the answers are full-volume encryption (LUKS, BitLocker, FileVault) and the at-rest encryption above. Not this. - SELF-DESTRUCT
For recordings that must not survive: a secure-erase that overwrites the file's contents before unlinking it, rather than just removing the directory entry. This is irreversible and there is no undo. It is opt-in and gated behind an - SELF-DESTRUCT
explicit confirmation for that reason. Be aware of the honest limit: on an SSD, wear levelling means the drive may keep a copy of the old blocks where no software can reach them. Overwriting is effective on spinning disks and is - SELF-DESTRUCT
best-effort everywhere else. Full-disk encryption is the real answer. If the volume is encrypted, destroying the key destroys everything on it at once. - VERIFYING A DOWNLOAD
Two independent checks ship with every release. - 1. The hash: is this the file that was published?
sha256sum -c SHA256SUMS --ignore-missing Or use the in-browser verifier , which hashes locally and uploads nothing. - 2. The signature: was that list published by the maintainer?
gpg --import veilvoice-signing-key.asc gpg --verify SHA256SUMS.asc SHA256SUMS Check the fingerprint is exactly: 8101 FB3B B28D 02FB 239E 0CDF 9CC1 C7E7 A9B5 833A “Good signature” from an unexpected key means nothing. The fingerprint is the - 2. The signature: was that list published by the maintainer?
part that matters. - 3. The strongest check: rebuild it yourself
Release binaries are reproducible: build the tagged commit and you get byte-identical output. git checkout v0.1.1 export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) cargo build --release --locked sha256sum target/release/veilvoice - HOW IT WORKS
Three mechanisms, each individually lossy. Undoing the output means defeating all three. - Phase is discarded, every frame
Each analysis frame keeps only its magnitude spectrum; the measured phase, which encodes the exact waveform and the speaker's micro-timing, is thrown away and a synthetic one generated. This is deletion, not obfuscation: infinitely many - Phase is discarded, every frame
waveforms share any magnitude spectrogram. - Every speaker is collapsed onto one identity
Pitch register, vocal-tract length and long-term spectral tilt are each mapped to a single canonical value. These mappings are many-to-one : a whole population lands on the same output, so there is nothing to invert. Every correction is - Every speaker is collapsed onto one identity
drawn from a multi-second average, never the current frame, because per-frame spectral shape is what distinguishes /i/ from /u/, and normalising that would erase the vowels along with the accent. The test suite asserts vowel contrast - Every speaker is collapsed onto one identity
survives. - The remainder is CSPRNG-modulated
The residual transform changes every frame from a ChaCha20 stream whose seed comes from the OS CSPRNG, lives only in page-locked memory, and is zeroized on drop. There is no fixed transform to undo. Full argument, including the threat - The remainder is CSPRNG-modulated
model: the whitepaper . - WHAT IT WILL NOT DO
Read this part twice. Misunderstanding it is the only way this software gets someone hurt. It does not hide what you said. The words are preserved on purpose and can be transcribed. It does not fully remove a strong accent. The melody - WHAT IT WILL NOT DO
goes; which phonemes you produced cannot be changed by any filter. It does not sanitise the background. Room acoustics, other voices, a passing siren. It does not hide your speaking rate or rhythm. A weak biometric, but a real one. It does - WHAT IT WILL NOT DO
not help against an attacker already running code on your machine. It does not clean the filename , the filesystem timestamps, or the channel you send it over. The app lock is not tamper-proof. It stops someone who sits down at your - WHAT IT WILL NOT DO
unlocked session. It does not stop someone holding the disk, who can delete the lock file or attack the stored hash offline. - BUILDING FROM SOURCE
git clone https://github.com/tilas01/veilvoice && cd veilvoice cargo build --release The toolchain is pinned by rust-toolchain.toml and rustup installs it automatically. On Debian or Ubuntu you will need the audio and windowing headers - BUILDING FROM SOURCE
first: sudo apt install libasound2-dev libudev-dev pkg-config \ libgtk-3-dev libxkbcommon-dev libwayland-dev - USING IT AS A LIBRARY
[dependencies] veilvoice-core = { git = "https://github.com/tilas01/veilvoice" } veilvoice-audio = { git = "https://github.com/tilas01/veilvoice" } The engine allocates nothing in process() and is safe to call from inside an audio - USING IT AS A LIBRARY
callback: use veilvoice_core::{DeidConfig, Deidentifier}; let mut deid = Deidentifier::new(DeidConfig::default())?; let mut out = vec![0.0; block.len()]; deid.process(&block, &mut out); - FAQ
Will it make me sound like a different specific person? No, and that is deliberate. It maps you onto a canonical, deliberately synthetic voice that belongs to nobody. Impersonating a real person is a different tool with different ethics. - FAQ
Why does it sound robotic? Because pitch is held constant by design, that steadiness is the sound of your pitch information being gone. Some of the metallic quality is the harmonic-comb resynthesis, and the whitepaper explains what would - FAQ
improve it. Can I get my original voice back? No. That is the point. Keep your own copy of the original if you need one; nothing in the output can reconstruct it. Does it work offline? Only offline. There is no network code, and continuous - FAQ
integration fails the build if an HTTP client so much as enters the dependency graph. Is it safe for a real source-protection situation? The de-identification is strong and the reasoning is published so you can check it. But no external - FAQ
firm has audited this code, and the limits above are real. For a situation where being identified would cause serious harm, treat it as one layer among several, not the whole plan. Why GPL and not something more permissive? So that a - FAQ
version you are handed can always be inspected. Anyone may build a business on it; nobody may close it and hand users something they can no longer read. VeilVoice · GPL-3.0-or-later · source · home · disclaimer · licence Signing key - FAQ
8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A Written and maintained by tilas01 , who holds the copyright. Every change is reviewed, built and tested before release.
Tools and generators
assets/generate.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Generate VeilVoice's icon and banner. The artwork is *generated*, not committed as opaque binaries, so anyone can see exactly how it was made, tweak the palette, and - line 1
reproduce byte-identical output. That matters for a project whose whole pitch is verifiability: a binary blob in the repository is one more thing a reader has to take on trust. Pure standard library, with no Pillow and no build step: - line 1
python assets/generate.py Outputs icon.png, icon.ico and banner.png next to this script. """ import io import math import os import struct import sys import zlib # --- Tokyo Night - line 1
------------------------------------------------------------ BG = (0x1a, 0x1b, 0x26, 255) # editor background BG_DARK = (0x16, 0x16, 0x1e, 255) # deeper panel BORDER = (0x41, 0x48, 0x68, 255) # subtle outline FG = (0xc0, 0xca, 0xf5, 255) # - line 1
foreground text BLUE = (0x7a, 0xa2, 0xf7, 255) # the "clean voice" side CYAN = (0x7d, 0xcf, 0xff, 255) PURPLE = (0xbb, 0x9a, 0xf7, 255) # the "veiled voice" side GREEN = (0x9e, 0xce, 0x6a, 255) COMMENT = (0x73, 0x7a, 0xa2, 255) # muted - line 1
text NONE = (0, 0, 0, 0) # --- PNG encoding ----------------------------------------------------------- PNG_SIGNATURE = bytes((0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) def write_png(path, pixels): """Write 8-bit RGBA rows (list of - line 1
list of 4-tuples) as a PNG.""" - line 41
height = len(pixels) width = len(pixels[0]) raw = bytearray() for row in pixels: raw.append(0) # filter type 0 (None): keeps the output deterministic for r, g, b, a in row: raw += bytes((r, g, b, a)) def chunk(kind, data): return ( - line 41
struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) ) ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) blob = ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", - line 41
zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"") ) with open(path, "wb") as f: f.write(blob) return blob def blank(width, height, colour=NONE): return [[colour for _ in range(width)] for _ in range(height)] def scale(pixels, factor): - line 41
"""Nearest-neighbour upscale: pixel art must stay crisp, never blurred.""" out = [] for row in pixels: big = [] for px in row: big.extend([px] * factor) out.extend([list(big)] * factor) - line 81
return [list(r) for r in out] def blit(dst, src, x0, y0): for y, row in enumerate(src): for x, px in enumerate(row): if px[3] == 0: continue ty, tx = y0 + y, x0 + x if 0 <= ty < len(dst) and 0 <= tx < len(dst[0]): dst[ty][tx] = px def - line 81
rect(dst, x0, y0, w, h, colour): for y in range(y0, y0 + h): for x in range(x0, x0 + w): if 0 <= y < len(dst) and 0 <= x < len(dst[0]): dst[y][x] = colour # --- The mark --------------------------------------------------------------- # A - line 81
32x32 pixel-art badge. A voice enters as an even, solid waveform on the left # and leaves fragmented and recoloured on the right: the whole product in one # picture. Bar heights are hand-tuned rather than generated, because a formula # - line 81
produces something that reads as noise at 16 pixels. BARS = [ # x, half-height, veiled? (5, 3, False), (8, 6, False), (11, 9, False), (14, 5, False), (17, 8, True), (20, 4, True), (23, 9, True), (26, 6, True), ] # Rows knocked out of the - line 81
veiled bars, so they look dissolved rather than short. GAPS = {17: (1,), 20: (), 23: (2, 5), 26: (3,)} - line 121
def icon_32(): px = blank(32, 32, NONE) # Rounded-square body: corners cut by one pixel, which is all the rounding # that reads at this size. for y in range(32): for x in range(32): corner = (x < 2 and y < 2) or (x > 29 and y < 2) or (x < - line 121
2 and y > 29) or (x > 29 and y > 29) if corner: continue edge = x in (0, 1, 30, 31) or y in (0, 1, 30, 31) px[y][x] = BORDER if edge else BG_DARK centre = 16 for x, half, veiled in BARS: colour = PURPLE if veiled else BLUE gaps = - line 121
GAPS.get(x, ()) for dy in range(-half, half): y = centre + dy if veiled and (abs(dy) in gaps): continue rect(px, x, y, 2, 1, colour) # A single cyan pixel pair at the centre line: the "signal still there". rect(px, 14, centre - 1, 2, 2, - line 121
CYAN) return px # --- 5x7 pixel font --------------------------------------------------------- # Hand-drawn so the wordmark is genuinely pixel art rather than an anti-aliased # system font shrunk down. '#' is ink, '.' is empty. GLYPHS = { - line 121
"A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], "B": ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], "C": ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], "D": ["11110", "10001", - line 121
"10001", "10001", "10001", "10001", "11110"], "E": ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], "F": ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], "G": ["01111", "10000", "10000", "10111", "10001", - line 121
"10001", "01111"], - line 161
"H": ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], "I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], "J": ["00111", "00010", "00010", "00010", "00010", "10010", "01100"], "K": ["10001", "10010", - line 161
"10100", "11000", "10100", "10010", "10001"], "L": ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], "M": ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], "N": ["10001", "11001", "10101", "10011", "10001", - line 161
"10001", "10001"], "O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], "P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], "Q": ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], "R": - line 161
["11110", "10001", "10001", "11110", "10100", "10010", "10001"], "S": ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], "T": ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], "U": ["10001", "10001", "10001", - line 161
"10001", "10001", "10001", "01110"], "V": ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], "W": ["10001", "10001", "10001", "10101", "10101", "11011", "10001"], "X": ["10001", "10001", "01010", "00100", "01010", "10001", - line 161
"10001"], "Y": ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], "Z": ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], "1": ["00100", - line 161
"01100", "00100", "00100", "00100", "00100", "01110"], "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], # The digit set used to be exactly the handful the banner's own strings # happened to need. Editing one of those - line 161
strings to include a digit outside # that set produced nothing at all where the character should have been -- # `text()` silently drew a gap -- so the banner would have shipped with a # hole in a line describing the project, on the social - line 161
preview card, with # every test passing. That is finding F-37 exactly: a banner wrong about # the project, invisible to the suite, obvious on sight. # # The whole set is defined now rather than the one digit that was wanted, # so the next - line 161
string to go on the banner cannot reintroduce the same hole. # `check_glyphs()` below refuses to generate if one is ever missing again. "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], "4": ["00010", "00110", "01010", - line 161
"10010", "11111", "00010", "00010"], "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"], "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"], "7": ["11111", "00001", "00010", "00100", "01000", "01000", - line 161
"01000"], "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"], - line 201
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"], ",": ["00000", "00000", "00000", "00000", "01100", "01100", "01000"], "'": ["00100", "00100", - line 201
"00000", "00000", "00000", "00000", "00000"], ":": ["00000", "01100", "01100", "00000", "01100", "01100", "00000"], "/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"], "+": ["00000", "00100", "00100", "11111", "00100", - line 201
"00100", "00000"], "*": ["00000", "10101", "01110", "11111", "01110", "10101", "00000"], " ": ["00000"] * 7, } def text(pixels, string, x0, y0, colour, scale_factor=1, spacing=1): """Draw `string` in the 5x7 font. A character with no glyph - line 201
is a **hard error**, not a silent gap. It used to advance the cursor and draw nothing, which meant a string could contain a character this font had never defined and the only symptom was a blank space in the finished artwork. Changing the - line 201
licence line to A licence line edited to include a digit the set did not have hit exactly that, and the banner would have gone out with a gap in it on GitHub's social preview card, stating the wrong licence, with `--check` passing because - line 201
the generator and the committed file agreed with each other about the same mistake. That is finding F-37's shape a second time: artwork that is wrong about the project, invisible to every test, obvious the moment somebody looks. A - line 201
generator that cannot draw what it was asked to draw should say so. """ missing = sorted({ch for ch in string.upper() if ch not in GLYPHS}) if missing: raise SystemExit( ("assets/generate.py: no glyph for %s in %r." + chr(10) + "Add it to - line 201
GLYPHS rather than letting the banner render a gap.") % (", ".join(repr(ch) for ch in missing), string)) cursor = x0 for ch in string.upper(): glyph = GLYPHS.get(ch) - line 241
if glyph is None: cursor += (5 + spacing) * scale_factor continue for gy, row in enumerate(glyph): for gx, cell in enumerate(row): if cell != "1": continue rect( pixels, cursor + gx * scale_factor, y0 + gy * scale_factor, scale_factor, - line 241
scale_factor, colour, ) cursor += (5 + spacing) * scale_factor return cursor def text_width(string, scale_factor=1, spacing=1): return len(string) * (5 + spacing) * scale_factor - spacing * scale_factor # --- Banner - line 241
----------------------------------------------------------------- # --- the waveform band ------------------------------------------------------ # # The band sits below the tagline; overlapping the two turns the hyphen in # - line 241
"DE-IDENTIFICATION" into a plus sign and makes both harder to read. # # # What the picture is saying, and why no bar is missing any more # # The left half is a voice: a smooth travelling wave, every bar in step with # its neighbours. The - line 241
right half is the same voice after VeilVoice: the same # bars, the same energy, but the phase relationship between them is gone. # # An earlier version expressed that by *deleting* one bar in five. It read as a # broken image rather than - line 241
as a design -- the first thing anyone said about it # was that bars were missing -- and it was also the wrong idea. VeilVoice does # not remove parts of the signal; it keeps the words and destroys the structure # that identifies the - line 241
speaker. Gaps say "data lost". Incoherence says - line 281
# "voiceprint destroyed, words intact", which is the actual claim. # # Animated, this reads immediately: the left marches, the right seethes. BAND_MID = 336 # vertical centre of the waveform # The animated rectangle, cropped to what - line 281
actually moves. # # An APNG frame may declare a sub-rectangle, and every byte outside it is not # in the file at all. The first version declared a generous 1280x156 band "so # no frame can clip a tall bar" -- which was true and cost about - line 281
a quarter of # the file for rows and columns that never change. The bars reach BAR_MAX above # and below BAND_MID and span BAR_LEFT to width-BAR_LEFT, so the box below is # that, plus two pixels of margin for the anti-aliased ends. # # The - line 281
margin is not decoration: a bar end is blended into the row *outside* its # whole-pixel height, so a rectangle cropped exactly to BAR_MAX would clip the # very edge this animation exists to make smooth. BAND_TOP = 336 - 62 - 2 BAND_HEIGHT - line 281
= (62 + 2) * 2 BAND_X = 78 BAND_WIDTH = 1128 BAR_STEP = 12 BAR_WIDTH = 6 BAR_LEFT = 80 BAR_MAX = 62 # How many levels of partial coverage an anti-aliased bar end may take. See the # note in `draw_bar`: this trades an invisible amount of - line 281
precision for a large # amount of compressibility. AA_STEPS = 16 # Peak of the harmonic sums below, measured rather than assumed. # # Three sinusoids whose amplitudes sum to 1.0 only reach 1.0 if they all peak # together, and the phase - line 281
offsets are there precisely so they do not. Without # this the band quietly lost a fifth of its height when the harmonics were # added -- the animation still worked, still looped, and simply looked smaller, # which is the kind of change no - line 281
test notices. - line 321
# # Sampled from the same expressions the function uses, at import, so editing an # amplitude re-measures instead of leaving a stale constant behind. def _wave_peak(): coherent = 0.0 incoherent = 0.0 steps = 720 for step in range(steps): - line 321
angle = 2.0 * math.pi * step / steps coherent = max(coherent, abs( 0.62 * math.sin(angle) + 0.26 * math.sin(2.0 * angle + 0.9) + 0.12 * math.sin(3.0 * angle + 2.1))) for extra in (1.0, 2.0, 3.0): incoherent = max(incoherent, abs( 0.70 * - line 321
math.sin(angle) + 0.30 * math.sin(angle * (extra + 1.0) / extra))) return coherent, incoherent WAVE_PEAK_COHERENT, WAVE_PEAK_INCOHERENT = _wave_peak() def _wave(i, phase, coherent): """Half-height of bar `i` at animation `phase` (0.0 to - line 321
1.0), in *fractional* pixels. Deterministic in both arguments, so every frame is reproducible and the loop closes exactly: `phase` enters only through `sin`, always at a whole-number multiple of the base frequency, and the integer hash - line 321
below does not depend on it. Returns a float on purpose. Rounding each height to a whole pixel is what made the first animation look stepped rather than fluid: a bar near the top of its travel changes by a fraction of a pixel per frame, so - line 321
it sat still for several frames and then jumped. `draw_bar` renders the fractional part as partial coverage instead, which is what the browser does for the CSS bars this is meant to match. # Why three harmonics rather than one - line 361
A single sinusoid is the shape of a test tone. Speech is a fundamental plus harmonics whose amplitudes fall off, and its energy breathes rather than holding steady -- so the bars are summed from three components at 1x, 2x and 3x the base - line 361
rate, with the amplitudes falling roughly as 1/k, plus a slow envelope at 1x. **Every multiplier is a whole number, and that is load-bearing.** `sin(k * 2*pi*phase + c)` has period exactly 1 in `phase` for integer `k`, so the frame at - line 361
phase 1.0 is byte-identical to the frame at 0.0 no matter how many terms are added. A rate of, say, 1.7 would look perfectly good in any single frame and tear once per loop, which is the sort of defect that survives review because nobody - line 361
watches an animation for a whole cycle. The amplitudes are chosen to sum to 1.0 so the result stays in [-1, 1] and the band cannot overflow its own height. """ if coherent: # A travelling wave: neighbouring bars differ by a fixed phase - line 361
step, so # the crest walks along the band. The harmonics inherit that step # multiplied, which is what gives a real waveform its shorter ripples # riding on the fundamental. angle = 2.0 * math.pi * (phase - i * 0.085) wave = ( 0.62 * - line 361
math.sin(angle) + 0.26 * math.sin(2.0 * angle + 0.9) + 0.12 * math.sin(3.0 * angle + 2.1) ) shape = 0.5 + 0.5 * (wave / WAVE_PEAK_COHERENT) # A gentle standing envelope so the band is not a rectangle of equal # peaks, and a slow breath so - line 361
the whole thing does not pulse in lockstep. envelope = 0.62 + 0.38 * abs(((i * 7) % 23) / 23.0 - 0.5) * 2.0 breath = 0.88 + 0.12 * math.sin(angle * 1.0 - i * 0.31) return BAR_MAX * (0.20 + 0.80 * shape) * envelope * breath # Incoherent: - line 361
each bar keeps its own pseudo-random phase and rate, so the # bars never line up. Same energy, no shared structure -- which is the # picture of what VeilVoice does to the phase relationships in a voice. h = (i * 2654435761) & 0xFFFFFFFF - line 361
offset = ((h >> 7) % 1000) / 1000.0 - line 401
# Whole-number rates only, for the same loop-closing reason as above. rate = 1.0 + float((h >> 17) % 3) angle = 2.0 * math.pi * (phase * rate + offset) second = 2.0 * math.pi * (phase * (rate + 1.0) + offset * 1.7) wave = 0.70 * - line 401
math.sin(angle) + 0.30 * math.sin(second) shape = 0.5 + 0.5 * (wave / WAVE_PEAK_INCOHERENT) envelope = 0.55 + 0.45 * (((h >> 3) % 100) / 100.0) breath = 0.86 + 0.14 * math.sin(2.0 * math.pi * phase + offset * 6.283) return BAR_MAX * (0.18 - line 401
+ 0.82 * shape) * envelope * breath def _blend(fg, bg, alpha): """`fg` over `bg` at `alpha`, rounded to whole channel values. Both are opaque here, so this is a plain linear interpolation. Rounding with `int(v + 0.5)` rather than - line 401
truncating keeps the two ends symmetric -- truncation biases every edge pixel one step towards the background, which over a whole band reads as bars that are subtly too short. """ return ( int(fg[0] * alpha + bg[0] * (1.0 - alpha) + 0.5), - line 401
int(fg[1] * alpha + bg[1] * (1.0 - alpha) + 0.5), int(fg[2] * alpha + bg[2] * (1.0 - alpha) + 0.5), 255, ) def draw_bar(dst, x, centre, half, w, colour): """One bar, centred on `centre`, with anti-aliased ends. `half` is fractional. The - line 401
rows fully inside the bar are painted flat; the single row at each end is blended against whatever is already there by the fraction it actually covers. That is the whole of the smoothness fix: the bar's apparent height now changes - line 401
continuously instead of in whole pixels, so at 60 frames a second the crest glides rather than clicking from one row to the next. """ top = centre - half bottom = centre + half - line 441
first = int(math.floor(top)) last = int(math.ceil(bottom)) for y in range(first, last): if y < 0 or y >= len(dst): continue # How much of this one-pixel row the bar covers, in [0, 1]. covered = min(bottom, y + 1.0) - max(top, float(y)) if - line 441
covered <= 0.0: continue # Quantise coverage to sixteen steps. # # Continuous coverage produces a different edge colour on almost every # frame, and a PNG's compression works on repeated bytes -- so the file # grew from 300 KB to 405 KB - line 441
for a difference no eye can see at 1/60 s. # Sixteen steps is finer than the eye can separate at this size and # gives the compressor something to find: the animation stays fluid and # the download stops being larger than the rest of the - line 441
site put # together. covered = round(covered * AA_STEPS) / AA_STEPS if covered <= 0.0: continue for px_x in range(x, x + w): if px_x < 0 or px_x >= len(dst[0]): continue if covered >= 0.999: dst[y][px_x] = colour else: dst[y][px_x] = - line 441
_blend(colour, dst[y][px_x], covered) def draw_band(px, phase, width=None, y_offset=0, x_offset=0): """Draw the waveform band into `px` at the given animation phase. `y_offset` shifts the drawing up by that many rows, so the same routine - line 441
can fill the whole banner (offset 0) or just the strip an APNG frame replaces (offset BAND_TOP). One drawing routine, one coordinate convention: the alternative is two, and the second one is where the bug goes. """ - line 481
if width is None: width = len(px[0]) for i, x in enumerate(range(BAR_LEFT, width - BAR_LEFT, BAR_STEP)): progress = (x - BAR_LEFT) / float(width - 2 * BAR_LEFT) coherent = progress < 0.45 half = max(2.0, _wave(i, phase, coherent)) colour = - line 481
BLUE if coherent else PURPLE draw_bar(px, x - x_offset, BAND_MID - y_offset, half, BAR_WIDTH, colour) def band_strip(phase, background): """Just the animated rectangle, for one APNG frame. Drawn onto a copy of the *static banner's* own - line 481
pixels for that region, so the faint grid behind the bars is preserved and each frame replaces the region outright. That is why the frames declare `blend_op = SOURCE`: there is nothing to blend against, the strip is already complete. """ - line 481
rows = [ list(background[y][BAND_X:BAND_X + BAND_WIDTH]) for y in range(BAND_TOP, BAND_TOP + BAND_HEIGHT) ] draw_band( rows, phase, width=len(background[0]), y_offset=BAND_TOP, x_offset=BAND_X, ) return rows def banner(): """GitHub's - line 481
social-preview card is 1280x640.""" w, h = 1280, 640 px = blank(w, h, BG) # A faint 16-pixel grid: texture without competing with the wordmark. grid = (0x1f, 0x21, 0x30, 255) for y in range(0, h, 16): - line 521
for x in range(w): px[y][x] = grid for x in range(0, w, 16): for y in range(h): px[y][x] = grid draw_band(px, 0.0) # Icon badge, top left. blit(px, scale(icon_32(), 4), 80, 80) # Wordmark. word_scale = 12 text(px, "VEILVOICE", 240, 96, FG, - line 521
word_scale) # Tagline and footer. text(px, "IRREVERSIBLE VOICE DE-IDENTIFICATION", 242, 204, BLUE, 4) text(px, "THE VOICEPRINT IS DESTROYED. THE WORDS STAY READABLE.", 80, 430, COMMENT, 3) text(px, "FULLY OFFLINE", 80, 478, GREEN, 3) - line 521
text(px, "SECURE AUDITED RUST CODE", 80, 512, CYAN, 3) text(px, "GPL-3.0-OR-LATER", 80, 546, COMMENT, 3) # Attribution, in the same green as the offline claim. text(px, "BY TILAS01 ON GITHUB", 80, 580, GREEN, 3) # A hairline accent along - line 521
the bottom edge. rect(px, 0, h - 8, w, 8, BLUE) return px # --- Windows ICO ------------------------------------------------------------ # --- macOS .icns ------------------------------------------------------------ # # An `.icns` is a - line 521
flat container: the magic `icns`, the total byte length, then # one record per image -- a four-character type, the record length including its # own header, and the data. Every type used here is PNG-encoded, which is what # every macOS - line 521
since 10.7 reads, so the same `write_png_bytes` that makes the # other artwork makes these too. # # The type codes are not sizes, they are names for sizes, and getting one wrong - line 561
# produces an icon macOS silently declines to draw. They are listed with the # dimension each one means rather than left as four-letter magic. ICNS_TYPES = ( (b"icp4", 16), (b"icp5", 32), (b"icp6", 64), (b"ic07", 128), (b"ic08", 256), - line 561
(b"ic09", 512), ) def write_icns(path, source_32): """Write a macOS icon containing every size the Finder asks for.""" records = b"" for kind, size in ICNS_TYPES: factor = size // 32 pixels = scale(source_32, factor) if factor > 1 else - line 561
source_32 if size < 32: # 16x16 is the one size smaller than the source. Taking every other # pixel keeps the mark's hard edges; averaging turns a two-pixel # bar into two grey ones and the icon into a smudge at the size it # is seen most - line 561
often. pixels = [row[::2] for row in source_32[::2]] blob = write_png_bytes(pixels) records += kind + struct.pack(">I", len(blob) + 8) + blob out = b"icns" + struct.pack(">I", len(records) + 8) + records with open(path, "wb") as handle: - line 561
handle.write(out) return out # --- freedesktop icon theme and launcher ------------------------------------ # # Linux and the BSDs find an application's icon through the hicolor theme: a # PNG per size under - line 561
`<prefix>/share/icons/hicolor/<size>x<size>/apps/`, named # after the desktop entry. Without them a launcher shows a generic placeholder, # which is what VeilVoice has looked like on every one of those platforms. FREEDESKTOP_SIZES = (16, - line 561
32, 48, 64, 128, 256) - line 601
DESKTOP_ENTRY = """[Desktop Entry] Type=Application Name=VeilVoice GenericName=Voice de-identification Comment=Destroy the biometric voiceprint in a recording, keeping the words Exec=veilvoice-gui Icon=veilvoice Terminal=false - line 601
Categories=AudioVideo;Audio;Security; Keywords=voice;anonymise;anonymize;privacy;audio;de-identification; StartupWMClass=VeilVoice """ def write_freedesktop(root, source_32): """Icon-theme PNGs and a launcher entry, for Linux and the - line 601
BSDs.""" written = [] for size in FREEDESKTOP_SIZES: if size < 32: pixels = [row[::2] for row in source_32[::2]] else: pixels = scale(source_32, size // 32) directory = os.path.join(root, "hicolor", "%dx%d" % (size, size), "apps") - line 601
os.makedirs(directory, exist_ok=True) path = os.path.join(directory, "veilvoice.png") write_png(path, pixels) written.append(path) path = os.path.join(root, "veilvoice.desktop") with io.open(path, "w", encoding="utf-8", newline="\n") as - line 601
handle: handle.write(DESKTOP_ENTRY) written.append(path) return written def write_ico(path, sizes, source_32): """An ICO whose entries are embedded PNGs (supported since Windows Vista).""" images = [] for size in sizes: factor = max(1, - line 601
size // 32) images.append((size, write_png_bytes(scale(source_32, factor)))) - line 641
header = struct.pack("<HHH", 0, 1, len(images)) offset = 6 + 16 * len(images) entries, blobs = b"", b"" for size, blob in images: # 0 in the width/height byte means 256. dim = 0 if size >= 256 else size entries += struct.pack( "<BBBBHHII", - line 641
dim, dim, 0, 0, 1, 32, len(blob), offset ) blobs += blob offset += len(blob) with open(path, "wb") as f: f.write(header + entries + blobs) def write_png_bytes(pixels): height = len(pixels) width = len(pixels[0]) raw = bytearray() for row - line 641
in pixels: raw.append(0) for r, g, b, a in row: raw += bytes((r, g, b, a)) def chunk(kind, data): return ( struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) ) ihdr = - line 641
struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) return ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"") - line 681
) # --- APNG ------------------------------------------------------------------- # # An animated banner, in the same spirit as everything else here: generated # from source, not a committed blob somebody has to trust. # # APNG rather than - line 681
GIF, for three reasons that all matter to this project: # # * It is a PNG. The encoder above already writes PNG chunks, so this is an # extension of code that is already here and already reviewed, not a second # image format with its own - line 681
quantiser. # * GIF is limited to 256 colours, so the palette would have to be quantised # -- a lossy step whose output depends on the quantiser, which is exactly # the kind of thing that stops a build being reproducible. # * A browser that - line 681
does not understand APNG shows the first frame, which is # the static banner. The failure mode is "no animation", never "no image". # # Nothing embeds this file today. The README shows the GIF below, because a # README is read in clients - line 681
that do not all draw an APNG, and the website # draws its banner in CSS so that it follows the reader's palette. The APNG # is kept because it is the higher-fidelity copy of the same animation -- # 60 fps against 50, full alpha, and half - line 681
the bytes -- and because deleting a # working generator is the maintainer's call, not a tidy-up. Said plainly # here rather than left for somebody to discover: it is currently unused. # # Only the waveform band is animated. Frame 0 is the - line 681
whole banner; every later # frame declares a sub-rectangle covering the band alone, which is why the file # is a fraction of the size of a full-frame animation. APNG_DISPOSE_NONE = 0 APNG_BLEND_SOURCE = 0 def _chunk(kind, data): return ( - line 681
struct.pack(">I", len(data)) + kind + data - line 721
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) ) def _raw_rows(pixels): raw = bytearray() for row in pixels: raw.append(0) # filter 0 (None), for deterministic output for r, g, b, a in row: raw += bytes((r, g, b, a)) return - line 721
bytes(raw) def write_apng(path, frames, delay_num, delay_den): """Write an APNG. `frames` is a list of `(x, y, pixels)`. The first must be the full image and sit at the origin; the rest are sub-rectangles that replace what is under them. - line 721
The delay is a *rational* number of seconds, `delay_num/delay_den`, because that is what the format stores and because 60 frames per second is 1/60 -- a value milliseconds cannot express without rounding (16 ms is 62.5 fps, 17 ms is 58.8) - line 721
and rounding a frame interval is what makes an animation visibly stutter. """ first_x, first_y, first = frames[0] if (first_x, first_y) != (0, 0): raise ValueError("the first APNG frame must be the whole image") width = len(first[0]) - line 721
height = len(first) out = [PNG_SIGNATURE] out.append(_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))) # num_plays 0 means loop for ever. out.append(_chunk(b"acTL", struct.pack(">II", len(frames), 0))) def - line 721
fctl(sequence, x, y, pixels): return _chunk(b"fcTL", struct.pack( ">IIIIIHHBB", sequence, len(pixels[0]), len(pixels), - line 761
x, y, delay_num, delay_den, # delay as an exact fraction of a second APNG_DISPOSE_NONE, APNG_BLEND_SOURCE, )) sequence = 0 out.append(fctl(sequence, 0, 0, first)) sequence += 1 out.append(_chunk(b"IDAT", zlib.compress(_raw_rows(first), - line 761
9))) for x, y, pixels in frames[1:]: out.append(fctl(sequence, x, y, pixels)) sequence += 1 out.append(_chunk( b"fdAT", struct.pack(">I", sequence) + zlib.compress(_raw_rows(pixels), 9), )) sequence += 1 out.append(_chunk(b"IEND", b"")) - line 761
blob = b"".join(out) with open(path, "wb") as handle: handle.write(blob) return blob def decode_apng(blob): """Decode our own APNG back to `(x, y, pixels)` frames. Exists for `--check`, and for the same reason `decode_png` does: zlib's - line 761
output differs between Python builds, so comparing compressed bytes would fail spuriously while saying nothing about whether the picture changed. Frames are compared as pixels. """ if blob[:8] != PNG_SIGNATURE: raise ValueError("not a - line 761
PNG") pos = 8 width = height = None frames = [] - line 801
pending = None # (x, y, w, h) from the most recent fcTL data = b"" def flush(): if pending is None: return x, y, w, h = pending raw = zlib.decompress(data) stride = w * 4 rows = [] for row_index in range(h): start = row_index * (stride + - line 801
1) if raw[start] != 0: raise ValueError("expected filter type 0") line = raw[start + 1:start + 1 + stride] rows.append([tuple(line[i * 4:i * 4 + 4]) for i in range(w)]) frames.append((x, y, rows)) while pos < len(blob): length = - line 801
struct.unpack(">I", blob[pos:pos + 4])[0] kind = blob[pos + 4:pos + 8] payload = blob[pos + 8:pos + 8 + length] if kind == b"IHDR": width, height, depth, colour = struct.unpack(">IIBB", payload[:10]) if (depth, colour) != (8, 6): raise - line 801
ValueError("expected 8-bit RGBA") elif kind == b"fcTL": flush() _, w, h, x, y = struct.unpack(">IIIII", payload[:20]) pending = (x, y, w, h) data = b"" elif kind == b"IDAT": data += payload elif kind == b"fdAT": data += payload[4:] # skip - line 801
the sequence number elif kind == b"IEND": flush() break pos += 12 + length - line 841
return frames # How the animation is shaped. # # **Sixty frames per second, one full cycle per second.** The first version ran # 24 frames at 70 ms -- about 14 fps -- which is fine for a blinking cursor and # far too coarse for a - line 841
travelling wave: the crest visibly jumped from bar to # bar instead of moving along them. # # The delay is exactly 1/60 s rather than a rounded 16 or 17 ms. At 16 ms the # loop runs 0.96 s and at 17 ms it runs 1.02 s, and either way the - line 841
frame # interval no longer divides the display's own 60 Hz refresh evenly, which is # what produces a stutter that is hard to name but easy to see. # # The phase of frame `i` is `i / FRAMES`, so the last frame lands exactly one # cycle - line 841
from the first: the loop closes with no seam and no repeated frame. BANNER_FRAMES = 60 BANNER_DELAY_NUM = 1 BANNER_DELAY_DEN = 60 def banner_frames(): """The animated banner: frame 0 whole, the rest just the waveform band.""" base = - line 841
banner() frames = [(0, 0, base)] for index in range(1, BANNER_FRAMES): phase = index / float(BANNER_FRAMES) frames.append((BAND_X, BAND_TOP, band_strip(phase, base))) return frames # --- GIF - line 841
-------------------------------------------------------------------- # # The same animation again, as a GIF, because a README is read in a hundred # clients and GIF is the one animated format all of them draw. The APNG stays: # it is the - line 841
better picture and it is what the website serves. # - line 881
# The note on the APNG above says GIF was rejected because "GIF is limited to # 256 colours, so the palette would have to be quantised -- a lossy step whose # output depends on the quantiser, which is exactly the kind of thing that stops # - line 881
a build being reproducible". That was right about quantising and wrong about # this picture, and the difference is one measurement: # # the whole banner 63 distinct colours # the waveform frames 255 distinct colours between them # the two - line 881
together 261 # # 261 is over the limit for *one* palette and well under it for the two that # GIF actually allows: a frame may carry its own colour table. So frame 0 gets # its 63 colours and the waveform frames share their 255. Nothing is - line 881
quantised, # nothing is approximated, and the bytes are the same on every machine -- which # is the property that mattered, not the format. # # The one real loss is the frame delay. GIF measures it in hundredths of a # second and 1/60 s is - line 881
not a hundredth of anything, so the GIF runs 50 frames at # 2/100 s: the same one-second loop as the APNG's 60 at 1/60, at the fastest # rate this format can honestly express. Rounding 1/60 to 1/100 instead would # give a banner that runs - line 881
at 60 % speed in some viewers and full speed in # others, which is worse than being 50 fps everywhere. GIF_FRAMES = 50 GIF_DELAY = 2 # hundredths of a second: 50 frames = one second def gif_frames(): """Frame 0 is the whole banner; the - line 881
rest are the waveform band.""" base = banner() frames = [(0, 0, base)] for index in range(1, GIF_FRAMES): phase = index / float(GIF_FRAMES) frames.append((BAND_X, BAND_TOP, band_strip(phase, base))) return frames def - line 881
gif_palette(frame_pixels): """The exact colours these frames use, in a fixed order. - line 921
Sorted rather than first-seen, so the table does not depend on which row the generator reached first -- a detail that would otherwise make the file's bytes a function of a loop order rather than of the picture. """ colours = set() for - line 921
pixels in frame_pixels: for row in pixels: colours.update(row) return sorted(colours) def lzw_compress(indices, min_code_size): """GIF's LZW, written the way giflib writes it. The one thing worth stating, because it is what every - line 921
implementation of this gets wrong: the decoder builds its dictionary one entry *behind* the encoder, so the encoder has to widen its codes one entry early. giflib widens once the counter has gone *past* `1 << bits`, which means code 511 - line 921
still goes out in nine bits and code 512 does not. The symmetric-looking `== 1 << bits` produces a file this script can read back perfectly and no browser can, which is a bug that only a real decoder finds. A prefix is carried as its own - line 921
code rather than as the string it stands for, so the dictionary is keyed on `(code, byte)`. The string form is the textbook one and is quadratic in the length of a match. """ clear = 1 << min_code_size end = clear + 1 code_size = - line 921
min_code_size + 1 table = {} next_code = end + 1 packed = bytearray() accumulator = 0 held = 0 def emit(code, width): nonlocal accumulator, held accumulator |= code << held held += width - line 961
while held >= 8: packed.append(accumulator & 0xFF) accumulator >>= 8 held -= 8 emit(clear, code_size) stream = iter(indices) try: prefix = next(stream) except StopIteration: emit(end, code_size) if held: packed.append(accumulator & 0xFF) - line 961
return bytes(packed) for index in stream: key = (prefix, index) found = table.get(key) if found is not None: prefix = found continue emit(prefix, code_size) if next_code == 4096: # The dictionary is full. Tell the decoder to throw its away - line 961
too; # without the clear code the two stop agreeing on what any code # means, and everything after this point is noise. emit(clear, code_size) table = {} code_size = min_code_size + 1 next_code = end + 1 else: table[key] = next_code - line 961
next_code += 1 if next_code > (1 << code_size) and code_size < 12: code_size += 1 prefix = index emit(prefix, code_size) emit(end, code_size) - line 1001
if held: packed.append(accumulator & 0xFF) return bytes(packed) def gif_sub_blocks(data): """GIF carries data in blocks of at most 255 bytes, terminated by a zero.""" out = bytearray() for start in range(0, len(data), 255): chunk = - line 1001
data[start:start + 255] out.append(len(chunk)) out += chunk out.append(0) return bytes(out) def gif_colour_table(palette): """A GIF colour table is a power-of-two number of RGB triples.""" size = 2 while size < len(palette): size *= 2 if - line 1001
size > 256: raise ValueError("a GIF colour table holds 256 colours at most, and " "this frame needs %d" % len(palette)) out = bytearray() for colour in palette: out += bytes(colour[:3]) out += bytes(3 * (size - len(palette))) return - line 1001
bytes(out), size def write_gif(path, frames, delay): """Write an animated GIF89a. Nothing is quantised; see the note above. `frames` is the same `(x, y, pixels)` shape `write_apng` takes, so the two animations come from one set of drawings - line 1001
rather than from two. """ width = len(frames[0][2][0]) height = len(frames[0][2]) - line 1041
# Frame 0's colours are the global table, so a viewer that draws only the # first frame -- and some do, in some contexts -- still shows the static # banner rather than nothing. first = gif_palette([frames[0][2]]) global_table, global_size - line 1041
= gif_colour_table(first) global_bits = max(1, (global_size - 1).bit_length()) band = gif_palette([pixels for _, _, pixels in frames[1:]]) if len(frames) > 1 else [] out = bytearray(b"GIF89a") out += struct.pack("<HH", width, height) # - line 1041
global table present | colour resolution 8 | unsorted | size out.append(0x80 | (7 << 4) | (global_bits - 1)) out.append(0) # background colour index out.append(0) # no pixel aspect ratio out += global_table # NETSCAPE2.0: loop forever. - line 1041
Without it most viewers play the animation # once, which for a banner reads as a page that stopped loading. out += b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00" for number, (x, y, pixels) in enumerate(frames): palette = first if number == - line 1041
0 else band table, size = gif_colour_table(palette) bits = max(1, (size - 1).bit_length()) lookup = {colour: index for index, colour in enumerate(palette)} out += b"\x21\xf9\x04" # graphic control extension # Disposal 1, "leave it where it - line 1041
is". "Restore to background" would # blink the banner through the waveform fifty times a second, and # "restore to previous" makes every frame depend on the one before it # in a way no viewer implements the same way twice. out.append(1 << - line 1041
2) out += struct.pack("<H", delay) out.append(0) # transparent index, unused out.append(0) # block terminator out.append(0x2C) # image descriptor out += struct.pack("<HHHH", x, y, len(pixels[0]), len(pixels)) out.append(0x00 if number == 0 - line 1041
else 0x80 | (bits - 1)) if number != 0: - line 1081
out += table min_code_size = max(2, bits) out.append(min_code_size) out += gif_sub_blocks(lzw_compress( [lookup[colour] for row in pixels for colour in row], min_code_size)) out.append(0x3B) # trailer with open(path, "wb") as handle: - line 1081
handle.write(bytes(out)) return bytes(out) def decode_png(blob): """Decode the narrow PNG subset this script writes: 8-bit RGBA, filter 0. Only needs to read our own output, so it skips the general cases. It exists for `--check`: comparing - line 1081
decoded pixels rather than compressed bytes makes the reproducibility test independent of the zlib version, which differs between Python builds and would otherwise cause spurious CI failures. """ if blob[:8] != PNG_SIGNATURE: raise - line 1081
ValueError("not a PNG") pos, width, height, idat = 8, None, None, b"" while pos < len(blob): length = struct.unpack(">I", blob[pos:pos + 4])[0] kind = blob[pos + 4:pos + 8] data = blob[pos + 8:pos + 8 + length] if kind == b"IHDR": width, - line 1081
height, depth, colour = struct.unpack(">IIBB", data[:10]) if (depth, colour) != (8, 6): raise ValueError("expected 8-bit RGBA") elif kind == b"IDAT": idat += data elif kind == b"IEND": break pos += 12 + length raw = zlib.decompress(idat) - line 1121
stride = width * 4 rows = [] for y in range(height): start = y * (stride + 1) if raw[start] != 0: raise ValueError("expected filter type 0") line = raw[start + 1:start + 1 + stride] rows.append([tuple(line[x * 4:x * 4 + 4]) for x in - line 1121
range(width)]) return rows # The website serves its own copies of the artwork rather than reaching up out # of `website/`, which keeps that directory exactly what GitHub Pages publishes. # The copies were previously kept in step by hand, - line 1121
and `--check` only ever # looked at `assets/` -- so `website/assets/banner.png` could drift from its # generator and nothing would notice. It had. The generator now writes both # and checks both, which is the only version of this that - line 1121
stays true. WEB_COPIES = ("icon.png", "icon-32.png", "banner.png", "banner-animated.png", "banner.gif") def website_assets(here): return os.path.join(os.path.dirname(here), "website", "assets") def check(here): """Verify the committed - line 1121
artwork still matches what this script produces.""" mark = icon_32() expected = { "icon.png": scale(mark, 8), "icon-32.png": mark, "banner.png": banner(), } web = website_assets(here) problems = [] def check_png(path, label, pixels): try: - line 1121
with open(path, "rb") as handle: actual = decode_png(handle.read()) - line 1161
except (OSError, ValueError) as exc: problems.append(f"{label}: cannot read ({exc})") return if actual != pixels: problems.append(f"{label}: pixels differ from the generator output") def check_apng(path, label, want_frames): try: with - line 1161
open(path, "rb") as handle: actual_frames = decode_apng(handle.read()) except (OSError, ValueError) as exc: problems.append(f"{label}: cannot read ({exc})") return if len(actual_frames) != len(want_frames): problems.append("%s: %d frames, - line 1161
generator produces %d" % (label, len(actual_frames), len(want_frames))) return for index, (want, got) in enumerate(zip(want_frames, actual_frames)): if (want[0], want[1]) != (got[0], got[1]) or want[2] != got[2]: problems.append("%s: frame - line 1161
%d differs from the generator" % (label, index)) return for name, pixels in expected.items(): check_png(os.path.join(here, name), name, pixels) if name in WEB_COPIES: check_png(os.path.join(web, name), "website/assets/" + name, pixels) - line 1161
frames = banner_frames() check_apng(os.path.join(here, "banner-animated.png"), "banner-animated.png", frames) check_apng(os.path.join(web, "banner-animated.png"), "website/assets/banner-animated.png", frames) # The GIF: byte comparison - line 1161
rather than decoded pixels. The APNG above is # compared decoded because zlib's output differs between Python builds and # a byte comparison would fail on a machine that had changed nothing. LZW # has no such freedom -- this file writes - line 1161
every code itself -- so the bytes # are the stronger check here and the cheaper one. want_gif = write_gif(os.path.join(here, ".gif-check"), gif_frames(), GIF_DELAY) - line 1201
try: os.remove(os.path.join(here, ".gif-check")) except OSError: pass for label, path in (("banner.gif", os.path.join(here, "banner.gif")), ("website/assets/banner.gif", os.path.join(web, "banner.gif"))): try: with open(path, "rb") as - line 1201
handle: if handle.read() != want_gif: problems.append("%s: differs from the generator output" % label) except OSError as exc: problems.append("%s: cannot read (%s)" % (label, exc)) # The platform icons: byte comparison, because these are - line 1201
containers this # script writes end to end rather than images something else re-encodes. for name, produced in ( ("icon.icns", write_icns(os.path.join(here, ".icns-check"), mark)), ): try: with open(os.path.join(here, name), "rb") as - line 1201
handle: if handle.read() != produced: problems.append("%s: differs from the generator output" % name) except OSError as exc: problems.append("%s: cannot read (%s)" % (name, exc)) try: os.remove(os.path.join(here, ".icns-check")) except - line 1201
OSError: pass for size in FREEDESKTOP_SIZES: rel = os.path.join("linux", "hicolor", "%dx%d" % (size, size), "apps", "veilvoice.png") pixels = ([row[::2] for row in mark[::2]] if size < 32 else scale(mark, size // 32)) - line 1201
check_png(os.path.join(here, rel), rel, pixels) desktop = os.path.join(here, "linux", "veilvoice.desktop") try: with io.open(desktop, encoding="utf-8", newline="") as handle: - line 1241
if handle.read().replace("\r\n", "\n") != DESKTOP_ENTRY: problems.append("linux/veilvoice.desktop: differs from the generator") except OSError as exc: problems.append("linux/veilvoice.desktop: cannot read (%s)" % exc) raw_path = - line 1241
os.path.join(here, "icon-32.rgba") want = bytes(b for row in mark for px in row for b in px) try: with open(raw_path, "rb") as f: if f.read() != want: problems.append("icon-32.rgba: bytes differ") except OSError as exc: - line 1241
problems.append(f"icon-32.rgba: cannot read ({exc})") if problems: for line in problems: print(f" MISMATCH {line}") print() print("Run 'python assets/generate.py' and commit the result.") return 1 print(" all generated assets match the - line 1241
generator") return 0 def main(): here = os.path.dirname(os.path.abspath(__file__)) if "--check" in sys.argv: return check(here) mark = icon_32() write_png(os.path.join(here, "icon.png"), scale(mark, 8)) # 256x256 - line 1241
write_png(os.path.join(here, "icon-32.png"), mark) # 1:1 source # Raw RGBA for the window icon. The GUI embeds this with `include_bytes!`, # so the application needs no PNG decoder just to draw its own title bar. with - line 1241
open(os.path.join(here, "icon-32.rgba"), "wb") as f: for row in mark: for r, g, b, a in row: f.write(bytes((r, g, b, a))) write_ico(os.path.join(here, "icon.ico"), [16, 32, 48, 64, 128, 256], mark) - line 1281
write_icns(os.path.join(here, "icon.icns"), mark) write_freedesktop(os.path.join(here, "linux"), mark) write_png(os.path.join(here, "banner.png"), banner()) # The animated banner. Frame 0 is byte-for-byte the picture above, so a # viewer - line 1281
with no APNG support shows the static banner rather than nothing. write_apng( os.path.join(here, "banner-animated.png"), banner_frames(), BANNER_DELAY_NUM, BANNER_DELAY_DEN, ) # And the GIF, for the README and for anything that will not - line 1281
draw an APNG. write_gif(os.path.join(here, "banner.gif"), gif_frames(), GIF_DELAY) # And the website's own copies, from the same run rather than by hand. web = website_assets(here) os.makedirs(web, exist_ok=True) for name in WEB_COPIES: - line 1281
with open(os.path.join(here, name), "rb") as source: blob = source.read() with open(os.path.join(web, name), "wb") as target: target.write(blob) for name in ("icon.png", "icon-32.png", "icon-32.rgba", "icon.ico", "icon.icns", "banner.png", - line 1281
"banner-animated.png", "banner.gif"): size = os.path.getsize(os.path.join(here, name)) print(f" {name:<20} {size:>9,} bytes") print(f" copied {len(WEB_COPIES)} of them into website/assets/") return 0 if __name__ == "__main__": raise - line 1281
SystemExit(main())
assets/linux/veilvoice.desktop
- line 1
[Desktop Entry] Type=Application Name=VeilVoice GenericName=Voice de-identification Comment=Destroy the biometric voiceprint in a recording, keeping the words Exec=veilvoice-gui Icon=veilvoice Terminal=false - line 1
Categories=AudioVideo;Audio;Security; Keywords=voice;anonymise;anonymize;privacy;audio;de-identification; StartupWMClass=VeilVoice
assets/screenshots/README.md
- line 1
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> - Screenshots
Pictures of VeilVoice running, for the README, the website, the wiki and the reference pages. - The two kinds here, and why they are different
**`gui-*.png` are photographs of the running application.** They are the one class of committed binary in this repository that cannot be reproduced from source, because they are pictures of a program drawing itself on somebody's screen. - The two kinds here, and why they are different
`tools/shots/gui.ps1` takes them: it starts the release build, fixes the window to one size and position, clicks each tab, and captures the window's real frame bounds. Re-running it takes a minute, so a change to the interface can be - The two kinds here, and why they are different
followed by a change to its pictures in the same commit. That script fails rather than writing a wrong picture, and it does not remember where the tabs are. It **finds** them: it scans the strip of pixels the labels sit in and groups the - The two kinds here, and why they are different
lit columns into runs, one run per label, and stops if the count is not the one it expects. It did remember them, once, and they went stale the first time a tab was inserted. Every click still landed on *a* tab, so every capture was - The two kinds here, and why they are different
different, the duplicate check saw nothing wrong, and three tabs were quietly photographed under the wrong names. Two guards remain from that: an identical pair of consecutive captures stops the run, and after each click the pixel above - The two kinds here, and why they are different
the label has to be the raised background a selected tab is drawn on. **`cli-*.svg` are drawings, and they are generated.** Each one is a pure function of the `cli-*.txt` beside it, which holds exactly what the command printed. `python - The two kinds here, and why they are different
tools/shots/terminal.py --check` regenerates every drawing into memory and compares, and CI fails on a difference, the same arrangement the banners and the reference diagrams have. A picture of a command line that disagrees with the - The two kinds here, and why they are different
command line is documentation that lies, and this makes it impossible to commit by accident. The `.txt` is the file to read in a diff. An SVG diff is unreadable; a diff of what the program printed is the review. - Redoing them
cargo build --release -p veilvoice-gui -p veilvoice-cli powershell -ExecutionPolicy Bypass -File tools/shots/gui.ps1 python tools/shots/terminal.py --capture python tools/shots/terminal.py The first two steps need a machine and a person - Redoing them
looking at the result. The last one is the reproducible half, and it is the half CI checks. - Two of the pictures are redacted, and here is exactly which
A screenshot of a working application is a screenshot of somebody's machine. Two tabs put that on the page, so `tools/shots/gui.ps1` paints over those regions before writing the file, in the colours the interface draws them in, so the - Two of the pictures are redacted, and here is exactly which
replacement reads as part of the application rather than as a black bar. | File | What was covered | What it says instead | |---|---|---| | `gui-studio.png` | the two audio device dropdowns | `your microphone`, `your virtual cable` | | - Two of the pictures are redacted, and here is exactly which
`gui-install.png` | the running-from and install-to paths | the same paths, under a user called `you` | | `gui-lock.png` | where the app lock file lives | the same path, under a user called `you` | The device names are product names: a - Two of the pictures are redacted, and here is exactly which
headset model and a particular virtual-cable setup, which together describe the maintainer's hardware. The paths contain the **account name**, and this project is published under a pseudonym on purpose, and an account name is not that - Two of the pictures are redacted, and here is exactly which
pseudonym. Nothing else in this directory is altered, and no other picture is. **A tab that starts showing a path or a device name needs adding to the tables in that script.** Nothing can check it for you: the text is inside a PNG. - What is deliberately not captured at all
`veilvoice devices` makes a good demonstration and a poor thing to publish, so it is not in the command list. The monitor tab is photographed in whatever state the machine is in, which is why it is photographed on a machine with nothing - What is deliberately not captured at all
running.
assets/screenshots/cli-anonymise.txt
- line 1
De-identify an audio file and write a WAV Usage: veilvoice anonymise [OPTIONS] <INPUT> Arguments: <INPUT> Audio file to read (wav, mp3, flac, ogg, m4a, ...) Options: -o, --output <OUTPUT> Where to write the result. Defaults to - line 1
`<input>.veiled.wav` --intensity <INTENSITY> How far pitch and formants are pushed from the original, 0.0–1.0 [default: 1] --keep-accent Keep the speaker's accent and intonation intact --reseed-secs <RESEED_SECS> Seconds between rolls of - line 1
the modulation seed. 0 keeps one stream for the whole session [default: 2] --reseed-range <RESEED_RANGE> Draw each gap from a range instead, in milliseconds: `250,1800`. A fixed interval is a fixed thing to observe. With a range, the gap - line 1
before every roll is drawn fresh from the modulation stream, so the ratchet has no period at all. Without this, the range is **drawn from the operating system's random source at launch**, so it is a property of this run rather than a - line 1
number compiled into every copy of VeilVoice. Pass `--reseed-range fixed` to use `--reseed-secs` instead. A value that is not a usable range is **refused with the reason**, never adjusted to fit. --clean-metadata <CLEAN_METADATA> Also - line 1
strip metadata from the written file [default: true] [possible values: true, false] - line 41
--encrypt <ENCRYPT> Encrypt the result at rest. On by default: the words survive de-identification on purpose, so an unencrypted result is still a recording of everything that was said [default: true] [possible values: true, false] - line 41
--encrypt-to <PUBKEY> Seal to a recipient's public key file instead of a passphrase --yes Skip the confirmation when writing an unencrypted recording -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-capture.txt
- line 1
Which screen recorders are running, and which you meant to run. **VeilVoice does not hide its own window from capture.** You can record this application with OBS or anything else, deliberately, and nothing here prevents it. Excluding a - line 1
window from capture needs `unsafe` FFI, which every crate in this workspace forbids, so the exclusion is not built -- see ROADMAP.md. What this does is tell you a recorder is running, once, and then stop telling you if you say you meant - line 1
it. A monitor that warns every thirty seconds while you record a tutorial is a monitor you switch off, and then it is not watching for the recorder you did *not* start. Two things it cannot do. It only knows the programs in its table, so - line 1
an empty report is not evidence that nothing is recording. And it cannot tell whether a program that is running is actually capturing anything -- a meeting application being open is not somebody watching your screen. Usage: veilvoice - line 1
capture <COMMAND> Commands: status What is running, what is allowed, and what this cannot see list Every program this build knows how to notice calls Where to point Discord, Signal, Telegram, Element and the rest so your voice goes through - line 1
VeilVoice first allow Stop notifying about one program deny Start notifying about one program again check Look now, and exit non-zero if something unallowed is running help Print this message or the help of the given subcommand(s) Options: - line 1
-h, --help Print help (see a summary with '-h')
assets/screenshots/cli-clean.txt
- line 1
Strip identifying metadata from an audio or image file, in place Usage: veilvoice clean [OPTIONS] <FILE> Arguments: <FILE> File to clean Options: --policy <POLICY> Whether to leave plausible placeholder tags behind Possible values: - - line 1
strip: Remove every tag - realistic: Replace tags with plausible, non-identifying values [default: strip] -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-companions.txt
- line 1
Optional third-party software VeilVoice works with, and whether this machine already has it. Nothing here is part of VeilVoice and nothing here is needed to run it. A virtual audio cable is what lets live mode feed a veiled microphone into - line 1
a call; an audio editor is how most people trim a recording first. With no arguments this only *reports*. `--install NAME` is the explicit yes, one named program at a time, and even then VeilVoice will not run somebody else's installer: - line 1
for proprietary software it prints the vendor's page, and for anything needing root it prints the command rather than asking for a password. Usage: veilvoice companions [OPTIONS] Options: --install <NAME> Install one, by name. Without - line 1
this, nothing is installed -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-conversation.txt
- line 1
A recording with several people in it: a voice each, and subtitles. Run an interview through `anonymise` and both people come out as the same voice -- private, and unusable, because nobody can tell a question from its answer. This gives - line 1
each speaker their own destination voice and destroys each voiceprint just as thoroughly. **You say who is talking.** Working that out from the audio needs a trained model; there is none here and no server to ask, and a wrong guess would - line 1
either merge two people or invent a third without anything in the output showing it. So the plan is a text file of turns, or one microphone per person. **What a conversation keeps** is the shape of the conversation: how many people, who - line 1
spoke when, and for how long. That is kept on purpose -- it is what makes the result worth listening to -- and it is information about the conversation. Usage: veilvoice conversation <COMMAND> Commands: inspect Describe a plan: who is in - line 1
it, which voice each gets, and any overlaps fix Correct a plan before rendering it render Render a recording according to a plan preview Draw a still of the page, without rendering any audio help Print this message or the help of the given - line 1
subcommand(s) Options: -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-fix.txt
- line 1
Correct a plan before rendering it. A stretch of audio given to the wrong person is the one mistake here that **cannot be heard in the result**: every voice in a veiled recording is unfamiliar, so a listener has nothing to compare against - line 1
and nobody notices. It has to be right before the render, which is what these are for. Every one of them rewrites the plan file in place and changes nothing else. The audio is not touched and nothing is rendered. Usage: veilvoice - line 1
conversation fix <PLAN> <COMMAND> Commands: reassign Give the stretch of audio at a moment to a different speaker split Cut the stretch at a moment in two merge Join two neighbouring stretches of the same speaker move Move where a stretch - line 1
starts and ends name Change what a speaker is called colour Change the colour a speaker is drawn in help Print this message or the help of the given subcommand(s) Arguments: <PLAN> The plan file to correct Options: -h, --help Print help - line 1
(see a summary with '-h')
assets/screenshots/cli-guard.txt
- line 1
Record and check the integrity of VeilVoice's own files Usage: veilvoice guard [OPTIONS] <COMMAND> Commands: init Record the current state of the watched files check Compare the watched files against the record status Show where the record - line 1
is kept, and what it is worth help Print this message or the help of the given subcommand(s) Options: --path <PATH> Where the record is kept. Defaults to this platform's config directory, beside the app lock -h, --help Print help
assets/screenshots/cli-help.txt
- line 1
VeilVoice destroys the biometric voiceprint of a speaker: the pitch, the formants, the timbre and the melody of an accent. It keeps the words clean and transcribable. This command line talks to no servers, ever: the one thing in VeilVoice - line 1
that reaches the network is the desktop app's check-for-updates button, and it is not here. Usage: veilvoice <COMMAND> Commands: anonymise De-identify an audio file and write a WAV live Scramble a microphone live, into a device or a - line 1
virtual cable record Record yourself already veiled, straight into an encrypted file devices List the audio devices this machine offers clean Strip identifying metadata from an audio or image file, in place encrypt Encrypt a file into a - line 1
`.veil` container decrypt Decrypt a `.veil` container keygen Generate a hybrid post-quantum key pair guard Record and check the integrity of VeilVoice's own files lock Manage the application lock that guards the desktop app watch Show - line 1
which applications are using the microphone and camera shred Securely erase a file, then delete it. Irreversible info Show version and build information accel The graphics hardware here, and what it is good for gui Open the desktop - line 1
application install Copy VeilVoice somewhere the system can find it, and add it to PATH uninstall Undo what `install` did: the PATH entry, the uninstall entry, and the installed copy policy Settings fixed so the interface cannot turn them - line 1
off mandate The two things VeilVoice insists on, unless you say otherwise conversation A recording with several people in it: a voice each, and subtitles capture Which screen recorders are running, and which you meant to run decoy A second - line 1
passphrase that opens an empty VeilVoice, and its limits failsafe The safety catch: what it watches for, and what it cannot do privilege What VeilVoice is running with, and what that lets it see appctl Learn what normally runs here, then - line 1
notice what does not input What running programs can see your keyboard and mouse sentry Canaries, and how fast a folder is changing companions Optional third-party software VeilVoice works with, and whether this machine already has it - line 1
verify Check a download, and what does the checking video Turn a veiled recording into a video with a black picture import Take the sound out of a recording made somewhere else volumes Encrypted volumes this machine has: Cryptomator and - line 1
VeraCrypt help Print this message or the help of the given subcommand(s) Options: - line 41
-h, --help Print help (see a summary with '-h') -V, --version Print version
assets/screenshots/cli-live.txt
- line 1
Scramble a microphone live, into a device or a virtual cable Usage: veilvoice live [OPTIONS] Options: -i, --input <INPUT> Input device name. Defaults to the system default -o, --output <OUTPUT> Output device name. Defaults to a virtual - line 1
cable if one is found --intensity <INTENSITY> How far pitch and formants are pushed from the original, 0.0–1.0 [default: 1] --keep-accent Keep the speaker's accent and intonation intact --reseed-secs <RESEED_SECS> Seconds between rolls of - line 1
the modulation seed. 0 keeps one stream for the whole session [default: 2] --reseed-range <RESEED_RANGE> Draw each gap from a range instead, in milliseconds: `250,1800`. A fixed interval is a fixed thing to observe. With a range, the gap - line 1
before every roll is drawn fresh from the modulation stream, so the ratchet has no period at all. Without this, the range is **drawn from the operating system's random source at launch**, so it is a property of this run rather than a - line 1
number compiled into every copy of VeilVoice. Pass `--reseed-range fixed` to use `--reseed-secs` instead. A value that is not a usable range is **refused with the reason**, never adjusted to fit. --preview Listen to yourself veiled, - line 1
instead of sending it anywhere. Routes the veiled voice to this machine's **default output** rather than to a virtual cable, so it goes to your headphones and to nothing else. This is the way to find out what you sound like, and that the - line 1
microphone is the one you meant, before an interview starts rather than during it. Use headphones. Speakers plus a microphone is a feedback loop. - line 41
--no-monitor Do not draw the level meters. The meters are on by default because the two questions in a live session are "is it hearing me" and "is anything coming out", and a bar answers both at a glance. This turns them off for a terminal - line 41
that is being logged or read by something other than a person. -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-preview.txt
- line 1
Draw a still of the page, without rendering any audio. The layout, the speaker circles and which voice each speaker becomes -- answered in a second rather than in the length of the recording. With `--ffmpeg` it also prints the command that - line 1
would turn frames into a video file, and whether `ffmpeg` is on this machine. **It never runs it**: this project ships no codec and starts no program you did not. Usage: veilvoice conversation preview [OPTIONS] <PLAN> Arguments: <PLAN> The - line 1
plan file Options: --audio <AUDIO> A recording, so the waveform is real rather than flat --at <AT> Which second of the conversation to draw [default: 0] -o, --output <OUTPUT> Where to write the SVG. Defaults to the plan's name --ffmpeg - line 1
Print the ffmpeg command, and whether ffmpeg is installed --size <SIZE> Frame size: monitor, 720p, 1080p, 1440p, 4k, or 1920x1080. This replaces the old `--width` and `--height`, which were two ways to say one thing and could be set to a - line 1
pair no video can be made from. `monitor` matches this display where the platform will say what it is, and says so and uses 1080p where it will not. [default: monitor] --fps <FPS> Frames per second for the printed ffmpeg command, from 5 to - line 1
60 [default: 30] --padding <PADDING> Margin around everything, in pixels - line 41
[default: 48] --background <BACKGROUND> A `#rrggbb` colour, or the path to an image file --black Plain black behind everything. Overrides `--background` --theme <THEME> Colour scheme, from the nine the website and the app offer --one-voice - line 41
Draw it as if every speaker had the same voice -h, --help Print help (see a summary with '-h')
assets/screenshots/cli-render.txt
- line 1
Render a recording according to a plan. Writes the audio and both subtitle formats. Audio that no turn claims is **silenced**, never passed through -- it has not been veiled, and a gap in a plan must not put a real voice into the result. - line 1
How much went is printed. Usage: veilvoice conversation render [OPTIONS] <PLAN> <INPUT> Arguments: <PLAN> The plan file <INPUT> The recording Options: -o, --output <OUTPUT> Where to write the audio. The subtitles take the same name - line 1
--intensity <INTENSITY> 0..1 -- how far the transform pushes [default: 1] --keep-accent Leave the speaker's accent and intonation intact --reseed-secs <RESEED_SECS> Seconds between modulation seed rolls; 0 keeps one stream [default: 2] - line 1
--reseed-range <RESEED_RANGE> Draw each gap from a range instead, in milliseconds: `250,1800`. A fixed interval is a fixed thing to observe. With a range, the gap before every roll is drawn fresh from the modulation stream, so the ratchet - line 1
has no period at all. Without this, the range is **drawn from the operating system's random source at launch**, so it is a property of this run rather than a number compiled into every copy of VeilVoice. Pass `--reseed-range fixed` to use - line 1
`--reseed-secs` instead. A value that is not a usable range is **refused with the reason**, never adjusted to fit. --page - line 41
Also write a self-contained HTML player beside the audio. The waveform, a circle per speaker that lights when they speak, and the subtitles. It reads the audio and the WebVTT track by name from the same directory, so move all of them or - line 41
none. --size <SIZE> Frame size: monitor, 720p, 1080p, 1440p, 4k, or 1920x1080. Read whether or not `--page` was given, so a size that describes no picture fails the same way with and without it. [default: monitor] --padding <PADDING> - line 41
Margin around everything, in pixels [default: 48] --background <BACKGROUND> A `#rrggbb` colour, or the path to an image file --black Plain black behind everything. Overrides `--background` --theme <THEME> Colour scheme, from the nine the - line 41
website and the app offer. Defaults to Tokyo Night. An unknown name is refused, and the error lists every one it could have been. --one-voice Give every speaker the **same** voice. More private: the output then carries no trace of *which* - line 41
speaker somebody was, so two recordings of the same group cannot be lined up by voice. The price is that only the names and the picture say who is speaking -- by ear alone, nobody can. It also has no speaker limit, because one voice cannot - line 41
collide with itself. -h, --help Print help (see a summary with '-h')
assets/screenshots/session-anonymise.txt
- line 1
$ veilvoice keygen Choose a passphrase to protect the private key. Passphrase: Repeat: Deriving key (Argon2id, deliberately slow)... ✓ public key veilvoice.pub ✓ private key veilvoice.key (encrypted) Algorithm X25519 + ML-KEM-768 hybrid - line 1
Share the public key freely. Anyone holding it can encrypt to you; only the private key can open it. $ veilvoice anonymise interview.wav -o veiled.veil --encrypt-to veilvoice.pub Input File interview.wav Duration 3.00 s Sample rate 16000 - line 1
Hz Result Written veiled.veil Speed 112.9x realtime Accent neutralised Seed rolls 1051-1648 ms, drawn fresh before every roll -- no period to observe At rest sealed to a public key (X25519 + ML-KEM-768) ✓ done, and the voiceprint in this - line 1
file is not recoverable Open it again with: veilvoice decrypt <file> -o out.wav
assets/screenshots/session-info.txt
- line 1
$ veilvoice info VeilVoice Version 0.1.22 Engine 0.1.22 Crypto 0.1.22 Audio 0.1.22 Metadata 0.1.22 Monitor 0.1.22 Licence GPL-3.0-or-later Network access none, by construction Live audio available VeilVoice destroys the voiceprint, not the - line 1
words. See docs/WHITEPAPER.md for what that does and does not protect against.
assets/screenshots/session-refusal.txt
- line 1
$ veilvoice anonymise interview.wav -o veiled.wav Input File interview.wav Duration 3.00 s Sample rate 16000 Hz Choose a passphrase for this recording. It is separate from the app lock, and there is no way to recover it. ✗ there is no - line 1
terminal here to ask for a passphrase. This is what happens in a script, a scheduled job, or anything with its input redirected. Run it in a terminal, if somebody is there to type. Or, for the two commands that write a recording, seal it - line 1
to a public key instead, which types nothing and is what works in a script: veilvoice anonymise <FILE> --encrypt-to <PUBKEY> veilvoice encrypt <FILE> --to <PUBKEY> Make the key once, in a terminal, with veilvoice keygen. veilvoice - line 1
anonymise can also write a recording with no encryption at all, using --encrypt false --yes. That leaves every word that was said readable by anyone who gets the file. Nothing was written.
assets/screenshots/session-unencrypted.txt
- line 1
$ veilvoice anonymise interview.wav -o veiled.wav --encrypt false --yes Input File interview.wav Duration 3.00 s Sample rate 16000 Hz ✗ WRITING THIS RECORDING UNENCRYPTED The de-identified recording will be written to disk unencrypted. - line 1
VeilVoice destroys the voiceprint, not the words. Anyone who can read this file can still hear everything that was said: another user, a backup, a sync client, anyone who later gets the disk. Deleting it afterwards is not a fix: on an SSD, - line 1
SD card or USB stick the original blocks can survive every overwrite. That is why at-rest encryption is the default rather than an option you have to find. The file will be created readable only by your account. That is a file permission - line 1
and nothing more. It does not survive a copy, a backup, or anyone who has the disk. Result Written veiled.wav Speed 110.7x realtime Accent neutralised Seed rolls 779-1531 ms, drawn fresh before every roll -- no period to observe At rest - line 1
UNENCRYPTED ✓ done, and the voiceprint in this file is not recoverable The words are still there; that is deliberate. To hide the message as well, encrypt it: veilvoice encrypt ! continuing without at-rest encryption, as asked
assets/screenshots/session-verify.txt
- line 1
$ veilvoice verify auto . Checking what is in . ok embedded key fingerprint 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A ok signature over the hash list is good ok sha256 matches - line 1
(dca6f01e4c39c066894c89d3c70263d88b59f80000292b3e0a2385e400b5ea50) INTACT. veilvoice-v0.1.22-linux-x86_64.tar.gz is byte-for-byte what was published, signed by 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A. That is not the same as knowing it - line 1
was built from the published source -- the same person produced the binary and the list. For that, compare against a hash somebody else produced from their own build: veilvoice verify --explain Checking it again with your own GnuPG found - line 1
at /usr/bin/gpg Your GnuPG keyring already had the VeilVoice signing key 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A. It is a public key: it lets you check signatures and can sign nothing and decrypt nothing. It carries no e-mail address. To - line 1
remove it: gpg --delete-keys 8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A ok your GnuPG agrees: signed by the VeilVoice key And the same thing, typed by you, which is the part this program cannot do for itself: gpg --import - line 1
./veilvoice-signing-key.asc gpg --verify ./SHA256SUMS.asc ./SHA256SUMS sha256sum -c SHA256SUMS --ignore-missing Worth doing. This program checked the signature with a key built into itself, and it came out of the same download you are - line 1
checking. The fingerprint on the website is the independent answer.
tools/audit/actions.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ Every GitHub Action a workflow runs is pinned to a commit, not to a tag. # Why a tag is not a pin `uses: some/action@v2` does not name a version. It names a *label*, - line 1
and whoever owns that repository can move it to any commit they like, at any time, without telling anybody. Two of the labels this project was using were not even tags: `rustsec/audit-check@v2` is a branch, which moves by design. Whatever - line 1
that label points at on the morning a workflow runs is downloaded and executed on a runner that has a checkout of this repository and a token. So an unpinned action is a standing invitation: compromise the action's repository, or simply - line 1
change your mind about what `v2` means, and you are running code inside this project's builds. That is the shape of most of the supply-chain attacks on CI that have actually happened. A 40-character commit SHA cannot be moved. It is the - line 1
only form of `uses:` that says what will run. # Why the version stays in a comment `uses: actions/checkout@3d3c42e5... # v7` keeps the human-readable version where a reader can see it, and Dependabot reads that comment: it updates the SHA - line 1
and the comment together, so pinning does not mean going stale. This check is here because the pin is easy to lose, one paste at a time, and a rule nothing enforces is a rule that decays. Pure standard library. """ from __future__ import - line 1
annotations import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) - line 41
ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) WORKFLOWS = os.path.join(ROOT, ".github", "workflows") # `uses:` lines that name something to download. A local action (`./.github/...`) # and a container (`docker://`) are not tag - line 41
references and are left alone. USES = re.compile(r"^\s*-?\s*uses:\s*(?P<ref>[^\s#]+)") PINNED = re.compile(r"^[^@]+@[0-9a-f]{40}$") def main(): if not os.path.isdir(WORKFLOWS): print(" no workflows to check") return 0 loose, pinned = [], 0 - line 41
for name in sorted(os.listdir(WORKFLOWS)): if not name.endswith((".yml", ".yaml")): continue path = os.path.join(WORKFLOWS, name) with open(path, encoding="utf-8") as handle: for number, line in enumerate(handle, 1): found = - line 41
USES.match(line) if not found: continue ref = found.group("ref") if ref.startswith(("./", "docker://")): continue if PINNED.match(ref): pinned += 1 else: loose.append((name, number, ref)) if loose: print(" these actions are named by a - line 41
label somebody else can move:") for name, number, ref in loose: print(" %s:%d %s" % (name, number, ref)) print() print(" Pin each to the commit the label points at now, keeping the") print(" version in a trailing comment so Dependabot can - line 41
update both:") print(" git ls-remote https://github.com/<owner>/<repo> 'refs/tags/<tag>^{}'") - line 81
print(" uses: <owner>/<repo>@<40-character sha> # <tag>") return 1 print(" %d action reference(s), every one pinned to a commit" % pinned) return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/build_output.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """No build output lives inside this repository except where it is expected. python tools/audit/build_output.py # report, and fail on a stray one There is no `--check`, for - line 1
the same reason `dependencies.py` has none: reading and checking are one run. # What this is for Cargo marks every directory it builds into with a `CACHEDIR.TAG` carrying a fixed signature, so build output announces itself and does not - line 1
have to be guessed at by name. Two such directories are expected here: `target/` at the root, and `fuzz/target/` for the fuzzing package, which is a separate Cargo project on purpose. Anything else is a build nobody asked for. # Why - line 1
nothing was watching, and why nothing would have `.gitignore` carries a bare `target/`, which git matches at **any** depth. That is the right rule, because a stray build directory is certainly not source. It also means such a directory - line 1
never appears in `git status`, never appears in a diff, and is never cleaned, so the only way to notice it is to go looking with `du`. One was found this way: `tools/measured/generate.py` redirected the test build to - line 1
`%LOCALAPPDATA%/veilvoice/target` on Windows and, through a fallback nobody had thought about, to `<repo>/veilvoice/target` on every other machine. It was a second complete copy of the workspace build, inside the repository, rebuilt every - line 1
time the measured numbers were regenerated. It was fifteen gigabytes when it was first found, and what found it was a mutation-testing campaign dying for want of disk, several steps removed from the cause. F-185. So this is not a tidiness - line 1
check. A stray build directory costs the disk twice over, doubles the compile in any pass that regenerates, and hides from every tool that would otherwise report it. Pure standard library, like everything in `tools/`. """ - line 41
import os import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Cargo writes this first line into the `CACHEDIR.TAG` of every directory it # builds into. Matching on it rather than on the name - line 41
`target` means a # directory called `target` that holds something else is left alone, and a # build directory under any other name is still found. SIGNATURE = "Signature: 8a477f597d28d172789f06886806bc55" # The two that belong here, as - line 41
paths relative to the root. EXPECTED = {"target", os.path.join("fuzz", "target")} # Never descended into: git's own storage is large, is not ours, and holds no # build output of this project's making. SKIP = {".git"} def - line 41
build_directories(): """Every directory in the tree that Cargo has marked as build output.""" found = [] for base, names, files in os.walk(ROOT): names[:] = [n for n in names if n not in SKIP] if "CACHEDIR.TAG" not in files: continue tag = - line 41
os.path.join(base, "CACHEDIR.TAG") try: with open(tag, "r", encoding="utf-8", errors="replace") as handle: first = handle.readline().strip() except OSError: continue if first != SIGNATURE: continue found.append(os.path.relpath(base, ROOT)) - line 41
# Nothing below a build directory is worth walking, and some of it is # very deep. names[:] = [] return sorted(found) - line 81
def size_of(relative): """Bytes under `relative`, for saying how much a stray one is costing.""" total = 0 for base, _, files in os.walk(os.path.join(ROOT, relative)): for name in files: try: total += os.path.getsize(os.path.join(base, - line 81
name)) except OSError: pass return total def readable(count): """A size somebody can read, which is the point of reporting it at all.""" for unit in ("B", "KiB", "MiB", "GiB"): if count < 1024 or unit == "GiB": return "%.1f %s" % (count, - line 81
unit) count /= 1024.0 return "%.1f GiB" % count def main(): found = build_directories() strays = [d for d in found if d.replace("/", os.sep) not in EXPECTED] if strays: print("these directories hold build output and are not where it - line 81
belongs:") for stray in strays: print(" %s (%s)" % (stray.replace(os.sep, "/"), readable(size_of(stray)))) print() print( "`.gitignore` matches `target/` at any depth, so none of this shows " "in `git status` and none of it is ever - line 81
cleaned. Find whatever set " "CARGO_TARGET_DIR to a path inside the repository and point it at " "the root `target/` instead, then delete the directory above." ) return 1 if len(found) == 1: print(" 1 build directory, and it is where it - line 81
belongs") else: - line 121
print(" %d build directories, every one where it belongs" % len(found)) return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/crate_tables.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every crate in the workspace appears in every table that lists the crates. python tools/audit/crate_tables.py There is no `--check`: reading and checking are one run. # - line 1
What went wrong Three documents list the crates: the README's Layout table, the README's "use it as a library" table, and `docs/USING_THE_CRATES.md`. They listed thirteen, twelve and thirteen crates. The workspace has twenty-seven. The - line 1
front page of the website renders the README, so the twelve-crate table was what the site said the project was made of, and it had been wrong for fifteen crates' worth of work. Nobody had to do anything careless for that to happen: a crate - line 1
is added, and the tables are somewhere else. # What is checked That each table names every crate it is supposed to name, and names no crate that does not exist. The descriptions are left alone: a sentence assembled from a crate name is - line 1
padding, and every one of these is written by hand. The library tables cover the crates with a `src/lib.rs`, because a binary-only crate is not something anybody can depend on. The Layout table covers every workspace member, because it - line 1
describes the repository. """ import io import os import re import sys ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) # Each table, by the document it is in and the heading it sits under. - line 1
TABLES = [ ("README.md", "the README's \"use it as a library\" table", "libraries"), - line 41
("README.md", "the README's Layout table", "all"), ("docs/USING_THE_CRATES.md", "docs/USING_THE_CRATES.md's crate table", "libraries"), ] ROW = re.compile(r"^\|\s*`(?P<crate>veilvoice-[a-z]+)`\s*\|", re.M) def read(relative): with - line 41
io.open(os.path.join(ROOT, relative), encoding="utf-8") as handle: return handle.read() def crates(): """Every workspace member, and which of them are libraries.""" everything, libraries = set(), set() directory = os.path.join(ROOT, - line 41
"crates") for name in sorted(os.listdir(directory)): if not os.path.isfile(os.path.join(directory, name, "Cargo.toml")): continue everything.add(name) if os.path.isfile(os.path.join(directory, name, "src", "lib.rs")): libraries.add(name) - line 41
return everything, libraries def tables(text): """The crate rows of each table in a document, in the order they appear. A table ends at the first line that is not one of its rows, so two tables in one document do not run together. """ - line 41
found, current = [], [] for line in text.splitlines(): match = ROW.match(line) if match: current.append(match.group("crate")) elif current: found.append(current) current = [] if current: - line 81
found.append(current) return found def main(): everything, libraries = crates() problems = [] by_document = {} for relative, _, _ in TABLES: if relative not in by_document: by_document[relative] = tables(read(relative)) seen = {} for - line 81
relative, description, scope in TABLES: index = seen.get(relative, 0) seen[relative] = index + 1 found = by_document[relative] if index >= len(found): problems.append("%s no longer exists" % description) continue listed = set(found[index]) - line 81
wanted = libraries if scope == "libraries" else everything for crate in sorted(wanted - listed): problems.append("%s does not list %s" % (description, crate)) for crate in sorted(listed - wanted): reason = ("is not a crate in this - line 81
workspace" if crate not in everything else "has no src/lib.rs, so nothing can depend on it") problems.append("%s lists %s, which %s" % (description, crate, reason)) if problems: print("the crate tables disagree with the workspace:") for - line 81
problem in problems: print(" %s" % problem) print() print( "The website's front page renders the README, so a table that is " "short is the site telling a reader the project is smaller than it " - line 121
"is. Add the row, with a sentence somebody wrote." ) return 1 print(" %d crates, %d of them libraries, in all %d tables" % (len(everything), len(libraries), len(TABLES))) return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/dependabot.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every manifest in this tree is covered by a Dependabot entry. python tools/audit/dependabot.py # report, and fail on any gap There is no `--check`, for the reason - line 1
`dependencies.py` has none: this tool only reads, so reporting and checking are the same run and there is no mode in which it passes quietly over a manifest nobody monitors. # What this is for `.github/dependabot.yml` names the directories - line 1
to watch. Nothing in Cargo or in GitHub Actions tells it when a new one appears, so a crate added outside the workspace, a second workflow directory, or a `package.json` for a website that grew a real dependency would all be unmonitored, - line 1
and would be unmonitored *quietly*. That is the failure this is built to make loud: not a dependency with a known problem, but one nothing is looking at. `CLAUDE.md` asks that a fact existing in more than one place be derived or checked - line 1
rather than repeated. The list of directories to monitor exists in the configuration and in the tree, and the tree is the one that is true. This is the check that keeps them the same. # What it does not check Whether Dependabot is - line 1
*enabled* for the repository, and whether alerts are on. Those are repository settings rather than files, and a tool that read the tree and reported on a setting it cannot see would be guessing. Nor does it validate the whole file as YAML. - line 1
This project ships no YAML parser and will not add one to read one file it wrote itself; what it reads is the `package-ecosystem` and `directory` pairs, which have one shape. Pure standard library, like everything in `tools/`. """ import - line 1
os import sys - line 41
HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) CONFIG = os.path.join(ROOT, ".github", "dependabot.yml") # The manifest each ecosystem is recognised by. Only the ones this repository - line 41
# can actually grow are listed: adding every ecosystem GitHub supports would be # a list to keep true for no benefit, and an unknown manifest appearing is # caught by a person rather than by a longer table. MANIFESTS = { "cargo": - line 41
("Cargo.toml",), "npm": ("package.json",), "bundler": ("Gemfile",), "pip": ("requirements.txt", "pyproject.toml"), } # Directories that are not this project's code and are never monitored. SKIP = {".git", "target", "node_modules", "wiki", - line 41
".dependabot"} def entries(): """The (ecosystem, directory) pairs the configuration declares. Read line by line rather than parsed as YAML. An entry opens with `- package-ecosystem:` and its `directory:` follows within the same block, - line 41
which is the only shape this file has and the only one it is allowed to grow: a second `directory` under one ecosystem would be read as a second entry here and would be reported, which is the safe direction to be wrong in. """ if not - line 41
os.path.isfile(CONFIG): return None found = [] ecosystem = None with open(CONFIG, "r", encoding="utf-8") as handle: for line in handle: stripped = line.strip() if stripped.startswith("#"): continue if "package-ecosystem:" in stripped: - line 81
ecosystem = stripped.split("package-ecosystem:", 1)[1].strip().strip("\"'") continue if stripped.startswith("directory:") and ecosystem: directory = stripped.split("directory:", 1)[1].strip().strip("\"'") found.append((ecosystem, - line 81
directory)) ecosystem = None return found def ignored(): """Every `ignore` entry: the dependency, and the update types it silences. Line by line, for the same reason `entries` is: this file is read by something that is not here, so the - line 81
check has to read what is written rather than what a YAML library can be talked into. """ out, name, types, inside = [], None, None, False with open(CONFIG, "r", encoding="utf-8") as handle: for line in handle: stripped = line.strip() if - line 81
stripped.startswith("#"): continue if stripped == "ignore:": inside = True continue if inside and stripped and not stripped.startswith(("-", "update-types:")): # Any other key at this level ends the ignore block. if ":" in stripped and not - line 81
stripped.startswith("dependency-name:"): if name is not None: out.append((name, types)) name, types = None, None inside = False continue if not inside: continue if "dependency-name:" in stripped: if name is not None: out.append((name, - line 81
types)) types = None name = stripped.split("dependency-name:", 1)[1].strip().strip("\"'") - line 121
elif stripped.startswith("update-types:"): types = stripped.split("update-types:", 1)[1].strip() if name is not None: out.append((name, types)) return out def declared_dependencies(): """Every dependency name any manifest in this tree asks - line 121
for.""" names = set() for current, directories, files in os.walk(ROOT): directories[:] = [d for d in directories if d not in SKIP] if "Cargo.toml" not in files: continue with open(os.path.join(current, "Cargo.toml"), "r", encoding="utf-8") - line 121
as handle: for line in handle: stripped = line.strip() if stripped.startswith("#") or "=" not in stripped: continue key = stripped.split("=", 1)[0].strip().strip("\"'") if key and all(c.isalnum() or c in "-_" for c in key): names.add(key) - line 121
return names def workspace_members(): """The directories the root `Cargo.toml` already covers through the workspace. Cargo is workspace-aware to Dependabot: an entry on the root reaches every member through the one `Cargo.lock`, so a - line 121
member needing an entry of its own would be twenty-seven pull requests for one bumped version. What needs its own entry is a manifest the workspace does **not** reach, which is what this exists to tell apart. """ root = os.path.join(ROOT, - line 121
"Cargo.toml") if not os.path.isfile(root): return set() covered = set() inside = False with open(root, "r", encoding="utf-8") as handle: - line 161
for line in handle: stripped = line.strip() if stripped.startswith("["): inside = stripped.strip("[]") == "workspace" continue if not inside or stripped.startswith("#"): continue # `members = ["crates/*"]`, possibly over several lines. - line 161
Only the # quoted pieces matter, and a trailing `/*` is a directory of them. for piece in stripped.split('"')[1::2]: if piece.endswith("/*"): parent = os.path.join(ROOT, piece[:-2]) if os.path.isdir(parent): for name in - line 161
sorted(os.listdir(parent)): if os.path.isfile(os.path.join(parent, name, "Cargo.toml")): covered.add("/%s/%s" % (piece[:-2].strip("/"), name)) elif piece: covered.add("/%s" % piece.strip("/")) return covered def found_in_tree(): """Every - line 161
(ecosystem, directory) this repository actually has a manifest for.""" found = set() for here, directories, files in os.walk(ROOT): directories[:] = [d for d in directories if d not in SKIP and not d.startswith(".")] relative = - line 161
os.path.relpath(here, ROOT).replace(os.sep, "/") where = "/" if relative == "." else "/%s" % relative for ecosystem, names in MANIFESTS.items(): if any(name in files for name in names): found.add((ecosystem, where)) # The workflows, which - line 161
live in a directory the walk above skips because it # begins with a dot. Dependabot addresses them as "/" rather than as the # directory they are in, which is its own convention and is why this is # separate rather than another row in the - line 161
table. if os.path.isdir(os.path.join(ROOT, ".github", "workflows")): found.add(("github-actions", "/")) return found - line 201
def main(): declared = entries() if declared is None: print("there is no .github/dependabot.yml, so nothing is monitored.") print() print( "Dependabot reads that path on the default branch and no other. " "A configuration anywhere else, - line 201
committed or not, does nothing." ) return 1 covered_by_workspace = workspace_members() have = set(declared) gaps = [] for ecosystem, where in sorted(found_in_tree()): if (ecosystem, where) in have: continue if ecosystem == "cargo" and - line 201
where in covered_by_workspace: # Reached through the root entry, if there is one. if ("cargo", "/") in have: continue gaps.append( "%s in %s has no entry, so nothing is watching it" % (ecosystem, where) ) # And the other direction: an - line 201
entry naming a directory that is not there # any more. Dependabot ignores it silently, and a configuration carrying a # line about a crate that was deleted is the kind of thing somebody later # reads as evidence that the crate exists. for - line 201
ecosystem, where in declared: if where == "/": continue if not os.path.isdir(os.path.join(ROOT, where.strip("/"))): gaps.append( "%s names %s, which is not in this tree any more" % (ecosystem, where) ) # And the `ignore` list, which is the - line 201
part of this file that can quietly - line 241
# stop doing what it says. Two ways it goes wrong, both silent: # # * an entry naming a dependency that is no longer in any manifest. It # does nothing, and it reads as evidence of a decision about something # this project still uses. # * - line 241
an entry with no `update-types`. That silences *every* update for # that dependency, including the security update, which is the exact # opposite of what this file exists to do. Every entry here is meant to # hold back a major while - line 241
letting patches through. have_dependency = declared_dependencies() for name, types in ignored(): if name not in have_dependency: gaps.append( "the ignore list names %s, which no manifest asks for any more" % name ) if not types: - line 241
gaps.append( "the ignore for %s has no update-types, so it silences that " "dependency's security updates too" % name ) if gaps: print("the Dependabot configuration and this tree disagree:") for gap in gaps: print(" %s" % gap) print() - line 241
print( "Fix .github/dependabot.yml. Add an entry for a manifest nothing " "covers, take out one naming a directory that has gone, drop an " "ignore for a dependency this tree no longer asks for, or give a " "bare ignore its update-types. A - line 241
manifest nothing watches is worse " "than one with a known problem, because nobody is looking, and an " "ignore with no update-types is worse still: it looks like a " "held-back major and silences the advisory as well." ) return 1 print( " - line 241
%d Dependabot entries, covering every manifest in the tree; " - line 281
"%d held-back major(s), every one still used and none silencing a " "security update" % (len(declared), len(ignored())) ) for ecosystem, where in declared: print(" %-16s %s" % (ecosystem, where)) return 0 if __name__ == "__main__": - line 281
sys.exit(main())
tools/audit/dependencies.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every dependency says what it is for, where it is declared. python tools/audit/dependencies.py # report, and fail on any gap There is no `--check`: this tool only reads. - line 1
Reporting and checking are the same run, so there is no mode in which it passes quietly over a dependency nobody explained. Roadmap item 126. A dependency is a decision: it is code this project ships and does not review, it is build time - line 1
on every machine that compiles this, and on the BSDs and the 32-bit targets it is one more thing that has to work. The decision is worth one sentence at the moment it is made, and the moment it is made is the line in the manifest. Written - line 1
after the pass that found `sha2` in `veilvoice-verify`, `hex` in its tests and `hex-literal` in the crypto crate's, none of which any line of code referred to. All three had been compiled by every build on every platform for however long - line 1
they had been there. None of them was noticed by reading the manifests, because a manifest full of bare names reads as a list rather than as a set of decisions; they were noticed by asking what each one was *for* and finding that three had - line 1
no answer. **This does not check that a dependency is used.** That is `cargo udeps` and it needs nightly. What it checks is that somebody said why, which is the part a tool cannot do and the part that makes the unused ones visible. # What - line 1
counts as saying why A comment line directly above the entry, or a trailing comment on it. Both forms are already in this tree and both read fine in a manifest, so neither is imposed on the other. Sections named `dependencies`, - line 1
`dev-dependencies` and `build-dependencies` are covered, including the platform-specific ones under `target.'cfg(...)'`, and `[workspace.dependencies]` at the root. Feature lists, profiles and metadata are not dependencies and are left - line 1
alone. Pure standard library, like everything in `tools/`. - line 41
""" import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) def manifests(): """Every Cargo.toml this project owns, the fuzzing package included. `fuzz/` is excluded - line 41
from the workspace and is still Rust written here, so its dependencies are decisions on the same terms. `tools/docs/generate.py` documents it beside the members for the same reason. """ found = [os.path.join(ROOT, "Cargo.toml")] crates = - line 41
os.path.join(ROOT, "crates") for name in sorted(os.listdir(crates)): path = os.path.join(crates, name, "Cargo.toml") if os.path.isfile(path): found.append(path) fuzz = os.path.join(ROOT, "fuzz", "Cargo.toml") if os.path.isfile(fuzz): - line 41
found.append(fuzz) return found def is_dependency_section(name): """Whether a `[...]` header opens a list of dependencies. `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`, `[workspace.dependencies]` and the - line 41
`[target.'cfg(windows)'.dependencies]` family all end in one of those words. `[features]` and `[profile.release]` do not, and `[package.metadata]` does not, so nothing else is caught by matching on the last segment. """ last = - line 41
name.split(".")[-1] return last in ("dependencies", "dev-dependencies", "build-dependencies") - line 81
def unexplained(path): """The dependency entries in `path` with no reason beside them.""" with open(path, "r", encoding="utf-8") as handle: lines = handle.read().replace("\r\n", "\n").split("\n") problems = [] section = None for number, - line 81
line in enumerate(lines): stripped = line.strip() if stripped.startswith("["): section = stripped.strip("[]") continue if not stripped or stripped.startswith("#"): continue if section is None or not is_dependency_section(section): continue - line 81
if "=" not in stripped: continue name = stripped.split("=")[0].strip().split(".")[0] above = lines[number - 1].strip() if number else "" trailing = "#" in line.split("=", 1)[1] if above.startswith("#") or trailing: continue - line 81
problems.append((number + 1, name, section)) return problems def counted(path): """How many dependency entries `path` declares.""" with open(path, "r", encoding="utf-8") as handle: lines = handle.read().replace("\r\n", "\n").split("\n") - line 81
section = None total = 0 for line in lines: stripped = line.strip() if stripped.startswith("["): section = stripped.strip("[]") elif ( - line 121
section is not None and is_dependency_section(section) and stripped and not stripped.startswith("#") and "=" in stripped ): total += 1 return total def advisory_ids(path): """The RUSTSEC identifiers a policy file ignores, in the order - line 121
written.""" found = [] with open(path, "r", encoding="utf-8") as handle: for line in handle: stripped = line.split("#", 1)[0].strip() if stripped.startswith('"RUSTSEC-'): found.append(stripped.strip('",')) return found def - line 121
policies_disagree(): """Where `.cargo/audit.toml` and `deny.toml` ignore different advisories. cargo-audit and cargo-deny each read their own file and neither reads the other's, so the same three exceptions are written twice. The arguments - line 121
live in `.cargo/audit.toml`; `deny.toml` carries the identifiers and a pointer. Two lists that are meant to be one list drift the first time somebody edits one of them, so this says which identifiers are on one side only. Both are read as - line 121
text rather than parsed, because the standard library gained a TOML reader in 3.11 and this runs on 3.10 too. """ audit = set(advisory_ids(os.path.join(ROOT, ".cargo", "audit.toml"))) deny = set(advisory_ids(os.path.join(ROOT, - line 121
"deny.toml"))) problems = [] for ident in sorted(audit - deny): problems.append("%s is ignored in .cargo/audit.toml and not in deny.toml" % ident) for ident in sorted(deny - audit): problems.append("%s is ignored in deny.toml and not in - line 121
.cargo/audit.toml" % ident) return problems - line 161
def main(): disagreements = policies_disagree() if disagreements: print("the two advisory policies do not ignore the same advisories:") for problem in disagreements: print(" %s" % problem) print() print( "The argument for an exception is - line 161
written once, in .cargo/audit.toml; " "deny.toml repeats the identifier and nothing else. Make the lists match." ) return 1 problems = [] total = 0 for path in manifests(): relative = os.path.relpath(path, ROOT).replace(os.sep, "/") total - line 161
+= counted(path) for number, name, section in unexplained(path): problems.append("%s:%d: %s (in [%s])" % (relative, number, name, section)) if problems: print("these dependencies do not say what they are for:") for problem in problems: - line 161
print(" %s" % problem) print() print( "Write one sentence above each, or after it on the same line, " "saying what this project calls in it. If there is no answer, " "that is the answer: take it out." ) return 1 print(" %d dependencies, - line 161
every one of them explained where it is declared" % total) print(" %d advisory exceptions, the same in both policy files" % len(advisory_ids( os.path.join(ROOT, "deny.toml")))) return 0 - line 201
if __name__ == "__main__": sys.exit(main())
tools/audit/features.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every feature selection a release builds is built here too. python tools/audit/features.py # print the selections, and check them python tools/audit/features.py --build - line 1
# and compile each one # The failure this exists to catch `veilvoice-audio` has a `live` feature, off on the BSDs and on the musl and cross targets because `cpal` has no backend for them. That is nine of the twelve jobs in the release - line 1
workflow, and **no job in CI built that configuration at all**: every `cargo` line in `ci.yml` takes the default features, so a module that compiles only with `live` on passed every check on every platform and then failed nine release jobs - line 1
at once. That is exactly what happened. `pub mod record;` lost its `#[cfg(feature = "live")]` when a `playback` module was inserted above it and took over the attribute line that had belonged to `record`; the crate then built everywhere CI - line 1
looked and nowhere it did not. It was found by dispatching a release, two days and four commits later, which is the slowest and most expensive way this repository has ever found a compile error. # Why it reads the workflow `CLAUDE.md` asks - line 1
that a fact living in more than one place be derived rather than repeated. The set of feature selections a release builds lives in `release.yml`, and that is the copy that is true: a target added to the matrix with a new selection must be - line 1
built by this check without anybody remembering to add it here. So the selections are read out of the workflow, and a workflow rewritten into a shape this cannot read fails rather than quietly checking nothing. Pure standard library, like - line 1
everything in `tools/`. """ import argparse import os import re import subprocess - line 41
import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) WORKFLOW = os.path.join(ROOT, ".github", "workflows", "release.yml") # The selection every native target uses. Read from - line 41
the workflow like the # others, but named here because it is the one CI already builds and so is # reported rather than compiled again. ALREADY_IN_CI = "--workspace" def selections(): """The `cargo build` argument sets the release workflow - line 41
uses. Each arrives as `CARGO_ARGS=<arguments>` in the step that decides what a target builds. Read as text rather than as YAML for the reason `dependabot.py` gives: this project ships no YAML parser and will not add one to read a file it - line 41
wrote itself. """ if not os.path.isfile(WORKFLOW): return None with open(WORKFLOW, "r", encoding="utf-8") as handle: body = handle.read() found = [] for match in re.finditer(r'echo\s+"CARGO_ARGS=([^"]+)"', body): arguments = - line 41
match.group(1).strip() if arguments not in found: found.append(arguments) return found # Where these builds go, and why it is not `target/`. # # `-p veilvoice-cli --no-default-features` produces a binary called `veilvoice` # with no live - line 41
mode in it, and the ordinary build produces one with live mode in # it under the same name. Sharing a directory means whichever ran last is the # one every other check reads, and `tools/shots/terminal.py` reads it to ask # what `veilvoice - line 41
live --help` prints. Running this tool would then fail that # check with a mismatch that is nothing to do with the drawings, which is - line 81
# exactly the confusion it happened to cause the day it was written. # # So this compiles somewhere of its own. The cost is that these selections do # not share the main build's cache and are compiled from scratch the first time. TARGET = - line 81
os.path.join("target", "feature-audit") def build(arguments): """Compile one selection, and say what happened.""" command = ["cargo", "build", "--release", "--locked"] + arguments.split() print(" %s" % " ".join(command)) where = - line 81
dict(os.environ, CARGO_TARGET_DIR=os.path.join(ROOT, TARGET)) finished = subprocess.run(command, cwd=ROOT, env=where) return finished.returncode == 0 def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - line 81
"--build", action="store_true", help="compile each selection rather than only listing it", ) options = parser.parse_args() found = selections() if found is None: print("there is no .github/workflows/release.yml, so nothing builds a - line 81
release.") return 1 # A workflow whose shape changed under this would otherwise report success # over an empty list, which is the way a check stops checking without # anybody noticing. if len(found) < 2: print( "only %d feature selection - line 81
could be read out of release.yml, and a " "release builds at least two: the whole workspace on the native " "targets and the command line alone where cpal has no backend. The " "workflow has changed shape and this check is no longer - line 81
reading it." % len(found) - line 121
) return 1 print(" %d feature selections in the release workflow" % len(found)) for arguments in found: note = " (built by the test job already)" if arguments == ALREADY_IN_CI else "" print(" %s%s" % (arguments, note)) if not - line 121
options.build: return 0 failed = [] for arguments in found: if arguments == ALREADY_IN_CI: # Built by `cargo build --workspace --release` in the test job, on # every platform rather than only this one. Building it twice would # add four - line 121
minutes to say the same thing. continue print() if not build(arguments): failed.append(arguments) if failed: print() print("these feature selections do not compile:") for arguments in failed: print(" cargo build --release %s" % arguments) - line 121
print() print( "A release builds each of these, so this would have failed the " "release workflow instead. The usual cause is code reachable " "without a feature that needs it: check that every module, import " "and enum variant guarded by - line 121
a `#[cfg(feature = ...)]` still " "carries its own attribute." ) return 1 return 0 - line 161
if __name__ == "__main__": sys.exit(main())
tools/audit/publishing.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The step that publishes a release says which commit it is publishing. python tools/audit/publishing.py # report, and fail on any gap No `--check`, for the reason the - line 1
other tools here have none: this only reads, so reporting and checking are one run. # What this is for A release is a claim that these binaries came from this source. Everything in `release.yml` above the publishing step exists to support - line 1
it: each binary is built twice in different directories and compared, the hashes are signed, and the notes tell a reader how to reproduce the build themselves from the tag. The tag is the load-bearing part of that sentence, and creating a - line 1
release for a tag that does not exist yet makes GitHub create the tag. With no `target_commitish` it creates it **at the default branch**, not at the commit the run compiled. The reader then checks out the tag, rebuilds, gets different - line 1
bytes, and correctly concludes the release does not reproduce. That is not hypothetical. It is F-170: v0.1.20 and v0.1.21 were both tagged at whatever `main` happened to be, thirteen minutes and one branch away from what they actually - line 1
published. # And the other half `release.yml` reads a release's notes out of `CHANGELOG.md` by matching `## v<version>` exactly. It used to print "No changelog section found" into the notes and publish anyway, which is how v0.1.21 went out - line 1
describing nothing at all. The step must be able to fail, and this checks that the refusal is still there, because a guard that is deleted in an edit is a guard nobody notices the absence of. `tools/release/version.py` asks the matching - line 1
question of the changelog itself, before a release is ever dispatched. This asks it of the workflow. Pure standard library, like everything in `tools/`. """ - line 41
import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) WORKFLOW = os.path.join(ROOT, ".github", "workflows", "release.yml") # The action that creates the - line 41
release. Named rather than matched loosely, so # swapping it for another one is a decision somebody makes here too, with this # file's argument in front of them. PUBLISHER = "softprops/action-gh-release" def main(): if not - line 41
os.path.isfile(WORKFLOW): print("there is no .github/workflows/release.yml, so nothing publishes.") return 1 with open(WORKFLOW, "r", encoding="utf-8") as handle: lines = handle.read().splitlines() gaps = [] # ---- the publishing step - line 41
names the commit it is publishing -------------- steps = [i for i, line in enumerate(lines) if PUBLISHER in line and not line.strip().startswith("#")] if not steps: gaps.append( "no step uses %s, so this check cannot see how the release is - line 41
" "created and is checking nothing" % PUBLISHER ) for at in steps: # The step's `with:` block: everything more indented than the `- uses:` # line, up to the next thing at that indentation or less. indent = len(lines[at]) - - line 41
len(lines[at].lstrip()) body = [] for line in lines[at + 1:]: if line.strip() and (len(line) - len(line.lstrip())) <= indent: break - line 81
body.append(line) block = "\n".join(body) if "target_commitish:" not in block: gaps.append( "the %s step (line %d) sets no target_commitish, so GitHub " "would create the tag at the default branch rather than at the " "commit this run - line 81
built" % (PUBLISHER, at + 1) ) elif not re.search(r"target_commitish:\s*\$\{\{\s*github\.sha\s*\}\}", block): gaps.append( "the %s step (line %d) sets target_commitish to something " "other than github.sha, so the tag may not name the - line 81
commit " "that was built" % (PUBLISHER, at + 1) ) # ---- a release with no notes does not publish --------------------------- # # The workflow still *writes* "No changelog section found" into the notes, # and that is correct: a dry run - line 81
publishes nothing, so it is allowed to # report the gap and carry on. What must exist is the refusal on the path # that does publish. That is what is checked, rather than the presence of # the sentence, because forbidding the sentence - line 81
would fail the dry run's # honest report along with the defect. body = "\n".join(lines) if "has no '## $tag' section" not in body: gaps.append( "release.yml no longer refuses to publish when CHANGELOG.md has no " "section for the version, - line 81
so a heading written in the wrong shape " "would silently produce an empty release" ) if gaps: print("the release workflow would publish something it should not:") for gap in gaps: print(" %s" % gap) print() print( "A release is a claim - line 81
that these binaries came from this source. " "A tag that does not name what was built, or notes that say the " "notes are missing, breaks that claim rather than weakening it." - line 121
) return 1 print(" the release step tags the commit it built, and refuses empty notes") return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/randomness.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ Every random number this project draws comes from a cryptographic source. # Why this is a guard and not a habit A de-identifier whose randomness is predictable is a - line 1
de-identifier that does not work. The seed that drives the veiling is what stands between a recording and the voice it came from, and a nonce reused is an AEAD broken. None of that degrades gracefully: a weak draw produces output that - line 1
looks exactly like a strong one, and nothing downstream notices. Rust makes the wrong thing easy to reach. `rand::thread_rng`, `SmallRng` and `StdRng` are the three most obvious names in the most obvious crate, they are what most examples - line 1
use, and none of them promises what this project needs. `SmallRng` says in its own documentation that it is not cryptographically secure. `seed_from_u64` takes 64 bits where 256 are wanted. # What is allowed * `getrandom`, which is the - line 1
operating system's CSPRNG and the source everything else here is built on. * `rand_core::OsRng`, a thin wrapper over the same call. * `ChaCha20Rng`, but only seeded from one of those. It is a CSPRNG, and it is used where a draw has to be - line 1
reproducible from a seed: the veiling is deterministic for a given seed by design, which is what lets the tests assert anything about it at all. # What this deliberately does not read Test code. A test that wants a fixed sequence should - line 1
have one, and `from_seed([0u8; 32])` in a test is the opposite of a defect: it is what makes the assertion possible. The walk stops at `mod tests`, `tests.rs`, `tests/`, fuzz targets and examples. Pure standard library. """ from __future__ - line 1
import annotations - line 41
import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) CRATES = os.path.join(ROOT, "crates") # Names that do not promise a cryptographic draw, each with what - line 41
to use instead. FORBIDDEN = { "thread_rng": "getrandom::getrandom, or ChaCha20Rng seeded from it", "SmallRng": "ChaCha20Rng; SmallRng documents itself as not cryptographically secure", "StdRng": "ChaCha20Rng, which names the algorithm - line 41
rather than leaving it to the crate", "seed_from_u64": "from_seed with 32 bytes out of getrandom; 64 bits is not a seed", "from_entropy": "getrandom::getrandom directly, so a failure can be reported", } PATTERN = re.compile(r"\b(%s)\b" % - line 41
"|".join(FORBIDDEN)) def is_test(path): parts = path.replace(os.sep, "/") return ("/tests/" in parts or parts.endswith("/tests.rs") or parts.endswith("_fuzz.rs") or "/examples/" in parts or "/fuzz/" in parts or parts.endswith("build.rs")) - line 41
def production_lines(path): """Every line of a file that is not inside its test module.""" out = [] in_tests = False with open(path, encoding="utf-8") as handle: for number, line in enumerate(handle, 1): if re.match(r"\s*mod tests\b", - line 41
line): in_tests = True if in_tests: continue out.append((number, line)) return out - line 81
def main(): offenders, scanned = [], 0 for current, directories, files in os.walk(CRATES): directories[:] = [d for d in directories if d != "target"] for name in sorted(files): if not name.endswith(".rs"): continue path = - line 81
os.path.join(current, name) if is_test(path): continue scanned += 1 for number, line in production_lines(path): stripped = line.strip() if stripped.startswith("//"): continue found = PATTERN.search(line) if found: offenders.append( - line 81
(os.path.relpath(path, ROOT), number, found.group(1))) if offenders: print(" these draw randomness from a source that is not cryptographic:") for where, number, what in offenders: print(" %s:%d %s" % (where, number, what)) print(" use %s" - line 81
% FORBIDDEN[what]) print() print(" A weak draw produces output that looks exactly like a strong") print(" one, so nothing downstream will ever notice this for you.") return 1 print(" %d production source file(s), every random draw from the - line 81
OS CSPRNG" % scanned) return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/reachable.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every public item is named by something other than its own declaration. python tools/audit/reachable.py # report, and fail on anything reached # by nothing at all There - line 1
is no `--check`, for the same reason `dependencies.py` has none: reading and checking are one run, so there is no mode in which this passes quietly over an item nobody calls. # What this asks, and what it does not It asks the narrowest - line 1
version of the question: is this name written down anywhere in the workspace apart from the line that declares it. Not "does a released binary reach it", not "is it on a path a user can take". Just whether anything at all, a caller or a - line 1
test, ever says the name. That is deliberately weak, and the weakness is the point. A public item reached only by its own tests is a normal and often correct thing: a constructor a front end has not been written for yet, a reader kept - line 1
beside a writer so the format has two sides. Twenty-six such items were counted in this tree the day this was written and every one of them was fine. An item reached by **nothing** is a different thing. It is code that is compiled, - line 1
documented, published into the generated reference and the wiki, carried by every build on every platform, and answerable to nobody, because there is no caller whose behaviour would change if it were wrong. # Why the compiler cannot answer - line 1
this `dead_code` stops at the crate boundary. Anything `pub` in a library might be called by a consumer the compiler cannot see, so it is never reported, and in a workspace whose libraries have exactly two consumers, both in the workspace, - line 1
that is a whole class of defect nothing was looking at. It is worse than silence. A public accessor keeps its private field alive: the field is read, by the accessor, so `dead_code` says nothing about the field either, and both survive - line 1
together. `VaultStore::last_audit` and the `audit` field behind it were found that way, together with a `clone` of the audit result performed on every vault open for a value no line of code ever read. - line 41
# The shape of the check A declaration is `pub fn`, `pub struct`, `pub enum`, `pub trait`, `pub const`, `pub static`, `pub type` or `pub mod` at the start of a line, with or without a restriction like `pub(crate)`. Declarations inside a - line 41
`#[cfg(test)]` module are not the subject: a test helper is reached by its test by construction. A mention is the name appearing as a word anywhere in any `.rs` file in `crates/`, on any line but the declaration's own. Method-call syntax, - line 41
a trait impl, a macro, a glob re-export and a doc link all contain the name, so all of them count. Sibling declarations of the same name in other crates count too, which makes this check slightly *more* forgiving than it looks and is the - line 41
right direction for a guard to err in. Pure standard library, like everything in `tools/`. """ import collections import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) CRATES = - line 41
os.path.join(ROOT, "crates") DECLARATION = re.compile( r'^\s*pub(?:\([^)]*\))?\s+(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?' r"(fn|struct|enum|trait|const|static|type|mod)\s+([A-Za-z_][A-Za-z0-9_]*)" ) WORD = - line 41
re.compile(r"[A-Za-z_][A-Za-z0-9_]*") CFG_TEST = re.compile(r"\s*#\[cfg\(test\)\]") # Names that are declared and named nowhere else on purpose. Empty, and meant to # stay that way: an entry here is an argument that something unreachable - line 41
should # be kept, which is a thing to write out in full at the moment it is made rather # than a line to add so a build goes green. EXEMPT = {} - line 81
def sources(): """Every Rust file under `crates/`, in a stable order.""" found = [] for base, _, names in os.walk(CRATES): for name in sorted(names): if name.endswith(".rs"): found.append(os.path.join(base, name)) return sorted(found) def - line 81
test_lines(lines): """Mark the lines inside each `#[cfg(test)]` module. Brace counting from the attribute to the close of the module it guards. Crude, and sufficient: these modules are written one way in this tree, they are always at the - line 81
end of a file, and being wrong here can only make the check more forgiving, never less. """ inside = [False] * len(lines) index = 0 while index < len(lines): if not CFG_TEST.match(lines[index]): index += 1 continue opening = index while - line 81
opening < len(lines) and "{" not in lines[opening]: opening += 1 depth = 0 started = False cursor = opening while cursor < len(lines): depth += lines[cursor].count("{") - lines[cursor].count("}") if "{" in lines[cursor]: started = True if - line 81
started and depth <= 0: break cursor += 1 for line in range(index, min(cursor + 1, len(lines))): inside[line] = True index = cursor + 1 - line 121
return inside def read(): """Return the declarations and the set of places every word is written.""" declarations = [] mentions = collections.defaultdict(list) for path in sources(): with open(path, "r", encoding="utf-8", errors="replace") - line 121
as handle: lines = handle.read().split("\n") inside = test_lines(lines) for number, line in enumerate(lines): if not inside[number]: found = DECLARATION.match(line) if found: declarations.append((path, number, found.group(1), - line 121
found.group(2))) for word in WORD.findall(line): mentions[word].append((path, number)) return declarations, mentions def unreached(declarations, mentions): """Declarations whose name appears nowhere but on their own line.""" problems = [] - line 121
for path, number, kind, name in declarations: if name in EXEMPT: continue elsewhere = [ place for place in mentions[name] if place != (path, number) ] if not elsewhere: relative = os.path.relpath(path, ROOT).replace(os.sep, "/") - line 121
problems.append("%s:%d: pub %s %s" % (relative, number + 1, kind, name)) return problems def main(): declarations, mentions = read() problems = unreached(declarations, mentions) if problems: - line 161
print("these public items are named by nothing but their own declaration:") for problem in problems: print(" %s" % problem) print() print( "Each is compiled into every build, published into the generated " "reference and the wiki, and - line 161
answerable to no caller. Give it one, " "or a test, or take it out. `dead_code` cannot see any of this: a " "public item might have a consumer outside the crate, and an " "accessor keeps its private field alive while it does." ) return 1 - line 161
print( " %d public items, every one of them named somewhere other than its " "own declaration" % len(declarations) ) return 0 if __name__ == "__main__": sys.exit(main())
tools/audit/rsa_usage.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ No crate reaching `pgp` may gain an RSA private-key code path. # What this is holding up `rsa` is in the dependency graph through `pgp`, and it carries - line 1
RUSTSEC-2023-0071: the Marvin attack, key recovery through a timing side channel, with no fixed version available. `.cargo/audit.toml` accepts that advisory on one specific ground, that the advisory is about RSA *private key* operations - line 1
and VeilVoice only ever verifies a signature against a public key compiled into it. There is no secret for a timing oracle to leak. That is an argument about how the crate is used rather than about the crate, so it is enforced here rather - line 1
than believed. If a secret-key or decryption path ever appears, this fails and the acceptance has to be re-argued. # Why this is not a `grep` It was one, and the grep counted the word `decrypt` wherever it appeared, including inside a - line 1
string. The sentence the verifier prints about the signing key it imported is: It is a public key: it lets you check signatures and can sign nothing and decrypt nothing. It carries no e-mail address. which is a promise that no private-key - line 1
operation happens, and it failed the check that no private-key operation happens. The guard had been green for as long as that sentence lived in a crate that did not reach `pgp`; consolidating twenty-seven crates into thirteen moved it - line 1
into one that does. Weakening the pattern to get past it would have been the wrong repair, because the pattern is deliberately broad. So the source is stripped of comments and string literals first, and what is left is the code. A guard - line 1
that reads prose is not reading the program. Pure standard library. """ - line 41
from __future__ import annotations import glob import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) # Names that would mean a private key is being handled - line 41
or a decryption is being # performed. Broad on purpose: this is the check that lets an accepted advisory # stay accepted, so it should fire on anything near the line rather than only on # the exact call somebody thought of when writing it. - line 41
PATTERN = re.compile( r"\b(SignedSecretKey|SecretKeyParams|decrypt\w*|sign_binary_data|sign_text_data)\b" ) def strip_rust(text): """Blank out comments, string literals and char literals, keeping offsets. Every removed byte becomes a space - line 41
rather than disappearing, so a line and column reported against the result still points at the real source. """ out = list(text) i, n = 0, len(text) def blank(start, stop): for k in range(start, min(stop, n)): if out[k] != "\n": out[k] = " - line 41
" while i < n: two = text[i:i + 2] if two == "//": stop = text.find("\n", i) stop = n if stop == -1 else stop blank(i, stop) i = stop - line 81
elif two == "/*": # Rust block comments nest. depth, j = 1, i + 2 while j < n and depth: if text[j:j + 2] == "/*": depth += 1 j += 2 elif text[j:j + 2] == "*/": depth -= 1 j += 2 else: j += 1 blank(i, j) i = j elif text[i] == "r" and i + 1 - line 81
< n and text[i + 1] in '#"': # Raw string: r"...", r#"..."#, r##"..."## and so on. j = i + 1 hashes = 0 while j < n and text[j] == "#": hashes += 1 j += 1 if j < n and text[j] == '"': close = '"' + "#" * hashes stop = text.find(close, j + - line 81
1) stop = n if stop == -1 else stop + len(close) blank(i, stop) i = stop else: i += 1 elif text[i] == '"': j = i + 1 while j < n: if text[j] == "\\": j += 2 continue if text[j] == '"': j += 1 break j += 1 blank(i, j) - line 121
i = j elif text[i] == "'": # A char literal, or a lifetime. A lifetime has no closing quote, # so only blank when one turns up within a few bytes. j = i + 1 if j < n and text[j] == "\\": j += 2 elif j < n: j += 1 if j < n and text[j] == - line 121
"'": blank(i, j + 1) i = j + 1 else: i += 1 else: i += 1 return "".join(out) def crates_reaching_pgp(): """Every crate whose manifest names `pgp`, found rather than listed.""" found = [] for manifest in sorted(glob.glob(os.path.join(ROOT, - line 121
"crates", "*", "Cargo.toml"))): with open(manifest, encoding="utf-8") as handle: if any(line.startswith("pgp = ") for line in handle): found.append(os.path.dirname(manifest)) return found def main(): crates = crates_reaching_pgp() if not - line 121
crates: print(" no crate depends on pgp any more, so this is checking nothing") return 1 names = [os.path.basename(c) for c in crates] print(" crates reaching pgp: %s" % ", ".join(names)) offenders = [] scanned = 0 - line 161
for crate in crates: for path, _, files in os.walk(os.path.join(crate, "src")): for name in sorted(files): if not name.endswith(".rs"): continue full = os.path.join(path, name) with open(full, encoding="utf-8") as handle: text = - line 161
handle.read() scanned += 1 code = strip_rust(text) for found in PATTERN.finditer(code): line = code.count("\n", 0, found.start()) + 1 offenders.append( (os.path.relpath(full, ROOT), line, found.group(0)) ) if offenders: print(" a - line 161
private-key or decryption path appears in code:") for where, line, what in offenders: print(" %s:%d %s" % (where, line, what)) print() print(" RUSTSEC-2023-0071 is accepted in .cargo/audit.toml only on the") print(" ground that no such - line 161
path exists. Either remove this, or re-argue") print(" the acceptance on the facts as they now are.") return 1 print(" %d source file(s), no private-key or decryption path in any of them" % scanned) return 0 if __name__ == "__main__": - line 161
sys.exit(main())
tools/audit/state_paths.py
- line 1
#!/usr/bin/env python3 """Find state files that one part of VeilVoice writes and another reads. F-141 was this: the desktop application created app locks through `LockStore::create`, which writes one file, and loaded them through - line 1
`open_default`, which reads another. Both were correct on their own. Nothing compared them, so a lock set in the window was written somewhere the window never looked, and the app lock had never worked from the GUI. F-142 was the same shape - line 1
a week later, in new code: three files were migrated into the obfuscated store and shredded, and only one of them was ever read back out of it. The shape is: **two spellings of where something lives.** This looks for it. What it checks - line 1
-------------- 1. Every literal state filename (`something.conf`, `something.manifest`) that appears in more than one crate is derived the same way in each. Two crates spelling one file's location differently is the F-141 defect exactly. - line 1
2. No state filename is written in one crate and never read in any. What it cannot check -------------------- It reads text, so it sees spellings rather than behaviour: two derivations that differ textually but agree at runtime are - line 1
reported, and two that agree textually while a function underneath them disagrees are not. It is a net with a known mesh size, not a proof, and the tests beside each feature are what prove the behaviour. SPDX-License-Identifier: - line 1
GPL-3.0-or-later """ from __future__ import annotations import re import sys - line 41
from collections import defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[2] #: Filenames that are state rather than source: things VeilVoice keeps between #: runs, which is where this class of defect lives. - line 41
STATE = re.compile(r'"([a-z0-9][a-z0-9._-]*\.(?:conf|manifest|bin|dat|txt|json))"') #: How a path is built around that filename, on the same line. DERIVATION = re.compile(r"(default_path\(\)|default_dir\(\)|with_file_name|join)") def - line 41
crate_of(path: Path) -> str: parts = path.relative_to(ROOT).parts return parts[1] if parts[0] == "crates" else parts[0] def functions(text: str) -> list[str]: """Split a Rust source into function bodies, roughly. Roughly is enough and - line 41
precise would be a parser. What matters is that a derivation split over several lines -- which is what `rustfmt` does to a long one -- is read as one thing. The first version of this scanned line by line and reported `integrity.manifest` - line 41
as built two ways across the command line and the window, when both build it identically and one of them simply wrapped. A detector whose first finding is its own formatting is a detector nobody will keep. """ out: list[str] = [] current: - line 41
list[str] = [] for line in text.splitlines(): if re.match(r"\s*(pub(\([a-z]+\))?\s+)?(async\s+)?fn\s", line): if current: out.append("\n".join(current)) current = [line] elif current: current.append(line) if current: - line 41
out.append("\n".join(current)) - line 81
return out def scan() -> tuple[dict, dict]: """Where each state filename appears, and how its path is built.""" places: dict[str, set[str]] = defaultdict(set) shapes: dict[str, set[str]] = defaultdict(set) for source in sorted((ROOT / - line 81
"crates").rglob("*.rs")): text = source.read_text(encoding="utf-8", errors="replace") # Test modules build paths in temporary directories on purpose. at = text.find("mod tests") if at != -1: text = text[:at] # Comments describe paths - line 81
without building them. text = "\n".join( line for line in text.splitlines() if not line.strip().startswith("//") ) for body in functions(text): names = set(STATE.findall(body)) if not names: continue shape = - line 81
"+".join(sorted(set(DERIVATION.findall(body)))) or "literal" for name in names: places[name].add(crate_of(source)) shapes[name].add(shape) return places, shapes def check() -> list[str]: problems: list[str] = [] places, shapes = scan() for - line 81
name, crates in sorted(places.items()): if len(crates) < 2: continue built = shapes[name] if len(built) > 1: problems.append( f"{name}: built {len(built)} different ways across " - line 121
f"{', '.join(sorted(crates))} -- {sorted(built)}. " f"Two spellings of one file's location is F-141." ) return problems def main() -> int: problems = check() places, _ = scan() shared = {n: c for n, c in places.items() if len(c) > 1} if - line 121
problems: for line in problems: print(f" {line}") print(f"\n{len(problems)} state path(s) spelled more than one way.") return 1 print( f"every shared state file is derived one way " f"({len(shared)} shared across crates, {len(places)} in - line 121
total)" ) return 0 if __name__ == "__main__": sys.exit(main())
tools/docs/generate.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Generate a README for every crate, a page for every source file, and the website mirror of both. python tools/docs/generate.py # write the documentation python - line 1
tools/docs/generate.py --check # verify it is current # Why this is generated rather than written Forty-eight source files each need a description, a flowchart and a banner. Written by hand that is forty-eight documents that start out true - line 1
and drift, and drift is what finding **F-41** was: `website/assets/` had parted company with the generator that produced it, silently, and nobody could tell by looking. So the description of a file is **extracted from that file's own doc - line 1
comments** (`//!` and `///`), and the flowchart is **derived from the file's own items and the calls between them**. Neither can disagree with the source, because neither is a separate statement about the source -- they are the source, - line 1
rearranged. If a page here reads thinly, the fix is to write the doc comment in the `.rs` file, which improves rustdoc and the code review at the same time. `--check` regenerates into memory and compares, exactly as `assets/generate.py` - line 1
and `tools/search-index/generate.py` do, so CI fails when the tree and its documentation part company. # The four decisions this file implements Recorded in `HANDOFF.md` section 9.7, and repeated here because this is where they are - line 1
actually load-bearing. **1. Flowcharts are Mermaid, not images.** GitHub renders ```mermaid fences natively in Markdown, so a flowchart stays *text*: diffable, greppable, reviewable in a pull request, and picked up by the search index like - line 1
any other prose. A PNG flowchart is an opaque blob, which is the thing this project refuses to ship. **2. Banners are generated SVG.** Forty-eight more PNGs is forty-eight binaries the repository has to carry and a `--check` that has to - line 1
decode each one. SVG is text: it costs almost nothing, scales to any width, and is auditable by reading - line 41
it. Deterministic, from a hash of the name -- no timestamps, no randomness. **3. Per-file documentation comes from the source.** See above. **4. Website parity comes from this one generator.** The same model is rendered twice: once as - line 41
Markdown for GitHub and once as HTML for the site. Two hand-maintained copies would drift; one model cannot. # The one thing the website cannot do, said plainly Mermaid is a JavaScript library. This site loads **nothing** from a third - line 41
party and has no bundler, so the website cannot run Mermaid, and shipping a copy of it would contradict the reason the site is written the way it is. The website therefore renders the *same graph* through a small layout engine in this - line 41
file, as inline SVG that needs no script at all -- so the diagram works in the no-JavaScript edition too. It is the same nodes and the same edges from the same model; it is **not** the same picture, because a different layout algorithm - line 41
draws it. That difference is stated on the page rather than glossed, and the Mermaid source is offered beside it so a reader can render it themselves. Pure standard library. No build step, no dependencies. """ import hashlib import html as - line 41
html_mod import io import math import os import re import subprocess import sys import textwrap # --- what is documented ----------------------------------------------------- # # Every crate in the workspace. The request in HANDOFF section - line 41
9.7 asked for # one crate first as a template; the follow-up asked for all of them, so this # is the whole list. It is kept as an explicit tuple rather than a directory # scan so that adding a crate to the workspace and forgetting to - line 41
document it - line 81
# fails the `--check` in CI against ALL_CRATES below, rather than silently # documenting whatever happens to be on disk. CRATES = ( "fuzz", "veilvoice-audio", "veilvoice-cli", "veilvoice-conversation", "veilvoice-core", "veilvoice-crypto", - line 81
"veilvoice-guard", "veilvoice-gui", "veilvoice-meta", "veilvoice-policy", "veilvoice-setup", "veilvoice-verify", "veilvoice-video", "veilvoice-watch", ) # Every crate in the workspace, so this script can say what it is *not* yet # covering - line 81
rather than quietly covering less than the tree contains. A silent # partial pass is exactly the failure mode section 4.5 of the audit describes. ALL_CRATES = ( "fuzz", "veilvoice-audio", "veilvoice-cli", "veilvoice-conversation", - line 81
"veilvoice-core", "veilvoice-crypto", "veilvoice-guard", "veilvoice-gui", "veilvoice-meta", "veilvoice-policy", "veilvoice-setup", "veilvoice-verify", "veilvoice-video", "veilvoice-watch", ) def workspace_crates(root): - line 121
"""Every crate `Cargo.toml` lists as a member, plus `fuzz`. # Why this exists [`ALL_CRATES`] is hand-written, and its whole job is to let this tool say what it is *not* covering rather than quietly covering less than the tree contains. A - line 121
hand-written list of what exists has one failure mode, and it happened: two crates were added to the workspace and to neither list, so they had no page, no banner, no diagram and no entry under "not yet covered" -- invisible rather than - line 121
uncovered, which is the exact failure the list was written to prevent. So the list is now checked against the workspace manifest. It stays written out, because a generator that discovers its own inputs cannot tell you it is missing one; it - line 121
is simply told, loudly, when the two disagree. `fuzz` is a workspace *exclusion* -- it needs nightly and libFuzzer -- so it is not a member and is added here by name. """ manifest = read(os.path.join(root, "Cargo.toml")) members = [] - line 121
inside = False for line in manifest.split("\n"): stripped = line.strip() if stripped.startswith("members"): inside = True continue if inside: if stripped.startswith("]"): break name = stripped.strip(",").strip('"') if - line 121
name.startswith("crates/"): members.append(name[len("crates/") :]) return tuple(sorted(set(members) | {"fuzz"})) def crates_missing_from_the_lists(root): """Crates the workspace has that this file does not name, and vice versa.""" actual = - line 121
set(workspace_crates(root)) listed = set(ALL_CRATES) - line 161
return sorted(actual - listed), sorted(listed - actual) # The canonical address, preview picture and sitemap entry every page of the # website carries. One implementation, called by every generator that writes # a page, so a page cannot be - line 161
produced without them. sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "site")) import seo # noqa: E402 REPO = "tilas01/veilvoice" REF = "main" # Bounds on the derived diagrams. A file with ninety items - line 161
produces a picture # nobody can read, which is worse than no picture: it looks like information. MAX_DIAGRAM_NODES = 22 MAX_LABEL = 30 # How wide a wrapped line of a label may be. # # Wider than `MAX_LABEL`, which is the point at which - line 161
wrapping *starts*, so # that a name broken at its own `::` does not then have to be broken again a # few characters later. `reseed_range_is_finer_than_a_frame` is 34: at 30 it # would wrap twice and read as three lines of fragments. - line 161
WRAP_WIDTH = 38 # --- palette ---------------------------------------------------------------- def palette(root): """The Tokyo Night tokens, read from the stylesheet that defines them. Hardcoding the hexes here would give the documentation - line 161
its own copy of the site's palette, and a copy is a thing that drifts. `website/css/themes.css` is where a colour is decided; this reads the `:root` block so that changing a colour there changes it in every banner and every diagram, and so - line 161
that a token being renamed fails loudly here rather than silently rendering black. """ path = os.path.join(root, "website", "css", "themes.css") with io.open(path, encoding="utf-8") as handle: text = handle.read() - line 201
start = text.index(":root,") block = text[start:text.index("}", start)] found = dict(re.findall(r"--([a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{6})", block)) needed = ("bg", "bg-soft", "bg-inset", "border", "fg", "muted", "accent", "accent-2", - line 201
"cyan", "ok", "warn", "err") missing = [name for name in needed if name not in found] if missing: raise SystemExit( "website/css/themes.css no longer defines: %s\n" "The documentation palette is read from that file on purpose; " "update - line 201
this list rather than hardcoding a colour." % ", ".join(missing) ) return found # --- reading the tree ------------------------------------------------------- def repo_root(): here = os.path.dirname(os.path.abspath(__file__)) return - line 201
os.path.abspath(os.path.join(here, "..", "..")) # The functional line count, from the one tool that counts it. # # Imported rather than reimplemented. Two counters would be two answers to the # same question, and the number appears in the - line 201
README, in `docs/MEASURED.md` # and in 28 crate documents at once, so a second implementation would be 30 # places quietly disagreeing. sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "loc")) import count - line 201
as loc # noqa: E402 def functional_lines_by_crate(): """Each crate's functional line count, measured once per run.""" if not hasattr(functional_lines_by_crate, "cache"): functional_lines_by_crate.cache = loc.per_crate() return - line 201
functional_lines_by_crate.cache - line 241
def functional_lines_note(crate, indent=""): """The sentence that states a crate's functional line count, or nothing. Two different numbers live near each other here and neither may be mistaken for the other. The **Lines** column above is - line 241
the length of each file, which is what a reader gets scrolling it, and it counts blank lines and comments. This is the other measure, and because this project is written with a very high comment-to-code ratio the two are far apart. Both - line 241
are stated with their definitions rather than one being quietly redefined to match the other. """ count = functional_lines_by_crate().get(crate) if count is None: return [] sentence = ( "**{:,} functional lines of Rust** in this crate. A - line 241
functional line is " "{}. That is a different measure from the **Lines** column above, which " "is the length of each file and counts blank lines and comments too. " "Both are produced by `tools/loc/count.py`." ).format(count, - line 241
loc.DEFINITION) return [indent + line for line in textwrap.wrap(sentence, 78)] + [""] def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() def crate_description(root, crate): """The one-line description from - line 241
the crate's own `Cargo.toml`.""" text = read(os.path.join(root, crate_dir(crate).replace("/", os.sep), "Cargo.toml")) match = re.search(r'^description\s*=\s*"([^"]*)"', text, re.M) return match.group(1) if match else "" # Where a crate - line 241
keeps Rust, and what each location means. # # `src` is the crate itself. The other two are real code that ships in the # repository and that a reader may well be looking for -- an example is often # the fastest way to understand an API, - line 241
and a fuzz target says exactly which - line 281
# parsers are considered to read untrusted bytes. # # They are documented, and they are kept out of the crate's module graph: an # example is not something the crate depends on, and drawing it as one would # make the flowchart say - line 281
something untrue about the crate's shape. AREAS = ( ("src", "src", True), ("examples", "example", False), ("tests", "test", False), ) # Where a crate's directory is, and which areas it has. # # `fuzz/` is a crate too: its own `Cargo.toml`, - line 281
its own six Rust files, and # arguably the most informative one in the repository -- the set of fuzz # targets is this project's own answer to "which parsers here read bytes # somebody else produced". It simply does not live under - line 281
`crates/`, so the # layout is a lookup rather than an assumption. LAYOUT = { "fuzz": ("fuzz", (("fuzz_targets", "fuzz", False),)), } # Crates whose `README.md` is written by hand and must not be generated over. # # `fuzz/README.md` - line 281
explains how to run the targets and records that they have # **not** been run to convergence -- a sentence `docs/AUDIT.md` cites by name. # Generating over it lost that, silently, with every check still passing. HAND_WRITTEN_README = - line 281
frozenset({"fuzz"}) def crate_dir(crate): return LAYOUT.get(crate, ("crates/" + crate, AREAS))[0] def crate_areas(crate): return LAYOUT.get(crate, ("crates/" + crate, AREAS))[1] def source_files(root, crate): """Every `.rs` file in a - line 281
crate, as (subdirectory, filename, kind, in_graph). - line 321
Sorted within each area, and the areas in a fixed order, so the output is identical on every machine -- `os.listdir` guarantees no order at all, and a generator whose output depends on filesystem ordering fails `--check` on somebody else's - line 321
computer for no reason they can see. """ out = [] for subdir, kind, in_graph in crate_areas(crate): base = os.path.join(root, crate_dir(crate).replace("/", os.sep), subdir) if not os.path.isdir(base): continue for name in - line 321
sorted(os.listdir(base)): if name.endswith(".rs"): out.append((subdir, name, kind, in_graph)) return out # --- parsing Rust ----------------------------------------------------------- # # This is a *syntactic* reader, not a compiler front - line 321
end, and the difference is # worth stating because it bounds what the derived diagrams can claim. It knows # about line comments, block comments, string and character literals, and brace # depth. It does not resolve types, generics, traits - line 321
or macros. So a call edge # means "the name of this function appears, called, inside that function's # body" -- which is true, useful, and not the same as a type-resolved call # graph. The pages say so rather than implying more. ITEM = - line 321
re.compile( r"^(?P<vis>pub(?:\s*\([^)]*\))?\s+)?" r"(?:default\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+\"[^\"]*\"\s+)?" r"(?P<kind>fn|struct|enum|trait|mod|type|const|static|union)\s+" r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)" ) IMPL = - line 321
re.compile(r"^impl(?:\s*<[^>]*>)?\s+(?P<body>.+?)\s*\{?\s*$") FN_ANY = re.compile(r"\bfn\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)") USE_CRATE = re.compile(r"\b(?:crate|super)::([a-z_][a-z0-9_]*)") MOD_DECL = - line 321
re.compile(r"^\s*(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([a-z_][a-z0-9_]*)\s*;") def literal_runs(text): - line 361
"""Where the comments and the literals are, as `(start, end, kind)`. `kind` is `"com"` for a comment and `"str"` for a string or character literal. Two things read this. `strip_code_noise` below blanks the runs, so that a brace inside - line 361
prose cannot throw the structure off. The source pages colour them. One scanner rather than two. Two would eventually disagree, and the disagreement would show up as a page that painted a comment as code, or a call graph with an edge in it - line 361
that the compiler does not see. """ runs = [] index = 0 length = len(text) while index < length: char = text[index] pair = text[index:index + 2] if pair == "//": end = text.find("\n", index) end = length if end == -1 else end - line 361
runs.append((index, end, "com")) index = end elif pair == "/*": depth = 1 scan = index + 2 while scan < length and depth: if text[scan:scan + 2] == "/*": depth += 1 scan += 2 elif text[scan:scan + 2] == "*/": depth -= 1 scan += 2 else: - line 361
scan += 1 runs.append((index, min(scan, length), "com")) index = scan elif char == 'r' and re.match(r'r#*"', text[index:index + 8] or ""): hashes = re.match(r'r(#*)"', text[index:]).group(1) close = '"' + hashes end = text.find(close, - line 361
index + len(hashes) + 2) - line 401
end = length if end == -1 else end + len(close) runs.append((index, min(end, length), "str")) index = end elif char in ('"', "'"): # A lifetime (`'a`) is not a character literal. Telling them apart # syntactically: a character literal - line 401
closes within a few bytes. quote = char scan = index + 1 closed = False while scan < length and scan - index < 12: if text[scan] == "\\": scan += 2 continue if text[scan] == quote: closed = True scan += 1 break if quote == "'" and - line 401
text[scan] == "\n": break scan += 1 if quote == '"' and not closed: while scan < length: if text[scan] == "\\": scan += 2 continue if text[scan] == '"': scan += 1 break scan += 1 if not closed and quote == "'": index += 1 continue - line 401
runs.append((index, min(scan, length), "str")) index = scan else: index += 1 return runs def strip_code_noise(text): - line 441
"""A copy of the source with comments and literals replaced by spaces. Offsets are preserved -- spaces in, not deletions -- so anything found in the stripped copy is at the same position in the original. Brace counting and call detection - line 441
both run against this, so a `{` inside a string or a doc comment cannot throw the structure off. That is not hypothetical in this tree: `lock.rs` and `shred.rs` both contain braces inside prose. """ out = list(text) for start, end, _ in - line 441
literal_runs(text): for position in range(start, end): if out[position] != "\n": out[position] = " " return "".join(out) def module_doc(text): """The `//!` block at the top of the file, as Markdown lines. The `# veilvoice-core` heading - line 441
many of these files open with is dropped: the page supplies its own title, and two would nest wrongly. Everything else is kept verbatim, including the fenced examples, because those examples are compiled by `cargo test` and are therefore - line 441
known to work. """ lines = [] for line in text.splitlines(): stripped = line.strip() if stripped.startswith("//!"): lines.append(stripped[3:].lstrip() if stripped[3:4] == " " else stripped[3:]) elif stripped.startswith("//") or - line 441
stripped.startswith("#!["): continue elif stripped == "": if lines: continue else: break while lines and not lines[0].strip(): lines.pop(0) if lines and re.match(r"^#\s+\S", lines[0]): - line 481
lines.pop(0) while lines and not lines[0].strip(): lines.pop(0) while lines and not lines[-1].strip(): lines.pop() return lines def parse_file(text): """Items, doc comments and intra-file call edges. Returns a dict with `items` (top-level - line 481
and `impl` members, in source order) and `calls` (pairs of function names where the first's body names the second). """ clean = strip_code_noise(text) lines = text.splitlines() clean_lines = clean.splitlines() items = [] pending = [] depth - line 481
= 0 current_impl = None impl_depth = None skip_until = None # depth at which a `mod tests` block closes for number, raw in enumerate(lines, start=1): cleaned = clean_lines[number - 1] if number - 1 < len(clean_lines) else "" stripped = - line 481
raw.strip() opens = cleaned.count("{") closes = cleaned.count("}") if skip_until is not None: depth += opens - closes if depth <= skip_until: skip_until = None continue if stripped.startswith("///"): - line 521
pending.append(stripped[3:].lstrip() if stripped[3:4] == " " else stripped[3:]) depth += opens - closes continue if stripped.startswith("//") or stripped.startswith("#["): depth += opens - closes continue if not stripped: pending = [] - line 521
depth += opens - closes continue # A `#[cfg(test)] mod tests` block is not part of the crate's surface. if re.match(r"^\s*mod\s+tests?\s*\{", raw) or re.match(r"^\s*mod\s+tests?$", stripped): skip_until = depth depth += opens - closes - line 521
pending = [] continue if current_impl is not None and depth <= impl_depth: current_impl = None impl_depth = None impl_match = IMPL.match(stripped) if impl_match and depth == 0: body = impl_match.group("body") body = re.sub(r"\s*\{\s*$", - line 521
"", body) current_impl = body.split(" for ")[-1].strip() impl_depth = depth pending = [] depth += opens - closes continue item = ITEM.match(stripped) at_item_level = depth == 0 or ( current_impl is not None and depth == impl_depth + 1) if - line 521
item and at_item_level: kind = item.group("kind") name = item.group("name") if not (kind == "mod" and stripped.rstrip().endswith(";")): - line 561
items.append({ "kind": kind, "name": name, "owner": current_impl, "vis": (item.group("vis") or "").strip(), "public": (item.group("vis") or "").strip() == "pub", "line": number, "doc": pending, "signature": re.sub(r"\s*\{\s*$", "", - line 561
stripped).rstrip(), }) pending = [] depth += opens - closes continue pending = [] depth += opens - closes # --- call edges --------------------------------------------------------- # Each function's body is taken from the noise-stripped - line 561
copy by matching # braces from its opening one, then scanned for the names of the other # functions this file defines. owners = {} for item in items: if item["kind"] == "fn": owners[item["name"]] = item["owner"] calls = [] for item in - line 561
items: if item["kind"] != "fn": continue body = _function_body(clean, item["name"]) if body is None: continue for other in sorted(owners): if other == item["name"]: continue if _calls(body, other, owners[other], item["owner"]): - line 561
calls.append((item["name"], other)) return {"items": items, "calls": sorted(set(calls))} - line 601
def _calls(body, name, callee_owner, caller_owner): r"""Does `body` call `name`, as opposed to merely containing the word? The first version of this asked whether `\bname\s*\(` matched, and drew an edge from `SpectralState::transform` to - line 601
`SpectralState::new` because the body constructs a `Complex::new(..)`. An edge that is not a call is worse than a missing one: these diagrams are offered as *derived from the source*, so a reader has no reason to doubt one. What is - line 601
inspected is the qualifier immediately before the name: * nothing -- a free function in this file. A call. * ``self.`` -- a method on this type. A call. * ``Self::`` -- likewise. * ``Owner::`` -- the type that actually owns that method. A - line 601
call. * ``fn`` -- a definition, not a call. * anything else -- somebody else's function that happens to share a name, which in Rust is most of `new`, `len`, `from` and `default`. Not a call. """ for match in re.finditer(r"\b%s\s*\(" % - line 601
re.escape(name), body): head = body[:match.start()].rstrip() if re.search(r"\bfn$", head): continue if head.endswith("::"): qualifier = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*::$", head) qualifier = qualifier.group(1) if qualifier else "" - line 601
if qualifier not in ("Self", callee_owner or "", caller_owner or ""): continue elif head.endswith("."): receiver = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*\.$", head) if not receiver or receiver.group(1) != "self": continue return True - line 601
return False def _function_body(clean, name): """The braced body of `fn name`, taken from the noise-stripped source.""" - line 641
match = re.search(r"\bfn\s+%s\b" % re.escape(name), clean) if not match: return None start = clean.find("{", match.end()) if start == -1: return None depth = 0 for index in range(start, len(clean)): if clean[index] == "{": depth += 1 elif - line 641
clean[index] == "}": depth -= 1 if depth == 0: return clean[start:index + 1] return clean[start:] def module_edges(root, crate, files): """Which module uses which, from `crate::` and `super::` paths. This is the crate-level flowchart's raw - line 641
material, and it is derived rather than drawn: a module that stops depending on another loses the arrow the next time this runs, with no chance for the picture to keep asserting a relationship the code gave up. """ src = [name for subdir, - line 641
name, _, in_graph in files if in_graph and subdir == "src"] stems = {name[:-3] for name in src} - {"lib", "main"} edges = [] for name in src: stem = name[:-3] text = strip_code_noise( read(os.path.join(root, crate_dir(crate).replace("/", - line 641
os.sep), "src", name))) for target in sorted(set(USE_CRATE.findall(text))): if target in stems and target != stem: edges.append((stem, target)) if stem in ("lib", "main"): for target in MOD_DECL.findall(text): if target in stems: - line 641
edges.append((stem, target)) return sorted(set(edges)) - line 681
# --- the model -------------------------------------------------------------- RUSTDOC_LINK = re.compile(r"\[`([^`\]]+)`\]\((?!)") BARE_RUSTDOC = re.compile(r"\[`([^`\]]+)`\](?!\()") # Rustdoc lays crates out as sibling directories, so a - line 681
doc comment may link to # `../veilvoice_core/index.html` or `../../veilvoice_crypto/shred/index.html`. # Both are correct in rustdoc output and dangling everywhere else. RUSTDOC_PATH = re.compile( - line 681
r"\[([^\]]+)\]\((?:\.\./)+([a-z][a-z0-9_]*)((?:/[a-z][a-z0-9_]*)*)/index\.html\)") def rewrite_rustdoc_paths(lines, known, target): """Point rustdoc's cross-crate links at this generator's own pages. `known` maps a crate name to the set of - line 681
module stems it contains, so a link naming a module that has been deleted or renamed degrades to the crate page rather than to a confident link at nothing. `target` is a callback that formats one link for whichever of the four renderings - line 681
is being written -- the repository README, the per-file page, the website, or the GitHub wiki all spell the same destination differently. """ out = [] in_fence = False for line in lines: if line.strip().startswith("```"): in_fence = not - line 681
in_fence out.append(line) continue if in_fence: out.append(line) continue def replace(match): label = match.group(1) crate = match.group(2).replace("_", "-") modules = [m for m in match.group(3).split("/") if m] - line 721
if crate not in known: return match.group(0) stem = modules[-1] if modules else None if stem is not None and stem not in known[crate]: stem = None return target(label, crate, stem) out.append(RUSTDOC_PATH.sub(replace, line)) return out def - line 721
link_targets(known): """The five spellings of "link to this crate, or to this file in it".""" def readme(label, crate, stem): if stem: return "[%s](../../docs/files/%s/%s.md)" % (label, crate, stem) return "[%s](../%s/README.md)" % (label, - line 721
crate_dir(crate).split("/")[-1]) def filepage(label, crate, stem): if stem: return "[%s](../%s/%s.md)" % (label, crate, stem) return "[%s](../../../%s/README.md)" % (label, crate_dir(crate)) def site_crate(label, crate, stem): if stem: - line 721
return "[%s](%s/%s.html)" % (label, crate, stem) return "[%s](%s.html)" % (label, crate) def site_file(label, crate, stem): if stem: return "[%s](../%s/%s.html)" % (label, crate, stem) return "[%s](../%s.html)" % (label, crate) def - line 721
wiki(label, crate, stem): if stem: return "[[%s|%s]]" % (label, wiki_file_page(crate, stem)) return "[[%s|%s]]" % (label, wiki_crate_page(crate)) return {"readme": readme, "filepage": filepage, "site_crate": site_crate, - line 761
"site_file": site_file, "wiki": wiki} def markdown_doc(lines): """Doc-comment lines, with rustdoc's intra-doc links made readable. ``[`AccentConfig`]`` is a link rustdoc resolves and Markdown does not, so on GitHub it renders as the - line 761
literal text `[AccentConfig]` -- a link that looks broken, in a document whose argument is that everything here can be checked. Outside a fence it becomes a plain code span, which is what it means. Inside a fence nothing is touched: that - line 761
is compiled example code. """ out = [] in_fence = False for line in lines: if line.strip().startswith("```"): in_fence = not in_fence out.append(line) continue out.append(line if in_fence else BARE_RUSTDOC.sub(r"`\1`", line)) return out - line 761
def slug(text): """GitHub's heading-anchor rule, which is what GitHub and this site both use. Copied in behaviour from `tools/search-index/generate.py`, deliberately: the two generators produce links into the same documents, and a table of - line 761
contents whose anchors disagree with the search results' anchors would send a reader to the top of the page instead of the section they asked for. """ text = re.sub(r"\s+", " ", text).strip().lower() text = re.sub(r"[^\w\- ]+", "", text, - line 761
flags=re.UNICODE) return text.replace(" ", "-") def doc_headings(lines): """The headings inside a doc-comment block, for a table of contents.""" out = [] in_fence = False - line 801
for line in lines: stripped = line.strip() if stripped.startswith("```"): in_fence = not in_fence continue if in_fence: continue heading = re.match(r"^(#{1,6})\s+(.*)$", stripped) if heading: title = re.sub(r"[*`_]", "", - line 801
heading.group(2)).strip() out.append((len(heading.group(1)), title)) return out class Anchors(object): """Hands out heading anchors for one page, in the order they appear. GitHub's rule for a repeated heading is to suffix the second and - line 801
later occurrences (`items`, `items-1`, `items-2`), so that is the rule here -- the same document is rendered by GitHub from the Markdown and by this generator into HTML, and an anchor that differs between the two is a contents entry that - line 801
works in one place and not the other. """ def __init__(self): self.seen = {} def take(self, title): base = slug(title) count = self.seen.get(base, 0) self.seen[base] = count + 1 return base if count == 0 else "%s-%d" % (base, count) def - line 801
sections_for_page(doc, fixed): """Every heading on a page, in order, with its anchor allocated once.""" anchors = Anchors() out = [(level, title, anchors.take(title)) for level, title in doc_headings(doc)] out += [(level, title, - line 801
anchors.take(title)) for level, title in fixed] - line 841
return out def toc_markdown(sections): """A table of contents as a nested Markdown list. `sections` is a list of (level, title) pairs. Levels are normalised so the shallowest heading on the page sits at the left margin -- a doc comment - line 841
that happens to start at `##` should not produce a list indented for no reason. """ if not sections: return [] base = min(level for level, _, _ in sections) out = ["## Contents", ""] for level, title, anchor in sections: out.append("%s- - line 841
[%s](#%s)" % (" " * (level - base), title, anchor)) out.append("") return out def toc_html(sections): """The same table of contents for the website, in the site's `nav.toc`.""" if not sections: return [] out = ['<nav class="toc" - line 841
aria-label="Contents">'] for _, title, anchor in sections: out.append(' <a href="#%s">%s</a>' % (anchor, esc(title))) out.append('</nav>') return out def first_sentence(lines): """The opening sentence of a doc block, for a table cell or a - line 841
subtitle.""" prose = [] for line in lines: stripped = line.strip() if stripped.startswith("#") or stripped.startswith("```"): if prose: break - line 881
continue if not stripped: if prose: break continue prose.append(stripped) text = " ".join(prose) text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) text = re.sub(r"\[`?([^\]`]+)`?\]", r"\1", text) text = text.replace("**", - line 881
"").replace("`", "") match = re.search(r"^(.+?[.!?])(\s|$)", text) if match: return match.group(1).strip() return text.strip() def known_modules(root): """Every crate, and the module stems inside it. Used to decide whether a rustdoc link - line 881
naming a module still names something real. A link that resolves to a page which does not exist is worse than one that resolves to the crate: the first is a broken promise, the second is a slightly less specific kept one. """ return - line 881
{crate: {name[:-3] for name in source_files(root, crate)} for crate in ALL_CRATES} # Every crate's `//!` block has to say what the crate is for **in plain words**, # under this heading, as well as technically. # # The two are for different - line 881
readers and the technical one does not become the # plain one by being read slowly. A person deciding whether to trust a privacy # tool should be able to find out what each part of it does without knowing what # a formant or a KDF is, and - line 881
the place to put that is beside the code, where it # is reviewed in the same diff as the thing it describes. # # Required rather than encouraged: this generator refuses to write a page for a # crate that has not got one, which is the only - line 881
version of "we should document # that" that survives a busy week. `tools/docs/sources.py` requires the same of - line 921
# the website's own files, under the same heading, for the same reason. PLAIN_HEADING = "In plain words" def has_plain_words(doc): """Whether a `//!` block carries the plain-words section.""" return any( - line 921
line.strip().lstrip("#").strip().lower() == PLAIN_HEADING.lower() for line in doc ) def build(root, crate): """Everything the renderers need, gathered once.""" files = source_files(root, crate) entries = [] for subdir, name, kind, in_graph - line 921
in files: path = os.path.join(root, crate_dir(crate).replace("/", os.sep), subdir, name) text = read(path) doc = module_doc(text) parsed = parse_file(text) # Where each item begins and ends, and what to call the anchor that # marks it. - line 921
Worked out here rather than in the renderer because three # of them need it: the source page wraps the lines, the diagram links # to them, and the item table numbers them. clean_lines = strip_code_noise(text).splitlines() raw_lines = - line 921
text.splitlines() anchors = source_anchors(parsed["items"]) for item in parsed["items"]: first, last = item_span(clean_lines, item["line"]) item["span"] = (item_lead(raw_lines, first), last) item["anchor"] = anchors[id(item)] # Page names - line 921
have to be unique within a crate, and `src/lib.rs` and a # test called `lib.rs` would otherwise collide. Only `src` keeps the # bare stem, so existing page addresses do not move. stem = name[:-3] if subdir == "src" else "%s-%s" % (subdir, - line 921
name[:-3]) entries.append({ "name": name, "stem": stem, "kind": kind, "in_graph": in_graph, - line 961
"area": subdir, "rel": "%s/%s/%s" % (crate_dir(crate), subdir, name), "lines": text.count("\n") + (0 if text.endswith("\n") else 1), "doc": doc, "summary": first_sentence(doc), "items": parsed["items"], "calls": parsed["calls"], "text": - line 961
text, }) return { "crate": crate, "description": crate_description(root, crate), "files": entries, "edges": module_edges(root, crate, files), } def crates_without_plain_words(models): """Which crates have no plain-words section, in order. - line 961
`fuzz` is exempt for the same reason its README is hand-written: it has no library and no `//!` block for this generator to read. Its plain-words section lives in `fuzz/README.md`, where the rest of its documentation is. """ missing = [] - line 961
for model in models: if model["crate"] in HAND_WRITTEN_README: continue lib = next( (entry for entry in model["files"] if entry["stem"] in ("lib", "main")), None, ) if lib is None or not has_plain_words(lib["doc"]): - line 961
missing.append(model["crate"]) return missing # --- graphs ----------------------------------------------------------------- def crate_graph(model): - line 1001
"""Nodes and edges for the crate-level flowchart.""" by_stem = {entry["stem"]: entry for entry in model["files"] if entry.get("in_graph")} order = [] for preferred in ("lib", "main"): if preferred in by_stem: order.append(preferred) order - line 1001
+= [entry["stem"] for entry in model["files"] if entry.get("in_graph") and entry["stem"] not in order] nodes = [] for stem in order: entry = by_stem[stem] nodes.append({ "id": stem, "label": [entry["name"], "%d lines" % entry["lines"]], - line 1001
"url": "https://github.com/%s/blob/%s/%s" % (REPO, REF, entry["rel"]), # Where the same box goes on this site. A crate page sits beside # its files' pages, so this is a bare filename. "site_url": "%s/%s.src.html" % (model["crate"], - line 1001
entry["stem"]), "root": stem in ("lib", "main"), }) return nodes, list(model["edges"]) def wrap_label(label): """A box's label as however many lines it needs, never cut. It used to be cut: anything past `MAX_LABEL` became an ellipsis. The - line 1001
first replacement wrapped it to two lines and then cut the second one, which is the same defect with a longer fuse and no ellipsis to admit it: `DeidConfig::reseed_range_is_finer_than_a_frame` came out as `reseed_range_is_finer_than_a_f`, - line 1001
and nothing on the page said so. So it wraps at the name's own boundaries and does not cut. A qualified name breaks after its `::`, and a long identifier breaks after an `_`, because those are where a reader's eye breaks them anyway. Only - line 1001
a single run with no boundary in it at all is split by width, and that is the one case where there is nothing better to do. - line 1041
The box grows to fit and the rank wraps to fit the box, both of which the layout already does, so a long name costs height rather than legibility. """ if len(label) <= MAX_LABEL: return [label] def fold(text): """Wrap one run at - line 1041
`WRAP_WIDTH`, breaking after an underscore.""" if len(text) <= WRAP_WIDTH: return [text] pieces = [piece for piece in re.split(r"(?<=_)", text) if piece] lines, current = [], "" for piece in pieces: if current and len(current) + len(piece) - line 1041
> WRAP_WIDTH: lines.append(current) current = piece else: current += piece # A single piece longer than a line: nothing to break on. while len(current) > WRAP_WIDTH: lines.append(current[:WRAP_WIDTH]) current = current[WRAP_WIDTH:] if - line 1041
current: lines.append(current) return lines # The owner gets its own line where there is one. `Type::` above the method # reads the way the name is said, and it is where the eye breaks it anyway. if "::" in label: owner, _, rest = - line 1041
label.rpartition("::") return [owner + "::"] + fold(rest) return fold(label) def file_graph(entry): """Nodes and edges for one file's flowchart. Functions the file defines, and the calls between them. Types are included when they own - line 1041
methods, so a reader can see which functions belong to what. Bounded at `MAX_DIAGRAM_NODES`: past that a diagram stops being readable - line 1081
and the item table below it is the better answer, so the page says the diagram was bounded rather than silently showing a subset. """ functions = [item for item in entry["items"] if item["kind"] == "fn"] calls = entry["calls"] named = - line 1081
{item["name"] for item in functions} # Prefer the functions that participate in the call graph, then public # ones, then the rest -- so a bounded diagram keeps the part with structure # in it rather than the first N alphabetically. - line 1081
involved = {a for a, _ in calls} | {b for _, b in calls} def rank(item): return (0 if item["name"] in involved else 1, 0 if item["public"] else 1, item["line"]) chosen = sorted(functions, key=rank)[:MAX_DIAGRAM_NODES] chosen_names = - line 1081
{item["name"] for item in chosen} chosen.sort(key=lambda item: item["line"]) # Which functions nothing else in this file calls. Those are the ways in # -- what a caller outside the file reaches first -- and they are the most # useful thing - line 1081
a reader can be shown, so they are marked rather than left # to be inferred from the arrow directions. called = {b for _, b in calls} nodes = [] for item in chosen: label = item["name"] if item["owner"]: label = "%s::%s" % (item["owner"], - line 1081
item["name"]) label = wrap_label(label) if item["public"] and item["name"] not in called: role = "entry" # public, and nothing here calls it: a way in elif item["public"]: role = "api" # public, but also used internally else: role = - line 1081
"helper" # private to this file nodes.append({ - line 1121
"id": item["name"], # The line number is half of "reference the lines"; the URL is the # other half. A reader who wants to know what a box actually does # should be one click from the code, not one search. "label": label + ["line %d" % - line 1121
item["line"]], "line": item["line"], "url": "https://github.com/%s/blob/%s/%s#L%d" % (REPO, REF, entry["rel"], item["line"]), # The same function on this site, with the whole of it marked # rather than the one line its name is on. - line 1121
"site_url": "%s.src.html#%s" % (entry["stem"], item["anchor"]), "role": role, "root": role == "entry", }) edges = [(a, b) for a, b in calls if a in chosen_names and b in chosen_names] truncated = len(functions) - len(chosen) return nodes, - line 1121
edges, truncated, len(named) # --- Mermaid ---------------------------------------------------------------- def reachable(start, calls, limit=40): """Everything `start` can reach through the call edges, breadth first. Bounded, and the - line 1121
bound is not decoration: a call graph derived syntactically can contain a cycle -- mutual recursion, or two helpers that both reach a third -- and an unbounded walk over one does not terminate. """ seen = [] queue = [start] visited = - line 1121
{start} while queue and len(seen) < limit: current = queue.pop(0) for a, b in calls: if a == current and b not in visited: visited.add(b) seen.append(b) queue.append(b) return seen - line 1161
def contains(entry): """A structured account of what one file holds, derived from its items. Returns (counts, types, ways_in) where `ways_in` pairs each entry point with what calling it reaches. Nothing here is written by hand, so nothing - line 1161
here can drift from the code it describes. """ items = entry["items"] functions = [i for i in items if i["kind"] == "fn"] types = [i for i in items if i["kind"] in ("struct", "enum", "trait", "union")] constants = [i for i in items if - line 1161
i["kind"] in ("const", "static")] calls = entry["calls"] called = {b for _, b in calls} ways_in = [] for item in functions: if not item["public"] or item["name"] in called: continue ways_in.append((item, reachable(item["name"], calls))) - line 1161
counts = { "types": len(types), "functions": len(functions), "constants": len(constants), "public": len([i for i in functions if i["public"]]), "lines": entry["lines"], } return counts, types, ways_in def contains_markdown(entry): """The - line 1161
"what is in here" section, as Markdown.""" counts, types, ways_in = contains(entry) if not (types or ways_in or counts["functions"]): return [] out = ["## What this file contains", ""] out.append( - line 1201
"%d lines defining **%d function%s** (%d public), **%d type%s** and " "**%d constant%s**. Everything below is read out of the source, so it " "cannot disagree with the code." % (counts["lines"], counts["functions"], "" if - line 1201
counts["functions"] == 1 else "s", counts["public"], counts["types"], "" if counts["types"] == 1 else "s", counts["constants"], "" if counts["constants"] == 1 else "s")) out.append("") if types: out.append("**The types it owns.**") - line 1201
out.append("") for item in types: summary = first_sentence(item["doc"]) or "" out.append("- `%s %s` (line %d)%s" % (item["kind"], item["name"], item["line"], " -- " + summary if summary else "")) out.append("") if ways_in: - line 1201
out.append("**What happens when it runs.** These are the ways in: " "public, and nothing else in this file calls them, so they " "are what an outside caller reaches first.") out.append("") for item, reaches in ways_in: name = ("`%s::%s`" % - line 1201
(item["owner"], item["name"]) if item["owner"] else "`%s`" % item["name"]) summary = first_sentence(item["doc"]) or "" out.append("- %s (line %d)%s" % (name, item["line"], " -- " + summary if summary else "")) if reaches: out.append(" - - line 1201
reaches: %s" % ", ".join("`%s`" % r for r in reaches[:12])) out.append("") return out def legend_markdown(nodes): """Say what the colours mean. Colouring without a legend is worse than not.""" - line 1241
used = {n.get("role") for n in nodes} shown = [(role, why) for role, _, why in ROLES if role in used] if not shown: return [] out = ["_Colour key: "] out.append("; ".join("**%s** -- %s" % (role, why) for role, why in shown)) - line 1241
out.append("._") return ["".join(out), ""] def mermaid_theme(colours): """The init directive, declared once and reused by every diagram.""" return ( '%%%%{init: {"theme":"base","themeVariables":{' '"background":"%(bg)s",' - line 1241
'"primaryColor":"%(bg-soft)s",' '"primaryTextColor":"%(fg)s",' '"primaryBorderColor":"%(accent)s",' '"secondaryColor":"%(bg-inset)s",' '"tertiaryColor":"%(bg-inset)s",' '"lineColor":"%(muted)s",' '"textColor":"%(fg)s",' - line 1241
'"mainBkg":"%(bg-soft)s",' '"nodeBorder":"%(accent)s",' '"clusterBkg":"%(bg-inset)s",' '"clusterBorder":"%(border)s",' '"fontFamily":"ui-monospace, SFMono-Regular, Consolas, monospace",' '"fontSize":"14px"' '}}}%%%%' ) % colours def - line 1241
mermaid_id(name): """A Mermaid-safe node id. Names here are Rust identifiers, so this is conservative rather than clever.""" return "n_" + re.sub(r"[^A-Za-z0-9_]", "_", name) # What each role means, and the colour it is drawn in. # - line 1281
# Three roles, not seven. A diagram whose legend needs studying has replaced # one problem with another, and the useful question a reader asks of a source # file is only ever "where does this start, and what does it reach?". ROLES = ( - line 1281
("entry", "accent", "a way in: public, and nothing in this file calls it"), ("api", "cyan", "public, and also used inside this file"), ("helper", "accent-2", "private to this file"), ) def mermaid(colours, nodes, edges, direction="TD"): - line 1281
out = [mermaid_theme(colours), "flowchart %s" % direction] for node in nodes: label = "<br/>".join(node["label"]) shape = ('(["%s"])' if node.get("root") else '["%s"]') % label out.append(" %s%s" % (mermaid_id(node["id"]), shape)) for src, - line 1281
dst in edges: out.append(" %s --> %s" % (mermaid_id(src), mermaid_id(dst))) # One `click` per node: GitHub renders these as real links inside the # fence, so a box in the diagram opens the code it stands for. for node in nodes: if not - line 1281
node.get("url"): continue out.append(' click %s href "%s" "open the source"' % (mermaid_id(node["id"]), node["url"])) # Roles as classes rather than per-node styling: one declaration each, # readable in the diff, and the same three colours - line 1281
on every page so a # reader learns them once. used = {node.get("role") for node in nodes} for role, token, _ in ROLES: if role not in used: continue out.append(" classDef %s fill:%s,stroke:%s,color:%s" % (role, colours["bg-soft"], - line 1281
colours[token], colours["fg"])) members = [mermaid_id(n["id"]) for n in nodes if n.get("role") == role] out.append(" class %s %s" % (",".join(members), role)) return "\n".join(out) - line 1321
# --- SVG: shared helpers ---------------------------------------------------- MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" CHAR_W = 8.06 # width of one monospace character at 13px: 13 * TEXT_RATIO # The same width as - line 1321
a fraction of the font size, for text set at other sizes. # # Deliberately above the 0.585 that `CHAR_W` implies at 13px. The font is # whichever of the stack the reader has, the widths differ a little between # them, and a layout that - line 1321
assumes the narrowest is a layout that overflows on # the others. `CHAR_RATIO` in `tools/site-tests/images.test.js` is this number # and has to stay this number: that suite measures what this lays out, so the # generator being the more - line 1321
optimistic of the two would fail the build on every # drawing. TEXT_RATIO = 0.62 assert abs(CHAR_W - 13.0 * TEXT_RATIO) < 0.01, "CHAR_W and TEXT_RATIO disagree" LINE_H = 17.0 def esc(text): """XML-escape, and force ASCII. Same rule as the - line 1321
search generator's static page and `website/js/*.js`: a file a reader may open raw must not depend on the viewer guessing the right encoding, because a viewer that guesses CP1252 turns an em dash into mojibake. Non-ASCII becomes a numeric - line 1321
reference. """ out = html_mod.escape(text, quote=True) return out.encode("ascii", "xmlcharrefreplace").decode("ascii") def seeded(name): """A deterministic byte stream from a name. The banners have to be reproducible -- `--check` compares - line 1321
them byte for byte -- so nothing here may consult the clock, the filesystem or a global random state. SHA-256 of the name, read as needed. """ digest = hashlib.sha256(name.encode("utf-8")).digest() - line 1361
while True: for byte in digest: yield byte digest = hashlib.sha256(digest).digest() # --- SVG: the banners ------------------------------------------------------- BANNER_W = 1200 # Tall enough for a title and two lines of subtitle, which - line 1361
is what most of them # need. A banner whose subtitle runs longer grows instead of cutting it: see # `banner_svg`. BANNER_H = 150 # One line of subtitle, in the 15px stack the subtitle is drawn in. BANNER_LINE = 19.0 def - line 1361
banner_size(relative): """The pixel size of a banner that has already been written. The `<img>` tags carry it so the browser can reserve the space before the file arrives. Without that, every heading below a banner moves down when it - line 1361
loads, and a reader who followed a link to one of those headings lands above it. Read from the drawing rather than written down here, because a banner whose subtitle runs to a third line is taller than the rest. """ path = - line 1361
os.path.join(repo_root(), "website", "assets", "banners", relative) try: with io.open(path, encoding="utf-8") as handle: head = handle.read(400) except OSError: return BANNER_W, BANNER_H box = re.search(r'viewBox="0 0 ([0-9]+) ([0-9]+)"', - line 1361
head) return (int(box.group(1)), int(box.group(2))) if box else (BANNER_W, BANNER_H) def banner_svg(colours, title, subtitle, kind): """A banner for a crate or a file. The mark is the same soundbar motif as the project's own banner, with - line 1361
the - line 1401
bar heights derived from a hash of the title -- so every file gets a distinct silhouette, the same one every time, and no two are confusable at a glance. `assets/generate.py` draws the real thing in pixels; this is its text-shaped sibling, - line 1401
and the two share the palette rather than a copy of it. """ bars = 26 stream = seeded(title) heights = [] for _ in range(bars): low = next(stream) high = next(stream) # Two bytes averaged: a flatter distribution than one, so a silhouette # - line 1401
is a waveform rather than a picket fence. heights.append(0.18 + 0.82 * ((low + high) / 510.0)) # Wrapped before anything is drawn, because how many lines the subtitle # needs decides how tall the banner is. # # Room to the right of the - line 1401
text, less a margin that keeps it clear of the # `CRATE` label in the corner. A character in this stack is about 0.585 of # the font size wide, which is where `CHAR_W` comes from at 13. text_x = 34.0 + bars * 13.0 + 24.0 room = BANNER_W - - line 1401
text_x - 34.0 columns = max(20, int(room / (15.0 * TEXT_RATIO))) lines = wrap_text(subtitle, columns) # **The banner grows; the subtitle is never cut.** # # It used to keep two lines and end the second with an ellipsis. That reads # as a - line 1401
summary and is a sentence that stopped: the failsafe's banner # said what the crate notices and not what it does about it, which is the # half that matters. A banner is the first thing on a crate's page and is # what the README and the - line 1401
website show, so it is the one place a # description should not be abbreviated. Height is cheap and a truncated # sentence is not. # # Two lines still measure exactly 150, so every banner that already fitted # is unchanged, byte for byte. - line 1401
height = BANNER_H + max(0, len(lines) - 2) * BANNER_LINE height = int(round(height)) - line 1441
out = [] add = out.append add('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" ' 'width="%d" height="%d" role="img" aria-label="%s">' % (BANNER_W, height, BANNER_W, height, esc(title))) # Every generated document says so, in a - line 1441
form the writer can read back. # Without it the no-clobber guard cannot tell a banner it wrote from one # somebody drew by hand, and refuses to run at all. add('<!-- GENERATED by tools/docs/generate.py. Do not edit. -->') - line 1441
add('<title>%s</title>' % esc(title)) add('<rect x="0" y="0" width="%d" height="%d" rx="10" fill="%s"/>' % (BANNER_W, height, colours["bg"])) add('<rect x="0.5" y="0.5" width="%d" height="%d" rx="10" fill="none" ' 'stroke="%s"/>' % - line 1441
(BANNER_W - 1, height - 1, colours["border"])) # The mark: bars fading from the "clean voice" blue to the "veiled" purple, # left to right, which is the same story the project banner tells. x = 34.0 step = 13.0 width = 7.0 mid = height / - line 1441
2.0 for index, bar in enumerate(heights): half = bar * 46.0 ratio = index / float(bars - 1) colour = colours["accent"] if ratio < 0.42 else ( colours["cyan"] if ratio < 0.58 else colours["accent-2"]) add('<rect x="%.1f" y="%.1f" - line 1441
width="%.1f" height="%.1f" rx="3" ' 'fill="%s" opacity="%.2f"/>' % (x, mid - half, width, half * 2, colour, 0.55 + 0.45 * bar)) x += step add('<text x="%.1f" y="%.1f" font-family="%s" font-size="30" ' 'font-weight="700" fill="%s" - line 1441
letter-spacing="0.5">%s</text>' % (text_x, mid - 12.0, MONO, colours["fg"], esc(title))) # The subtitle wraps, and this is a fix rather than a nicety. # # It was one line, drawn at whatever length it happened to be, and it ran # off the - line 1441
right edge of nearly every banner in this repository: measured at - line 1481
# 665 pixels past the edge on `veilvoice-failsafe`, whose subtitle simply # stopped after "while you are". A banner is the first thing on a crate's # page, in its README and in the wiki, and it was cutting its own sentence # in half in all - line 1481
three. # # **This is finding F-37 again.** That one deleted more than half the pixel # rows of the project banner, carrying the licence and the authorship, on # every viewport, for as long as the banner had existed, with every test # - line 1481
passing. The lesson recorded then was that a picture has to be looked at. # The lesson this time is that three hundred of them cannot be, so # `tools/site-tests/images.test.js` measures every piece of text in every # generated drawing - line 1481
against the canvas it is on, and that is what found # this. # # `lines` and the height that holds all of them were worked out at the top # of this function, before the canvas was sized. for index, line in enumerate(lines): add('<text - line 1481
x="%.1f" y="%.1f" font-family="%s" font-size="15" ' 'fill="%s">%s</text>' % (text_x, mid + 14.0 + index * 19.0, MONO, colours["muted"], esc(line))) add('<text x="%d" y="30" font-family="%s" font-size="12" fill="%s" ' 'text-anchor="end" - line 1481
letter-spacing="1.5">%s</text>' % (BANNER_W - 24, MONO, colours["border"], esc(kind.upper()))) add('</svg>') return "\n".join(out) + "\n" # --- SVG: the diagrams ------------------------------------------------------ # What a drawing is - line 1481
allowed to be, in the markdown renderings. The layout # already wraps a rank to fit, so this only ever matches or exceeds the # canvas -- an `<img width>` narrower than the picture would scale it down # again, which is the thing being - line 1481
fixed. DIAGRAM_MAX_W = 640 def rank_nodes(nodes, edges): """Longest-path ranking, with cycles broken by ignoring back edges. - line 1521
A call graph has cycles (mutual recursion, and more often two helpers that both call a third which calls a fourth). A layered drawing needs an acyclic graph, so edges that would point backwards are kept in the picture but not allowed to - line 1521
influence the ranking. The alternative -- dropping them -- would make the diagram assert that a call does not happen. """ ids = [node["id"] for node in nodes] index = {name: number for number, name in enumerate(ids)} rank = {name: 0 for - line 1521
name in ids} forward = [(a, b) for a, b in edges if a in index and b in index and index[a] < index[b]] for _ in range(len(ids)): changed = False for src, dst in forward: if rank[dst] < rank[src] + 1: rank[dst] = rank[src] + 1 changed = - line 1521
True if not changed: break return rank def diagram_markdown(source, alt, note, mermaid_source, extra=None): """The drawing as an image, the explanation, and the Mermaid source under it. A Mermaid fence alone was what the repository and the - line 1521
wiki showed, and it left the layout to GitHub: a rank a dozen nodes wide is either wider than the column or scaled until the labels stop being readable. The generator already draws the same graph for the website, so the repository shows - line 1521
that same picture -- one layout, checked in one place -- and keeps the source in a `<details>` so GitHub can still render it natively for anyone who wants the interactive version. The `<img>` carries the drawing's own pixel width rather - line 1521
than `100%`, for the reason the SVG does: an image told to fill the column is scaled up on a wide screen and down on a narrow one, and only one of those is wanted. """ out = [] if note: out.append(note) - line 1561
if extra: out.append(extra) out.append('<p align="center">') out.append(' <img src="%s" alt="%s" width="%d">' % (source, alt, DIAGRAM_MAX_W)) out.append('</p>\n') out.append("<details>") out.append("<summary>The same graph as Mermaid - line 1561
source</summary>\n") out.append("```mermaid\n%s\n```\n" % mermaid_source) out.append("</details>\n") return out # What the dashed edges mean, said once so the width calculation and the # drawing use the same string. DASHED_KEY = "dashed: a - line 1561
call that goes back up, or across a wrapped rank" # The narrowest a drawing's note is allowed to be set. Below this a paragraph # stops being a paragraph: forty-eight characters is about eight words, which # is a line somebody reads rather - line 1561
than scans down. NOTE_MIN_COLUMNS = 48 def footer_height(key, note_lines): """How much room the colour key and the note need under a drawing. Written as one function because the first version worked it out in one place and drew it in - line 1561
another, and the two disagreed: the dashed row of the key was drawn and not counted, and nothing allowed for the descenders of the last line. The note came out clipped by the bottom edge of its own picture, which is the fault this whole - line 1561
change exists to fix. The arithmetic here mirrors the drawing loop line for line. If one changes, change both, and the pictures say immediately whether you did. """ height = 4.0 if key: # A row per role, plus one for the dashed back edge. - line 1561
height += 6.0 + (len(key) + 1) * 16.0 if note_lines: - line 1601
height += 6.0 + len(note_lines) * 15.0 # Descenders of the last line, and a little air under it. return height + 12.0 def strip_markup(text): """A note's words without its markup, for drawing as plain SVG text. The notes are written for - line 1601
HTML and carry `<strong>` and entities. An SVG `<text>` renders neither, so the tags would appear as tags. """ if not text: return "" plain = re.sub(r"<[^>]+>", "", text) plain = (plain.replace("—", "--").replace("&", "&") - line 1601
.replace("<", "<").replace(">", ">")) return re.sub(r"\s+", " ", plain).strip() def wrap_text(text, columns): """Prose as lines no wider than `columns`, broken on words. For the note drawn inside a diagram. Same rule the terminal - line 1601
drawings follow, and for the same reason: a picture that cuts its own explanation off at the edge has not explained anything. """ out = [] line = "" for word in text.split(): candidate = word if not line else line + " " + word if - line 1601
len(candidate) > columns and line: out.append(line) line = word else: line = candidate if line: out.append(line) return out - line 1641
def diagram_svg(colours, nodes, edges, width=640, on_site=False, note=None): """Lay the same graph out as SVG, for readers with no JavaScript. A simple layered drawing: rank by longest path, order within a rank by declaration order, centre - line 1641
each rank. It is not Mermaid's algorithm and does not pretend to be -- see this module's docstring. It is deterministic, needs nothing at run time, and is legible for the sizes this project produces. # Why a rank wraps Ranking alone put - line 1641
every node of one rank on one line, and a rank is as wide as the file is busy: `veilvoice-core/chain.rs` reached **4490 px** of canvas. The drawing was then `width="100%"` inside a 630 px column, so the browser scaled it to **0.147** and - line 1641
the 13 px labels rendered under two pixels tall. Measured, not guessed -- the same page reported `scrollWidth` 561 against a `clientWidth` of 390 on a phone, so the wide diagrams were also what pushed the reference pages sideways on - line 1641
mobile. So a rank is broken into as many lines as it needs to fit `width`, and the canvas is only as wide as the widest line actually is. The drawing then carries real `width` and `height` attributes and a `max-width: 100%`, which means it - line 1641
renders at its own size on a desktop and scales *down* on a narrow screen -- never up, and never to a fifth of legible. """ marker = "<!-- GENERATED by tools/docs/generate.py. Do not edit. -->" if not nodes: return ('<svg - line 1641
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d 40" ' 'width="%d" height="40" style="max-width:100%%;height:auto" ' 'role="img" aria-label="no items">%s<text x="10" ' 'y="25" font-family="%s" font-size="13" fill="%s">' 'nothing to - line 1641
draw</text></svg>\n' % (width, width, marker, MONO, colours["muted"])) rank = rank_nodes(nodes, edges) rows = {} for node in nodes: rows.setdefault(rank[node["id"]], []).append(node) pad_x, pad_y = 14.0, 10.0 - line 1681
gap_x, gap_y = 26.0, 46.0 margin = 20.0 box = {} for node in nodes: text_w = max(len(line) for line in node["label"]) * CHAR_W box[node["id"]] = (text_w + pad_x * 2, len(node["label"]) * LINE_H + pad_y * 2) # A rank becomes one or more - line 1681
lines, each narrow enough to fit. Declaration # order is preserved across the break, so reading the lines top to bottom # reads the rank left to right. budget = max(width - margin * 2, max(w for w, _ in box.values())) lines = [] for number - line 1681
in sorted(rows): line, used = [], 0.0 for node in rows[number]: w = box[node["id"]][0] step = w if not line else gap_x + w if line and used + step > budget: lines.append(line) line, used, step = [], 0.0, w line.append(node) used += step if - line 1681
line: lines.append(line) # `math.fsum` rather than `sum`, and the reason is a build that passed here # and failed on CI with no change to the source. CPython 3.12 gave `sum` # compensated summation over floats, so the same widths added up - line 1681
to a value # a fraction different from the one 3.11 produced. Everything downstream is # a centring calculation, and one box landed at x=40.1 on one interpreter # and x=40.2 on the other, which is enough for a committed drawing to stop # - line 1681
matching its generator. # # `fsum` is exactly rounded, so it returns the same value on every version # and on every platform. A generated file checked byte for byte in CI has # no business depending on which Python is installed. - line 1681
line_widths = [math.fsum(box[n["id"]][0] for n in line) + gap_x * (len(line) - 1) for line in lines] - line 1721
def lay_out(canvas_w): placed, y = {}, margin for line, line_w in zip(lines, line_widths): height = max(box[n["id"]][1] for n in line) x = (canvas_w - line_w) / 2.0 for node in line: w, h = box[node["id"]] placed[node["id"]] = (x, y + - line 1721
(height - h) / 2.0, w, h) x += w + gap_x y += height + gap_y return placed, y - gap_y + margin canvas_w = max(line_widths) + margin * 2 # Room under the drawing for the colour key and the explanation. # # Both used to sit outside the - line 1721
picture: the key as a line of markdown and # the note as a paragraph in the page. That is fine on the page and useless # everywhere else the drawing goes, which is a README, the wiki, and an # `<img>` where nothing around it comes with it. - line 1721
A picture that needs a # caption it does not carry is a picture that arrives without its meaning. role_names = {node.get("role") for node in nodes} key = [(role, token, why) for role, token, why in ROLES if role in role_names] # The canvas - line 1721
has to be at least as wide as its own key. # # A file with one small function makes a drawing 121 pixels across, and the # key is a fixed sentence about five times that. Widening for it is the # only honest answer: the key cannot be - line 1721
wrapped without becoming a wall of # text, and a key drawn off the edge of the picture is the fault this whole # change is about. if key: widest = max([len("%s: %s" % (role, why)) for role, _, why in key] + [len(DASHED_KEY)]) canvas_w = - line 1721
max(canvas_w, widest * 11.0 * TEXT_RATIO + margin * 2 + 15.0) # And at least wide enough for the note to be a paragraph rather than a # column of one word. # # A crate with a single file draws a picture 141 pixels across, and the - line 1761
# note wrapped into it came out as fifteen lines with `veilvoice-workspace,` # sticking out of the side, because a word longer than the column cannot be # wrapped, only broken, and breaking a name is worse than widening a # picture that - line 1761
has room to spare. if note: longest_word = max(len(word) for word in note.split()) if note.split() else 0 columns = max(NOTE_MIN_COLUMNS, longest_word) canvas_w = max(canvas_w, columns * 11.0 * TEXT_RATIO + margin * 2) placed, canvas_h = - line 1761
lay_out(canvas_w) note_lines = (wrap_text(note, int((canvas_w - margin * 2) / (11.0 * TEXT_RATIO))) if note else []) footer = footer_height(key, note_lines) # A back edge is drawn out to the side of both of its endpoints, so it can # need - line 1761
room the boxes did not. Widen and lay out again rather than let the # arrow leave the canvas -- an SVG does not clip to its viewBox on every # engine, and the ones that do clip drew a line that stopped in mid-air. def side_of(src, dst): - line 1761
sx, _, sw, _ = placed[src] dx, _, dw, _ = placed[dst] return max(sx + sw, dx + dw) + 22.0 back = [(a, b) for a, b in edges if a in placed and b in placed and not placed[b][1] > placed[a][1] + placed[a][3]] if back: overflow = - line 1761
max(side_of(a, b) for a, b in back) + 6.0 - canvas_w if overflow > 0: canvas_w += overflow * 2.0 placed, canvas_h = lay_out(canvas_w) note_lines = (wrap_text(note, int((canvas_w - margin * 2) / (11.0 * TEXT_RATIO))) if note else []) footer - line 1761
= footer_height(key, note_lines) drawing_h = canvas_h canvas_h += footer - line 1801
out = [] add = out.append add('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.0f %.0f" ' 'width="%.0f" height="%.0f" style="max-width:100%%;height:auto" ' 'role="img" aria-label="flowchart">' % (canvas_w, canvas_h, canvas_w, - line 1801
canvas_h)) add(marker) # One arrowhead per colour that is used. # # An SVG marker does not inherit the stroke of the path it is on: there is # `context-stroke` for that and it is not in every engine this site # supports, so a single grey - line 1801
head on a coloured line would leave every # arrow ending in the wrong colour. Cheaper to define the four. arrow_tokens = ["muted", "warn"] + [token for _, token, _ in ROLES] add("<defs>") for token in arrow_tokens: add('<marker id="a-%s" - line 1801
viewBox="0 0 8 8" refX="7" refY="4" ' 'markerWidth="7" markerHeight="7" orient="auto-start-reverse">' '<path d="M0 0 L8 4 L0 8 z" fill="var(--%s, %s)"/></marker>' % (token, token, colours[token])) add("</defs>") # `var(--token)` rather - line 1801
than a hex: this SVG is inline in the page, so it # inherits the custom properties the stylesheet defines, and the diagram # follows whichever of the nine themes the reader chose -- or one of their # own. The second value in each `var()` - line 1801
is the fallback for a context with # no stylesheet, which is what a raw SVG file opened on its own is. add('<rect x="0" y="0" width="%.0f" height="%.0f" ' 'fill="var(--bg-inset, %s)" rx="8"/>' % (canvas_w, canvas_h, colours["bg-inset"])) # - line 1801
An edge is drawn in the colour of the box it leaves, so a reader can # follow one call out of a box and see where it went without tracing every # line back. Grey when the source has no role, which is the crate diagram, # where a file is a - line 1801
file and there is nothing to distinguish. role_token = {role: token for role, token, _ in ROLES} source_role = {node["id"]: role_token.get(node.get("role"), "muted") for node in nodes} for src, dst in edges: if src not in placed or dst not - line 1801
in placed: - line 1841
continue sx, sy, sw, sh = placed[src] dx, dy, dw, dh = placed[dst] x1, y1 = sx + sw / 2.0, sy + sh x2, y2 = dx + dw / 2.0, dy token = source_role.get(src, "muted") if dy > sy + sh: add('<path d="M%.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f - line 1841
%.1f" ' 'fill="none" stroke="var(--%s, %s)" stroke-width="1.4" ' 'opacity="0.85" marker-end="url(#a-%s)"/>' % (x1, y1, x1, y1 + 20, x2, y2 - 20, x2, y2 - 3, token, colours[token], token)) else: # A back edge -- or an edge between two nodes - line 1841
that wrapping put on # the same line -- drawn to the side so it cannot be mistaken for a # forward one, and kept rather than dropped. # A back edge is the same call and a different fact about it, so # it is one colour for all of them - line 1841
rather than the source's: what # matters is that the reader can tell a cycle from a step, and # colouring these by origin would bury that under five hues. side = max(sx + sw, dx + dw) + 22.0 add('<path d="M%.1f %.1f C %.1f %.1f, %.1f %.1f, - line 1841
%.1f %.1f" ' 'fill="none" stroke="var(--warn, %s)" stroke-width="1.1" ' 'opacity="0.7" stroke-dasharray="4 3" marker-end="url(#a-warn)"/>' % (sx + sw, sy + sh / 2.0, side, sy + sh / 2.0, side, dy + dh / 2.0, dx + dw + 3, dy + dh / 2.0, - line 1841
colours["warn"])) role_token = {role: token for role, token, _ in ROLES} for node in nodes: x, y, w, h = placed[node["id"]] # Wrapped in an anchor so the whole box is the target rather than just # the words. # # Two addresses, because this - line 1841
drawing is rendered twice. In a README or # the wiki it is an image on GitHub, where the only useful destination # is the blob -- and where a link inside an `<img>` does not work at # all, so it is the standalone SVG that carries it. On - line 1841
this site it is # inline, the link works, and the destination is the source page one # directory along: same origin, the reader's own theme, and the - line 1881
# function marked rather than one line of it. # # An off-site link opens in a new tab, because taking the page away to # show one function is the wrong trade and `noopener noreferrer` is # what a new tab should get. A link that stays on - line 1881
this site does not: # the back button is right there, and a tab per box is a mess. url = node.get("site_url") if on_site else None url = url or node.get("url") linked = bool(url) if linked: if on_site and node.get("site_url"): add('<a - line 1881
href="%s">' % esc(url)) else: add('<a href="%s" target="_blank" rel="noopener noreferrer">' % esc(url)) token = role_token.get(node.get("role"), "border") stroke = "var(--%s, %s)" % (token, colours.get(token, colours["border"])) add('<rect - line 1881
x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="7" ' 'fill="var(--bg-soft, %s)" stroke="%s" stroke-width="1.5"/>' % (x, y, w, h, colours["bg-soft"], stroke)) for number, line in enumerate(node["label"]): token = "fg" if number == 0 else - line 1881
"muted" add('<text x="%.1f" y="%.1f" font-family="%s" font-size="13" ' 'fill="var(--%s, %s)" text-anchor="middle">%s</text>' % (x + w / 2.0, y + pad_y + LINE_H * (number + 0.75), MONO, token, colours[token], esc(line))) if linked: - line 1881
add('</a>') y = drawing_h + 4.0 if key: y += 6.0 for role, token, why in key: add('<rect x="%.1f" y="%.1f" width="9" height="9" rx="2" ' 'fill="var(--%s, %s)"/>' % (margin, y, token, colours[token])) add('<text x="%.1f" y="%.1f" - line 1881
font-family="%s" font-size="11" ' 'fill="var(--muted, %s)">%s</text>' % (margin + 15, y + 9, MONO, colours["muted"], esc("%s: %s" % (role, why)))) - line 1921
y += 16.0 # The dashed line means something too, and a key that explains three of # four things is a key somebody has to guess the rest of. add('<path d="M%.1f %.1f L%.1f %.1f" stroke="var(--warn, %s)" ' 'stroke-width="1.1" - line 1921
stroke-dasharray="4 3"/>' % (margin, y + 4.5, margin + 9, y + 4.5, colours["warn"])) add('<text x="%.1f" y="%.1f" font-family="%s" font-size="11" ' 'fill="var(--muted, %s)">%s</text>' % (margin + 15, y + 9, MONO, colours["muted"], - line 1921
esc(DASHED_KEY))) y += 16.0 if note_lines: y += 6.0 for line in note_lines: add('<text x="%.1f" y="%.1f" font-family="%s" font-size="11" ' 'fill="var(--muted, %s)">%s</text>' % (margin, y + 9, MONO, colours["muted"], esc(line))) y += 15.0 - line 1921
add('</svg>') return "\n".join(out) + "\n" # --- the source, on this site ------------------------------------------------ # # Every diagram box has always carried a link, and every one of them left the # site: a GitHub blob URL, in a new - line 1921
tab, drawn in somebody else's colours. That # is a reasonable thing for a README to do, because a README is read on GitHub. # It is the wrong thing for the reference pages, where the reader has chosen a # theme, is halfway through a call - line 1921
graph, and wants to see what one box does. # # So the source is served here as well, one page per file, coloured with the # same six classes `website/js/markdown.js` uses for code blocks, so a reader # who changes theme changes this too. - line 1921
Clicking a box opens that file at that # function, with the whole function marked rather than one line, and the mark # is `:target` in the stylesheet rather than script: it survives a reader with # JavaScript off, and it survives a page - line 1921
opened from a bookmark. # Which characters get which class. Escaping happens per piece, after the match, # because `esc` turns a non-ASCII character into `—` and a number pattern # run over that output would colour the 8212 and break - line 1921
the entity. CODE_TOKENS = re.compile( - line 1961
r"(?P<attr>#!?\[[A-Za-z_][A-Za-z0-9_:]*)" r"|(?P<kw>\b(?:fn|let|mut|pub|use|mod|struct|enum|impl|trait|for|in|if|else" r"|match|return|const|static|crate|self|super|where|as|dyn|move|async|await" r"|ref|type|unsafe)\b)" - line 1961
r"|(?P<fun>\b[A-Za-z_][A-Za-z0-9_]*(?=\s*\())" r"|(?P<num>\b\d[\d_]*(?:\.\d+)?\b)") TOKEN_CLASS = {"attr": "tok-attr", "kw": "tok-kw", "fun": "tok-fn", "num": "tok-num"} def code_html(chunk): """One run of ordinary code, escaped and - line 1961
coloured.""" out = [] at = 0 for match in CODE_TOKENS.finditer(chunk): out.append(esc(chunk[at:match.start()])) kind = match.lastgroup out.append('<span class="%s">%s</span>' % (TOKEN_CLASS[kind], esc(match.group()))) at = match.end() - line 1961
out.append(esc(chunk[at:])) return "".join(out) def highlight_source(text): """Every line of a file, escaped and coloured, one string per line. Comments and literals come from `literal_runs`, which is the same scanner the call graph counts - line 1961
braces with, so a keyword inside a comment and a brace inside a comment are the same fact read twice rather than two guesses. Everything outside a run is ordinary code and gets the pattern above. A run may cross a line -- a block comment, - line 1961
a raw string -- and the split below cuts it at the newline and reopens the span on the next line, so the markup stays balanced line by line. That matters here beyond tidiness: the page is one element per line and an unclosed span would - line 1961
swallow the rest of the file. """ kinds = [None] * len(text) - line 2001
for start, end, kind in literal_runs(text): for position in range(start, end): kinds[position] = kind lines = text.split("\n") if text.endswith("\n"): lines.pop() out = [] offset = 0 for line in lines: row = [] at = 0 while at < len(line): - line 2001
kind = kinds[offset + at] run = at + 1 while run < len(line) and kinds[offset + run] == kind: run += 1 piece = line[at:run] if kind is None: row.append(code_html(piece)) else: row.append('<span class="tok-%s">%s</span>' % ("com" if kind == - line 2001
"com" else "str", esc(piece))) at = run out.append("".join(row)) offset += len(line) + 1 return out def item_span(clean_lines, start): """The first and last line of the item declared at `start`. Braces are counted in the noise-stripped - line 2001
copy, so one inside a comment or a string cannot end an item early -- which is not hypothetical here, because `strip_code_noise` exists for exactly that in this tree. An item with no body, a `use` or a type alias, ends at its own - line 2001
semicolon. A declaration this cannot make sense of runs to the end of the file rather than to a guess. That is visible as a mark that is obviously too long, - line 2041
which is the failure worth having: the other direction marks the wrong lines and looks right. """ depth = 0 opened = False for number in range(start, len(clean_lines) + 1): line = clean_lines[number - 1] for char in line: if char == "{": - line 2041
depth += 1 opened = True elif char == "}": depth -= 1 if opened: if depth <= 0: return start, number elif ";" in line: return start, number return start, len(clean_lines) def item_lead(lines, start): """The first line of what a reader - line 2041
would call this item. An item's declaration is not where it begins on the page. Above it sit its `///` documentation and its attributes, and somebody who clicked a box labelled `still_named` wants those: they are where the reason is - line 2041
written, and this project puts a great deal of the reason there. Walks back over documentation and attribute lines only, so a blank line or a line of code stops it and the mark can never reach into whatever is above. """ number = start - line 2041
while number > 1: above = lines[number - 2].strip() if above.startswith("///") or above.startswith("#["): number -= 1 continue break - line 2081
return number ANCHOR_UNSAFE = re.compile(r"[^A-Za-z0-9_]+") def source_anchors(items): """One page-unique anchor per item, in declaration order. Two `impl` blocks in one file may each define `fmt`, and a page with two `id="fn-fmt"` is a - line 2081
page where one of the two links goes to the wrong function. The type owns the name where there is one, and a collision after that is numbered rather than silently dropped. """ anchors = {} used = {} for item in items: base = item["name"] - line 2081
if item["owner"]: base = "%s-%s" % (item["owner"], item["name"]) base = ANCHOR_UNSAFE.sub("-", base).strip("-").lower() or "item" used[base] = used.get(base, 0) + 1 anchors[id(item)] = ("fn-%s" % base if used[base] == 1 else "fn-%s-%d" % - line 2081
(base, used[base])) return anchors def html_source(colours, model, entry, fingerprint): """One file, as it is in the tree, on a page of this site.""" crate = model["crate"] rows = highlight_source(entry["text"]) # Where each item begins - line 2081
and ends, so its lines can be wrapped and marked # as one. Every item gets a wrapper, because every one of them is something # the diagram or the item table may link to and an anchor that is not on # the page is a link that goes nowhere. # - line 2081
# A span is cut short at the next item's first line. `parse_file` collects # items at the top level and inside an `impl` and nowhere else, so two of # them overlapping means the brace counting lost its place rather than that - line 2121
# one contains the other. Cutting there keeps the markup closing in the # order it opened, and keeps a confused span from marking lines that # visibly belong to something else. opens = {} closes = {} items = entry["items"] for index, item - line 2121
in enumerate(items): start, end = item["span"] start = min(max(start, 1), len(rows)) if index + 1 < len(items): end = min(end, items[index + 1]["span"][0] - 1) end = max(start, min(end, len(rows))) opens.setdefault(start, - line 2121
[]).append(item["anchor"]) closes[end] = closes.get(end, 0) + 1 body = [] body.append("<h1><code>%s</code></h1>" % esc(entry["rel"])) body.append('<p style="color:var(--muted)">' '<a href="%s.html">what this file is for</a> · ' '<a - line 2121
href="../%s.html">%s</a> · %d lines · ' '<a href="https://github.com/%s/blob/%s/%s" ' 'rel="noopener noreferrer">the same file on GitHub</a></p>' % (esc(entry["stem"]), esc(crate), esc(crate), entry["lines"], REPO, REF, - line 2121
entry["rel"])) body.append('<p style="color:var(--muted)">The file as it is in the tree, ' 'in the colours you chose. A line number is a link, and so is ' 'every box in this file’s diagram: it opens here with the ' 'function it names - line 2121
marked.</p>') body.append('<pre class="src"><code>') for number, row in enumerate(rows, start=1): for anchor in opens.get(number, ()): body.append('<span class="src-item" id="%s">' % anchor) body.append('<span class="ln" - line 2121
id="L%d">%s</span>' % (number, row)) for _ in range(closes.get(number, 0)): body.append('</span>') body.append('</code></pre>') return html_page(colours, 2, "%s source · VeilVoice reference" % entry["rel"], - line 2161
"The source of %s, on this site." % entry["rel"], body, fingerprint) # --- Markdown --------------------------------------------------------------- BANNER_NOTE = ( "<!-- SPDX-License-Identifier: GPL-3.0-or-later -->\n" "<!-- GENERATED by - line 2161
tools/docs/generate.py from the doc comments in the\n" " source. Do not edit this file: edit the `//!` and `///` comments in\n" " the .rs files and run the generator again. CI verifies it with\n" " python tools/docs/generate.py --check\n" - line 2161
"-->\n" ) def file_page_path(crate, stem): return "docs/files/%s/%s.md" % (crate, stem) def tidy(text): """Collapse runs of blank lines, until it stops changing. `str.replace` does not rescan its own output, so one pass over four - line 2161
consecutive newlines leaves three. The same lesson as the placeholder un-parking in `inline_html`, in a much smaller place. """ while "\n\n\n" in text: text = text.replace("\n\n\n", "\n\n") return text.rstrip("\n") + "\n" def - line 2161
markdown_crate(colours, model, links): crate = model["crate"] nodes, edges = crate_graph(model) lib = next((entry for entry in model["files"] if entry["stem"] in ("lib", "main")), None) out = [BANNER_NOTE] out.append('<p align="center">\n' - line 2201
' <img src="../../assets/banners/%s.svg" alt="%s" width="100%%">\n' '</p>\n' % (crate, crate)) out.append("# %s\n" % crate) if model["description"]: out.append("> %s\n" % model["description"]) TOC_AT = len(out) out.append("") if lib and - line 2201
lib["doc"]: out.append("\n".join(markdown_doc(rewrite_rustdoc_paths(lib["doc"], links[0], links[1]))) + "\n") fixed = [(2, "How the crate fits together"), (2, "The files")] if any(item["public"] and item["owner"] is None for entry in - line 2201
model["files"] for item in entry["items"]): fixed.append((2, "Public items")) fixed.append((2, "Reading it elsewhere")) sections = sections_for_page(lib["doc"] if lib else [], fixed) out.insert(TOC_AT, "\n".join(toc_markdown(sections))) - line 2201
out.append("## How the crate fits together\n") out.extend(diagram_markdown( "../../assets/diagrams/%s.svg" % crate, "how %s fits together" % crate, "Every arrow below is a `crate::` or `super::` path one module actually\n" "uses, read out - line 2201
of the source by the generator rather than drawn by\n" "hand. A dependency that goes away loses its arrow the next time this\n" "file is written.\n", mermaid(colours, nodes, edges))) out.append("## The files\n") out.append("| File | Lines - line 2201
| What it is |") out.append("|---|---:|---|") for entry in model["files"]: summary = entry["summary"] or "_no module documentation yet_" summary = summary.replace("|", "\\|") out.append("| [`%s`](../../%s) | %d | %s |" % (entry["name"], - line 2201
file_page_path(crate, entry["stem"]), entry["lines"], summary)) out.append("") out.extend(functional_lines_note(crate)) - line 2241
public = [] for entry in model["files"]: for item in entry["items"]: if item["public"] and item["owner"] is None: public.append((entry, item)) if public: out.append("## Public items\n") out.append("| Item | Where | What |") - line 2241
out.append("|---|---|---|") for entry, item in public: what = first_sentence(item["doc"]) or "" what = what.replace("|", "\\|") out.append("| `%s %s` | [`%s`](../../%s) | %s |" % (item["kind"], item["name"], entry["name"], - line 2241
file_page_path(crate, entry["stem"]), what)) out.append("") out.append("## Reading it elsewhere\n") out.append( "- On the website: [`website/reference/%s.html`](../../website/reference/%s.html)\n" "- The audit this code is held to: - line 2241
[`docs/AUDIT.md`](../../docs/AUDIT.md)\n" "- The claims and their limits: [`docs/WHITEPAPER.md`](../../docs/WHITEPAPER.md)\n" "- Using the crates from your own project: [`docs/USING_THE_CRATES.md`](../../docs/USING_THE_CRATES.md)\n" % - line 2241
(crate, crate)) out.append( "Signing key fingerprint `%s`.\n" % FINGERPRINT_PLACEHOLDER) return tidy("\n".join(out)) FINGERPRINT_PLACEHOLDER = "@@FINGERPRINT@@" def markdown_file(colours, model, entry, links): crate = model["crate"] nodes, - line 2241
edges, truncated, total = file_graph(entry) out = [BANNER_NOTE] out.append('<p align="center">\n' ' <img src="../../../assets/banners/%s/%s.svg" alt="%s" width="100%%">\n' - line 2281
'</p>\n' % (crate, entry["stem"], entry["name"])) out.append("# `%s`\n" % entry["rel"]) out.append("[`%s`](../../../%s/README.md) · %d lines · " "[read the source](https://github.com/%s/blob/%s/%s)\n" % (crate, - line 2281
crate_dir(crate), entry["lines"], REPO, REF, entry["rel"])) TOC_AT = len(out) out.append("") if entry["doc"]: out.append("\n".join(markdown_doc(rewrite_rustdoc_paths(entry["doc"], links[0], links[1]))) + "\n") else: out.append( "> This - line 2281
file has no `//!` module documentation yet. That is a gap in\n" "> the source rather than in this page: write the comment in\n" "> `%s` and it appears here.\n" % entry["rel"]) fixed = [] if contains_markdown(entry): fixed.append((2, "What - line 2281
this file contains")) fixed.append((2, "What calls what")) if entry["items"]: fixed.append((2, "Items")) sections = sections_for_page(entry["doc"], fixed) out.insert(TOC_AT, "\n".join(toc_markdown(sections))) - line 2281
out.extend(contains_markdown(entry)) out.append("## What calls what\n") if nodes: out.append( "The functions this file defines, and the calls between them. Both\n" "are read out of the source: an edge means the callee's name appears,\n" - line 2281
"called, inside the caller's body. It is a syntactic reading, not a\n" "type-resolved one, so a call made through a trait object or a macro\n" "will not appear.\n") if truncated: out.append( "_%d of %d functions are drawn; the diagram is - line 2281
bounded at %d so it\n" "stays readable. The full list is in the table below._\n" % (len(nodes), total, MAX_DIAGRAM_NODES)) - line 2321
out.extend(diagram_markdown( "../../../assets/diagrams/%s/%s.svg" % (crate, entry["stem"]), "what calls what in %s" % entry["name"], None, mermaid(colours, nodes, edges), extra="\n".join(legend_markdown(nodes)))) else: out.append("This - line 2321
file defines no functions of its own.\n") if entry["items"]: out.append("## Items\n") out.append("| Item | Line | Documentation |") out.append("|---|---:|---|") for item in entry["items"]: name = ("`%s::%s`" % (item["owner"], item["name"]) - line 2321
if item["owner"] else "`%s`" % item["name"]) vis = (item["vis"] + " ") if item["vis"] else "" what = first_sentence(item["doc"]) or "" what = what.replace("|", "\\|") out.append("| %s <sub>%s%s</sub> | - line 2321
[%d](https://github.com/%s/blob/%s/%s#L%d) | %s |" % (name, vis, item["kind"], item["line"], REPO, REF, entry["rel"], item["line"], what)) out.append("") out.append("---\n") out.append("Generated from `%s`. On the website: " - line 2321
"[`website/reference/%s/%s.html`](../../../website/reference/%s/%s.html).\n" % (entry["rel"], crate, entry["stem"], crate, entry["stem"])) out.append("Signing key fingerprint `%s`.\n" % FINGERPRINT_PLACEHOLDER) return tidy("\n".join(out)) - line 2321
# --- HTML ------------------------------------------------------------------- def html_page(colours, depth, title, description, body, fingerprint): """One page of the website mirror. `depth` is how far below `website/` the page sits, so - line 2321
the relative links to the stylesheet and the icon are correct from `reference/x.html` and from `reference/crate/file.html` alike. Getting this wrong produces a page that - line 2361
renders unstyled and passes every test that does not look at it -- which is finding F-37's shape, so the depth is computed rather than typed. """ up = "../" * depth out = [] add = out.append add('<!doctype html>') add('<!-- - line 2361
SPDX-License-Identifier: GPL-3.0-or-later -->') add('<!-- GENERATED by tools/docs/generate.py. Do not edit. -->') add('<html lang="en" data-theme="tokyo-night">') add('<head>') add('<meta charset="utf-8">') add('<meta name="viewport" - line 2361
content="width=device-width, initial-scale=1">') add('<title>%s</title>' % esc(title)) add('<meta name="description" content="%s">' % esc(description)) add('<link rel="icon" href="%sassets/icon-32.png" type="image/png">' % up) add('<link - line 2361
rel="prefetch" href="%sindex.html">' % up) add('<link rel="prefetch" href="%swiki.html">' % up) add('<link rel="stylesheet" href="%scss/themes.css">' % up) add('<link rel="stylesheet" href="%scss/main.css">' % up) add('<script - line 2361
src="%sjs/theme.js"></script>' % up) add('<script src="%sjs/prefetch.js" defer></script>' % up) add('<script src="%sjs/legal.js" defer></script>' % up) # These pages carry heading ids a reader can link to, and the header here # is a - line 2361
different height from the one on the hand-written pages. The offset # that clears it is measured rather than written down, so the script that # measures it is loaded here too. add('<script src="%sjs/teleport.js" defer></script>' % up) - line 2361
add('</head>') add('<body>') add('<header class="top">') add(' <div class="wrap">') add(' <div class="brand">') add(' <img src="%sassets/icon-32.png" alt="" width="32" height="32">' % up) add(' <a href="%sindex.html">VEILVOICE</a>' % up) - line 2361
add(' </div>') # The same thirteen links every hand-written page carries, in the same # order. They used to be five: a reader who clicked `reference` landed on a # page whose header had quietly dropped `download`, `guide`, `verify`, # - line 2361
`security`, `faq`, `roadmap`, `releases` and `what`, with no way back to - line 2401
# any of them except home. A header that changes shape as you move through # the site is worse than a long one, so this is the whole set, prefixed for # the page's depth. `tools/site-tests/links.test.js` compares it against # `index.html` - line 2401
and fails if the two drift apart again. add(' <nav class="links">') add(' <a href="%sindex.html#what">what</a>' % up) add(' <a href="%sindex.html#download">download</a>' % up) add(' <a href="%sindex.html#demo">demo</a>' % up) add(' <a - line 2401
href="%sindex.html#guide">guide</a>' % up) add(' <a href="%sindex.html#verify">verify</a>' % up) add(' <a href="%sindex.html#crypto">security</a>' % up) add(' <a href="%sfaq.html">faq</a>' % up) add(' <a href="%sroadmap.html">roadmap</a>' - line 2401
% up) add(' <a href="%sreleases.html">releases</a>' % up) add(' <a href="%swiki.html">wiki</a>' % up) add(' <a href="%sreference/index.html">reference</a>' % up) add(' <a href="%ssearch.html">index</a>' % up) add(' <a - line 2401
href="https://github.com/%s" rel="noopener noreferrer">github</a>' % REPO) add(' </nav>') add(' <div class="controls">') add(' <label for="theme" style="position:absolute;left:-9999px">Colour scheme</label>') add(' <select id="theme" - line 2401
class="theme-pick" title="Colour scheme"></select>') add(' </div>') add(' </div>') add('</header>') add('<main class="wrap" style="padding-top:30px">') out.extend(body) add('</main>') add('<footer class="wrap" style="padding:40px
tools/docs/guides.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """One guide per program, assembled from the one guide that describes them all. python tools/docs/guides.py # write them python tools/docs/guides.py --check # verify they - line 1
are current # Why three files, and why they are not written three times A release ships two programs and somebody arriving with one of them in front of them should not have to read about the other two to find their part. - line 1
`docs/USER_GUIDE.md` is the whole story and stays the whole story; these are views of it, one per program, for the wiki and for the archive. **They are assembled, never copied.** Three hand-maintained descriptions of one application is the - line 1
shape of half the findings in this repository: a document that describes the program, with nothing comparing the two, quietly going stale in one place and not the others. Here there is one source, and `--check` regenerates into memory and - line 1
compares, so CI fails if a guide and its source have parted company. # What each one is for The split is by *what the reader has*, not by subject: - somebody at a terminal wants the command line and the walkthrough; - somebody looking at a - line 1
window wants the tabs, the lock and the vaults; - somebody who has just downloaded an archive and has not run anything yet wants the verifier, and wants it before they trust either of the others. Pure standard library, like the rest of the - line 1
documentation tooling. """ import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) - line 41
SOURCE = os.path.join(ROOT, "docs", "USER_GUIDE.md") # Each guide: the file to write, the wiki page, a title, an opening, and the # sections of the user guide it is made of, by the words in their headings. GUIDES = [ { "file": - line 41
"docs/GUIDE_CLI.md", "wiki": "wiki/Guide-command-line.md", "title": "VeilVoice: the command line", "program": "`veilvoice`", "opening": "Everything the application does, over SSH, in a container, in a " "script, or on a machine with no - line 41
graphics toolkit at all. If you " "have a terminal and nothing else, this is your half of VeilVoice.\n" "\n" "Every command takes `--help`, and the help is the reference; this " "is the part that says what to reach for and why.", - line 41
"sections": [ "Installing", "The two programs, and which one you want", "The command line", "An interview, start to finish", "Things VeilVoice will not do", ], }, { "file": "docs/GUIDE_GUI.md", "wiki": "wiki/Guide-desktop-app.md", "title": - line 41
"VeilVoice: the desktop application", "program": "`veilvoice-gui`", "opening": "The window. The same engine as the command line with somewhere to " "click, plus the things that only make sense with a screen: live " "level meters, the app - line 41
lock, and the microphone monitor.\n" "\n" "One tab for each thing it does. Every one of them is below, and " "`veilvoice-gui --tab <name>` opens the window on one directly.", "sections": [ "Installing", "The two programs, and which one you - line 41
want", - line 81
"The desktop app", "The app lock", "Locking the window when you walk away", "VeilVoice checking its own files", "Saving into a Cryptomator vault or a VeraCrypt volume", "Things VeilVoice will not do", ], }, { "file": - line 81
"docs/GUIDE_VERIFY.md", "wiki": "wiki/Guide-verifier.md", "title": "VeilVoice: checking a download", "program": "`veilvoice verify`", "opening": "Read this one first. It is about deciding whether the archive you " "just downloaded is the - line 81
one that was published.\n" "\n" "The short version: unpack the archive, run `veilvoice verify` " "inside the folder, read the verdict. If you would rather not use " "a terminal, the desktop application's Verify tab does the same " "check - line 81
with the same code underneath.", "sections": [ "The two programs, and which one you want", "The verifier, `veilvoice verify`", "Getting help, and checking for yourself", ], }, ] BANNER = ( "<!-- SPDX-License-Identifier: GPL-3.0-or-later - line 81
-->\n" "<!-- GENERATED by tools/docs/guides.py from docs/USER_GUIDE.md.\n" " Do not edit: edit the section in the user guide and run the tool.\n" " Verified in CI with `python tools/docs/guides.py --check`. -->\n" ) def read(path): with - line 81
io.open(path, encoding="utf-8") as handle: return handle.read().replace("\r\n", "\n") - line 121
def sections(text): """Every `## ` section of the guide, keyed by its heading without a number. A section runs to the next `## `, so its `### ` subsections come with it, which is the whole point: the tabs belong with the tab section. """ - line 121
found = {} order = [] current = None body = [] for line in text.split("\n"): if line.startswith("## "): if current is not None: found[current] = "\n".join(body).strip("\n") # "## 5.7 Saving into a vault" -> "Saving into a vault" current = - line 121
re.sub(r"^\d+(?:\.\d+)*\.?\s*", "", line[3:].strip()) order.append(current) body = [line] continue if current is not None: body.append(line) if current is not None: found[current] = "\n".join(body).strip("\n") return found, order def - line 121
renumber(body, number): """The section heading, with the guide's own numbering replaced.""" lines = body.split("\n") if lines and lines[0].startswith("## "): title = re.sub(r"^\d+(?:\.\d+)*\.?\s*", "", lines[0][3:].strip()) lines[0] = "## - line 121
%d. %s" % (number, title) return "\n".join(lines) # A link to another file in `docs/`, written as a bare file name because that # is what resolves when the guide is sitting in `docs/`. NEIGHBOUR = - line 121
re.compile(r"\]\((?!https?:|#|/)([A-Za-z0-9_.-]+\.md)(#[^)]*)?\)") - line 161
def for_the_wiki(page): """The same page, with its links rewritten for a flat namespace. The wiki has no directories: every page is a name. A guide written for `docs/` links to its neighbours as `REPRODUCIBLE_BUILDS.md`, which is right - line 161
there and wrong in the wiki, where it resolves to a page that does not exist. Rewritten to absolute URLs into the repository rather than to wiki pages, because most of those neighbours have no wiki page: the reader is sent to the real - line 161
document instead of to a dead end. """ return NEIGHBOUR.sub( lambda m: "](https://github.com/tilas01/veilvoice/blob/main/docs/%s%s)" % (m.group(1), m.group(2) or ""), page) def build(): text = read(SOURCE) found, _order = sections(text) - line 161
out = {} for guide in GUIDES: missing = [name for name in guide["sections"] if name not in found] if missing: raise SystemExit( "docs/USER_GUIDE.md no longer has these sections, which %s is\n" "assembled from: %s\n" "Either they were - line 161
renamed, in which case update GUIDES in\n" "tools/docs/guides.py, or the parser needs fixing." % (guide["file"], ", ".join(repr(m) for m in missing))) parts = [ BANNER, "# %s" % guide["title"], "", "**%s.** %s" % (guide["program"], - line 161
guide["opening"]), - line 201
"", "The whole story, including the parts about the other two programs, " "is in [`USER_GUIDE.md`](USER_GUIDE.md). This is the same text, " "narrowed to one program.", "", "---", "", ] for number, name in enumerate(guide["sections"], - line 201
start=1): parts.append(renumber(found[name], number)) parts.append("") parts.append("---") parts.append("") # The last rule is a separator with nothing after it. while parts and parts[-1] in ("", "---"): parts.pop() parts.append("") page = - line 201
"\n".join(parts) out[guide["file"]] = page out[guide["wiki"]] = for_the_wiki(page) return out def main(): check = "--check" in sys.argv files = build() problems = [] for rel, text in sorted(files.items()): path = os.path.join(ROOT, - line 201
rel.replace("/", os.sep)) if check: try: with io.open(path, encoding="utf-8", newline="") as handle: actual = handle.read() except OSError: problems.append("%s: missing" % rel) continue if actual.replace("\r\n", "\n") != text: - line 201
problems.append("%s: differs from docs/USER_GUIDE.md" % rel) - line 241
else: with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) if check: if problems: for line in problems: print(" MISMATCH %s" % line) print() print("Run 'python tools/docs/guides.py' and commit the result.") - line 241
return 1 print(" %d per-program guides match the user guide" % len(files)) return 0 print(" wrote %d per-program guides from docs/USER_GUIDE.md" % len(files)) return 0 if __name__ == "__main__": sys.exit(main())
tools/docs/sources.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """A page, a banner and a workchart for every file the website is made of. python tools/docs/sources.py # write the pages python tools/docs/sources.py --check # verify they - line 1
are current `tools/docs/generate.py` does this for every crate and every `.rs` file. The website's own source had nothing: ten files that the site explicitly invites people to open and read, with no page, no picture and no index. This is - line 1
the same treatment for them. # Two explanations, and only one of them can be generated Every page here says what a file does **technically**, and then says the same thing **in plain words**. The technical half is derived: the functions, - line 1
what calls what, how long it is. The plain half cannot be: a generator that wrote "this file handles theme switching" from a filename would be padding, and a reader can tell. So the plain-words paragraph is **written in the file itself**, - line 1
under an `In plain words` heading in its header comment, and this tool **refuses to generate a page without one**. That keeps the explanation beside the code it explains, makes it reviewable in a diff of that code, and means it cannot - line 1
quietly rot into describing something the file stopped doing. # The workchart is syntactic, and says so Functions are found by their declarations and edges by looking for a callee's name inside a caller's body. That is a *reading* of the - line 1
text, not a resolved call graph: a call made through a variable, or built at run time, will not appear. `generate.py` documents the same limit for Rust and for the same reason, because a diagram derived from source is worth having - line 1
precisely because nobody hand-maintained it, and worth doubting for exactly this. CSS has no functions. Its chart is the file's own section comments, in order, which is the structure a stylesheet actually has. Pure standard library. """ - line 41
import argparse import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) MARKER = "GENERATED by tools/docs/sources.py" # What is covered. The - line 41
website's own source, which is what was asked for: the # scripts the pages run and the stylesheets they are drawn with. ROOTS = ["website/js", "website/css"] # The heading a file's plain-words paragraph lives under. PLAIN_HEADING = "In - line 41
plain words" sys.path.insert(0, HERE) import generate as docs import seo # noqa: E402 the addresses every page carries # noqa: E402 (the path has to be set first) # --- reading a file - line 41
--------------------------------------------------------- def header_comment(text, kind): """The comment block at the top of a file, as lines of prose. Stops at the first line that is not part of it, so a file's header is whatever the - line 41
author wrote before the code starts and nothing else. """ lines = [] if kind == "css": start = text.find("/*") if start != 0: return [] end = text.find("*/") if end < 0: return [] - line 81
for raw in text[2:end].split("\n"): raw = raw.strip() if raw.startswith("*"): raw = raw[1:] lines.append(raw.strip()) else: for raw in text.split("\n"): stripped = raw.strip() if not stripped.startswith("//"): if stripped == "": # A blank - line 81
line inside the header is part of it; a blank # line after the header has already ended the block. if lines: break continue break lines.append(stripped[2:].strip()) # The licence line is machinery, not prose. while lines and - line 81
lines[0].startswith("SPDX-License-Identifier"): lines.pop(0) while lines and not lines[0]: lines.pop(0) while lines and not lines[-1]: lines.pop() return lines def split_plain(lines): """Separate the technical header from the plain-words - line 81
paragraph. Returns `(technical, plain)`. `plain` is empty when the file has not been given one, which is what `--check` refuses. """ for index, line in enumerate(lines): if line.rstrip(":").strip().lower() == PLAIN_HEADING.lower(): - line 81
technical = lines[:index] plain = lines[index + 1 :] while technical and not technical[-1]: technical.pop() while plain and not plain[0]: - line 121
plain.pop(0) return technical, plain return lines, [] JS_DECLARATIONS = [ # `function name(` re.compile(r"^\s*function\s+([A-Za-z_$][\w$]*)\s*\("), # `var name = function(` and `const name = (a) =>` - line 121
re.compile(r"^\s*(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:function|\()"), # `name: function(` inside an object re.compile(r"^\s*([A-Za-z_$][\w$]*)\s*:\s*function\s*\("), ] PY_DECLARATION = - line 121
re.compile(r"^\s*def\s+([A-Za-z_][\w]*)\s*\(") # `/* ---------- name ---------- */`, which is how this project's stylesheets # mark their sections. CSS_SECTION = re.compile(r"/\*\s*-{3,}\s*(.+?)\s*-{3,}\s*\*/") def functions_in(text, - line 121
kind): """Every function this file declares, with the line it starts on.""" if kind == "css": found = [] for number, line in enumerate(text.split("\n"), start=1): match = CSS_SECTION.search(line) if match: found.append((match.group(1), - line 121
number)) return found patterns = [PY_DECLARATION] if kind == "py" else JS_DECLARATIONS found = [] seen = set() for number, line in enumerate(text.split("\n"), start=1): for pattern in patterns: match = pattern.match(line) if match: name = - line 121
match.group(1) if name not in seen: - line 161
seen.add(name) found.append((name, number)) break return found def bodies(text, names): """Roughly, the text belonging to each function. From one declaration to the next. Crude, and honest about being crude: a nested function's body is - line 161
counted as part of its parent's, which can only ever *add* an edge that a reader can see is there. It never removes one. """ lines = text.split("\n") starts = sorted((line, name) for name, line in names) out = {} for index, (line, name) in - line 161
enumerate(starts): end = starts[index + 1][0] - 1 if index + 1 < len(starts) else len(lines) out[name] = "\n".join(lines[line - 1 : end]) return out def call_graph(text, kind): """Nodes and edges, in the shape `generate.py`'s drawing code - line 161
wants.""" declared = functions_in(text, kind) if kind == "css" or not declared: return [], [] names = [name for name, _ in declared] where = dict(declared) inside = bodies(text, declared) nodes = [] for name in names[: - line 161
docs.MAX_DIAGRAM_NODES]: nodes.append( { "id": "n_%s" % re.sub(r"\W", "_", name), "label": [name, "line %d" % where[name]], "role": "function", } ) - line 201
drawn = {node["label"][0] for node in nodes} edges = [] for caller in names: if caller not in drawn: continue body = inside.get(caller, "") for callee in names: if callee == caller or callee not in drawn: continue if re.search(r"\b%s\s*\(" - line 201
% re.escape(callee), body): edges.append( ( "n_%s" % re.sub(r"\W", "_", caller), "n_%s" % re.sub(r"\W", "_", callee), ) ) return nodes, edges def kind_of(path): if path.endswith(".css"): return "css" if path.endswith(".py"): return "py" - line 201
return "js" def slug_of(rel): return rel.replace("/", "-").replace(".", "-") def covered(): """Every file this tool documents, as repo-relative paths, sorted.""" found = [] for root in ROOTS: directory = os.path.join(ROOT, - line 201
root.replace("/", os.sep)) if not os.path.isdir(directory): continue for name in sorted(os.listdir(directory)): - line 241
if name.endswith((".js", ".css", ".py")): found.append("%s/%s" % (root, name)) return found # --- writing ---------------------------------------------------------------- NOTE = ( "<!-- SPDX-License-Identifier: GPL-3.0-or-later -->\n" - line 241
"<!-- GENERATED by tools/docs/sources.py from the header comment in the\n" " source. Do not edit this file: edit the comment at the top of the\n" " source file and run the generator again. CI verifies it with\n" " python - line 241
tools/docs/sources.py --check\n" "-->\n" ) def paragraphs(lines): """Comment lines as Markdown paragraphs, keeping blank-line breaks.""" out = [] block = [] for line in lines: if line: block.append(line) elif block: out.append(" - line 241
".join(block)) block = [] if block: out.append(" ".join(block)) return out def markdown_page(colours, rel, text): kind = kind_of(rel) header = header_comment(text, kind) technical, plain = split_plain(header) declared = functions_in(text, - line 241
kind) nodes, edges = call_graph(text, kind) slug = slug_of(rel) lines = text.count("\n") + 1 - line 281
out = [NOTE] out.append( '<p align="center">\n' ' <img src="../../assets/sources/%s.svg" alt="%s" width="100%%">\n' "</p>\n" % (slug, rel) ) out.append("# `%s`\n" % rel) out.append( "[the source](https://github.com/%s/blob/%s/%s) · - line 281
%d lines\n" % (docs.REPO, docs.REF, rel, lines) ) out.append("## What it does\n") if technical: for para in paragraphs(technical): out.append(para + "\n") else: out.append("_This file has no header comment yet._\n") out.append("## %s\n" % - line 281
PLAIN_HEADING) for para in paragraphs(plain): out.append(para + "\n") if kind == "css": out.append("## The sections it is made of\n") if declared: out.append("| Section | Line |") out.append("|---|---:|") for name, number in declared: - line 281
out.append("| %s | %d |" % (name.replace("|", "\\|"), number)) out.append("") else: out.append("_This stylesheet has no section markers._\n") else: out.append("## What calls what\n") out.append( "Read out of the source: an edge means the - line 281
callee's name appears,\n" "called, inside the caller's body. It is a syntactic reading, not a\n" "resolved one, so a call made through a variable will not appear.\n" - line 321
) out.append( '<p align="center">\n' ' <img src="../../assets/sources/%s-chart.svg" alt="what calls what in %s" ' 'width="%d">\n' "</p>\n" % (slug, rel, docs.DIAGRAM_MAX_W) ) if declared: out.append("| Function | Line |") - line 321
out.append("|---|---:|") for name, number in declared: out.append("| `%s` | %d |" % (name, number)) out.append("") return docs.tidy("\n".join(out)) def html_page(colours, rel, text, fingerprint): kind = kind_of(rel) header = - line 321
header_comment(text, kind) technical, plain = split_plain(header) declared = functions_in(text, kind) nodes, edges = call_graph(text, kind) slug = slug_of(rel) lines = text.count("\n") + 1 body = [] # Sized from the drawing, so the heading - line 321
under it does not move when the # banner arrives and a link to that heading lands above it. banner_w, banner_h = docs.banner_size(os.path.join("..", "sources", "%s.svg" % slug)) body.append( '<p><img src="../../assets/sources/%s.svg" - line 321
alt="%s" width="%d" height="%d" ' 'style="width:100%%;height:auto"></p>' % (slug, docs.esc(rel), banner_w, banner_h) ) body.append("<h1><code>%s</code></h1>" % docs.esc(rel)) body.append( '<p style="color:var(--muted)"><a - line 321
href="index.html">the website\'s source</a> ' "· %d lines · " '<a href="https://github.com/%s/blob/%s/%s" rel="noopener noreferrer">' "read it on GitHub</a></p>" % (lines, docs.REPO, docs.REF, docs.esc(rel)) - line 361
) body.append('<section id="s-what">') body.append("<h2>WHAT IT DOES</h2>") for para in paragraphs(technical) or ["This file has no header comment yet."]: body.append("<p>%s</p>" % docs.inline_html(para)) body.append("</section>") - line 361
body.append('<section id="s-plain">') body.append("<h2>%s</h2>" % PLAIN_HEADING.upper()) for para in paragraphs(plain): body.append("<p>%s</p>" % docs.inline_html(para)) body.append("</section>") if kind == "css": body.append('<section - line 361
id="s-sections">') body.append("<h2>THE SECTIONS IT IS MADE OF</h2>") body.append("<table><tr><th>Section</th><th>Line</th></tr>") for name, number in declared: body.append("<tr><td>%s</td><td>%d</td></tr>" % (docs.esc(name), number)) - line 361
body.append("</table>") body.append("</section>") else: body.append('<section id="s-calls">') body.append("<h2>WHAT CALLS WHAT</h2>") body.append('<div class="diagram">') body.append(docs.diagram_svg(colours, nodes, edges).rstrip("\n")) - line 361
body.append("</div>") body.append( '<p style="color:var(--muted);font-size:13px">Read out of the source: an ' "edge means the callee’s name appears, called, inside the caller’s " "body. A syntactic reading, not a resolved - line 361
one.</p>" ) if declared: body.append("<table><tr><th>Function</th><th>Line</th></tr>") for name, number in declared: body.append( "<tr><td><code>%s</code></td><td>%d</td></tr>" % (docs.esc(name), number) ) - line 401
body.append("</table>") body.append("</section>") return docs.html_page( colours, 2, "%s · VeilVoice" % rel, "What %s does, and the same thing in plain words." % rel, # A list of lines, not a joined string. `html_page` does `out.extend`, # - line 401
so a string is extended one character at a time -- the page rendered # its own markup as spaced-out text, which is precisely what that looks # like, and it took looking at the page to see it. body, fingerprint, ) def wiki_page_name(rel): - line 401
"""What the GitHub wiki calls a page for this file. The wiki has one flat namespace, so a page name has to carry the path. The crate pages already use `Crate-x` and `File-crate-stem`; these use `Source-website-js-theme-js` for the same - line 401
reason and by the same rule. """ return "Source-%s" % slug_of(rel) def wiki_page(rel, text): """One source file's page, for the GitHub wiki. The same content as the repository's copy, with wiki links and raw image URLs. Generated from the - line 401
same header comment as the other two, so the three cannot disagree -- which is the whole reason any of this is generated. """ kind = kind_of(rel) header = header_comment(text, kind) technical, plain = split_plain(header) declared = - line 401
functions_in(text, kind) slug = slug_of(rel) lines = text.count("\n") + 1 - line 441
out = [] out.append( "<!-- GENERATED by tools/docs/sources.py from the header comment in the " "source. Do not edit: edit the source file and run the generator. -->" ) out.append("\n" % (rel, docs.RAW, slug)) - line 441
out.append("# `%s`\n" % rel) out.append( "[[The website's source|Source-index]] · %d lines · " "[read the source](https://github.com/%s/blob/%s/%s)\n" % (lines, docs.REPO, docs.REF, rel) ) out.append("## What it does\n") for - line 441
para in paragraphs(technical) or ["_This file has no header comment yet._"]: out.append(para + "\n") out.append("## %s\n" % PLAIN_HEADING) for para in paragraphs(plain): out.append(para + "\n") if kind == "css": out.append("## The sections - line 441
it is made of\n") out.append("| Section | Line |") out.append("|---|---:|") for name, number in declared: out.append("| %s | %d |" % (name.replace("|", "\\|"), number)) out.append("") else: out.append("## What calls what\n") out.append( - line 441
"Read out of the source: an edge means the callee's name appears,\n" "called, inside the caller's body. A syntactic reading, not a\n" "resolved one.\n" ) out.append( "\n" % (rel, - line 441
docs.RAW, slug) ) - line 481
if declared: out.append("| Function | Line |") out.append("|---|---:|") for name, number in declared: out.append("| `%s` | %d |" % (name, number)) out.append("") return docs.tidy("\n".join(out)) def wiki_index(files): out = [ "<!-- - line 481
GENERATED by tools/docs/sources.py from the header comments in the " "source. Do not edit: edit the source files and run the generator. -->" ] out.append("# The website's own source\n") out.append( "Every script and stylesheet this site is - line 481
made of, each explained " "technically and then in plain words. The same pages are in the " "repository and on the website; all three come out of one generator, so " "they cannot disagree.\n" ) out.append("| File | Lines | What it is |") - line 481
out.append("|---|---:|---|") for rel, text in files: technical, _ = split_plain(header_comment(text, kind_of(rel))) summary = (paragraphs(technical) or [""])[0].split(". ")[0].rstrip(".") out.append( "| [[`%s`|%s]] | %d | %s |" % (rel, - line 481
wiki_page_name(rel), text.count("\n") + 1, summary.replace("|", "\\|")) ) out.append("") return docs.tidy("\n".join(out)) def index_markdown(files): out = [NOTE] out.append("# The website's own source\n") out.append( "Ten files the site - line 481
invites you to open and read, each with a page of its\n" - line 521
"own: what it does, and the same thing in plain words.\n" ) out.append("| File | Lines | What it is |") out.append("|---|---:|---|") for rel, text in files: header = header_comment(text, kind_of(rel)) technical, _ = split_plain(header) - line 521
summary = (paragraphs(technical) or [""])[0] summary = summary.split(". ")[0].rstrip(".").replace("|", "\\|") out.append( "| [`%s`](%s.md) | %d | %s |" % (rel, slug_of(rel), text.count("\n") + 1, summary) ) out.append("") return - line 521
docs.tidy("\n".join(out)) def index_html(colours, files, fingerprint): body = ["<h1>The website’s own source</h1>"] body.append( '<p class="lead">Ten files the site invites you to open and read, each with ' "a page of its own: what - line 521
it does, and the same thing in plain words.</p>" ) body.append("<table><tr><th>File</th><th>Lines</th><th>What it is</th></tr>") for rel, text in files: header = header_comment(text, kind_of(rel)) technical, _ = split_plain(header) summary - line 521
= (paragraphs(technical) or [""])[0].split(". ")[0].rstrip(".") body.append( '<tr><td><a href="%s.html"><code>%s</code></a></td><td>%d</td><td>%s</td></tr>' % (slug_of(rel), docs.esc(rel), text.count("\n") + 1, docs.inline_html(summary)) ) - line 521
body.append("</table>") return docs.html_page( colours, 2, "The website's source · VeilVoice", "Every file the website is made of, explained twice.", # A list of lines, not a joined string. `html_page` does `out.extend`, # so a string is - line 521
extended one character at a time -- the page rendered - line 561
# its own markup as spaced-out text, which is precisely what that looks # like, and it took looking at the page to see it. body, fingerprint, ) def outputs(): """Every file this generator owns, as {relative path: text}, and any refusal.""" - line 561
colours = docs.palette(ROOT) fingerprint = docs.read( os.path.join(ROOT, "website", "assets", "fingerprint.txt") ).strip() files = {} loaded = [] missing_plain = [] for rel in covered(): with io.open(os.path.join(ROOT, rel.replace("/", - line 561
os.sep)), encoding="utf-8") as h: text = h.read() loaded.append((rel, text)) header = header_comment(text, kind_of(rel)) _, plain = split_plain(header) if not plain: missing_plain.append(rel) continue slug = slug_of(rel) - line 561
files["assets/sources/%s.svg" % slug] = docs.banner_svg( colours, rel, (paragraphs(header) or [""])[0][:96], kind_of(rel) ) if kind_of(rel) != "css": nodes, edges = call_graph(text, kind_of(rel)) files["assets/sources/%s-chart.svg" % slug] - line 561
= docs.diagram_svg( colours, nodes, edges ) files["docs/source/%s.md" % slug] = markdown_page(colours, rel, text) files["wiki/%s.md" % wiki_page_name(rel)] = wiki_page(rel, text) files["website/reference/source/%s.html" % slug] = html_page( - line 601
colours, rel, text, fingerprint ) if missing_plain: return {}, missing_plain files["docs/source/index.md"] = index_markdown(loaded) files["wiki/Source-index.md"] = wiki_index(loaded) files["website/reference/source/index.html"] = - line 601
index_html( colours, loaded, fingerprint ) # The site serves only what is under `website/`, so the pictures have to # exist there too. Written by the generator that owns them, for the reason # `assets/generate.py` learned the hard way -- - line 601
finding F-41 was a second # copy drifting with nothing able to tell. for name in list(files): if name.startswith("assets/sources/"): files["website/" + name] = files[name] return files, [] def main(): parser = - line 601
argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", action="store_true") args = parser.parse_args() files, missing = outputs() files = seo.finished(files) if missing: print(" these files have no '%s' - line 601
section in their header comment:" % PLAIN_HEADING) for rel in missing: print(" %s" % rel) print() print(" Every page here says what a file does technically and then says the") print(" same thing in plain words. The plain half cannot be - line 601
generated -- a") print(" sentence assembled from a filename is padding, and a reader can tell.") print(" Add it to the file's own header comment, under that heading.") return 1 - line 641
if args.check: problems = [] for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", os.sep)) try: with io.open(path, encoding="utf-8") as handle: if handle.read() != text: problems.append(rel) except OSError as - line 641
error: problems.append("%s (%s)" % (rel, error)) # A page this tool used to write and no longer does would otherwise sit # in the tree for ever, describing a file that has been deleted. The # same sweep `generate.py` runs, over the - line 641
directories this one owns -- # and the reason that tool can safely skip `reference/source/`. for base in ("docs/source", "website/reference/source", "assets/sources", "website/assets/sources"): directory = os.path.join(ROOT, - line 641
base.replace("/", os.sep)) if not os.path.isdir(directory): continue for current, _, names in os.walk(directory): for name in names: rel = os.path.relpath(os.path.join(current, name), ROOT) rel = rel.replace(os.sep, "/") if rel not in - line 641
files: problems.append("%s (no longer produced)" % rel) # The wiki is one flat namespace shared with `generate.py`, so the # sweep there is over a name prefix rather than a directory. That is why # these pages are called `Source-` and - line 641
nothing else in `wiki/` is. wiki = os.path.join(ROOT, "wiki") if os.path.isdir(wiki): for name in sorted(os.listdir(wiki)): rel = "wiki/%s" % name if name.startswith("Source-") and rel not in files: problems.append("%s (no longer - line 641
produced)" % rel) if problems: for rel in problems: print(" MISMATCH %s" % rel) - line 681
print() print(" Run 'python tools/docs/sources.py' and commit the result.") return 1 print(" %d source pages match their files" % len(files)) return 0 for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", - line 681
os.sep)) os.makedirs(os.path.dirname(path), exist_ok=True) with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) print(" wrote %d files for %d source files" % (len(files), len(covered()))) return 0 if - line 681
__name__ == "__main__": sys.exit(main())
tools/docs/wiki.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ The wiki's landing page, its sidebar, and a page for every prose document. python tools/docs/wiki.py # write them python tools/docs/wiki.py --check # verify they are - line 1
current # What was already there, and what was missing `tools/docs/generate.py` has long written the reference half of the wiki: a page per crate and per source file, 180 of them, each with its banner, its flowchart and its tables. - line 1
`tools/docs/guides.py` adds three, one per program. None of that is a way *in*. A GitHub wiki opens on a page called `Home`, and there was no `Home`; it shows a sidebar on every page if one is called `_Sidebar`, and there was no - line 1
`_Sidebar`. A reader arriving at the wiki landed on whichever page GitHub picked and had no list of the rest. Meanwhile the prose that answers the questions people actually arrive with, how to build it, how to check a download reproduces, - line 1
how to contribute, was in `docs/` and not in the wiki at all. # Why the prose is converted rather than written again `docs/` is the source. These pages are that text with its links rewritten for a flat namespace, so there is no second copy - line 1
to go stale: `--check` regenerates into memory and compares, and CI fails on a difference. The same decision, for the same reason, as every other generator here. # What the link rewriting has to handle The wiki has no directories. A - line 1
document in `docs/` links to its neighbour as `WHITEPAPER.md`, which is right in `docs/` and wrong in the wiki. Three cases: * a neighbour that has a wiki page becomes a `[[wiki link]]`, * a neighbour that does not becomes an absolute URL - line 1
into the repository, * a link to a file elsewhere in the tree becomes an absolute URL too. The third matters more than it looks: a relative link to `crates/...` or `tools/...` resolves, in a wiki, to a wiki page of that name, which does not - line 41
exist. Silently. That is the failure this rewriting exists to prevent. Pure standard library. """ from __future__ import annotations import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = - line 41
os.path.abspath(os.path.join(HERE, "..", "..")) WIKI = os.path.join(ROOT, "wiki") REPO = "https://github.com/tilas01/veilvoice" BANNER = ("<!-- GENERATED by tools/docs/wiki.py from the documents in the " "repository. Do not edit: edit the - line 41
source document and run the " "generator. -->") # Every prose document that becomes a wiki page: where it lives, the page it # becomes, and the one line the landing page and sidebar describe it by. # # The order is the order a reader needs - line 41
them, not alphabetical: what the thing # is, then how to get it, then how to check it, then how to work on it. DOCUMENTS = [ ("README.md", "What VeilVoice is", "The whole project in one page: what it does, what it refuses to claim, " "and - line 41
how to install it"), ("docs/USER_GUIDE.md", "User guide", "Every screen and every command, in order"), ("docs/INSTALL.md", "Installing", "Per operating system, with the verification step in place rather than " "bolted on"), ("docs/FAQ.md", - line 41
"Questions", "The questions people actually ask"), ("docs/WHITEPAPER.md", "How the veiling works", "What is done to a voice, why it cannot be undone, and what this is not"), ("docs/GUIDE_VERIFY.md", "Checking a download", - line 81
"The signature chain end to end, and what each verdict is worth"), ("docs/REPRODUCIBLE_BUILDS.md", "Building it yourself", "Building from source and comparing the result against what was " "published"), ("docs/SELF_SIGNING.md", "The - line 81
code-signing certificate", "What it is, and importing it"), ("docs/PACKAGING.md", "Packaging", "The deb, the RPM, the AUR recipe and the rest"), ("docs/USING_THE_CRATES.md", "Using it as a library", "The crates, and what depending on one - line 81
commits you to"), ("docs/WEBSITE.md", "The website", "Both editions, what generates each page, and the twelve scripts"), ("docs/CONTRIBUTING.md", "Contributing", "Building, the standing rules, the house style and what a change has to " - line 81
"pass"), ("docs/SECURITY.md", "Reporting a vulnerability", "Where to send it, what counts as one, and what does not"), ("docs/AUDIT.md", "Audit history", "Every defect found and fixed, in order, including the ones an earlier " "round had - line 81
called clean"), ("ROADMAP.md", "Roadmap", "What is done, what is being worked on, and what is planned"), ("CHANGELOG.md", "Release notes", "Every release, newest first"), ] # The three guides tools/docs/guides.py writes, listed so the - line 81
landing page and # the sidebar can point at them. Read from there rather than repeated: if a # guide is renamed, this notices rather than linking into nothing. GUIDES = [ ("Guide-command-line", "The command line", "`veilvoice`, every - line 81
command with worked examples"), ("Guide-desktop-app", "The desktop application", "`veilvoice-gui`, screen by screen"), ("Guide-verifier", "The verifier", "Checking a download, at length"), ] # A relative link to a Markdown file: - line 81
`](NAME.md)` or `](dir/NAME.md)`, with an # optional fragment. Anything absolute, or a bare fragment, is left alone. - line 121
RELATIVE_MD = re.compile(r"\]\((?!https?:|#|/)([A-Za-z0-9_./-]+\.md)(#[^)]*)?\)") # A relative link to anything else in the tree: source, a script, a directory. RELATIVE_OTHER = - line 121
re.compile(r"\]\((?!https?:|#|/|[A-Za-z0-9_./-]+\.md)([A-Za-z0-9_./-]+)(#[^)]*)?\)") # A relative image, which in a wiki must be an absolute raw URL to render. RELATIVE_IMG = re.compile(r"!\[([^\]]*)\]\((?!https?:|/)([A-Za-z0-9_./-]+)\)") - line 121
def page_name(source): """The wiki page a source document becomes.""" stem = os.path.basename(source)[: -len(".md")] return "Doc-" + stem.replace("_", "-") def read(path): with io.open(os.path.join(ROOT, path), encoding="utf-8") as handle: - line 121
return handle.read() def rewrite(text, source): """The same text, with every relative link made to work in a flat wiki.""" here = os.path.dirname(source) pages = {src: page_name(src) for src, _, _ in DOCUMENTS} # Also reachable by bare - line 121
name, which is how a document in `docs/` refers to # its neighbour. by_base = {os.path.basename(src): page for src, page in pages.items()} def md_link(found): target, fragment = found.group(1), found.group(2) or "" joined = - line 121
os.path.normpath(os.path.join(here, target)) if here else target joined = joined.replace(os.sep, "/") if joined in pages: return "](%s)" % pages[joined] base = os.path.basename(target) if base in by_base and not target.startswith("../"): - line 121
return "](%s)" % by_base[base] return "](%s/blob/main/%s%s)" % (REPO, joined, fragment) def other_link(found): target, fragment = found.group(1), found.group(2) or "" joined = os.path.normpath(os.path.join(here, target)) if here else target - line 161
return "](%s/blob/main/%s%s)" % (REPO, joined.replace(os.sep, "/"), fragment) def image(found): alt, target = found.group(1), found.group(2) joined = os.path.normpath(os.path.join(here, target)) if here else target return - line 161
"" % (alt, REPO, joined.replace(os.sep, "/")) text = RELATIVE_IMG.sub(image, text) text = RELATIVE_MD.sub(md_link, text) text = RELATIVE_OTHER.sub(other_link, text) return text def document_pages(): """One wiki page - line 161
per prose document.""" out = {} for source, title, _blurb in DOCUMENTS: body = rewrite(read(source), source) header = ( "%s\n" "*This page is [`%s`](%s/blob/main/%s) in the repository. " "The repository copy is the source; this is the same - line 161
text with its " "links rewritten for the wiki.*\n" % (BANNER, source, REPO, source) ) out[page_name(source)] = header + "\n" + body.rstrip("\n") + "\n" return out def home(): """The landing page: everything, grouped by what a reader came - line 161
to do.""" out = [BANNER, "# VeilVoice", ""] out.append("" % REPO) out.append("") out.append("Irreversible voice de-identification, fully offline. It destroys " "the biometric voiceprint, pitch, - line 161
formants, timbre and accent, " "and leaves the speech transcribable.") out.append("") out.append("This wiki is generated from the repository. Every page here has " "a source file there, and CI fails if the two disagree, so " - line 201
"nothing on it can quietly go stale.") out.append("") out.append("## Start here") out.append("") for source, title, blurb in DOCUMENTS[:5]: out.append("- **[[%s|%s]]** · %s" % (title, page_name(source), blurb)) out.append("") - line 201
out.append("## One program at a time") out.append("") for page, title, blurb in GUIDES: out.append("- **[[%s|%s]]** · %s" % (title, page, blurb)) out.append("") out.append("## Checking it, building it, packaging it") out.append("") - line 201
for source, title, blurb in DOCUMENTS[5:11]: out.append("- **[[%s|%s]]** · %s" % (title, page_name(source), blurb)) out.append("") out.append("## Working on it") out.append("") for source, title, blurb in DOCUMENTS[11:]: - line 201
out.append("- **[[%s|%s]]** · %s" % (title, page_name(source), blurb)) out.append("") out.append("## Every crate and every file") out.append("") out.append("**[[The reference|Reference]]** is a page for each of the " "thirteen - line 201
crates and each source file in them, generated from " "the doc comments in the code. Each crate page carries its " "banner, a flowchart of what calls what, and a table of every " "file in it.") out.append("") out.append("The same pages are - line 201
on " "[the website](https://tilas01.github.io/veilvoice/wiki.html), " "which additionally shows the syntax-highlighted source beside " "each one.") out.append("") - line 241
return "\n".join(out) + "\n" def sidebar(): """The navigation GitHub shows beside every page.""" out = [BANNER, "### [[VeilVoice|Home]]", ""] groups = [ ("Start here", [(page_name(s), t) for s, t, _ in DOCUMENTS[:5]]), ("Per program", [(p, - line 241
t) for p, t, _ in GUIDES]), ("Check and build", [(page_name(s), t) for s, t, _ in DOCUMENTS[5:11]]), ("Work on it", [(page_name(s), t) for s, t, _ in DOCUMENTS[11:]]), ] for heading, entries in groups: out.append("**%s**" % heading) - line 241
out.append("") for page, title in entries: out.append("- [[%s|%s]]" % (title, page)) out.append("") out.append("**Reference**") out.append("") out.append("- [[Every crate and file|Reference]]") out.append("") return "\n".join(out) + "\n" - line 241
def build(): pages = document_pages() pages["Home"] = home() pages["_Sidebar"] = sidebar() return pages # A wiki link, in either form GitHub accepts: `[[Page]]` or `[[Title|Page]]`. WIKI_LINK = - line 241
re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]") # A fenced block, and an inline code span. Their spans are worked out so a # `[[link]]` quoted inside one can be skipped, for the same reason # `tools/audit/rsa_usage.py` strips string - line 241
literals before looking for code: # text quoting a link is not a link. The roadmap entry describing this very # guard contains one in backticks. - line 281
# # The test is containment, not overlap, and that distinction is the whole of # it: the reference pages write `[[`devices.rs`|File-...]]`, where a code span # sits *inside* a link and the link is real. Only a link wholly inside a span # - line 281
is quoted rather than meant. FENCE = re.compile(r"^```.*?^```", re.S | re.M) CODE_SPAN = re.compile(r"`[^`\n]*`") def quoted_spans(text): """Where the fenced blocks and inline code spans are.""" return [found.span() for found in - line 281
FENCE.finditer(text)] + \ [found.span() for found in CODE_SPAN.finditer(text)] def links_in(text): """Every wiki link that is meant rather than quoted.""" spans = quoted_spans(text) out = [] for found in WIKI_LINK.finditer(text): start, - line 281
end = found.span() if any(a <= start and end <= b for a, b in spans): continue out.append(found.group(2) or found.group(1)) return out def dead_links(): """Every `[[link]]` in the wiki that names a page which does not exist. Worth checking - line 281
separately from whether the pages are current, because a wiki link to a missing page does not fail loudly: GitHub renders it as an invitation to create that page, so a typo looks like a feature. This is the whole reason the link rewriting - line 281
above exists, and a rule with no guard decays, so here is the guard. """ have = {name[: -len(".md")] for name in os.listdir(WIKI) if name.endswith(".md")} dead = [] for name in sorted(os.listdir(WIKI)): - line 321
if not name.endswith(".md"): continue with io.open(os.path.join(WIKI, name), encoding="utf-8") as handle: text = handle.read() for target in links_in(text): if target not in have: dead.append((name, target)) return dead def main(): check = - line 321
"--check" in sys.argv[1:] pages = build() if check: problems = [] for name, wanted in sorted(pages.items()): path = os.path.join(WIKI, name + ".md") if not os.path.exists(path): problems.append("missing: wiki/%s.md" % name) continue with - line 321
io.open(path, encoding="utf-8") as handle: if handle.read() != wanted: problems.append("stale: wiki/%s.md" % name) if problems: for line in problems: print(" " + line) print() print("Run 'python tools/docs/wiki.py' and commit the result.") - line 321
return 1 dead = dead_links() if dead: print(" these wiki links name a page that does not exist:") for where, target in dead[:20]: print(" %-38s -> %s" % (where, target)) if len(dead) > 20: print(" ... and %d more" % (len(dead) - 20)) - line 321
return 1 total = sum(len(links_in( - line 361
io.open(os.path.join(WIKI, n), encoding="utf-8").read())) for n in os.listdir(WIKI) if n.endswith(".md")) print(" %d wiki page(s) current, from %d document(s); " "%d internal link(s), all resolving" % (len(pages), len(DOCUMENTS), total)) - line 361
return 0 os.makedirs(WIKI, exist_ok=True) for name, body in sorted(pages.items()): with io.open(os.path.join(WIKI, name + ".md"), "w", encoding="utf-8", newline="\n") as handle: handle.write(body) print(" wrote %d wiki page(s) from %d - line 361
document(s)" % (len(pages), len(DOCUMENTS))) return 0 if __name__ == "__main__": sys.exit(main())
tools/loc/count.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Functional lines of Rust: lines that hold code, per crate and in total. python tools/loc/count.py # print the table python tools/loc/count.py --json # the same numbers, - line 1
for a generator python tools/loc/count.py --self-test # check the scanner against known cases # What is counted, and why the definition is stated wherever the number is A **functional line** is a line with code on it. Blank lines are not - line 1
counted, and neither are lines that hold only a comment. A line with code *and* a trailing comment counts once, because there is code on it. That definition is written next to every number this produces. A line count without one is not a - line 1
measurement, it is a number: "80,000 lines" means something different depending on whether the counter agreed with you about comments, and this project is written with a very high comment-to-code ratio, so the two answers are far apart - line 1
here in particular. # This is a new number, not a redefinition of an existing one The generated per-file pages, the artwork and the reference links already carry a line count, and it is the plain length of the file. That measure is not - line 1
touched. Changing what an existing number means, everywhere it appears, in order to match a new definition would silently alter every page and link carrying one, and somebody comparing two of them would have no way to tell which definition - line 1
they were reading. So there are two numbers with two definitions, each stated where it is used, rather than one number that quietly changed meaning. # Why a string-aware scan rather than a regular expression `//` inside a string literal is - line 1
not a comment: ```rust let url = "https://example.invalid/"; // this half is ``` - line 41
A counter that treats the first `//` as the start of a comment reads that line as code (correct here, by luck) and reads a line holding *only* a string containing `//` as a comment (wrong). Raw strings make it worse: `r"...//..."` and - line 41
`r#"..."#` have their own rules about what ends them. So this walks each line character by character, tracking whether it is inside a string, a raw string, or a block comment. It is not a Rust parser and does not need to be: it needs to - line 41
know whether any character on a line is code, which is a much smaller question than what that code means. # In plain words Counts how much actual code this project is, ignoring blank lines and comments, and says what it counted so the - line 41
number means something. Pure standard library. """ import argparse import io import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) # The definition, in one - line 41
place, so the tool and every page it feeds say the # same thing in the same words. DEFINITION = ( "a line holding code: blank lines and lines holding only a comment are " "not counted, and a line with code and a trailing comment counts - line 41
once" ) def functional_lines(source): """How many lines of `source` hold code. Walks the text tracking string, raw-string and block-comment state, because `//` inside a string is not a comment and a naive scan gets that wrong in - line 81
both directions. """ count = 0 in_block = 0 # Rust block comments nest, so this is a depth rather than a flag. for line in source.splitlines(): has_code = False i = 0 n = len(line) in_str = False in_raw = False raw_hashes = 0 while i < n: - line 81
ch = line[i] if in_block: if line.startswith("*/", i): in_block -= 1 i += 2 continue if line.startswith("/*", i): in_block += 1 i += 2 continue i += 1 continue if in_raw: if ch == '"': # A raw string ends at a quote followed by its own - line 81
number of # hashes, so `r#"a"b"#` is one string rather than two. if line.startswith("#" * raw_hashes, i + 1): in_raw = False i += 1 + raw_hashes continue i += 1 continue if in_str: if ch == "\\": i += 2 # An escape, so the next character - line 81
cannot close it. - line 121
continue if ch == '"': in_str = False i += 1 continue # Not inside anything: this is where comments and strings start. if line.startswith("//", i): break # The rest of the line is a comment. if line.startswith("/*", i): in_block += 1 i += - line 121
2 continue if ch == "r" and i + 1 < n and line[i + 1] in '#"': hashes = 0 j = i + 1 while j < n and line[j] == "#": hashes += 1 j += 1 if j < n and line[j] == '"': in_raw = True raw_hashes = hashes has_code = True i = j + 1 continue if ch - line 121
== '"': in_str = True has_code = True i += 1 continue if not ch.isspace(): has_code = True i += 1 if has_code: count += 1 return count def read(path): - line 161
with io.open(path, encoding="utf-8") as handle: return handle.read() def count_tree(directory): """Functional lines in every `.rs` file under `directory`.""" total = 0 for base, _dirs, files in os.walk(directory): for file in - line 161
sorted(files): if file.endswith(".rs"): total += functional_lines(read(os.path.join(base, file))) return total def per_crate(): """Every crate, with its functional line count, ordered by name. `fuzz` is included and is not under `crates/`: - line 161
it is a separate Cargo project at the root, holding the harnesses, and it is Rust somebody in this repository wrote and maintains. `tools/docs/generate.py` documents it beside the workspace members for the same reason, so leaving it out - line 161
here would give two different answers to "which crates are there" in two files that both claim to enumerate them. """ crates = {} root = os.path.join(ROOT, "crates") for name in sorted(os.listdir(root)): src = os.path.join(root, name, - line 161
"src") if os.path.isdir(src): crates[name] = count_tree(src) fuzz = os.path.join(ROOT, "fuzz") if os.path.isfile(os.path.join(fuzz, "Cargo.toml")): # Not `os.walk(fuzz)`: `corpus/`, `seeds/` and `artifacts/` hold inputs # rather than - line 161
source, and a `.rs` file that arrived there as a fuzzing # input is not code this project wrote. total = 0 for part in ("src", "fuzz_targets"): path = os.path.join(fuzz, part) if os.path.isdir(path): - line 201
total += count_tree(path) if total: crates["fuzz"] = total return dict(sorted(crates.items())) # The cases the scanner has to get right, and what each one is for. Written as # data rather than as a list of asserts so the reason each case - line 201
exists is beside # it: a counter is a small thing that is easy to get subtly wrong, and a wrong # number here would appear in the README and in 27 crate documents at once. CASES = [ ("let x = 1;", 1, "plain code"), ("", 0, "a blank line"), - line 201
(" ", 0, "a line of spaces"), ("// a comment", 0, "a whole-line comment"), (" // an indented comment", 0, "a comment with code's indentation"), ("/// a doc comment", 0, "a doc comment is still a comment"), ("//! a module doc comment", 0, - line 201
"so is a module one"), ("let x = 1; // why", 1, "code with a trailing comment counts once"), ("/* block */", 0, "a block comment on one line"), ("/* start\nmiddle\nend */", 0, "a block comment over three lines"), ("/* start\nend */ let x = - line 201
1;", 1, "code after a block comment ends"), ("let a = 1; /* mid */ let b = 2;", 1, "a block comment inside a line"), ("/* outer /* inner */ still outer */", 0, "Rust block comments nest"), ("/* outer /* inner */ still */ let x = 1;", 1, - line 201
"and the nesting closes"), ('let u = "https://example.invalid/";', 1, "// inside a string is not a comment"), ('let s = "//";', 1, "a line whose only content is a string of slashes"), ('let s = "a\\"b"; // real', 1, "an escaped quote does - line 201
not end the string"), ('let r = r"a//b";', 1, "a raw string with slashes in it"), ('let r = r#"a"b"#;', 1, "a raw string that contains a quote"), ('let r = r#"a"#; // real', 1, "a hashed raw string ends at the right place"), ("let s = - line 201
\"start\n", 1, "an unterminated string still holds code"), ("}", 1, "a closing brace is code"), ] def self_test(): """Check the scanner against every case above. Returns the exit status.""" wrong = [] - line 241
for source, expected, why in CASES: got = functional_lines(source) if got != expected: wrong.append(" %-46r expected %d, counted %d (%s)" % (source, expected, got, why)) if wrong: print("the functional line scanner is wrong on %d case(s):" - line 241
% len(wrong)) print("\n".join(wrong)) return 1 print(" the scanner is right on all %d cases" % len(CASES)) # And on the tree, because a scanner that passes its cases and reads no # files is a scanner nobody has run. Every crate must have - line 241
some code in it, # and no crate can have more functional lines than it has lines. crates = per_crate() if not crates: print("no crates were read") return 1 for name, count in crates.items(): if count <= 0: print("%s counted %d functional - line 241
lines, which cannot be right" % (name, count)) return 1 print(" and reads %d crates, the smallest of which has %d lines" % (len(crates), min(crates.values()))) return 0 def main(): parser = - line 241
argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--json", action="store_true") parser.add_argument("--self-test", action="store_true") args = parser.parse_args() if args.self_test: return self_test() crates - line 241
= per_crate() total = sum(crates.values()) if args.json: - line 281
print(json.dumps({"definition": DEFINITION, "crates": crates, "total": total}, indent=2, sort_keys=True)) return 0 width = max(len(n) for n in crates) for name, count in crates.items(): print(" %-*s %6d" % (width, name, count)) print(" - line 281
%-*s %6d" % (width, "TOTAL", total)) print() print(" Functional lines: %s." % DEFINITION) return 0 if __name__ == "__main__": sys.exit(main())
tools/measured/generate.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Numbers this project states about itself, measured rather than remembered. Why this exists --------------- The front page said "354 tests" and "no unsafe code, in any of - line 1
the nine crates" while the tree held 881 tests and nineteen crates. Both claims were true when they were typed. There *was* a guard. `tools/site-tests/css.test.js` compared the front page's test count against ``docs/AUDIT.md`` and failed - line 1
the build if they disagreed -- written after an earlier round of exactly this drift, with a comment saying "this was the one place claims were hand-typed with nothing watching them". It passed the whole time, because both numbers were - line 1
hand-typed and both drifted together. A check that compares one copy of a claim against another copy is a check that agrees with itself, which is the failure this repository has now recorded four separate times (F-61, F-63, and the audit's - line 1
own note about the banner). So the numbers come from the tree. The test count is **measured by running the tests**, because a static count of ``#[test]`` is not the same number -- it is 903 against a measured 881, since some tests sit - line 1
behind features that are not on by default. Counting the attribute would have produced a new wrong number with a new reason. What is generated ----------------- ``docs/MEASURED.md``, a small table of the measurements themselves. And, from - line 1
it, the ten places in ``README.md``, ``website/index.html`` and ``docs/AUDIT.md`` that state one of those numbers in a sentence. Those used to be typed, with ``tools/site-tests/css.test.js`` comparing each against this file and failing the - line 1
build on a disagreement. The comparison was right and it stays. The typing was the defect: every change to any Rust file moves the functional line count, so a commit that touched code and not those ten places failed a check that had - line 1
nothing to do with what the commit was for. It happened - line 41
four times in round thirty-three alone, which is the measurement that made this worth doing. Roadmap item 152. So the writer and the checker read the same anchors, and a claim can now drift only if this tool is not run -- which - line 41
``tools/verify.py`` and CI both do. ``docs/AUDIT.md`` is otherwise written by a person and stays that way. The one row touched here is the "Test suite" row of its state-of-the-tree table, which is a description of now rather than a record - line 41
of the past, and the regular expression is anchored to that row so that no past number elsewhere in the document is within reach of it. Usage ----- python tools/measured/generate.py # measure, write, and rewrite # every claim from the - line 41
result python tools/measured/generate.py --check # measure and compare """ from __future__ import annotations import io import os import re import subprocess import sys from pathlib import Path ROOT = - line 41
Path(__file__).resolve().parent.parent.parent OUTPUT = ROOT / "docs" / "MEASURED.md" # The functional line count comes from the tool that counts it, imported rather # than reimplemented, for the same reason the test count is measured - line 41
rather than # typed: a second implementation is a second answer. sys.path.insert(0, str(ROOT / "tools" / "loc")) import count as loc # noqa: E402 HEADER = """<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- GENERATED by - line 41
tools/measured/generate.py. Do not edit by hand. --> - line 81
# Measured Numbers this project states about itself, taken from the tree rather than from memory. Regenerated and checked by `tools/verify.py`; the documents and the website that quote them are compared against this file, so a claim that - line 81
drifts fails the build instead of ageing quietly. **The test count is a number about one machine.** Some tests are compiled only on one operating system, so running the same tree on another gives a different total: this tree measures 996 - line 81
on Windows and 988 on Linux. The row below says which machine produced the number in it, because a count with no platform beside it reads as a fact about the tree and is a fact about a computer. See F-77 in `docs/AUDIT.md`. **Functional - line 81
lines are a different measure from the per-file line counts** in the generated pages, the artwork and the reference links. A functional line is a line holding code, so blank lines and lines holding only a comment are not counted, and a - line 81
line with code and a trailing comment counts once. The per-file counts are the length of the file, blank lines and comments included, and are left exactly as they are: this project is written with a high comment-to-code ratio, so the two - line 81
numbers are far apart, and quietly redefining one to match the other would alter every page and link that carries it. The line count covers **28 crates**, one more than the workspace row above: `fuzz/` is a separate Cargo project at the - line 81
root holding the fuzzing harnesses, and it is Rust written and maintained here. `tools/docs/generate.py` documents it beside the workspace members for the same reason. | What | Measured | |---|---:| """ def workspace_crates() -> int: - line 81
"""How many crates the workspace actually contains.""" manifest = (ROOT / "Cargo.toml").read_text(encoding="utf-8") members = re.search(r"members\s*=\s*\[(.*?)\]", manifest, re.S) if not members: raise SystemExit("Cargo.toml has no - line 81
workspace members list") - line 121
return len(re.findall(r'"([^"]+)"', members.group(1))) def site_suites() -> int: """How many website suites the runner runs. Read out of ``run.js``'s own list rather than by globbing the directory. A suite file that exists and is not in - line 121
the list does not run, and counting it would state a number nobody gets -- the same shape of mistake as counting ``#[test]`` instead of running the tests. """ runner = (ROOT / "tools" / "site-tests" / "run.js").read_text(encoding="utf-8") - line 121
block = re.search(r"const SUITES\s*=\s*\[(.*?)\]", runner, re.S) if not block: raise SystemExit("tools/site-tests/run.js has no SUITES list") return len(re.findall(r'require\("\./([^"]+\.test\.js)"\)', block.group(1))) def measured_tests() - line 121
-> int: """Run the suite and total what actually ran. Not a count of ``#[test]``. Those two numbers differ, and the one worth stating is the one a reader gets when they run `cargo test` themselves. """ environment = dict(os.environ) # On - line 121
Windows the build goes under %LOCALAPPDATA%, away from a repository # that may sit in a synced folder or deep enough to meet the path limit. # # Everywhere else it goes where it always goes, which is the `target/` at # the root of this - line 121
repository, and the way to ask for that is to say # nothing. The line this replaces named `ROOT` as the fallback and so built # into `<repo>/veilvoice/target` on every machine that is not Windows: a # second complete copy of the workspace - line 121
build, inside the repository, # invisible to `git status` because `.gitignore` matches `target/` at any # depth, and never cleaned because nobody knew it was there. It was # fifteen gigabytes when it was found, and finding it took a - line 121
mutation # campaign dying for want of disk. F-185. if os.name == "nt" and "LOCALAPPDATA" in os.environ: environment.setdefault( "CARGO_TARGET_DIR", - line 161
str(Path(os.environ["LOCALAPPDATA"]) / "veilvoice" / "target"), ) finished = subprocess.run( ["cargo", "test", "--workspace"], cwd=ROOT, capture_output=True, env=environment, ) # Decoded explicitly as UTF-8. `text=True` uses the console - line 161
code page on # Windows, which turns every em dash into three wrong characters -- a # mistake this repository has already made once and has a suite to catch. output = finished.stdout.decode("utf-8", errors="replace") if finished.returncode - line 161
!= 0: stderr = finished.stderr.decode("utf-8", errors="replace") raise SystemExit( "the tests did not pass, so there is no number to record:\n" + (stderr or output)[-2000:] ) totals = [int(n) for n in re.findall(r"^test result: ok\. (\d+) - line 161
passed", output, re.M)] if not totals: raise SystemExit("no test results were found in cargo's output") return sum(totals) def audit_findings() -> tuple[int, int]: """How many findings the audit writes up, and the highest number it uses. A - line 161
finding "exists" here when it has an entry of its own: a line beginning `### F-n`, or one beginning `**F-n` followed by a colon or a ` --`. Those are the three shapes this document has used for a finding heading over nineteen rounds. - line 161
Anything else that names a finding is a mention, and `**F-73 had**: ...` is the one line that reads like a heading and is a sentence, so the separator is part of the pattern rather than optional. That distinction is the whole point of the - line 161
measurement, because F-93 and F-94 were fixed in code, described in their commit, referred to by a later round, and never given an entry -- so every count taken by grepping for `F-\d+` said they were documented and none of them was. Two - line 161
numbers rather than one. The count is what the verdict claims; the highest is the last number handed out. They agree only when no number has been skipped, so a reader comparing them can see a gap without reading the - line 201
document, and the suite fails when one opens. """ text = (ROOT / "docs" / "AUDIT.md").read_text(encoding="utf-8") entries = set() for line in text.split("\n"): found = re.match(r"^(?:### F-(\d+)\b|\*\*F-(\d+)(?::| --))", line) if found: - line 201
entries.add(int(found.group(1) or found.group(2))) if not entries: raise SystemExit("docs/AUDIT.md has no finding entries; the pattern must have moved") return len(entries), max(entries) def host_triple() -> str: """The target this machine - line 201
builds for, as rustc names it. Recorded beside the test count because that count is a number about this machine rather than about the tree. `rustc -vV` prints it; a compiler that will not answer leaves the row saying so rather than - line 201
guessing, because a guessed platform is worse than a missing one. """ try: finished = subprocess.run( ["rustc", "-vV"], cwd=ROOT, capture_output=True, check=False) except OSError: return "unknown" text = finished.stdout.decode("utf-8", - line 201
errors="replace") found = re.search(r"^host:\s*(\S+)$", text, re.M) return found.group(1) if found else "unknown" # Every place one of the measured numbers is stated in a sentence, and the # pattern that finds it. The first capture group - line 201
is the number, and only that # group is rewritten: the words around it belong to whoever wrote them. # # These are the same anchors `tools/site-tests/css.test.js` checks against # `docs/MEASURED.md`, deliberately, so that the tool which - line 201
writes a claim and # the suite which checks it cannot disagree about where the claim is. If one of # these patterns stops matching, the suite says so by name before this tool can # quietly write nothing. - line 241
# # The audit's three are anchored to the "Test suite" row. That document # discusses past numbers, F-71's own write-up quotes "354 tests across 9 # crates", and a looser pattern reads the history instead of the claim; it did # exactly - line 241
that the first time the suite was written. CLAIMS = [ ("website/index.html", r"(\d+) tests, and \d+ more suites", "tests"), ("website/index.html", r"\d+ tests, and (\d+) more suites", "suites"), ("website/index.html", r"in any of the (\d+) - line 241
crates", "crates"), ("docs/AUDIT.md", r"\| Test suite \| (\d+) tests across \d+ crates", "tests"), ("docs/AUDIT.md", r"\| Test suite \| \d+ tests across (\d+) crates", "crates"), ("docs/AUDIT.md", r"\| Test suite \|[^|]*?and (\d+) - line 241
site-test suites", "suites"), ("README.md", r"\((\d+) tests across \d+\ncrates", "tests"), ("README.md", r"\(\d+ tests across (\d+)\ncrates", "crates"), ("README.md", r"and (\d+) website suites", "suites"), ("README.md", r"\*\*(\d+) - line 241
functional lines of Rust\*\*", "lines"), # The defect count, in the two places that state it outside the audit. Its # measurement is `audit_findings`, which counts entries rather than # mentions, because F-93 and F-94 were fixed, described - line 241
in their commits, # referred to by a later round, and never given an entry of their own: any # count taken by grepping for a finding number said they were documented. # # The audit's own verdict is not written from here and will not be. It - line 241
is # the sentence the count is taken from, so writing it from the count would # be the tool agreeing with itself, which is the failure this file was # written after (F-61, F-63, F-71). ("README.md", r"\*\*(\d+) defects\*\*", "written_up"), - line 241
("website/index.html", r"(\d+) defects found and fixed across", "written_up"), ] def claimed(measurements: dict, check: bool) -> list: """Write every claim from the measurements, or report the ones that drifted. One file is read, rewritten - line 241
and written back per claim rather than all at once, because replacing a number changes every offset after it and a batch of spans taken from one read would land in the wrong places. Ten small passes over three files is not a cost worth - line 241
optimising away. """ problems = [] - line 281
for name, pattern, key in CLAIMS: path = ROOT / name text = path.read_text(encoding="utf-8") found = re.search(pattern, text) if not found: problems.append( f"{name}: nothing matches the pattern for the {key} count, " "so that claim is no - line 281
longer being written or watched" ) continue want = str(measurements[key]) if found.group(1) == want: continue if check: problems.append( f"{name}: the {key} count says {found.group(1)}, " f"the tree measures {want}" ) continue start, end = - line 281
found.span(1) with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text[:start] + want + text[end:]) return problems def measure() -> dict: """Every number, taken once. Returned as a mapping rather than rendered - line 281
straight into the table, because the same four values are also written into the sentences that state them and measuring twice would be two answers to one question. """ written_up, highest = audit_findings() return { "tests": - line 281
measured_tests(), "crates": workspace_crates(), "suites": site_suites(), "lines": sum(loc.per_crate().values()), "written_up": written_up, "highest": highest, - line 321
} def render(measurements: dict) -> str: rows = [ ("Tests, measured by running them", measurements["tests"]), ("Crates in the workspace", measurements["crates"]), ("Website suites", measurements["suites"]), ("Functional lines of Rust", - line 321
measurements["lines"]), ("Findings written up in the audit", measurements["written_up"]), ("Highest finding number used", measurements["highest"]), ] body = "".join(f"| {name} | {value} |\n" for name, value in rows) body += f"| Measured on - line 321
| `{host_triple()}` |\n" return HEADER + body def main() -> int: check = "--check" in sys.argv measurements = measure() fresh = render(measurements) if check: if not OUTPUT.is_file(): print(f"{OUTPUT.relative_to(ROOT)} is missing", - line 321
file=sys.stderr) return 1 current = OUTPUT.read_text(encoding="utf-8") if current != fresh: print( f"{OUTPUT.relative_to(ROOT)} is out of date. Regenerate it with:\n" " python tools/measured/generate.py", file=sys.stderr, ) return 1 - line 321
problems = claimed(measurements, check=True) if problems: for problem in problems: print(problem, file=sys.stderr) print( "Regenerate them with:\n python tools/measured/generate.py", - line 361
file=sys.stderr, ) return 1 print("measured numbers match the tree") return 0 OUTPUT.parent.mkdir(parents=True, exist_ok=True) with io.open(OUTPUT, "w", encoding="utf-8", newline="\n") as handle: handle.write(fresh) problems = - line 361
claimed(measurements, check=False) if problems: # Only a pattern that no longer matches reaches here when writing. That # is a claim nothing is writing and nothing is watching, which is the # state this tool exists to make impossible, so - line 361
it fails rather than # reporting a successful write of nine out of ten. for problem in problems: print(problem, file=sys.stderr) return 1 print(f"wrote {OUTPUT.relative_to(ROOT)}, and every claim taken from it") return 0 if __name__ == - line 361
"__main__": raise SystemExit(main())
tools/mutants/check.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """A surviving mutant is a diff somebody reads, not a number that moved. python tools/mutants/check.py mutants.out/missed.txt # Why a committed list rather than a count - line 1
`cargo mutants` changes the code a line at a time and reports how many changes the test suite failed to object to. Reporting that as a number has two failure modes and they are both quiet. A number that goes up says something survived but - line 1
not what, so somebody has to re-run the half-hour campaign to find out. A number that stays the same while one survivor is fixed and another appears says nothing at all. So the known survivors are committed, one per line, each with the - line 1
argument for why it cannot be killed written above it. A campaign is compared against that list. A new survivor fails the build and is named. A survivor that has been killed also fails, because the list is then claiming something untrue - line 1
about the code, and this repository does not let a file say something that is no longer so. # What belongs in the list Only a mutant that is **unkillable by construction**, with the reason written out. F-180's campaign left two, both - line 1
mutations of Argon2's ceiling on parallelism, which the memory ceiling always reaches first because eight KiB are wanted per lane: no input can distinguish the mutated code from the original, so no test can. That is a real entry. A mutant - line 1
that merely has no test yet is not an entry. It is a missing test. Pure standard library, like everything in `tools/`. """ import os import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) SURVIVORS = - line 1
os.path.join(ROOT, "tools", "mutants", "survivors.txt") - line 41
def listed(path): """The mutants argued for in the committed list, in order.""" if not os.path.isfile(path): return None out = [] with open(path, "r", encoding="utf-8") as handle: for line in handle: line = line.strip() if line and not - line 41
line.startswith("#"): out.append(line) return out def reported(path): """The mutants a campaign says survived. `missed.txt` is one mutant per line in cargo-mutants' own notation, and an absent file means the campaign found none, which is a - line 41
result rather than an error: a run over a file with no surviving mutants writes nothing. """ if not os.path.isfile(path): return [] out = [] with open(path, "r", encoding="utf-8") as handle: for line in handle: line = line.strip() if line: - line 41
out.append(line) return out def lint(): """Check the list itself, without needing a campaign. Every entry names a file and a line. Both go stale as soon as code moves above them, and nothing would notice until the next weekly run, which is - line 41
a month of a list quietly pointing at the wrong lines. This is the half of the check that a per-push build can afford, so `tools/verify.py` runs it. - line 81
It does not try to judge whether the named line is still the right one. That needs the campaign. What it establishes is that the entry still points somewhere real, which is what fails first when a file is edited. """ known = - line 81
listed(SURVIVORS) if known is None: print( "tools/mutants/survivors.txt is missing, so there is nothing to " "compare a campaign against.", file=sys.stderr, ) return 1 problems = [] for entry in known: head = entry.split(": ", 1)[0] parts - line 81
= head.split(":") if len(parts) < 3: problems.append("%s: not in cargo-mutants' file:line:column form" % entry) continue relative, line = parts[0], parts[1] path = os.path.join(ROOT, relative.replace("/", os.sep)) if not - line 81
os.path.isfile(path): problems.append("%s: no such file" % relative) continue try: number = int(line) except ValueError: problems.append("%s: %s is not a line number" % (relative, line)) continue with open(path, "r", encoding="utf-8", - line 81
errors="replace") as handle: total = sum(1 for _ in handle) if number < 1 or number > total: problems.append( "%s:%d: the file has %d lines, so this entry points past its end" % (relative, number, total) ) if problems: - line 121
print("these entries in tools/mutants/survivors.txt no longer point anywhere:") for problem in problems: print(" %s" % problem) print() print( "Line numbers move when the code above them moves. Re-run the " "campaign - line 121
(.github/workflows/mutants.yml, or cargo mutants locally) " "and rewrite the entries from its missed.txt, keeping the argument " "above each." ) return 1 print(" %d argued-for survivors, every one still pointing at real code" % len(known)) - line 121
return 0 def main(argv): if len(argv) == 2 and argv[1] == "--lint": return lint() if len(argv) != 2: print( "usage: check.py <path to mutants.out/missed.txt>\n" " check.py --lint", file=sys.stderr, ) return 2 known = listed(SURVIVORS) if - line 121
known is None: print( "tools/mutants/survivors.txt is missing, so there is nothing to " "compare a campaign against.", file=sys.stderr, ) return 1 found = reported(argv[1]) new = [m for m in found if m not in known] gone = [m for m in - line 121
known if m not in found] - line 161
if new: print("these mutants survived and are not argued for:") for mutant in new: print(" %s" % mutant) print() print( "A surviving mutant is a claim about the tests, not about the code: " "the suite accepted a change to a line and would - line 161
accept it in a " "release. Write the test that objects. Add it to " "tools/mutants/survivors.txt only if no input can tell the mutated " "code from the original, and write that argument above the line." ) if gone: print("these mutants are - line 161
argued for and no longer survive:") for mutant in gone: print(" %s" % mutant) print() print( "A test now kills them, so the argument above each in " "tools/mutants/survivors.txt is no longer true. Take the line out " "with its argument. A - line 161
list that over-claims is how a real survivor " "hides in it." ) if new or gone: return 1 print( " %d surviving mutant%s, every one of them argued for" % (len(found), "" if len(found) == 1 else "s") ) return 0 if __name__ == "__main__": - line 161
sys.exit(main(sys.argv))
tools/mutants/survivors.txt
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Mutants that survive the test suite and cannot be killed. # # `tools/mutants/check.py` compares a campaign against this file: a survivor # that is not here fails the build and is named, and a - line 1
line here that no longer # survives fails it too, because the argument below such a line would then be # claiming something untrue about the code. # # **Only a mutant that no test could kill belongs here.** Not one that has no # test yet: - line 1
that is a missing test, and the campaign that produced this list # turned eighty-one survivors into sixteen by writing them. What is left falls # into three kinds, and each entry says which. # # 1. Equivalent by arithmetic. The mutated - line 1
expression computes the same value # as the original for every input the program can reach, so no observation # distinguishes them. # 2. A property of the machine rather than of the code. The answer depends on # whether the operating - line 1
system granted something, or on what is on disk # beside the executable, so a test that demanded either answer would be # testing the computer it ran on. F-77 is this project's earlier record of # that distinction mattering. # 3. Reachable - line 1
only through a self-contradiction. Distinguishing the mutant # needs two things at once that a filesystem will not do together. # # Format: one cargo-mutants line per entry, exactly as `missed.txt` writes it, # with the argument above it. - line 1
# # **How completely this has been measured.** All four files have now been # campaigned end to end: the app lock 137 mutants, the vault 28, the tape 39, # and the encodings 463 as six shards of 78. A single run of the encodings does # not - line 1
finish here, because `cargo mutants` dies on `pthread_create` partway # through even at `--jobs 1`; `--shard k/6` gets past whatever that limit is. # # The shards found eight survivors in the encodings that the earlier partial run # never - line 1
reached, all in the base conversions, and five of those eight are now # killed rather than listed. Finding them took two searched inputs, named in the # test beside the reason each was needed. - line 41
# --- 1. Equivalent by arithmetic --------------------------------------------- # The run-length prefix sets the high bit of a length that is at most 127, so # the operands have no bit in common and `|` and `^` are the same operation. - line 41
crates/veilvoice-crypto/src/weave.rs:484:39: replace | with ^ in Weave::apply # Both hexadecimal decoders rebuild a byte from two nibbles: the first is # shifted into the high four bits, the second occupies the low four, and the # two - line 41
never overlap. `|` and `^` agree on disjoint bits. crates/veilvoice-crypto/src/weave.rs:608:54: replace | with ^ in Weave::undo crates/veilvoice-crypto/src/weave.rs:631:63: replace | with ^ in Weave::undo # The substitution table is built - line 41
as `((i * 167 + 13) % 256) as u8`. The cast to # `u8` already reduces modulo 256, so the `%` is redundant and `+ 256` before a # truncating cast changes nothing. Kept in the source because it states the # intent; unkillable because the - line 41
cast does the work either way. crates/veilvoice-crypto/src/weave.rs:770:43: replace % with + # The six-bit encoders pack three bytes into one number, each into its own eight # bits of a twenty-four bit window. No two of the three share a - line 41
bit, so `|` and # `^` produce the same number. crates/veilvoice-crypto/src/weave.rs:994:41: replace | with ^ in sixbit_encode crates/veilvoice-crypto/src/weave.rs:994:66: replace | with ^ in sixbit_encode # The base-91 decoder shifts each - line 41
new value above whatever is still in the # queue, and the queue never holds more than `bits` significant bits: it starts # empty, and the loop below shifts eight out at a time until fewer than eight # remain. So the two operands are - line 41
disjoint here as well. crates/veilvoice-crypto/src/weave.rs:984:26: replace | with ^ in base91_decode # --- 2. A property of the machine -------------------------------------------- # `unix_now` negates the duration in the pre-epoch arm, - line 41
which a machine whose # clock reads before 1970 would reach. Deleting the `-` is only observable on # such a machine, and a test cannot set the system clock. crates/veilvoice-crypto/src/lock.rs:182:19: delete - in unix_now # - line 41
`every_copy_current` answers false only when a vault's administrator-owned # spare could not be written, which needs a vault whose spare lives somewhere - line 81
# this process cannot write. Creating that state means either running the test # as two users or writing under /etc, and neither belongs in a unit test. crates/veilvoice-crypto/src/lock.rs:857:9: replace LockStore::every_copy_current -> - line 81
bool with true # `open_default` reads the real per-user lock location. Distinguishing these # means writing a lock into the account's own configuration directory, which is # a test with a side effect on the machine that runs it. - line 81
crates/veilvoice-crypto/src/lock.rs:874:5: replace open_default -> Result<(Option<LockStore>, bool), Error> with Ok((None, true)) crates/veilvoice-crypto/src/lock.rs:874:5: replace open_default -> Result<(Option<LockStore>, bool), Error> - line 81
with Ok((None, false)) # `portable_dir` and `is_portable` answer from a folder beside the running # executable. Under `cargo test` that is the test binary in the build # directory, so making these answer anything but the default means - line 81
planting a # folder inside `target/`, which a later build removes and which F-185 has just # finished arguing should never be written into by hand. crates/veilvoice-crypto/src/lock.rs:1058:5: replace portable_dir -> Option<PathBuf> with - line 81
None crates/veilvoice-crypto/src/lock.rs:1113:5: replace is_portable -> bool with true crates/veilvoice-crypto/src/lock.rs:1113:5: replace is_portable -> bool with false # `admin_dir` tries to create /etc/veilvoice and answers None when it - line 81
cannot. # On a machine where it can, calling it from a test changes that machine; on # one where it cannot, the mutant answering None is what the original already # does. Either way there is nothing to observe that is worth the side - line 81
effect. crates/veilvoice-crypto/src/vault.rs:329:5: replace admin_dir -> Option<PathBuf> with None crates/veilvoice-crypto/src/vault.rs:329:5: replace admin_dir -> Option<PathBuf> with Some(Default::default()) # `fully_locked` asks whether - line 81
every chunk was locked out of swap. Where the # operating system grants every lock, as it does on the machines this runs on, # the honest answer is already true and the mutant is indistinguishable. The # test beside it asserts the relation - line 81
between the two chunk counts instead, # which holds on a machine that grants none. crates/veilvoice-crypto/src/tape.rs:157:9: replace Tape::fully_locked -> bool with true # --- 3. Reachable only through a self-contradiction - line 81
--------------------------- # The site index is re-drawn only when reading it failed with "not there". # Telling that guard apart from one that accepts any failure needs a read of the # index that fails and a write to that same path that - line 81
then succeeds. A # filesystem that refuses the read refuses the write, so both the original and - line 121
# the mutant return the same error and nothing observes the difference. The # guard stays because the reasoning it encodes is right, and because a # filesystem that did behave that way is exactly what it protects against. - line 121
crates/veilvoice-crypto/src/vault.rs:139:23: replace match guard e.kind() == std::io::ErrorKind::NotFound with true in Vault::at
tools/release/check-windows-icons.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Check that a built Windows executable actually carries its icon. python tools/release/check-windows-icons.py target/release # Why this is a check rather than a comment - line 1
`crates/*/build.rs` embeds `assets/icon.ico` into each Windows binary's resource section. Nothing else verifies that it worked, and the failure is completely silent: the build succeeds, every test passes, and the only symptom is that - line 1
Explorer draws the generic executable glyph. The icon was already missing from every release for exactly that reason -- it was *shipped beside* the binary, where Windows never looks. So this reads the PE the linker produced and asserts the - line 1
resource is in it. It runs in the release workflow, on the artefacts that are about to be signed. Pure standard library, and it parses only the few header fields it needs rather than pulling in a PE library to answer one question. """ - line 1
import os import struct import sys # A resource section holding six icon images plus a version block is a few # kilobytes. Anything much smaller means the resource exists but the icon did # not go into it, which is a different failure with - line 1
the same symptom. MINIMUM_RSRC_BYTES = 2000 # The two a release publishes. `veilvoice-verify.exe` was a third until # 0.1.18 and is now inside both, so looking for its icon would fail on a # file the build has no reason to produce. - line 1
BINARIES = ("veilvoice-gui.exe", "veilvoice.exe") def rsrc_size(path): """Bytes in the PE's `.rsrc` section, or None if there is no such section.""" with open(path, "rb") as handle: - line 41
head = handle.read(2048) if head[:2] != b"MZ": raise ValueError("not a PE image (no MZ signature)") pe = struct.unpack("<I", head[0x3C:0x40])[0] if head[pe:pe + 4] != b"PE\0\0": raise ValueError("not a PE image (no PE signature)") sections - line 41
= struct.unpack("<H", head[pe + 6:pe + 8])[0] optional = struct.unpack("<H", head[pe + 20:pe + 22])[0] table = pe + 24 + optional for index in range(sections): entry = table + index * 40 name = head[entry:entry + - line 41
8].rstrip(b"\0").decode("ascii", "replace") if name == ".rsrc": return struct.unpack("<I", head[entry + 8:entry + 12])[0] return None def main(): if len(sys.argv) != 2: print(__doc__.strip().splitlines()[2]) return 2 directory = - line 41
sys.argv[1] problems = [] checked = 0 for name in BINARIES: path = os.path.join(directory, name) if not os.path.exists(path): # Not every job builds every binary; absence is not this check's # business, and inventing a requirement here - line 41
would fail builds for # a reason that has nothing to do with icons. continue checked += 1 try: size = rsrc_size(path) except (OSError, ValueError) as error: problems.append("%s: %s" % (name, error)) continue if size is None: - line 41
problems.append("%s: no .rsrc section -- the icon was not embedded" % name) - line 81
elif size < MINIMUM_RSRC_BYTES: problems.append( "%s: .rsrc is only %d bytes, so the resource exists but the " "icon is not in it" % (name, size)) else: print(" ok %-22s .rsrc %d bytes" % (name, size)) if checked == 0: print(" no Windows - line 81
binaries found in %s" % directory) print(" This check must not pass vacuously: point it at a directory") print(" containing the built executables.") return 1 if problems: print() for line in problems: print(" MISSING %s" % line) print() - line 81
print(" `crates/*/build.rs` should have embedded assets/icon.ico.") print(" A binary with no icon looks unfinished in Explorer, on the") print(" taskbar and in a pinned shortcut, and nothing else notices.") return 1 print(" all %d Windows - line 81
binaries carry their icon" % checked) return 0 if __name__ == "__main__": sys.exit(main())
tools/release/contents.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The signed list of what is inside each release archive. python tools/release/contents.py staging > staging/CONTENTS.sha256 # What this produces, and why a release - line 1
publishes it `SHA256SUMS` covers the archives. That proves a download is the one that was published and says nothing at all about the folder somebody unzipped it into, which is the copy they actually run. Nothing on disk records which - line 1
archive a directory was extracted from, so until this existed a verifier could only report the two separately and advise unzipping the checked file again. This lists every file inside every archive with its SHA-256. The release job writes - line 1
it **before** `SHA256SUMS` is computed, so the hash list covers it and the signature therefore covers it too, and the chain runs all the way down: SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each file on disk - line 1
`veilvoice_verify::check`'s `contents` module is the reader. The format is deliberately the shape of `sha256sum` output with a `# <archive>` line before each group, so that somebody with neither VeilVoice nor a parser can use it by eye. # - line 1
Why this is a script and not six lines of YAML It was six lines of YAML, and six lines of YAML cannot be run on a laptop, cannot be tested, and are exercised for the first time on the day a release goes out. This is the one link in the - line 1
chain above that nothing else checks, and a defect in it would appear as a verifier confidently reporting on files it had never compared. `crates/veilvoice-verify/tests/release_manifest.rs` builds a synthetic release, runs this, and reads - line 1
the result back with the parser that will read it for real. That test is the whole reason this is a file. # Why the standard library and not `tar` and `unzip` `zipfile` and `tarfile` read both formats without either tool being installed, - line 1
which matters because the publish job runs on one runner and the archives were - line 41
built on five. It also removes a difference this had already: `unzip -d` writes the files out and hashes them from disk, so an unpacking quirk of the runner would silently become part of the published list. Reading the members straight out - line 41
of the archive hashes what the archive actually holds. # In plain words Writes down the fingerprint of every file inside every release archive, so a checker can tell you the program in your folder is the one that was published, rather than - line 41
only that the zip you downloaded was. """ import argparse import hashlib import sys import tarfile import zipfile from pathlib import Path # The archive kinds this project publishes. A file that is not one of these is # not walked into: - line 41
`SHA256SUMS` itself, the signature and the key sit in the # same directory and are covered by the hash list rather than by this. TARBALLS = (".tar.gz", ".tgz") ZIPS = (".zip",) # Read in chunks. A release archive is tens of megabytes and - line 41
there is no reason # for this to hold one in memory, let alone five. CHUNK = 1 << 20 def digest(stream): """The SHA-256 of a stream, read in chunks.""" hasher = hashlib.sha256() while True: block = stream.read(CHUNK) if not block: break - line 41
hasher.update(block) return hasher.hexdigest() - line 81
class Refused(Exception): """A member path this will not write down.""" def member_path(raw, archive): """One archive member's path, as the manifest records it. **F-102.** This was `name.replace("\\", "/").lstrip("./")`, and `lstrip` takes - line 81
a *set of characters* rather than a prefix. Measured: `.hidden/file` came out as `hidden/file` and `../escape` came out as `escape`. Both are bad and the second is worse. A dotfile in a release would be published under a name no file on - line 81
disk has, so every verifier would report it missing on a release that is perfectly sound. And a member that climbs out of the release directory would be quietly rewritten into one that looks ordinary -- sanitised into acceptability, which - line 81
is exactly what the reader's own note says must never happen, because a manifest with such a path in it is not a manifest with one bad line: it is a file that did not come from this project's release job. So: exactly one leading `./` is - line 81
removed, which is a thing `tar` genuinely writes, and anything else that would not survive the reader is refused here rather than published. The rule is the reader's rule (`veilvoice_check::contents::parse`), stated on this side too, so - line 81
the two ends of this seam agree by construction rather than by attention. """ name = raw.replace("\\", "/") if name.startswith("./"): name = name[2:] if not name or name.endswith("/"): raise Refused("%s: a member with no name" % archive) - line 81
if name.startswith("/"): raise Refused("%s: an absolute path, %r" % (archive, raw)) if len(name) > 1 and name[1] == ":": raise Refused("%s: a drive letter, %r" % (archive, raw)) if any(part in ("..", ".") for part in name.split("/")): - line 81
raise Refused("%s: a path that leaves the release, %r" % (archive, raw)) return name - line 121
def members_of_zip(path): """Every file inside a zip, as `(path, sha256)`, sorted by path. Directories are skipped: they carry no bytes and a verifier compares files. So is anything whose name is empty after normalisation, which a zip is - line 121
free to contain and which no release of this project has ever held. """ out = [] with zipfile.ZipFile(path) as archive: for info in archive.infolist(): if info.is_dir(): continue name = member_path(info.filename, path.name) with - line 121
archive.open(info) as member: out.append((name, digest(member))) return sorted(out) def members_of_tar(path): """Every file inside a tar.gz, as `(path, sha256)`, sorted by path. Only regular files. A tar can hold links, devices and - line 121
directories; a link is a name rather than content, and the reader refuses one where a file should be (see F-99), so listing one here would publish a hash for something the checker will never accept. """ out = [] with tarfile.open(path, - line 121
"r:gz") as archive: for info in archive: if not info.isfile(): continue name = member_path(info.name, path.name) member = archive.extractfile(info) if member is None: continue out.append((name, digest(member))) return sorted(out) - line 161
def archives_in(directory): """Every release archive in a directory, sorted by name.""" found = [] for path in sorted(Path(directory).iterdir()): if not path.is_file(): continue name = path.name.lower() if name.endswith(TARBALLS) or - line 161
name.endswith(ZIPS): found.append(path) return found def manifest(directory): """The whole `CONTENTS.sha256`, as text. Two spaces between the hash and the path, as `sha256sum` writes it, and a blank line between archives so it can be read - line 161
by eye. The order is by archive name and then by path inside it, so two runs over the same directory produce the same bytes and a diff between two releases is a diff about the releases. """ lines = [] for archive in archives_in(directory): - line 161
if archive.name.lower().endswith(ZIPS): members = members_of_zip(archive) else: members = members_of_tar(archive) lines.append(f"# {archive.name}") for path, sha in members: lines.append(f"{sha} {path}") lines.append("") return - line 161
"\n".join(lines) + ("\n" if lines and lines[-1] != "" else "") def main(argv): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("directory", help="the staging directory holding the archives") - line 161
parser.add_argument( "-o", "--output", - line 201
help="write here rather than to standard output", ) args = parser.parse_args(argv) directory = Path(args.directory) if not directory.is_dir(): print(f"not a directory: {directory}", file=sys.stderr) return 1 found = archives_in(directory) - line 201
if not found: # An empty manifest would be published, covered by the hash list, and # read by a verifier as "this release lists nothing inside its # archives", which is a lie about a release that has archives. Better # to fail the job. - line 201
print(f"no release archives in {directory}", file=sys.stderr) return 1 try: text = manifest(directory) except Refused as why: # The release job stops here. An archive holding a path like that was # not built by this project, and publishing - line 201
a manifest that every # verifier will refuse is worse than not publishing one. print("refusing to write a contents list: %s" % why, file=sys.stderr) return 1 if args.output: Path(args.output).write_text(text, encoding="utf-8", - line 201
newline="\n") # Counted from the lines that are files, not derived from the line # count and the number of archives. The derivation was off by one per # archive: measured, it reported five files for six. files = sum(1 for line in - line 201
text.splitlines() if line and not line.startswith("# ")) print(f" {len(found)} archive(s), {files} files", file=sys.stderr) else: sys.stdout.write(text) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))
tools/release/manpage.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Man pages, written from the command's own `--help`. python tools/release/manpage.py target/release/veilvoice out/veilvoice.1 # Why this exists rather than a hand-written - line 1
page `lintian` reports `no-manual-page` for every binary in the Debian packages, and it is right to: somebody who installs a package on a Unix system types `man veilvoice` and, until now, got nothing. A man page written by hand would be a - line 1
second description of the interface, kept beside the first by nothing but attention. This project has already paid for that arrangement once: F-71 was two hand-typed numbers that drifted together because each was only ever compared against - line 1
the other. So the page is not written, it is *derived*, from the one description that cannot drift from the program: the program. # Why not help2man `help2man` does exactly this job and is the obvious answer. It was tried first and it - line 1
mangles the output: VeilVoice's help text contains em dashes, and every one of them came back as `???`, at `C`, at `C.utf8`, and with `LC_ALL` set either way. A manual page that renders the program's own description as three question marks - line 1
is worse than no manual page, because it looks finished. This is forty lines and gets the encoding right, which is the whole of what it adds. # When it runs At package build time, in `debian/rules` and in the RPM's `%install`, where the - line 1
binary exists. Nothing is committed, so nothing can go stale: the page is a function of the binary in the package it ships beside. # In plain words Turns `veilvoice --help` into a manual page, so `man veilvoice` works. - line 41
It is generated from the program itself every time a package is built, rather than written out and kept up to date by hand, because the second kind goes wrong quietly and nobody notices until a user reads it. """ import argparse import - line 41
datetime import os import re import subprocess import sys # Roff escapes for the characters that otherwise come out wrong or change # meaning. The backslash must be first: every other replacement introduces one. ESCAPES = [ ("\\", "\\e"), - line 41
("—", "\\(em"), ("–", "\\(en"), ("’", "\\(cq"), ("“", "\\(lq"), ("”", "\\(rq"), ("…", "\\&..."), ("-", "\\-"), ] def roff(text): """One line of help text, safe to put in a roff document.""" for src, dst in ESCAPES: text = text.replace(src, - line 41
dst) # A line starting with a full stop or an apostrophe is a roff request. if text[:1] in (".", "'"): text = "\\&" + text return text def wrapped(text, width=78): """Break a line of roff prose on spaces, never inside an escape. Escapes - line 41
are what makes this more than `textwrap`: `\\(em` is four - line 81
characters that must stay together, and splitting inside one puts a stray `(em` in the page. """ words = text.split(" ") lines, current = [], "" for word in words: if current and len(current) + 1 + len(word) > width: lines.append(current) - line 81
current = word else: current = f"{current} {word}" if current else word if current: lines.append(current) return lines or [""] def help_text(binary): """What the program says about itself.""" out = subprocess.run( [binary, "--help"], - line 81
capture_output=True, check=True, # Decode explicitly rather than trusting the locale, which is what # help2man got wrong. encoding="utf-8", errors="strict", ) return out.stdout.replace("\r\n", "\n").rstrip("\n") def sections(text): - line 81
"""Split help output into (heading, body-lines) pairs. clap emits `Usage:` and then headed blocks such as `Commands:` and `Options:`. Anything before the first heading is the description. """ blocks = [(None, [])] for line in - line 81
text.split("\n"): stripped = line.strip() # Two heading styles, because not every page here is written by clap. - line 121
# clap writes `Commands:` and `Options:` in title case with a colon; # the verifier's help, which was `veilvoice-verify` until 0.1.18 and is # `veilvoice verify` now, hand-writes `USAGE` and `EXIT STATUS` in # capitals with none. Matching - line 121
only the first left the whole of that # help inside DESCRIPTION, re-flowed into one paragraph that ran every # command together. if not line.startswith(" ") and ( re.fullmatch(r"[A-Z][A-Za-z ]*:", stripped) or re.fullmatch(r"[A-Z][A-Z - line 121
]{2,}", stripped) ): blocks.append((stripped.rstrip(":"), [])) # `Usage:` is different: clap puts the usage on the same line as the # word. Missing that left the synopsis buried in the description, which # is where a reader looks last. - line 121
elif stripped.startswith("Usage:") and not line.startswith(" "): blocks.append(("Usage", [stripped[len("Usage:"):].strip()])) else: blocks[-1][1].append(line) return blocks def page(binary, name, summary, version, date): text = - line 121
help_text(binary) blocks = sections(text) out = [ f'.\\" Generated from `{name} --help` by tools/release/manpage.py.', '.\\" Do not edit: it is rewritten from the program on every package build.', f'.TH {name.upper()} 1 "{date}" "{name} - line 121
{version}" "User Commands"', ".SH NAME", f"{roff(name)} \\- {roff(summary)}", ] # A manual page opens NAME, SYNOPSIS, DESCRIPTION, whatever clap's own # order happens to be. clap prints the description first and the usage # after it; a - line 121
reader looking for the synopsis looks second, so the two are # swapped here rather than left in the order they arrived. def rank(block): heading = block[0] if heading is not None and heading.lower().startswith("usage"): - line 161
return 0 return 1 if heading is None else 2 for heading, lines in sorted(blocks, key=rank): body = [l for l in lines if l.strip()] if not body: continue if heading is None: # A help text whose first line is its own title repeats what NAME - line 161
# has just said. `veilvoice-gui --help` opens that way, because in # a terminal the title is worth having; in a manual page it is the # line above. title = f"{name} - {summary}" body = [l for l in body if l.strip() != title] if not body: - line 161
continue out += [".SH DESCRIPTION"] # Prose: let roff fill and justify it, which is what prose wants. # # Wrapped at 80 columns in the *source*, which changes nothing # about the rendered page because roff refills it, and quiets # `mandoc - line 161
-Tlint`'s "input text line longer than 80 bytes". A # generated file that a linter complains about is a generated file # somebody eventually stops running the linter over. for line in body: out += wrapped(roff(line.strip())) continue if - line 161
heading.lower().startswith("usage"): out += [".SH SYNOPSIS"] else: out += [f".SH {roff(heading.upper())}"] # Everything else verbatim, between `.nf` and `.fi`. # # The first version reformatted these into `.TP` terms, which looked # better - line 161
for clap's own pages and mangled the verifier's, because only # one of the two is written by clap. The hand-laid-out one has indented # continuation lines carrying its meaning, and re-flowing them ran the # whole thing into a single - line 161
paragraph. # - line 201
# A page that reproduces `--help` exactly is worth more than one that # is prettier for one binary and wrong for the other, and it cannot go # wrong for a third. out += [".nf"] # Trailing whitespace only, so an intentional blank line - line 201
survives as a # blank line. Leading whitespace is the layout and must not be touched. out += [roff(line.rstrip()) for line in lines] out += [".fi"] out += [ ".SH SEE ALSO", "The full documentation is in the installed" f" - line 201
{roff('/usr/share/doc/veilvoice')} directory,", "and at " + roff("https://github.com/tilas01/veilvoice") + ".", ".SH REPORTING BUGS", roff("https://github.com/tilas01/veilvoice/issues"), ] return "\n".join(out) + "\n" def main(): ap = - line 201
argparse.ArgumentParser(description=__doc__) ap.add_argument("binary", help="the built executable to ask") ap.add_argument("output", help="where to write the roff page") ap.add_argument("--name", help="the command's name (default: the file - line 201
name)") ap.add_argument("--summary", default="irreversible voice de-identification, fully offline") args = ap.parse_args() name = args.name or os.path.basename(args.binary) version = ( subprocess.run( [args.binary, "--version"], - line 201
capture_output=True, encoding="utf-8", check=True ) .stdout.strip() .split()[-1] ) # SOURCE_DATE_EPOCH so a package built twice produces the same page, which # is the same rule docs/REPRODUCIBLE_BUILDS.md states for everything else. epoch - line 201
= os.environ.get("SOURCE_DATE_EPOCH") when = datetime.datetime.fromtimestamp( - line 241
int(epoch) if epoch else 0, datetime.timezone.utc ) if epoch else datetime.datetime.now(datetime.timezone.utc) text = page(args.binary, name, args.summary, version, when.strftime("%Y-%m-%d")) - line 241
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) with open(args.output, "w", encoding="utf-8", newline="\n") as fh: fh.write(text) return 0 if __name__ == "__main__": sys.exit(main())
tools/release/packaging.py
- line 1
#!/usr/bin/env python3 """Check that every distribution package agrees with the tree it packages. A package definition is a claim about what gets installed, and it is made in a file nobody builds during ordinary work: the deb, the RPM, the - line 1
ebuild, the Arch packages and the Windows installer are exercised at release time or on somebody else's machine. So they go stale quietly, and the first person to notice is a user whose install is missing a binary or naming one that no - line 1
longer exists. This checks the parts that can be checked without a package manager: 1. Every package installs exactly the binaries the workspace builds. 2. No package still refers to a binary that has been removed. 3. The Arch `.SRCINFO` - line 1
agrees with its `PKGBUILD`, since the AUR reads the former and builds the latter. 4. Versions written into packaging agree with Cargo.toml. It does not build anything, so it cannot tell you a package *works*. It tells you the package is - line 1
describing this tree rather than an older one, which is the failure that actually happens. SPDX-License-Identifier: GPL-3.0-or-later """ from __future__ import annotations import re import shlex import sys import tomllib from pathlib - line 1
import Path ROOT = Path(__file__).resolve().parents[2] #: The binaries the workspace actually produces. Read from the crates rather #: than listed here, so removing one is noticed instead of needing this file #: edited in the same breath. - line 1
def workspace_binaries() -> set[str]: names: set[str] = set() - line 41
for manifest in sorted((ROOT / "crates").glob("*/Cargo.toml")): data = tomllib.loads(manifest.read_text(encoding="utf-8")) for entry in data.get("bin", []): if "name" in entry: names.add(entry["name"]) # A crate with src/main.rs and no - line 41
[[bin]] still builds a binary named # after the package. if not data.get("bin") and (manifest.parent / "src" / "main.rs").exists(): names.add(data["package"]["name"]) return names #: Where each packaging file installs from, and how a - line 41
binary appears in it. PACKAGES = { "packaging/debian/rules": r"target/release/([a-z0-9-]+)", "packaging/rpm/veilvoice.spec": r"target/release/([a-z0-9-]+)", "packaging/homebrew/veilvoice.rb": r"target/release/([a-z0-9-]+)", - line 41
"packaging/flatpak/io.github.tilas01.VeilVoice.yml": r"target/release/([a-z0-9-]+)", "packaging/aur/PKGBUILD": r"target/release/([a-z0-9-]+)", "packaging/aur/PKGBUILD-git": r"target/release/([a-z0-9-]+)", - line 41
"packaging/gentoo/media-sound/veilvoice/veilvoice-9999.ebuild": r"cargo_target_dir\)/([a-z0-9-]+)", "packaging/wix/veilvoice.wxs": r"BinDir\)\\([a-z0-9-]+)\.exe", } def cargo_version() -> str: text = (ROOT / - line 41
"Cargo.toml").read_text(encoding="utf-8") match = re.search(r'^version = "([^"]+)"', text, re.M) assert match, "no workspace version in Cargo.toml" return match.group(1) def check() -> list[str]: problems: list[str] = [] built = - line 41
workspace_binaries() for relative, pattern in PACKAGES.items(): path = ROOT / relative if not path.exists(): - line 81
problems.append(f"{relative}: missing") continue text = path.read_text(encoding="utf-8") named = {m for m in re.findall(pattern, text) if m.startswith("veilvoice")} for name in sorted(named - built): problems.append( f"{relative}: installs - line 81
{name!r}, which the workspace no longer builds" ) # The AUR reads .SRCINFO and builds the PKGBUILD. Two files, one truth. pkgbuild = (ROOT / "packaging/aur/PKGBUILD").read_text(encoding="utf-8") srcinfo = (ROOT / - line 81
"packaging/aur/.SRCINFO").read_text(encoding="utf-8") version = cargo_version() pkgver = re.search(r"^pkgver=(\S+)", pkgbuild, re.M) if not pkgver or pkgver.group(1) != version: problems.append( f"packaging/aur/PKGBUILD: pkgver is " - line 81
f"{pkgver.group(1) if pkgver else 'unset'}, Cargo.toml says {version}" ) srcver = re.search(r"^\tpkgver = (\S+)", srcinfo, re.M) if not srcver or srcver.group(1) != version: problems.append( f"packaging/aur/.SRCINFO: pkgver is " - line 81
f"{srcver.group(1) if srcver else 'unset'}, Cargo.toml says {version}" ) for field in ("depends", "optdepends"): # shlex rather than a split: these are shell arrays whose entries are # quoted strings containing spaces and colons, and - line 81
splitting on # whitespace turns one description into a dozen package names. build_names: set[str] = set() for block in re.findall(rf"^{field}=\(([^)]*)\)", pkgbuild, re.M | re.S): for entry in shlex.split(block, comments=True): - line 81
build_names.add(entry.split(":")[0].strip()) src_names = { line.split(" = ", 1)[1].split(":")[0].strip() for line in srcinfo.splitlines() if line.strip().startswith(f"{field} = ") } - line 121
missing = src_names - build_names extra = build_names - src_names for name in sorted(missing): problems.append(f"packaging/aur/.SRCINFO: {field} {name!r} is not in the PKGBUILD") for name in sorted(extra): - line 121
problems.append(f"packaging/aur/.SRCINFO: {field} {name!r} from the PKGBUILD is missing") return problems def main() -> int: problems = check() if problems: for line in problems: print(f" {line}") print(f"\n{len(problems)} packaging - line 121
problem(s).") return 1 binaries = ", ".join(sorted(workspace_binaries())) print(f"every package installs exactly the workspace binaries: {binaries}") return 0 if __name__ == "__main__": sys.exit(main())
tools/release/version.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The workspace version, and every other place that repeats it. Why this exists --------------- `Cargo.toml` decides what version VeilVoice is. Eleven other files say it - line 1
again: the README tells a reader which archive to download, the WiX recipe names the folder it builds an installer from, the Homebrew formula names a tag, the Flatpak manifest names the same tag, the RPM spec carries a default, and so on. - line 1
None of them is derived from the first one. That is this project's oldest defect class, written up several times in `docs/AUDIT.md` under different names: N hand-kept copies of one fact, checked only against each other by whoever - line 1
remembers. Its cost here is specific and public. The README's install block is the first thing anybody runs, and a release that forgets to bump it hands every new reader a command that downloads the *previous* version and then verifies it - line 1
successfully, which is the worst possible failure: it looks like it worked. So the version has one source and this reads it. `--check` fails when any copy disagrees, and runs in `tools/verify.py` alongside every other generator. `--set` - line 1
moves them all at once. tools/release/version.py --check tools/release/version.py --set 0.1.17 What is deliberately not rewritten ---------------------------------- Three files keep a history: `packaging/debian/changelog`, the `%changelog` - line 1
in the RPM spec, and the `<releases>` list in the AppStream metainfo. Those name every past version on purpose and rewriting them would be vandalism. For those the rule is different and weaker: the *newest* entry must name the current - line 1
version. `--set` adds a new entry rather than editing the old one. `CHANGELOG.md` is not touched at all. It is prose about what changed, and no program should be writing that. """ - line 41
from __future__ import annotations import argparse import datetime import pathlib import re import sys def repo_root() -> pathlib.Path: return pathlib.Path(__file__).resolve().parents[2] def workspace_version(root: pathlib.Path) -> str: - line 41
"""The one source of truth: `[workspace.package] version` in Cargo.toml.""" text = (root / "Cargo.toml").read_text(encoding="utf-8") match = re.search(r'(?m)^version = "([0-9]+\.[0-9]+\.[0-9]+)"$', text) if not match: raise - line 41
SystemExit("Cargo.toml has no workspace version to read") return match.group(1) # Each entry is a file and a pattern with exactly one group: the version, in # whichever spelling that file uses. Every match in the file must agree with # the - line 41
workspace, and `--set` rewrites the group. # # The patterns are deliberately narrow. A blanket search for the version # string would also hit the changelog entries and the historical release # lists, which must not move. PLACES: - line 41
list[tuple[str, str, str]] = [ ("README.md", r"^V=v([0-9]+\.[0-9]+\.[0-9]+)$", "the Linux, macOS and BSD install blocks"), ("README.md", r'^\$V = "v([0-9]+\.[0-9]+\.[0-9]+)"$', "the Windows install block"), ("README.md", r"^sh - line 41
reproduce-veilvoice\.sh v([0-9]+\.[0-9]+\.[0-9]+)$", "the reproducible-build example"), ("README.md", r"^\*\*v([0-9]+\.[0-9]+\.[0-9]+): early but real\.\*\*", "the status section"), ("docs/USER_GUIDE.md", r"^sh reproduce-veilvoice\.sh - line 41
v([0-9]+\.[0-9]+\.[0-9]+)$", "the reproducible-build example"), # This one had gone three releases stale saying "v0.1.14 is released", # which is what a roadmap is read for. ("ROADMAP.md", r"\*\*v([0-9]+\.[0-9]+\.[0-9]+) is released\*\*", - line 41
"the where-we-are-now line"), ("docs/PACKAGING.md", r"-d Version=([0-9]+\.[0-9]+\.[0-9]+) ", "the WiX example"), - line 81
("docs/PACKAGING.md", r"BinDir=dist/veilvoice-v([0-9]+\.[0-9]+\.[0-9]+)-windows", "the WiX example"), ("docs/PACKAGING.md", r"-o dist/VeilVoice-([0-9]+\.[0-9]+\.[0-9]+)-x64\.msi", "the WiX example"), ("docs/PACKAGING.md", r'"vv_version - line 81
([0-9]+\.[0-9]+\.[0-9]+)"', "the rpmbuild example"), ("packaging/wix/veilvoice.wxs", r"-d Version=([0-9]+\.[0-9]+\.[0-9]+) ", "the build command in the header"), ("packaging/wix/veilvoice.wxs", - line 81
r"BinDir=dist\\veilvoice-v([0-9]+\.[0-9]+\.[0-9]+)-windows", "the build command in the header"), ("packaging/wix/veilvoice.wxs", r"-o dist/VeilVoice-([0-9]+\.[0-9]+\.[0-9]+)-x64\.msi", "the build command in the header"), - line 81
("packaging/homebrew/veilvoice.rb", r"refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz", "the source tarball"), ("packaging/flatpak/io.github.tilas01.VeilVoice.yml", r"^ tag: v([0-9]+\.[0-9]+\.[0-9]+)$", "the git tag built from"), - line 81
("packaging/rpm/veilvoice.spec", r'"vv_version ([0-9]+\.[0-9]+\.[0-9]+)"', "the rpmbuild example in the header"), ("packaging/rpm/veilvoice.spec", r"%\{!\?vv_version:([0-9]+\.[0-9]+\.[0-9]+)\}", "the default when none is passed"), - line 81
("packaging/aur/PKGBUILD", r"^pkgver=([0-9]+\.[0-9]+\.[0-9]+)$", "the Arch package version"), ("packaging/aur/.SRCINFO", r"^\tpkgver = ([0-9]+\.[0-9]+\.[0-9]+)$", "the Arch .SRCINFO version"), # **F-144.** The worked example a nervous user - line 81
copies to verify their # download. It said v0.1.9 while the workspace said 0.1.17 -- eight # releases, naming a real old tarball rather than an obvious placeholder, # so following the instructions verbatim downloaded and verified the wrong - line 81
# release perfectly. The surrounding text does say "replace this with the # release you want", which is exactly the sentence people skip. ("docs/INSTALL.md", r"^V=v([0-9]+\.[0-9]+\.[0-9]+)$", "the by-hand verification example"), # The - line 81
sentence four lines above that block, which tells the reader which # version to replace. It said v0.1.17 while the block under it said # v0.1.18, because this list watched the command and not the instruction # introducing it, and somebody - line 81
following the prose literally would fetch # the wrong release. ("docs/INSTALL.md", r"^`v([0-9]+\.[0-9]+\.[0-9]+)` and the archive name", "the sentence introducing the by-hand example"), ("docs/INSTALL.md", - line 81
r"veilvoice-v([0-9]+\.[0-9]+\.[0-9]+)-windows-x86_64\.zip", "the PowerShell hash example"), ("docs/INSTALL.md", r"veilvoice-v([0-9]+\.[0-9]+\.[0-9]+)-linux-x86_64\.tar\.gz", "the verifier examples"), ("docs/INSTALL.md", r"`--version - line 81
v([0-9]+\.[0-9]+\.[0-9]+)`", "the install-script option table"), ("docs/INSTALL.md", r"`-Version v([0-9]+\.[0-9]+\.[0-9]+)`", "the PowerShell option table"), ] # The three files that keep a history. Only the newest entry is checked, and # - line 81
`--set` prepends rather than edits. HISTORIES: list[tuple[str, str, str]] = [ ("packaging/debian/changelog", r"^veilvoice \(([0-9]+\.[0-9]+\.[0-9]+)-1\)", "the newest changelog entry"), ("packaging/rpm/veilvoice.spec", r"^\* .* - - line 81
([0-9]+\.[0-9]+\.[0-9]+)-1$", "the newest %changelog entry"), ("packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml", r'<release version="([0-9]+\.[0-9]+\.[0-9]+)"', "the newest release entry"), ] - line 121
def disagreements(root: pathlib.Path, version: str) -> list[str]: """Every place whose version is not the workspace version.""" problems: list[str] = [] for name, pattern, what in PLACES: path = root / name text = - line 121
path.read_text(encoding="utf-8") found = re.findall(pattern, text, flags=re.MULTILINE) if not found: # A pattern that stops matching is a defect in this file, not a # pass. Silently checking nothing is how a checker becomes # decorative. - line 121
problems.append(f"{name}: nothing matched for {what} -- the file changed shape") continue for seen in found: if seen != version: problems.append(f"{name}: {what} says {seen}, the workspace says {version}") for name, pattern, what in - line 121
HISTORIES: path = root / name text = path.read_text(encoding="utf-8") found = re.findall(pattern, text, flags=re.MULTILINE) if not found: problems.append(f"{name}: nothing matched for {what} -- the file changed shape") elif found[0] != - line 121
version: problems.append(f"{name}: {what} says {found[0]}, the workspace says {version}") problems.extend(changelog_heading(root, version)) return problems def changelog_heading(root: pathlib.Path, version: str) -> list[str]: - line 121
"""`CHANGELOG.md` has a section the release workflow can actually find. The workflow reads the notes for a release out of this file, matching `## v<version>` as a whole line and taking everything up to the next `##`. That reader is exact, - line 121
so a heading written in any other shape produces an empty section, and the workflow used to publish that as "No changelog section found" rather than stopping. - line 161
v0.1.21 went out that way. Its heading had been written `## 0.1.21 - 2026-09-10` while every other entry in the file is `## v0.1.20`, `## v0.1.19` and so on, so seven hundred lines of release notes were dropped and nobody reading the - line 161
release could tell what was in it. The workflow refuses to publish without a section now, but that is the last line of defence and it fires after twelve jobs have built and compared every binary. This is the same question asked here, where - line 161
it costs nothing and fails a pull request instead. The reader is reimplemented rather than shared because the two live in different languages, so what is checked is the *shape* the awk requires: an exact heading line, and something under - line 161
it. """ path = root / "CHANGELOG.md" if not path.is_file(): return ["CHANGELOG.md is missing, so a release would have no notes"] want = f"## v{version}" lines = path.read_text(encoding="utf-8").splitlines() try: at = lines.index(want) - line 161
except ValueError: near = [ line for line in lines if line.startswith("## ") and version in line ] if near: return [ "CHANGELOG.md: the heading for this release is " f"{near[0]!r}, and the release workflow matches {want!r} " "exactly, so - line 161
it would publish with no notes at all" ] return [ f"CHANGELOG.md: no {want!r} heading, so a release of {version} " "would publish with no notes at all" ] body = [] - line 201
for line in lines[at + 1:]: if line.startswith("## "): break body.append(line) if not any(line.strip() for line in body): return [f"CHANGELOG.md: {want} has nothing under it"] return [] def rewrite(root: pathlib.Path, old: str, new: str) - line 201
-> list[str]: """Move every plain copy of the version. Returns what was touched.""" touched: list[str] = [] for name, pattern, _what in PLACES: path = root / name text = path.read_text(encoding="utf-8") def swap(match: re.Match[str]) -> - line 201
str: whole = match.group(0) start, end = match.span(1) return whole[: start - match.start()] + new + whole[end - match.start() :] after = re.sub(pattern, swap, text, flags=re.MULTILINE) if after != text: path.write_text(after, - line 201
encoding="utf-8") touched.append(name) return sorted(set(touched)) def add_history(root: pathlib.Path, version: str) -> list[str]: """Prepend a new entry to each file that keeps a history.""" touched: list[str] = [] today = - line 201
datetime.date.today() path = root / "packaging/debian/changelog" text = path.read_text(encoding="utf-8") if not text.startswith(f"veilvoice ({version}-1)"): stamp = today.strftime("%a, %d %b %Y") + " 00:00:00 +0000" entry = ( f"veilvoice - line 201
({version}-1) unstable; urgency=medium\n" - line 241
"\n" " * See CHANGELOG.md in the source for what changed in this release.\n" "\n" f" -- tilas01 <tilas01@users.noreply.github.com> {stamp}\n" "\n" ) path.write_text(entry + text, encoding="utf-8") - line 241
touched.append("packaging/debian/changelog") path = root / "packaging/rpm/veilvoice.spec" text = path.read_text(encoding="utf-8") marker = "%changelog\n" if marker in text and f"- {version}-1\n" not in text: stamp = today.strftime("%a %b - line 241
%d %Y") entry = ( f"* {stamp} tilas01 <tilas01@users.noreply.github.com> - {version}-1\n" "- See CHANGELOG.md in the source for what changed in this release.\n" "\n" ) head, rest = text.split(marker, 1) path.write_text(head + marker + - line 241
entry + rest, encoding="utf-8") touched.append("packaging/rpm/veilvoice.spec") path = root / "packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml" text = path.read_text(encoding="utf-8") marker = " <releases>\n" if marker in text - line 241
and f'<release version="{version}"' not in text: entry = ( f' <release version="{version}" date="{today.isoformat()}">\n' " <description>\n" " <p>See the release notes for what changed.</p>\n" " </description>\n" " </release>\n" ) head, - line 241
rest = text.split(marker, 1) path.write_text(head + marker + entry + rest, encoding="utf-8") touched.append("packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml") return touched - line 281
def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--check", action="store_true", help="report copies that disagree, and fail") parser.add_argument("--set", metavar="X.Y.Z", - line 281
help="move the workspace and every copy to this version") args = parser.parse_args() root = repo_root() if args.set: if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.set): print(f"not a version: {args.set}", file=sys.stderr) return 2 - line 281
old = workspace_version(root) cargo = root / "Cargo.toml" text = cargo.read_text(encoding="utf-8") cargo.write_text( re.sub(r'(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"$', f'version = "{args.set}"', text, count=1), encoding="utf-8", ) touched - line 281
= ["Cargo.toml"] + rewrite(root, old, args.set) + add_history(root, args.set) print(f" {old} -> {args.set} in {len(touched)} files") for name in touched: print(f" {name}") left = disagreements(root, args.set) if left: for problem in left: - line 281
print(f" still wrong: {problem}", file=sys.stderr) return 1 return 0 version = workspace_version(root) problems = disagreements(root, version) if problems: for problem in problems: print(f" {problem}", file=sys.stderr) print(f" - line 281
{len(problems)} place(s) disagree with the workspace version", file=sys.stderr) return 1 print(f" every copy of the version says {version}") return 0 - line 321
if __name__ == "__main__": raise SystemExit(main())
tools/render/probe.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Measure the rendered page, rather than reason about it. python -m http.server 8787 --bind 127.0.0.1 --directory website python tools/render/probe.py overflow --width 390 - line 1
python tools/render/probe.py overflow --width 390 --page index.html python tools/render/probe.py eval --page index.html --js "innerWidth" Two commands: **overflow** -- for each page, the document's `clientWidth` against its `scrollWidth`, - line 1
and when they differ, the elements whose boxes reach past the right edge and the widest thing inside each. A page that scrolls sideways on a phone is a defect that no unit test can see and that every reader can. **eval** -- run an - line 1
expression in the page and print what it returns as JSON. For the one-off question: what is this element's computed width, did that media query match, what scale did the browser choose for that drawing. # Why this exists - line 1
`tools/render/shot.py` takes the picture. A picture answers "does this look right", and there is a second class of question -- *how many pixels wide is it actually* -- where a picture is the worst possible instrument, because reading a - line 1
number off a screenshot is exactly the eyeballing this project has been caught out by before. Three claims in this repository were falsified by measurement after being argued from first principles, and each cost a rewrite. The flowcharts - line 1
are the clearest: they were `width="100%"` inside a column, which sounds like the responsible thing to write, and it meant a 4490 px drawing rendered at a scale of **0.147** with labels under two pixels tall. Nothing about that is visible - line 1
in the source, and it took one number to settle. So: a measurement, printed, before the argument. Pure standard library. The DevTools plumbing is `shot.py`'s -- see its header for why there is a hand-rolled WebSocket client in this - line 1
repository. """ - line 41
import argparse import json import os import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import shot # noqa: E402 (the path has to be set first) # The pages worth checking by default: one of every shape - line 41
the site has, rather # than all 300-odd reference pages, which are three templates repeated. DEFAULT_PAGES = ( "index.html", "what.html", "guide.html", "download.html", "verify.html", "crypto.html", "search.html", "wiki.html", - line 41
"reference/index.html", "reference/veilvoice-core.html", "reference/veilvoice-core/chain.html", "nojs/index.html", ) # Viewport widths that stand for something real. 320 is the narrowest phone # still in use (an iPhone SE in its first - line 41
generation); 390 is the modern # baseline; 768 is a tablet held upright, which is where a two-column layout # usually collapses badly rather than not at all. DEFAULT_WIDTHS = (320, 390, 768) OVERFLOW_JS = r""" (() => { const view = - line 41
document.documentElement.clientWidth; const out = { clientWidth: view, - line 81
scrollWidth: document.documentElement.scrollWidth, offenders: [] }; if (out.scrollWidth <= view + 1) return out; // Two things have to be filtered out before the list means anything. // // The first is a scroll container's contents. The - line 81
navigation row on a phone // is deliberately a sideways scroller, so its links really do sit past the // right edge and are not a fault -- reporting them buries the real offender // under nine false ones, which is how the first run of this - line 81
looked. // // The second is inheritance: an element that is only wide because its parent // is wide is not the fault either. The fault is the innermost one. const scrolls = (el) => { const x = getComputedStyle(el).overflowX; return x === - line 81
"auto" || x === "scroll" || x === "hidden"; }; const inScroller = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { if (scrolls(p)) return true; } return false; }; const past = []; - line 81
document.querySelectorAll("body *").forEach(el => { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > view + 1 && !inScroller(el)) past.push([el, r]); }); const set = new Set(past.map(p => p[0])); for (const [el, r] of - line 81
past) { let innerBlamed = false; for (const child of el.children) if (set.has(child)) innerBlamed = true; if (innerBlamed) continue; const cs = getComputedStyle(el); out.offenders.push({ tag: el.tagName.toLowerCase(), cls: - line 81
(el.getAttribute("class") || "").slice(0, 48), id: el.id || null, - line 121
width: Math.round(r.width), right: Math.round(r.right), overflowX: cs.overflowX, whiteSpace: cs.whiteSpace, text: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60) }); } out.offenders.sort((a, b) => b.right - a.right); - line 121
out.offenders = out.offenders.slice(0, 12); return out; })() """ def open_browser(port, width, height, reduced_motion=False, no_js=False): browser = shot.Browser(port, width, height) browser.call("Page.enable") - line 121
browser.call("Runtime.enable") # See the same call in `shot.py`: the profile directory is reused # between runs, so without this a measurement can be taken against a # cached stylesheet and report a fix that is not on the page. - line 121
browser.call("Network.enable") browser.call("Network.setCacheDisabled", cacheDisabled=True) browser.call("Emulation.setDeviceMetricsOverride", width=width, height=height, deviceScaleFactor=1, mobile=False) - line 121
browser.call("Page.addScriptToEvaluateOnNewDocument", source=shot.ACCEPT) if reduced_motion: browser.call("Emulation.setEmulatedMedia", features=[ {"name": "prefers-reduced-motion", "value": "reduce"}]) if no_js: - line 121
browser.call("Emulation.setScriptExecutionDisabled", value=True) return browser def measure(browser, server, page, expression, width, height): browser.call("Emulation.setDeviceMetricsOverride", width=width, height=height, - line 121
deviceScaleFactor=1, mobile=False) browser.call("Page.navigate", url="%s/%s" % (server.rstrip("/"), page.lstrip("/"))) browser.await_event("Page.loadEventFired") - line 161
# The reveal-on-scroll work and the theme picker both settle a frame or two # after load, and measuring mid-transition reports a width nothing ever has. time.sleep(0.6) result = browser.call("Runtime.evaluate", expression=expression, - line 161
returnByValue=True, awaitPromise=True) if "exceptionDetails" in result: raise SystemExit("the expression threw: %s" % json.dumps(result["exceptionDetails"])[:400]) return result.get("result", {}).get("value") def command_overflow(args): - line 161
pages = args.page or list(DEFAULT_PAGES) widths = args.width or list(DEFAULT_WIDTHS) browser = open_browser(args.port, widths[0], args.height, args.reduced_motion, args.no_js) bad = 0 try: for width in widths: print("\n%d px" % width) for - line 161
page in pages: found = measure(browser, args.server, page, OVERFLOW_JS, width, args.height) over = found["scrollWidth"] - found["clientWidth"] if over <= 1: print(" ok %-38s %d" % (page, found["clientWidth"])) continue bad += 1 print(" - line 161
WIDE %-38s %d wide, scrolls to %d (+%d)" % (page, found["clientWidth"], found["scrollWidth"], over)) for one in found["offenders"]: print(" <%s%s%s> %dpx, right edge %d " "[overflow-x:%s white-space:%s] %s" % (one["tag"], (" class=%s" % - line 161
one["cls"]) if one["cls"] else "", (" id=%s" % one["id"]) if one["id"] else "", one["width"], one["right"], one["overflowX"], one["whiteSpace"], one["text"])) finally: browser.close() - line 201
print("\n%s" % ("no page scrolls sideways" if bad == 0 else "%d page/width combinations scroll sideways" % bad)) return 1 if bad else 0 def command_eval(args): pages = args.page or ["index.html"] widths = args.width or [1280] browser = - line 201
open_browser(args.port, widths[0], args.height, args.reduced_motion, args.no_js) try: for width in widths: for page in pages: value = measure(browser, args.server, page, args.js, width, args.height) print("%s @ %dpx" % (page, width)) - line 201
print(json.dumps(value, indent=1, sort_keys=True)) finally: browser.close() return 0 def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("command", choices=("overflow", "eval")) - line 201
parser.add_argument("--page", action="append", help="page under the server root; repeatable") parser.add_argument("--width", action="append", type=int, help="viewport width; repeatable") parser.add_argument("--height", type=int, - line 201
default=900) parser.add_argument("--js", default="1", help="expression to evaluate, for `eval`") parser.add_argument("--no-js", action="store_true") parser.add_argument("--reduced-motion", action="store_true") parser.add_argument("--port", - line 201
type=int, default=9225) parser.add_argument("--server", default="http://127.0.0.1:8787") args = parser.parse_args() if args.command == "overflow": return command_overflow(args) - line 241
return command_eval(args) if __name__ == "__main__": sys.exit(main())
tools/render/shot.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Screenshot a page of the site with headless Edge, over the DevTools protocol. python -m http.server 8787 --bind 127.0.0.1 --directory website python tools/render/shot.py - line 1
reference/veilvoice-core.html out.png --width N viewport width (default 1280) --height N viewport height (default 900) --full capture the whole page, not just the viewport --no-js render with script execution disabled --reduced-motion - line 1
render as though the reader asked for less motion --port N DevTools port (default 9223) --server URL page origin (default http://127.0.0.1:8787) # Why this exists at all `HANDOFF.md` section 8 has said since v0.1.7 that the page must be - line 1
*looked at*, and it has been right twice: three paragraphs of the walkthrough were invisible on the published site with every unit test passing (F-30's neighbourhood), and finding **F-37** had the banner rendering its own text illegibly on - line 1
every viewport for as long as the banner had existed. Neither was findable from the tests. Both were obvious on sight. # Why not `--screenshot` Edge's own `--headless=new --screenshot=FILE` exits 0 and writes nothing in this environment, - line 1
and the editor's browser pane has never composited here either -- two sessions lost time to that before it was written down. The DevTools protocol works, so this drives it directly. # Why a WebSocket client is in here The protocol is JSON - line 1
over a WebSocket, and the standard library has no WebSocket client. Adding a dependency for this would put a package on the critical path of a repository whose argument is that it has no supply chain worth attacking, to take a picture. The - line 1
framing is about sixty lines, so it is sixty lines below. Pure standard library. No build step, no dependencies. - line 41
""" import argparse import base64 import json import os import socket import struct import subprocess import sys import tempfile import time import urllib.request # Where to find a browser that speaks the DevTools protocol. # # Edge first, - line 41
because that is what this was written against and what the # committed screenshots were taken with. The rest are here because the tool was # Windows-only and the rule it exists to serve -- look at the page -- is not: # a session on Linux - line 41
could run every test in this repository and could not open # a single page of the site it had just rebuilt. # # `VEILVOICE_BROWSER` overrides the lot, for a browser installed somewhere # these lists do not name. Any Chromium will do; the - line 41
protocol is the same. BROWSER_CANDIDATES = ( r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - line 41
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/opt/pw-browsers/chromium", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", - line 41
"/usr/bin/microsoft-edge", "/snap/bin/chromium", ) def find_browser(): - line 81
"""The first browser on this machine that can be driven, or nothing.""" override = os.environ.get("VEILVOICE_BROWSER", "").strip() if override: return override if os.path.exists(override) else None return next((path for path in - line 81
BROWSER_CANDIDATES if os.path.exists(path)), None) # The legal gate covers the page until it is accepted, so every screenshot would # otherwise be a picture of the same modal. Setting the key the gate looks for # *before the document runs* - line 81
is the supported way past it -- and it is set per # session, so this changes nothing for a real reader. ACCEPT = 'sessionStorage["veilvoice-accepted-v1"] = "yes";' # --- the smallest WebSocket client that can carry this - line 81
----------------------- class Socket(object): """A WebSocket text channel. Client to server only ever sends text.""" def __init__(self, url): rest = url.split("://", 1)[1] hostport, _, path = rest.partition("/") host, _, port = - line 81
hostport.partition(":") self.sock = socket.create_connection((host, int(port or 80)), timeout=30) key = base64.b64encode(os.urandom(16)).decode("ascii") self.sock.sendall(( "GET /%s HTTP/1.1\r\n" "Host: %s\r\n" "Upgrade: websocket\r\n" - line 81
"Connection: Upgrade\r\n" "Sec-WebSocket-Key: %s\r\n" "Sec-WebSocket-Version: 13\r\n\r\n" % (path, hostport, key) ).encode("ascii")) self.buffer = b"" while b"\r\n\r\n" not in self.buffer: chunk = self.sock.recv(4096) if not chunk: raise - line 81
SystemExit("the browser closed the connection during the handshake") self.buffer += chunk head, _, rest = self.buffer.partition(b"\r\n\r\n") if b"101" not in head.split(b"\r\n")[0]: - line 121
raise SystemExit("upgrade refused: %s" % head.split(b"\r\n")[0]) self.buffer = rest def _read(self, count): while len(self.buffer) < count: chunk = self.sock.recv(65536) if not chunk: raise SystemExit("the browser closed the connection") - line 121
self.buffer += chunk out, self.buffer = self.buffer[:count], self.buffer[count:] return out def send(self, text): payload = text.encode("utf-8") header = bytearray([0x81]) # FIN + text frame length = len(payload) if length < 126: - line 121
header.append(0x80 | length) # the mask bit is mandatory client-side elif length < 65536: header.append(0x80 | 126) header += struct.pack(">H", length) else: header.append(0x80 | 127) header += struct.pack(">Q", length) mask = - line 121
os.urandom(4) header += mask masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) self.sock.sendall(bytes(header) + masked) def receive(self): """One complete message, reassembled across continuation frames.""" - line 121
chunks = [] while True: first, second = self._read(2) final = first & 0x80 length = second & 0x7F if length == 126: length = struct.unpack(">H", self._read(2))[0] elif length == 127: length = struct.unpack(">Q", self._read(8))[0] - line 161
# Server-to-client frames are never masked, so there is no key here. chunks.append(self._read(length)) if final: return b"".join(chunks).decode("utf-8", "replace") class Browser(object): def __init__(self, port, width, height): browser = - line 161
find_browser() if browser is None: raise SystemExit( "no browser found. Looked in:\n " + "\n ".join(BROWSER_CANDIDATES) + "\n\nSet VEILVOICE_BROWSER to the one on this machine.") profile = os.path.join(tempfile.gettempdir(), "vv-render-%d" - line 161
% port) command = [browser, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check", "--remote-debugging-port=%d" % port, "--user-data-dir=%s" % profile, "--window-size=%d,%d" % (width, - line 161
height)] # Chromium refuses to run as root without this, and a container is # very often root. It changes nothing about what is rendered. if hasattr(os, "geteuid") and os.geteuid() == 0: command.append("--no-sandbox") - line 161
command.append("about:blank") self.process = subprocess.Popen( command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.socket = Socket(self._wait_for_target(port)) self.next_id = 0 @staticmethod def _wait_for_target(port, - line 161
seconds=25): """Poll until DevTools answers. It is not listening the instant it starts.""" deadline = time.time() + seconds last = None while time.time() < deadline: try: raw = urllib.request.urlopen( "http://127.0.0.1:%d/json/list" % - line 161
port, timeout=2).read() for target in json.loads(raw.decode("utf-8")): - line 201
if target.get("type") == "page" and target.get("webSocketDebuggerUrl"): return target["webSocketDebuggerUrl"] except Exception as exc: # not listening yet, or no page yet last = exc time.sleep(0.25) raise SystemExit("DevTools never came up - line 201
on port %d (%s)" % (port, last)) def call(self, method, **params): self.next_id += 1 wanted = self.next_id self.socket.send(json.dumps({"id": wanted, "method": method, "params": params})) while True: message = - line 201
json.loads(self.socket.receive()) if message.get("id") == wanted: if "error" in message: raise SystemExit("%s failed: %s" % (method, message["error"])) return message.get("result", {}) def await_event(self, name, seconds=20): deadline = - line 201
time.time() + seconds while time.time() < deadline: self.socket.sock.settimeout(max(0.5, deadline - time.time())) try: message = json.loads(self.socket.receive()) except socket.timeout: break if message.get("method") == name: return True - line 201
return False def close(self): try: self.socket.sock.close() finally: self.process.terminate() def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) - line 241
parser.add_argument("page") parser.add_argument("output") parser.add_argument("--width", type=int, default=1280) parser.add_argument("--height", type=int, default=900) parser.add_argument("--full", action="store_true") - line 241
parser.add_argument("--no-js", action="store_true") parser.add_argument("--reduced-motion", action="store_true") parser.add_argument("--port", type=int, default=9223) parser.add_argument("--server", default="http://127.0.0.1:8787") args = - line 241
parser.parse_args() browser = Browser(args.port, args.width, args.height) try: browser.call("Page.enable") browser.call("Runtime.enable") # The disk cache is the reason this is here. # # The profile directory is reused between runs, so - line 241
Edge keeps a # cached copy of `main.css` and serves it back on the next one. A # stylesheet fix measured as applied by one tool photographed as # *not* applied by this one, and the two disagreed for a full round # of debugging before the - line 241
cache was the answer. A renderer whose # picture can be one edit out of date is worse than no renderer. browser.call("Network.enable") browser.call("Network.setCacheDisabled", cacheDisabled=True) - line 241
browser.call("Emulation.setDeviceMetricsOverride", width=args.width, height=args.height, deviceScaleFactor=1, mobile=False) browser.call("Page.addScriptToEvaluateOnNewDocument", source=ACCEPT) if args.reduced_motion: - line 241
browser.call("Emulation.setEmulatedMedia", features=[ {"name": "prefers-reduced-motion", "value": "reduce"}]) # Ordered deliberately: disabling scripts must happen before the # navigation, or the page has already run them. This switch is - line 241
what # caught the JavaScript toggle claiming "on" with scripts disabled. if args.no_js: browser.call("Emulation.setScriptExecutionDisabled", value=True) url = "%s/%s" % (args.server.rstrip("/"), args.page.lstrip("/")) - line 241
browser.call("Page.navigate", url=url) - line 281
browser.await_event("Page.loadEventFired") time.sleep(1.0) shot = browser.call("Page.captureScreenshot", format="png", captureBeyondViewport=bool(args.full)) with open(args.output, "wb") as handle: - line 281
handle.write(base64.b64decode(shot["data"])) print("wrote %s (%d bytes) from %s" % (args.output, os.path.getsize(args.output), url)) finally: browser.close() return 0 if __name__ == "__main__": sys.exit(main())
tools/repo/message_filter.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ Rewrite one commit message: roadmap "markers" become "roadmap items". git filter-branch -f --msg-filter 'python3 tools/repo/message_filter.py' \ --tag-name-filter cat - line 1
-- --all # What this is for The roadmap called its entries "markers" for a long time and now calls them roadmap items. The documents were changed; the commit messages describing that work still said marker, and so did the tags pointing at - line 1
them. It also drops the `Co-Authored-By:` and `Claude-Session:` trailers that survive on the pre-rewrite history a few release tags still point at. This repository's commits have one author, and those trailers make GitHub show a second. # - line 1
What it deliberately does not touch "marker" is a real word and this repository uses it for real things: the session marker written in `app::new`, the hidden-volume marker, and the markers a picture drops. Renaming those would be a wrong - line 1
answer that looks like a right one, so the test is narrow. A number has to follow, or the phrase has to be one of the counted forms the roadmap commits used ("Nine markers for the recording studio"). Everything else is left exactly as it - line 1
was. # Why this is a file rather than a one-liner Because it has to be run twice and produce the same thing both times: once in the container where the branches were rewritten, and once on a machine that can push tags, since a proxy that - line 1
refuses to move a ref refuses to move a tag. Two subtly different sed expressions would produce two different histories. Pure standard library. """ import re, sys COUNTS = - line 1
r"(?:One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|\d+)" - line 41
def fix(text): # `ROADMAP markers 30 and 31` reads wrong as `ROADMAP roadmap items`. text = re.sub(r"\bROADMAP markers(?=\s+\d)", "ROADMAP items", text) text = re.sub(r"\bROADMAP marker(?=\s+\d)", "ROADMAP item", text) # A number follows: - line 41
unambiguously a roadmap entry. text = re.sub(r"\bMarkers(?=\s+\d)", "Roadmap items", text) text = re.sub(r"\bmarkers(?=\s+\d)", "roadmap items", text) text = re.sub(r"\bMarker(?=\s+\d)", "Roadmap item", text) text = - line 41
re.sub(r"\bmarker(?=\s+\d)", "roadmap item", text) # `Nine markers for the recording studio`: counted, so a roadmap entry too. text = re.sub(r"(\b%s)\s+markers\b" % COUNTS, r"\1 roadmap items", text) text = re.sub(r"(\b%s)\s+Markers\b" % - line 41
COUNTS, r"\1 Roadmap items", text) # Attribution trailers that survive only on the pre-rewrite tagged history. out = [] for line in text.split("\n"): s = line.strip() if s.lower().startswith(("co-authored-by:", "claude-session:")): - line 41
continue if s.startswith("\U0001F916 Generated with") or s.startswith("Generated with [Claude Code]"): continue if s.startswith("https://claude.ai/code/session_"): continue out.append(line) text = "\n".join(out) # Collapse the blank lines - line 41
a stripped trailer leaves at the end. return re.sub(r"\n{3,}$", "\n", text.rstrip("\n")) + "\n" if __name__ == "__main__": sys.stdout.write(fix(sys.stdin.read()))
tools/repo/tidy.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """ Branch housekeeping for this repository, run from a machine that can push. # Why this exists rather than a note in a checklist Two things accumulate on the remote and - line 1
neither has an owner: branches Claude Code opened for a session, and branches Dependabot opened for an upgrade. Both are fully merged long before anybody notices them, and a list of stale branches is exactly the kind of thing that gets - line 1
tidied by hand once and then never again. # Why it is not run from CI Deleting a branch is not a thing to do on a schedule without somebody watching, and the session this was written in could not do it at all: the git proxy in a Claude - line 1
Code container answers a ref deletion with 403 while allowing every other push, so the deletion has to happen somewhere with ordinary credentials. # What it will not do It refuses to delete anything that is not already contained in `main`. - line 1
That is the whole safety property: a branch whose commits are all in `main` can be recreated from `main`, so deleting it loses nothing, and a branch with even one commit that is not is left alone and reported. `main` itself is never a - line 1
candidate, and neither is the current branch. Dry run unless `--go` is passed, because a list of what would happen is the useful output and the deletion is the rare one. Pure standard library. """ from __future__ import annotations import - line 1
argparse import subprocess import sys # Branches that are never candidates for deletion whatever their state. `main` - line 41
# is the published history; `dev` is where work happens and is routinely ahead # of `main` rather than contained in it. PROTECTED = {"main", "dev", "HEAD"} def git(*args, check=True): """Run git and give back its output, with the command - line 41
in any error.""" done = subprocess.run(["git", *args], capture_output=True, text=True) if check and done.returncode != 0: raise SystemExit("git %s failed: %s" % (" ".join(args), done.stderr.strip())) return done.stdout.strip() def - line 41
remote_branches(remote): """Every branch on the remote, without the remote's name on the front.""" out = git("for-each-ref", "--format=%(refname:short)", "refs/remotes/%s" % remote) names = [] for line in out.split("\n"): if not line: - line 41
continue name = line[len(remote) + 1:] if name in PROTECTED: continue names.append(name) return names def contained_in_main(remote, branch): """Whether every commit on this branch is already in the remote's main.""" done = subprocess.run( - line 41
["git", "merge-base", "--is-ancestor", "%s/%s" % (remote, branch), "%s/main" % remote], capture_output=True, text=True) return done.returncode == 0 def last_commit(remote, branch): """When this branch was last touched, for the report.""" - line 41
return git("log", "-1", "--format=%cs %h %s", "%s/%s" % (remote, branch)) - line 81
def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[1]) parser.add_argument("--remote", default="origin", help="the remote to work on") parser.add_argument("--go", action="store_true", help="actually delete; - line 81
without it, only say what would go") parser.add_argument("--including", metavar="BRANCH", action="append", default=[], help="also delete this branch even though it is not " "contained in main, naming it explicitly. Repeatable. " "What it - line 81
would lose is printed first.") args = parser.parse_args() print("fetching %s" % args.remote) git("fetch", args.remote, "--prune") branches = remote_branches(args.remote) if not branches: print("nothing but the protected branches on %s" % - line 81
args.remote) return 0 merged, unmerged = [], [] for branch in branches: (merged if contained_in_main(args.remote, branch) else unmerged).append(branch) # A branch named explicitly moves from the left-alone list to the delete # list, and - line 81
what deleting it loses is printed rather than implied. This is # the escape hatch for a branch whose work arrived by another route: a # Dependabot branch whose upgrade was applied by hand, for instance, is # never contained in `main` and - line 81
is still finished with. named = set(args.including) unknown = named - set(branches) if unknown: print("no such branch on %s: %s" % (args.remote, ", ".join(sorted(unknown)))) return 1 chosen = [b for b in unmerged if b in named] unmerged = - line 81
[b for b in unmerged if b not in named] merged += chosen if chosen: - line 121
print("\nnamed explicitly, and NOT contained in main. Deleting these") print("loses the commits listed, which exist nowhere else:") for branch in sorted(chosen): ahead = git("rev-list", "--count", "%s/main..%s/%s" % (args.remote, - line 121
args.remote, branch)) print(" %-46s %s commit(s) not in main" % (branch, ahead)) print(" %-46s %s" % ("", last_commit(args.remote, branch))) if unmerged: print("\nleft alone, because they are not contained in main:") for branch in - line 121
sorted(unmerged): print(" %-46s %s" % (branch, last_commit(args.remote, branch))) if not merged: print("\nnothing to delete") return 0 contained = [b for b in merged if b not in named] if contained: print("\ncontained in main, so deleting - line 121
loses nothing:") for branch in sorted(contained): print(" %-46s %s" % (branch, last_commit(args.remote, branch))) if not args.go: print("\n%d branch(es) would go. Pass --go to delete them." % len(merged)) return 0 print() failed = 0 for - line 121
branch in sorted(merged): done = subprocess.run(["git", "push", args.remote, "--delete", branch], capture_output=True, text=True) if done.returncode == 0: print(" deleted %s" % branch) else: failed += 1 print(" FAILED %s: %s" % (branch, - line 121
done.stderr.strip().split("\n")[-1])) return 1 if failed else 0 - line 161
if __name__ == "__main__": sys.exit(main())
tools/search-index/generate.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Build the search index for the repository and the website. python tools/search-index/generate.py # write the index python tools/search-index/generate.py --check # verify - line 1
it is current Two things come out of this script, and they exist for two different readers: * ``website/search-index.json`` -- the machine-readable index. ``search.js`` fetches it and scores against it. Nobody reads this by hand. * - line 1
``website/nojs/search.html`` -- a complete, static, browsable index of every file and every section in the project. It needs no JavaScript at all: the whole corpus is *in the page*, so a reader's own find-in-page searches it. The second is - line 1
not a courtesy stub. ``website/nojs/`` is a supported edition of this site, and "search" that silently does nothing without JavaScript would be exactly the kind of quiet degradation this project audits itself against. The static page - line 1
therefore carries the same entries as the JSON, from the same walk, in the same order -- it is the same index rendered for a different reader. # Why generated, and why ``--check`` Same reason as ``assets/generate.py``: an index committed - line 1
as an opaque blob is one more thing a reader has to take on trust, and a stale index is a search box that confidently reports the wrong thing. The output is deterministic -- files walked in sorted order, no timestamps, no absolute paths, - line 1
LF endings, sorted JSON keys -- so ``--check`` regenerates into memory and compares. CI runs it, so an index that has drifted from the tree fails the build rather than shipping. # What is indexed, stated precisely Everything ``git - line 1
ls-files`` reports that is text, split into *sections*. What a section contains depends on the format, and the difference is worth stating plainly rather than rounding up to "everything": * **Markdown** -- one section per heading, carrying - line 1
**all** the prose under it. Complete. * **HTML** -- one section per heading, carrying **all** the text up to the next heading. Complete. - line 41
* **Rust** -- one section per item (``fn``, ``struct``, ``enum``, ``trait``, ``mod``, ``const``, ...), carrying the item's name and its doc comment. **Not the function bodies.** The doc comments in this codebase are the argument for the - line 41
code, so they are the part worth searching; the statements inside a function are not. This is the one deliberate gap, and it means searching for a local variable will not find it. * **Everything else** (JavaScript, CSS, TOML, YAML, licence - line 41
texts, ...) -- the whole file, in consecutive chunks. Complete. Where a body is longer than ``MAX_EXCERPT`` it is **split into several sections**, never truncated: the bound is on how much one result *displays*, not on how much is - line 41
searched. An earlier version truncated, which quietly left most of every long file out of the index while the page claimed to search it. Pure standard library. No build step, no dependencies. """ import html import json import os import re - line 41
import subprocess import sys # --- limits ----------------------------------------------------------------- # Each of these bounds the output, and each is far past anything in this tree. # They exist so that a file nobody expected -- a - line 41
vendored blob, a generated # table, a lock file that grew -- cannot silently turn the index into something # a phone has to download. MAX_EXCERPT = 240 # characters shown in one result MAX_SECTIONS_PER_DOC = 400 MAX_FILE_BYTES = 512 * 1024 - line 41
MAX_HEADING = 120 # This script's own output is tracked, lives under `website/`, and would # otherwise be walked like any other file -- which does not merely bloat the # index, it stops it converging. Indexing the index makes the next - line 41
run's input # contain the previous run's output, so the file grows on every regeneration # and `--check` can never agree with a freshly built one. Excluded by path, and # asserted by a test, because the failure looks like flaky CI rather - line 41
than a bug. - line 81
GENERATED = frozenset({ "website/search-index.json", "website/nojs/search.html", # Generated from the tab list in the desktop application's source and from # the command captures, both of which are indexed where they are written. - line 81
"website/js/demo-data.js", }) # `tools/docs/generate.py` renders the doc comments in the source into four # places: a README per crate, a page per file, the website reference, and the # GitHub wiki. All four are the *same prose*, and that - line 81
prose is already indexed # at its origin -- the `.rs` files, whose `//!` and `///` comments this walk # reads directly. # # Indexing the renderings as well would return four results for one sentence, # three of them copies, and would - line 81
roughly quadruple the megabyte a reader's # browser downloads to search at all. So the source is indexed and the # renderings are not, which is the same decision as excluding this script's own # output above, for the same reason. - line 81
GENERATED_PREFIXES = ( "docs/files/", "website/reference/", "wiki/", "assets/banners/", "website/assets/banners/", # Every copy under here is a copy of something in `assets/`, which is # already indexed. The website needs its own copy - line 81
because it serves only # what is under `website/`; the index does not need two. "website/assets/", ) CRATE_README = re.compile(r"^crates/[^/]+/README\.md$") def is_generated(rel): """Is this file a rendering of something already indexed at - line 81
its source? **Every `.svg` in this repository is a drawing produced by a generator**, and all 536 of them carry a `GENERATED` marker in their first lines. None is hand-written prose. Indexing them put **1.7 MB of SVG markup into the index, - line 81
43.6 per cent of it**, measured: `roadmap-film.svg` alone was 42 KB, - line 121
twice, because the website keeps its own copy. Every byte of that is downloaded by every reader who uses the search, and it buys them nothing: the words inside a drawing are the words of the document it was drawn from, which is indexed at - line 121
that document, and a search result pointing at an SVG file is a result nobody can use. That is not a new argument. It is the one written above about the crate documentation, applied to the other kind of generated file. The banners were - line 121
excluded on it and the diagrams were not, which is how 43.6 per cent accumulated without anybody deciding to. """ return (rel in GENERATED or rel.endswith(".svg") or rel.startswith(GENERATED_PREFIXES) or bool(CRATE_README.match(rel))) REPO - line 121
= "tilas01/veilvoice" REF = "main" # --- what kind of thing is this --------------------------------------------- # Ordered: the first pattern that matches wins. KIND_RULES = [ (re.compile(r"^crates/[^/]+/(src|examples)/.*\.rs$"), "rust"), - line 121
(re.compile(r"^crates/[^/]+/tests/.*\.rs$"), "test"), (re.compile(r"^fuzz/.*\.rs$"), "test"), (re.compile(r"^website/nojs/"), "web"), (re.compile(r"^website/user-agreements/"), "legal"), (re.compile(r"^website/"), "web"), - line 121
(re.compile(r"^tools/"), "tool"), (re.compile(r"^assets/"), "tool"), (re.compile(r"^\.github/"), "build"), (re.compile(r"\.md$"), "doc"), (re.compile(r"\.(toml|lock|yml|yaml)$"), "build"), (re.compile(r"^(LICENSE|COPYING)"), "legal"), - line 121
(re.compile(r"\.(txt)$"), "legal"), ] KIND_LABELS = [ ("doc", "Documentation"), - line 161
("rust", "Rust source"), ("test", "Tests and fuzzing"), ("web", "Website"), ("tool", "Tools and generators"), ("build", "Build and CI"), ("legal", "Licence and legal"), ("other", "Other"), ] BINARY = re.compile( - line 161
r"\.(png|jpg|jpeg|gif|ico|icns|rgba|woff2?|ttf|otf|pdf|zip|gz|asc|wav|mp3|flac)$", re.I ) # Whole directories of bytes, whatever their files are named. # # The fuzzing seed corpus is deliberately hostile input whose files are named # after - line 161
their own hash and carry no extension, so the pattern above cannot # reach them. Indexing it put raw control characters into the index and from # there into the no-JS search page, where the character suite found them. A # search result - line 161
pointing at a truncated header is no use to anybody either. BINARY_DIRS = re.compile(r"^fuzz/seeds/") # Rust items worth an index entry of their own. RUST_ITEM = re.compile( r"^\s*(?:pub(?:\s*\([^)]*\))?\s+)?" - line 161
r"(?:default\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+\"[^\"]*\"\s+)?" r"(fn|struct|enum|trait|mod|type|const|static|union|macro_rules!)\s+" r"([A-Za-z_][A-Za-z0-9_]*)" ) RUST_IMPL = - line 161
re.compile(r"^\s*impl(?:\s*<[^>]*>)?\s+(.+?)\s*\{?\s*$") HTML_HEADING = re.compile( r"<h([1-4])\b([^>]*)>(.*?)</h\1>", re.I | re.S ) HTML_ID = re.compile(r"""\bid\s*=\s*["']([^"']+)["']""", re.I) TAG = re.compile(r"<[^>]+>") - line 161
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "site")) import seo # noqa: E402 the addresses every page carries - line 201
def repo_root(): here = os.path.dirname(os.path.abspath(__file__)) return os.path.abspath(os.path.join(here, "..", "..")) def tracked_files(root): """Every file git tracks -- exactly the set that ships.""" out = subprocess.run( ["git", - line 201
"ls-files", "-z"], cwd=root, check=True, stdout=subprocess.PIPE, ).stdout.decode("utf-8") names = [n for n in out.split("\0") if n] return sorted(names) def untracked_files(root): """Files git does not track and is not ignoring.""" out = - line 201
subprocess.run( ["git", "ls-files", "-z", "--others", "--exclude-standard"], cwd=root, check=True, stdout=subprocess.PIPE, ).stdout.decode("utf-8") return sorted(n for n in out.split("\0") if n) def warn_about_untracked(root): """Say so - line 201
when a new file would be indexed but has not been staged yet. The index is built from `git ls-files`, which lists *tracked* files. Write a new document, generate, and commit it in one step and the index you commit was built without it -- - line 201
because at the moment it was built, git had never heard of it. CI then regenerates from the committed tree, finds one more file, and fails with "differs from the generator output", which is true and tells you nothing about why. That - line 201
happened while this feature was being built, so the failure is now explained where it can be acted on rather than discovered from a red CI run ten minutes later. It is a warning rather than a refusal: an untracked file is a perfectly - line 201
ordinary state to be in, and the generator should still work. - line 241
""" would_index = [ rel for rel in untracked_files(root) if not BINARY.search(rel) and not BINARY_DIRS.search(rel) and not is_generated(rel) ] if not would_index: return print() print(" NOTE: %d file(s) are not tracked by git, so they are - line 241
NOT in this" % len(would_index)) print(" index. `git ls-files` is what this walks, and it lists tracked") print(" files only:") for rel in would_index[:10]: print(" %s" % rel) if len(would_index) > 10: print(" ...and %d more" % - line 241
(len(would_index) - 10)) print() print(" If they belong in the index, stage them and generate again:") print(" git add -A && python tools/search-index/generate.py") print() def kind_of(rel): for pattern, kind in KIND_RULES: if - line 241
pattern.search(rel): return kind return "other" def area_of(rel): """The part of the project a file belongs to, for filtering.""" parts = rel.split("/") if parts[0] == "crates" and len(parts) > 1: return parts[1] if parts[0] in ("website", - line 241
"tools", "docs", "fuzz", "assets", ".github"): return parts[0] return "root" def squash(text): - line 281
"""One line of plain text, collapsed, with no stray whitespace.""" return re.sub(r"\s+", " ", text).strip() def chunks(text): """Split text into bounded pieces that together cover **all** of it. This deliberately splits rather than - line 281
truncates, and the difference is the whole honesty of the feature. The first version kept the first 240 characters of each section and dropped the rest, which meant the index covered roughly an eighth of a long file while the page said it - line 281
searched every file. It was caught by a test asking whether searching for `onerror` -- a string this repository definitely contains, in its own hostile-input fixtures -- found anything. It did not. A search box that silently does not look - line 281
at most of the corpus is worse than no search box, because it answers "no results" with the same confidence either way. So every character of every indexed file now lands in exactly one chunk, and the bound applies to how much text a - line 281
single result *displays*, not to how much is searched. """ text = squash(text) if not text: return [] if len(text) <= MAX_EXCERPT: return [text] out = [] at = 0 while at < len(text): cut = text[at:at + MAX_EXCERPT] if at + MAX_EXCERPT < - line 281
len(text): # Break on a word boundary so a snippet reads as a sentence, but # never lose the tail: the next chunk resumes exactly where this # one stopped. space = cut.rfind(" ") if space > MAX_EXCERPT * 0.6: cut = cut[:space] - line 281
out.append(cut.strip()) at += len(cut) - line 321
while at < len(text) and text[at] == " ": at += 1 return [c for c in out if c] def slug(text): """GitHub's heading anchor rule, which is what both GitHub and this site use.""" s = squash(text).lower() s = re.sub(r"[^\w\- ]+", "", s, - line 321
flags=re.UNICODE) return s.replace(" ", "-") # --- per-format section extraction ------------------------------------------ def sections_markdown(text): out = [] current = {"h": "", "anchor": "", "line": 1, "body": []} in_fence = False for - line 321
number, line in enumerate(text.splitlines(), start=1): stripped = line.strip() if stripped.startswith("```") or stripped.startswith("~~~"): in_fence = not in_fence continue heading = re.match(r"^(#{1,6})\s+(.*)$", line) if not in_fence - line 321
else None if heading: if current["h"] or current["body"]: out.append(current) title = squash(heading.group(2)) title = re.sub(r"[*`_]", "", title) current = { "h": title[:MAX_HEADING], "anchor": slug(title), "line": number, "body": [], } - line 321
else: current["body"].append(line) if current["h"] or current["body"]: out.append(current) return out - line 361
def sections_rust(text): """One entry per item, carrying the doc comment written above it. The doc comments in this codebase are the argument for the code -- they say why a thing is done the way it is. Indexing the signature without them - line 361
would make the search find names and miss reasons. """ out = [] lines = text.splitlines() module_doc = [] for line in lines: s = line.strip() if s.startswith("//!"): module_doc.append(s[3:].strip()) elif s and not s.startswith("//"): break - line 361
if module_doc: out.append({"h": "(module)", "anchor": "", "line": 1, "body": module_doc}) pending = [] for number, line in enumerate(lines, start=1): s = line.strip() if s.startswith("///"): pending.append(s[3:].strip()) continue if - line 361
s.startswith("#[") or s.startswith("#!["): continue item = RUST_ITEM.match(line) name = None if item: name = item.group(1) + " " + item.group(2) else: impl = RUST_IMPL.match(line) # Only a real `impl` header, not a line that happens to - line 361
start with it. if impl and ("{" in line or line.rstrip().endswith("{")): name = "impl " + squash(impl.group(1)) if name: - line 401
out.append({ "h": name[:MAX_HEADING], "anchor": "", "line": number, "body": pending[:], }) if s: pending = [] return out def sections_html(text): out = [] for match in HTML_HEADING.finditer(text): attrs = match.group(2) title = - line 401
squash(html.unescape(TAG.sub(" ", match.group(3)))) if not title: continue ident = HTML_ID.search(attrs) line = text.count("\n", 0, match.start()) + 1 # Everything after this heading, up to the next one. Not a fixed window: # a window - line 401
drops whatever falls past it, which is the truncation bug # `chunks()` exists to avoid, arriving through a different door. tail = text[match.end():] nxt = HTML_HEADING.search(tail) if nxt: tail = tail[:nxt.start()] body = - line 401
squash(html.unescape(TAG.sub(" ", tail))) out.append({ "h": title[:MAX_HEADING], "anchor": ident.group(1) if ident else "", "line": line, "body": [body], }) return out def sections_plain(text): """Fall-back: the file in bounded chunks, so - line 401
long files stay findable.""" lines = text.splitlines() - line 441
out = [] step = 40 for start in range(0, len(lines), step): chunk = lines[start:start + step] body = squash("\n".join(chunk)) if not body: continue out.append({"h": "", "anchor": "", "line": start + 1, "body": [body]}) return out def - line 441
sections_for(rel, text): if rel.endswith(".md"): return sections_markdown(text) if rel.endswith(".rs"): return sections_rust(text) if rel.endswith(".html"): return sections_html(text) return sections_plain(text) # --- links - line 441
------------------------------------------------------------------ def doc_url(rel, line): """Where a result sends the reader. Website pages are on this site, so they get a same-site link to the exact section. Everything else lives in the - line 441
repository and gets a GitHub link with a line number, because that is where the file actually is. """ if rel.startswith("website/"): local = rel[len("website/"):] return local anchor = "#L%d" % line if line and line > 1 else "" return - line 441
"https://github.com/%s/blob/%s/%s%s" % (REPO, REF, rel, anchor) # --- the index -------------------------------------------------------------- def build(root): - line 481
docs = [] secs = [] for rel in tracked_files(root): if BINARY.search(rel) or BINARY_DIRS.search(rel) or is_generated(rel): continue full = os.path.join(root, rel.replace("/", os.sep)) try: size = os.path.getsize(full) except OSError: - line 481
continue if size > MAX_FILE_BYTES: continue try: with open(full, "r", encoding="utf-8") as handle: text = handle.read() except (OSError, UnicodeDecodeError): continue if "\0" in text: continue kind = kind_of(rel) title = rel.rsplit("/", - line 481
1)[-1] doc_index = len(docs) found = sections_for(rel, text) kept = 0 for section in found: if kept >= MAX_SECTIONS_PER_DOC: break body = section["body"] body_text = " ".join(body) if isinstance(body, list) else str(body) pieces = - line 481
chunks(body_text) if not pieces: # A heading with nothing under it is still worth finding. pieces = [""] if section["h"] else [] for offset, piece in enumerate(pieces): if kept >= MAX_SECTIONS_PER_DOC: break entry = { - line 521
"d": doc_index, # Continuations keep the heading so a result still says # where it is, and the reader is not shown a bare fragment. "h": section["h"], "l": section["line"], "x": piece, } if section["anchor"] and offset == 0: entry["a"] = - line 521
section["anchor"] secs.append(entry) kept += 1 docs.append({ "p": rel, "t": title, "k": kind, "r": area_of(rel), "n": text.count("\n") + 1, "b": size, "u": doc_url(rel, 0), }) areas = sorted({d["r"] for d in docs}) kinds = [[k, label] for - line 521
k, label in KIND_LABELS if any(d["k"] == k for d in docs)] return { "v": 1, "repo": REPO, "ref": REF, "kinds": kinds, "areas": areas, "docs": docs, "secs": secs, } def render_json(index): # ensure_ascii keeps the file pure ASCII, so no - line 521
viewer can turn a character # of it into mojibake; sort_keys and fixed separators keep it byte-stable. return json.dumps(index, ensure_ascii=True, sort_keys=True, - line 561
separators=(",", ":")) + "\n" # --- the static, no-JavaScript index ---------------------------------------- def esc(text): """HTML-escape, and force the result to ASCII. Numeric references keep the page byte-identical whatever a reader's - line 561
viewer guesses about encoding -- the same reason `website/js/*.js` is ASCII. """ out = html.escape(text, quote=True) return out.encode("ascii", "xmlcharrefreplace").decode("ascii") def render_static(index): kinds = dict(KIND_LABELS) - line 561
by_kind = {} for number, doc in enumerate(index["docs"]): by_kind.setdefault(doc["k"], []).append((number, doc)) secs_by_doc = {} for section in index["secs"]: secs_by_doc.setdefault(section["d"], []).append(section) total_docs = - line 561
len(index["docs"]) total_secs = len(index["secs"]) out = [] add = out.append add('<!DOCTYPE html>') add('<html lang="en">') add('<head>') add('<meta charset="utf-8">') add('<meta name="viewport" content="width=device-width, - line 561
initial-scale=1">') add('<title>Search index · VeilVoice (no JavaScript)</title>') add('<meta name="description" content="A complete static index of every ' 'file and section in VeilVoice. No JavaScript required.">') # Markup, not - line 561
script, so it works in this edition exactly as in the other. add('<link rel="prefetch" href="index.html">') - line 601
add('<link rel="prefetch" href="../index.html">') add('<style>') add(':root{--bg:#1a1b26;--fg:#c0caf5;--muted:#737aa2;--accent:#7aa2f7;' '--accent-2:#bb9af7;--border:#414868;--bg-inset:#16161e;color-scheme:dark}') - line 601
add('*{box-sizing:border-box}') add('body{background:var(--bg);color:var(--fg);margin:0;padding:20px;' 'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;' 'font-size:14px;line-height:1.65}') - line 601
add('main{max-width:900px;margin:0 auto}') add('a{color:var(--accent)}') add('h1{font-size:24px;margin:8px 0 4px;letter-spacing:.04em}') add('h2{font-size:16px;color:var(--accent);margin:30px 0 8px;' 'border-bottom:1px solid - line 601
var(--border);padding-bottom:6px}') add('p.lead{color:var(--muted);margin:6px 0 18px}') add('details{border:1px solid var(--border);border-radius:8px;' 'background:var(--bg-inset);margin:8px 0;padding:6px 12px}') - line 601
add('summary{cursor:pointer;padding:4px 0}') add('summary code{color:var(--fg)}') add('code,summary{overflow-wrap:anywhere}') add('.meta{color:var(--muted);font-size:12px}') add('ul{margin:6px 0 10px;padding-left:20px}') - line 601
add('ul.toc{columns:2;column-gap:24px}') add('ul.toc li{break-inside:avoid}') add('li{margin:4px 0}') # Same reason as `.x` below: a section name here is a Rust test # function, and `an_incomplete_directory_is_reported_and_not_completed` # - line 601
is 523 px of one word. add('.sec{color:var(--accent-2);overflow-wrap:anywhere}') # An excerpt is a line of source, and a line of source contains raw # URLs and regular expressions -- one is 1270 px of unbreakable # characters. Without - line 601
`anywhere` the static index scrolled sideways by # 555 px on a tablet and by more on a phone, measured with # `tools/render/probe.py overflow --page nojs/search.html`. `anywhere` # rather than `break-word` because this also has to shrink - line 601
the box's # intrinsic width, not merely break the line inside it. add('.x{color:var(--muted);overflow-wrap:anywhere}') add('nav.top{margin-bottom:14px}') add('nav.top a{margin-right:14px}') - line 601
add('.js-toggle{display:inline-flex;align-items:center;gap:7px;border:0;' 'color:var(--muted);font-size:13px;min-height:24px;white-space:nowrap}') - line 641
add('.js-toggle-track{position:relative;width:30px;height:16px;flex:none;' 'border:1px solid var(--border);border-radius:999px;' 'background:var(--bg-inset)}') add('.js-toggle-knob{position:absolute;top:2px;left:2px;width:10px;' - line 641
'height:10px;border-radius:50%;background:var(--muted)}') add('</style>') add('</head>') add('<body>') add('<main>') add('<nav class="top">') add('<a href="index.html">no-JavaScript edition</a>') add('<a class="js-toggle" - line 641
href="../index.html" role="switch" ' 'aria-checked="false" title="Switch to the full site, which runs ' 'scripts. This control changes no browser setting.">' '<span>JavaScript</span>' '<span class="js-toggle-track" aria-hidden="true">' - line 641
'<span class="js-toggle-knob"></span></span>' '<span class="js-toggle-state">off</span></a>') add('<a href="../search.html">live search</a>') add('<a href="https://github.com/%s">repository</a>' % esc(REPO)) add('</nav>') add('<h1>Search - line 641
index</h1>') add('<p class="lead">Every file and every section in VeilVoice, ' '%d files and %d sections, listed in full on this page. ' 'There is no JavaScript here and nothing to load: use your browser\'s ' 'own find-in-page (Ctrl+F, or - line 641
Cmd+F on a Mac) to search it. ' 'Section headings and their opening text are included, so searching for ' 'a term finds the place it is discussed, not just the file name.</p>' % (total_docs, total_secs)) add('<p class="lead">The <a - line 641
href="../search.html">live search</a> on the ' 'main site scores and ranks the same index, and adds sorting and ' 'filtering. It needs JavaScript. This page does not, and is generated ' 'from the same walk of the repository, so the two - line 641
never disagree.</p>') add('<p class="lead">Every entry is expanded rather than folded away, ' 'deliberately: text inside a collapsed section is not searchable by ' 'find-in-page on every browser, and an index that answers confidently ' - line 641
'with nothing would be worse than no index. That makes this a long ' 'page. The list below jumps to each part of it.</p>') add('<h2 id="contents">Contents</h2>') - line 681
add('<ul class="toc">') for kind, label in KIND_LABELS: entries = by_kind.get(kind) if not entries: continue add('<li><a href="#k-%s">%s</a> <span class="meta">%d files</span></li>' % (esc(kind), esc(label), len(entries))) add('</ul>') for - line 681
kind, label in KIND_LABELS: entries = by_kind.get(kind) if not entries: continue add('<h2 id="k-%s">%s <span class="meta">(%d)</span></h2>' % (esc(kind), esc(label), len(entries))) for number, doc in entries: sections = - line 681
secs_by_doc.get(number, []) # `open`, and not negotiable. # # The entire mechanism of this page is the reader's own # find-in-page. Content inside a *closed* `<details>` is only # searchable on engines that auto-expand it -- Chromium since - line 681
102, # and later still elsewhere -- so on an older Safari or Firefox a # collapsed index would answer every search with nothing while # looking perfectly fine. That is the precise failure mode this # project keeps finding in its own - line 681
website (F-30, F-31, F-33), and # it would be worse here because the page would be *confidently* # empty. Expanded costs height, which is free; collapsed costs # correctness on browsers a great many people run. add('<details open>') - line 681
add('<summary><code>%s</code> <span class="meta">· %s, %d lines,' ' %d sections</span></summary>' % (esc(doc["p"]), esc(doc["r"]), doc["n"], len(sections))) add('<p class="meta"><a href="%s">open %s</a></p>' % (esc(doc["u"]), - line 681
esc(doc["t"]))) if sections: add('<ul>') for section in sections: heading = section["h"] or ("line %d" % section["l"]) add('<li><span class="sec">%s</span> ' - line 721
'<span class="meta">line %d</span><br>' '<span class="x">%s</span></li>' % (esc(heading), section["l"], esc(section["x"]))) add('</ul>') add('</details>') add('</main>') add('</body>') add('</html>') return "\n".join(out) + "\n" def - line 721
finished(rel, text): """A page with the addresses every page of this site carries. The static index is a page like any other, so it gets the same canonical address and preview tags. `tools/site/seo.py` owns them; this calls it rather than - line 721
holding a second copy. """ if not rel.endswith(".html"): return text return seo.finish(rel[len("website/"):], text) # --- writing and checking --------------------------------------------------- OUTPUTS = [ ("website/search-index.json", - line 721
render_json), ("website/nojs/search.html", render_static), ] def write(root): index = build(root) for rel, render in OUTPUTS: path = os.path.join(root, rel.replace("/", os.sep)) os.makedirs(os.path.dirname(path), exist_ok=True) # - line 721
newline="\n": the committed file must be identical on every platform, # or --check fails on Windows for a reason that has nothing to do with # the index. - line 761
with open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(finished(rel, render(index))) print(" wrote %s" % rel) print(" %d files, %d sections" % (len(index["docs"]), len(index["secs"]))) warn_about_untracked(root) - line 761
return 0 def check(root): index = build(root) problems = [] for rel, render in OUTPUTS: path = os.path.join(root, rel.replace("/", os.sep)) want = finished(rel, render(index)) try: with open(path, "r", encoding="utf-8", newline="") as - line 761
handle: got = handle.read().replace("\r\n", "\n") except OSError as exc: problems.append("%s: cannot read (%s)" % (rel, exc)) continue if got != want: problems.append("%s: differs from the generator output" % rel) if problems: for line in - line 761
problems: print(" MISMATCH %s" % line) print() print("Run 'python tools/search-index/generate.py' and commit the result.") warn_about_untracked(root) return 1 print(" search index matches the repository (%d files, %d sections)" % - line 761
(len(index["docs"]), len(index["secs"]))) return 0 def main(): root = repo_root() if "--check" in sys.argv: return check(root) return write(root) - line 801
if __name__ == "__main__": sys.exit(main())
tools/shots/attrs.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Make the width and height on each screenshot tag match the file. python tools/shots/attrs.py # correct them python tools/shots/attrs.py --check # fail if any disagrees # - line 1
What the attributes are for, and what happens when they are wrong `<img width height>` tells the browser the shape of a picture before its bytes arrive, so the page reserves the right amount of room and does not jump under the reader as - line 1
each one loads. That is the whole job, and it only works while the numbers are true. They were `1371x988` on every screenshot tag, hand-typed once, and the captures are no longer that size: eight are 1400x1000 and the group tab is taller - line 1
because its panel is. Wrong numbers are worse than none, because the browser reserves the wrong space and then jumps anyway. So they are read out of the files. Nothing here is typed twice. Pure standard library: the PNG header is - line 1
thirty-three bytes and the width and height are two of them. """ import io import os import re import struct import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) # Every page - line 1
that shows a capture, and where it looks for one. PAGES = ["website/index.html", "website/what.html", "website/guide.html"] TAG = re.compile( r'<img([^>]*?)src="(?P<src>[^"]*assets/screenshots/gui-[^"]+\.png)"([^>]*?)>') - line 41
def size_of(path): with open(path, "rb") as handle: head = handle.read(33) if head[:8] != b"\x89PNG\r\n\x1a\n": raise SystemExit("%s: not a PNG" % path) return struct.unpack(">II", head[16:24]) def fixed(html, page): """The page with every - line 41
screenshot's width and height matching its file.""" problems = [] def one(match): before, src, after = match.group(1), match.group("src"), match.group(3) # The page lives in `website/`, and so do the pictures it names. path = - line 41
os.path.join(ROOT, "website", src.replace("/", os.sep)) if not os.path.isfile(path): problems.append("%s: %s does not exist" % (page, src)) return match.group(0) width, height = size_of(path) whole = before + after was = - line 41
(re.search(r'width="(\d+)"', whole), re.search(r'height="(\d+)"', whole)) if was[0] and was[1] and (int(was[0].group(1)), int(was[1].group(1))) != (width, height): problems.append("%s: %s says %sx%s and is %dx%d" % (page, - line 41
os.path.basename(src), was[0].group(1), was[1].group(1), width, height)) fix = lambda text: re.sub( # noqa: E731 r'width="\d+"', 'width="%d"' % width, re.sub(r'height="\d+"', 'height="%d"' % height, text)) return '<img%ssrc="%s"%s>' % - line 41
(fix(before), src, fix(after)) return TAG.sub(one, html), problems def main(): check = "--check" in sys.argv problems = [] changed = 0 for page in PAGES: - line 81
path = os.path.join(ROOT, page.replace("/", os.sep)) if not os.path.isfile(path): continue with io.open(path, encoding="utf-8", newline="") as handle: html = handle.read() fresh, found = fixed(html.replace("\r\n", "\n"), page) - line 81
problems.extend(found) if not check and fresh != html.replace("\r\n", "\n"): with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(fresh) changed += 1 if check: if problems: for line in problems: print(" %s" % - line 81
line) print() print("Run: python tools/shots/attrs.py") return 1 print(" every screenshot tag matches the file it names") return 0 print(" corrected %d page(s)" % changed if changed else " every screenshot tag already matches its file") - line 81
return 0 if __name__ == "__main__": sys.exit(main())
tools/shots/crop.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Trim the black border the window capture leaves around each screenshot. python tools/shots/crop.py # trim, in place python tools/shots/crop.py --check # verify there is - line 1
nothing left to trim # What this is for `tools/shots/gui.ps1` captures the window's DWM frame, which is the right rectangle to ask for: `GetWindowRect` includes the invisible resize border and the drop shadow, and using it put a strip of - line 1
desktop down two edges of every picture. The frame is closer, and it is still not exact. Measured on the committed captures: **eleven columns of pure black down each side, one row along the top and two along the bottom.** Eleven pixels of - line 1
nothing is not a disaster and it is not nothing either. It is the difference between a picture that sits in a page and a picture that has a ragged black margin somebody has to look past, and on a rounded container it is the part that stops - line 1
the rounding from meeting the content. So the border comes off here rather than in the capture script. Two reasons. The capture runs on Windows, where the window is; this runs everywhere, including in CI, so the check that it stayed off - line 1
runs everywhere too. And the exact border depends on the display, the theme and the compositor, so a number typed into the capture script would be right on one machine. # Why this is a crop and not a filter Nothing is redrawn, recoloured - line 1
or resampled. Rows and columns are removed, and only ones that are **entirely a single opaque colour that is also the colour of the corner**, which is the shape a capture border has and is not the shape anything the application draws has. - line 1
The picture that remains is the same pixels the application drew, which is what a screenshot has to be for it to be worth committing at all. # It has to be safe to run twice Every generator in this repository is checked by running it again - line 1
and comparing, so this one has to reach a fixed point. It does: once the border is gone, the - line 41
first row is no longer uniform, so a second run removes nothing. `--check` is exactly that second run, and it fails if anything would still come off. A guard on top of that: nothing is cropped if it would take more than `MAX_TRIM` from a - line 41
side, or leave an image smaller than `MIN_SIZE`. A capture that went wrong should be looked at rather than quietly shaved down to a strip. # In plain words The pictures of the application had a thin black edge around them, left over from - line 41
how a window is photographed. This takes it off, and refuses to take off anything that is not obviously that edge. Pure standard library: a small PNG reader and writer, because adding an image library to this repository to remove eleven - line 41
pixels would be a poor trade. """ import argparse import os import struct import sys import zlib HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) # Where the captures live, and where - line 41
the website's copies have to match. SOURCES = ["assets/screenshots", "website/assets/screenshots"] # The most that may come off one side, and the smallest picture left behind. # A capture that needs more than this is a capture that went - line 41
wrong. MAX_TRIM = 64 MIN_SIZE = 200 # How dark every channel has to be for a uniform edge to count as capture # border rather than as something the application drew. # # This is load-bearing, and it was found by running the tool without - line 41
it. With # the black gone, the top row of the picture is the **title bar**, which is a # uniform blue all the way across, so a rule that trims any uniform edge - line 81
# happily ate nine rows of it and would have kept going on the next run. The # border a window capture leaves is the desktop showing through, and here that # is pure black; a title bar is not, a panel is not, and the application's own # - line 81
background is never uniform to the edge because it has a border and a header # in it. # # So: uniform, opaque, and dark on every channel. Sixteen rather than zero # because a compositor may hand back 1 or 2 rather than a clean 0, and a - line 81
rule # that misses the border on one machine is a rule that does nothing. DARKEST_BORDER = 16 # --- PNG --------------------------------------------------------------------- def read_png(path): """An 8-bit, non-interlaced PNG as (width, - line 81
height, channels, rows). Refuses anything else rather than guessing. Every file this reads is one written by the capture script or by this one. """ with open(path, "rb") as handle: data = handle.read() if data[:8] != b"\x89PNG\r\n\x1a\n": - line 81
raise SystemExit("%s: not a PNG" % path) pos = 8 idat = bytearray() width = height = channels = None while pos < len(data): length = struct.unpack(">I", data[pos:pos + 4])[0] kind = data[pos + 4:pos + 8] body = data[pos + 8:pos + 8 + - line 81
length] if kind == b"IHDR": width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body) if depth != 8 or interlace != 0: raise SystemExit("%s: only 8-bit non-interlaced PNGs are handled" % path) channels = {0: 1, 2: 3, - line 81
4: 2, 6: 4}.get(colour) if channels is None: raise SystemExit("%s: unsupported colour type %d" % (path, colour)) elif kind == b"IDAT": idat += body - line 121
pos += 12 + length raw = zlib.decompress(bytes(idat)) stride = width * channels rows = [] previous = bytearray(stride) at = 0 for _ in range(height): kind = raw[at] at += 1 line = bytearray(raw[at:at + stride]) at += stride # The five PNG - line 121
filters, in the order the specification numbers them. if kind == 1: for x in range(channels, stride): line[x] = (line[x] + line[x - channels]) & 255 elif kind == 2: for x in range(stride): line[x] = (line[x] + previous[x]) & 255 elif kind - line 121
== 3: for x in range(stride): left = line[x - channels] if x >= channels else 0 line[x] = (line[x] + ((left + previous[x]) >> 1)) & 255 elif kind == 4: for x in range(stride): left = line[x - channels] if x >= channels else 0 up = - line 121
previous[x] corner = previous[x - channels] if x >= channels else 0 guess = left + up - corner da, db, dc = abs(guess - left), abs(guess - up), abs(guess - corner) if da <= db and da <= dc: best = left elif db <= dc: best = up else: best = - line 121
corner line[x] = (line[x] + best) & 255 elif kind != 0: raise SystemExit("%s: unknown filter %d" % (path, kind)) rows.append(bytes(line)) - line 161
previous = line return width, height, channels, rows def write_png(path, channels, rows): """Write back, filter 0 on every line. Not the smallest possible file, and deliberately: a fixed filter means the bytes are a function of the pixels - line 161
alone, so `--check` compares images rather than compression choices. """ colour = {1: 0, 2: 4, 3: 2, 4: 6}[channels] width = len(rows[0]) // channels header = struct.pack(">IIBBBBB", width, len(rows), 8, colour, 0, 0, 0) def chunk(kind, - line 161
body): return (struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)) raw = bytearray() for line in rows: raw.append(0) raw += line blob = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + - line 161
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) with open(path, "wb") as handle: handle.write(blob) # --- the crop ---------------------------------------------------------------- def pixel(rows, channels, x, y): at = x - line 161
* channels return rows[y][at:at + channels] def border(width, height, channels, rows): """How many rows and columns of capture border are on each side. - line 201
A side is trimmed only while every pixel along it is the same colour as the corner, and that colour is opaque. Anything else is the application, and the application's own background is not uniform to the edge: it has a border, a header and - line 201
a panel in it. """ corner = pixel(rows, channels, 0, 0) if channels == 4 and corner[3] != 255: return 0, 0, 0, 0 if any(value > DARKEST_BORDER for value in corner[:3]): # Not a capture border. See DARKEST_BORDER: without this the tool # - line 201
trimmed the title bar, which is uniform and is not desktop. return 0, 0, 0, 0 def row_is_border(y): return all(pixel(rows, channels, x, y) == corner for x in range(width)) def column_is_border(x): return all(pixel(rows, channels, x, y) == - line 201
corner for y in range(height)) top = 0 while top < height and row_is_border(top): top += 1 bottom = 0 while bottom < height - top and row_is_border(height - 1 - bottom): bottom += 1 left = 0 while left < width and column_is_border(left): - line 201
left += 1 right = 0 while right < width - left and column_is_border(width - 1 - right): right += 1 return top, bottom, left, right def cropped(path): """The rows this picture should have, and what came off. `None` if nothing.""" width, - line 201
height, channels, rows = read_png(path) top, bottom, left, right = border(width, height, channels, rows) if not (top or bottom or left or right): - line 241
return None if max(top, bottom, left, right) > MAX_TRIM: raise SystemExit( "%s: would trim %d pixels from one side, which is more than %d.\n" " That is not a capture border. Look at the picture rather than\n" " letting this shave it down." - line 241
% (path, max(top, bottom, left, right), MAX_TRIM)) new_width = width - left - right new_height = height - top - bottom if new_width < MIN_SIZE or new_height < MIN_SIZE: raise SystemExit( "%s: cropping would leave %dx%d, which is smaller - line 241
than %d on a side." % (path, new_width, new_height, MIN_SIZE)) kept = [row[left * channels:(width - right) * channels] for row in rows[top:height - bottom]] return channels, kept, (top, bottom, left, right), (width, height), (new_width, - line 241
new_height) def shots(): """Every capture, in a fixed order.""" found = [] for folder in SOURCES: base = os.path.join(ROOT, folder.replace("/", os.sep)) if not os.path.isdir(base): continue for name in sorted(os.listdir(base)): if - line 241
name.endswith(".png"): found.append(os.path.join(base, name)) return found def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", action="store_true", help="fail if any picture still - line 241
has a border to trim") args = parser.parse_args() pending = [] for path in shots(): result = cropped(path) if result is None: - line 281
continue channels, rows, trim, was, now = result rel = os.path.relpath(path, ROOT).replace(os.sep, "/") pending.append((path, channels, rows, trim, was, now, rel)) if args.check: if pending: for _, _, _, trim, was, now, rel in pending: - line 281
print(" %s still has a border: %dx%d would become %dx%d " "(top %d, bottom %d, left %d, right %d)" % (rel, was[0], was[1], now[0], now[1], *trim)) print("\n Run: python tools/shots/crop.py") return 1 print("no screenshot has a capture - line 281
border left on it") return 0 if not pending: print("nothing to trim") return 0 for path, channels, rows, trim, was, now, rel in pending: write_png(path, channels, rows) print(" %-46s %dx%d -> %dx%d" % (rel, was[0], was[1], now[0], now[1])) - line 281
print("trimmed %d screenshot(s)" % len(pending)) return 0 if __name__ == "__main__": sys.exit(main())
tools/shots/fit.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Make every window capture the same height: the tallest one's content. python tools/shots/fit.py # fit, in place python tools/shots/fit.py --check # verify every picture - line 1
is that height # One height for all of them, and which fault that chooses A window capture is wrong if it cuts a sentence in half at the bottom edge, and it is wrong if two thirds of it are empty background. The tabs make both happen at - line 1
once: the monitor tab draws a couple of hundred pixels of content and the group tab draws well over a thousand. This used to trim each picture to its own content, with a floor. Nothing was cut off and nothing was padding, and it produced - line 1
eight pictures 1000 tall, one 1095 and one 1315. **That is wrong for where they are actually shown.** The README and the website put them in a grid, and a grid with three heights in it steps: the row holding the group tab sits lower than - line 1
the rows either side, and the eye reads the inconsistency as the pictures being wrong rather than the panels being different lengths. So one height, and it is **the tallest content among them**. That is the only shared height that crops - line 1
nothing: * trimming everything to the shortest would cut the bottom off the group panel, which is publishing the first fault on purpose; * scaling them to match would make the text in one picture a different size from the text in the next, - line 1
which is worse than either fault; * padding the short ones with their own background is the remaining option, and the cost is real and is exactly this: the monitor tab has empty space below its content now. That cost is paid in the one - line 1
place it is cheap. Empty background at the bottom of a picture in a grid reads as the window having room; a stepped grid reads as a mistake. # Measured, not written down - line 41
No table of per-tab heights, and no constant naming the answer. The tallest content is measured across the whole set on every run, so a panel that grows a paragraph moves every picture together rather than becoming the one exception again. - line 41
`--check` proves the committed pictures are what this would produce. # What is read, and what is ignored The background colour is taken from the bottom-left corner, inside the padding, where no tab draws. Only the red, green and blue are - line 41
compared: `round.py` runs after this and writes transparency into the four corners without touching their colour, so a rounded picture still reports its own background correctly and this stays idempotent across runs. The rightmost columns - line 41
are ignored. The scroll bar runs the full height of the panel, so a sweep that included it would report every tab as full to the bottom and would measure nothing at all. Pure standard library, and a crop rather than a filter: rows are - line 41
removed, and no pixel that survives is altered. """ import argparse import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from crop import read_png, write_png, shots as all_shots # noqa: E402 HERE = - line 41
os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) # No picture is shorter than this, even if every tab's content fits in less. It # is the height the capture scripts ask the window to open - line 41
at, so it is the size # of the thing being photographed, and a picture of a window has no business # being shorter than the window. # # It is a floor rather than the answer: the answer is the tallest content in the # set, and that is - line 41
nearly always well above this. - line 81
FLOOR = 1000 # Kept below the content so the picture does not end flush against the last # line of text. PADDING = 24 # The scroll bar lives in the last of these columns and runs the whole height # of the panel. IGNORE_RIGHT = 40 # A - line 81
capture is 8-bit and the background is flat, so anything the application # drew differs by far more than this. Loose enough to survive the one-level # differences software rendering produces. TOLERANCE = 8 def content_bottom(width, height, - line 81
channels, rows): """The last row that has anything on it, and the background it stands on.""" # Bottom-left, inside the padding: no tab draws there, and `round.py` only # changes alpha, so the colour is the background whether or not the # - line 81
corners have been rounded already. edge = rows[height - 2] background = (edge[0], edge[1], edge[2]) last = 0 limit = max(1, width - IGNORE_RIGHT) for y in range(height): row = rows[y] for x in range(limit): at = x * channels if - line 81
(abs(row[at] - background[0]) > TOLERANCE or abs(row[at + 1] - background[1]) > TOLERANCE or abs(row[at + 2] - background[2]) > TOLERANCE): last = y break return last def wanted_height(path): """How tall this picture needs to be to hold - line 81
its own content.""" - line 121
width, height, channels, rows = read_png(path) bottom = content_bottom(width, height, channels, rows) return max(FLOOR, bottom + 1 + PADDING) def shared_height(paths): """The one height every picture is given: the tallest content among - line 121
them. Measured across the set rather than written down, so a panel that grows moves all of them together instead of becoming the one that sticks out. """ return max((wanted_height(path) for path in paths), default=FLOOR) def fitted(path, - line 121
target): """`(channels, rows, was, now)` if this picture is not `target` tall. Taller is trimmed. Shorter is **padded with its own background**, which is the cost this tool now pays on purpose: see the note at the top for why a stepped - line 121
grid is the worse of the two faults. """ width, height, channels, rows = read_png(path) if height == target: return None if height > target: return channels, rows[:target], (width, height), (width, target) # Padded with the row the - line 121
background was read from, so the added space is # the same colour as the space above it, including on a light palette. # Copied rather than synthesised: a row of the picture's own pixels cannot # be the wrong colour, and `round.py` runs - line 121
afterwards and only touches # alpha in the corners. # `bytearray`, matching what `read_png` hands back and what `write_png` # will accept: a plain list of ints looks equivalent and fails on the way # out, in a function two files away. - line 121
filler = bytes(rows[height - 2]) grown = list(rows) + [bytearray(filler) for _ in range(target - height)] return channels, grown, (width, height), (width, target) - line 161
def captures(): """The window captures, and only those. The same restriction `round.py` makes, for the same reason: "the empty part of this is background below the content" is true of a picture of a window and is not true of a diagram, - line 161
which would be quietly cut instead. """ return [path for path in all_shots() if os.path.basename(path).startswith("gui-")] def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", - line 161
action="store_true", help="fail if any capture still has empty space below " "its content") args = parser.parse_args() paths = captures() if not paths: print(" no window captures to fit") return 0 target = shared_height(paths) pending = [] - line 161
for path in paths: result = fitted(path, target) if result is None: continue channels, rows, was, now = result rel = os.path.relpath(path, ROOT).replace(os.sep, "/") pending.append((path, channels, rows, was, now, rel)) if args.check: if - line 161
pending: for _, _, _, was, now, rel in pending: print(" %s is %dx%d and every capture should be %dx%d" % (rel, was[0], was[1], *now)) print("\n Run: python tools/shots/fit.py") return 1 - line 201
print(" all %d captures are %d tall, which is the tallest one's content" % (len(paths), target)) return 0 if not pending: print(" all %d captures are already %d tall" % (len(paths), target)) return 0 for path, channels, rows, was, now, rel - line 201
in pending: write_png(path, channels, rows) print(" %-46s %dx%d -> %dx%d" % (rel, was[0], was[1], *now)) print(" fitted %d capture(s) to %d tall" % (len(pending), target)) return 0 if __name__ == "__main__": sys.exit(main())
tools/shots/gui.ps1
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Photograph every tab of the desktop application. # # powershell -ExecutionPolicy Bypass -File tools/shots/gui.ps1 # # Why this looks the way it does # ------------------------------ # # Three - line 1
earlier versions of this script drove the interface by clicking, and # each failed differently: # # 1. **Hard-coded tab coordinates.** They went stale the first time a tab was # inserted. Every click still landed on *a* tab, so every - line 1
capture differed # and nothing noticed; three tabs were published under the wrong names. # 2. **Finding the tabs by scanning for lit columns.** Better, and it caught # its own failure loudly, but it depends on the gaps between labels being - line 1
# wider than the gaps inside them. Capitalising the labels closed the space # between the first two and the scan merged them into one. # 3. **Clicking at all.** Synthetic mouse input needs the window in the # foreground, and Windows - line 1
refuses to give the foreground to a process that # does not already hold it. `SetForegroundWindow` reports that refusal by # returning false, which nothing was reading, so the click went nowhere and # whichever tab was already open got - line 1
photographed under nine names. # # So this does not click. `veilvoice-gui --tab <name>` opens the window on a # tab, and the application is started once per tab. There are no coordinates, # no scanning, no focus and no input. # # Capture - line 1
is `PrintWindow` with PW_RENDERFULLCONTENT, which asks the window to # draw itself into a bitmap. It needs neither focus nor visibility, so nothing # in front of it matters -- which is the other half of the same problem, and # the reason - line 1
the earlier versions quietly photographed the desktop wallpaper. # # The window is sized rather than maximised: see the note beside SetWindowPos. param( [string]$Exe = "$env:CARGO_TARGET_DIR\release\veilvoice-gui.exe", [string]$Out = - line 1
"assets\screenshots" ) - line 41
Add-Type -AssemblyName System.Drawing Add-Type @" using System; using System.Text; using System.Runtime.InteropServices; public class Shot { public delegate bool EnumProc(IntPtr h, IntPtr l); [DllImport("user32.dll")] public static extern - line 41
bool EnumWindows(EnumProc p, IntPtr l); [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); - line 41
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); [DllImport("user32.dll")] public static - line 41
extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int t, uint flags); [DllImport("dwmapi.dll")] public static extern int - line 41
DwmGetWindowAttribute(IntPtr h, int a, out RECT r, int size); [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; - line 41
} public static uint Want = 0; public static IntPtr Found = IntPtr.Zero; public static bool Check(IntPtr h, IntPtr l) { if (!IsWindowVisible(h)) return true; uint pid; GetWindowThreadProcessId(h, out pid); if (pid != Want) return true; - line 41
StringBuilder sb = new StringBuilder(300); GetWindowText(h, sb, 300); if (sb.ToString() == "VeilVoice") { Found = h; return false; } return true; } // The bounds a person sees. GetWindowRect includes the invisible resize // border and the - line 41
drop shadow, which put a strip of desktop down two edges of // every capture until this was used instead. public static RECT Frame(IntPtr h) { RECT r; if (DwmGetWindowAttribute(h, 9, out r, Marshal.SizeOf(typeof(RECT))) == 0) return r; - line 41
return new RECT(); } } - line 81
"@ # Without this, CopyFromScreen and the window rectangles disagree on any display # that is not at 100%, and every capture is cropped to the top-left corner. try { [Shot]::SetProcessDpiAwareness(2) | Out-Null } catch {} if (-not - line 81
$env:CARGO_TARGET_DIR) { $Exe = "target\release\veilvoice-gui.exe" } if (-not (Test-Path $Exe)) { Write-Error "no build at $Exe -- run: cargo build --release -p veilvoice-gui" exit 1 } New-Item -ItemType Directory -Force $Out | Out-Null # - line 81
The tabs, by the names the application answers to. These are # `Tab::key` in crates/veilvoice-gui/src/app.rs, and a test in that file keeps # them unique and stable, because each one is also a file name the README links. # The tab names, - line 81
asked of the application rather than written down here. A # hand-written copy of another list goes stale the first time a tab is added, # and it does it silently: the run succeeds and the new tab has no picture. $tabs = & $exe --tabs if - line 81
(-not $tabs) { throw "the application listed no tabs, so there is nothing to photograph" } # Group mode is off by default, which is correct and makes for a picture of an # empty panel. It is turned on for these captures through the - line 81
application's own # preference, set before the window opens and put back afterwards. $settings = Join-Path $env:APPDATA "veilvoice\settings.conf" $saved = $null if (Test-Path $settings) { $saved = Get-Content -Raw $settings } # # - line 81
`animations` is stilled for the same kind of reason: the mark in the header # animates, so it is a different shape in every photograph and two captures of # one tab differ for no reason anybody wants. It is a setting the application # - line 81
already has. $wanted = @{ "always_group" = "true"; "animations" = "false"; "animated_icon" = "false" } $forced = if ($saved) { $saved } else { "configured = true`n" } foreach ($key in $wanted.Keys) { - line 121
if ($forced -match "$key\s*=") { $forced = $forced -replace "$key\s*=.*", "$key = $($wanted[$key])" } else { $forced = $forced.TrimEnd() + "`n$key = $($wanted[$key])`n" } } New-Item -ItemType Directory -Force (Split-Path $settings) | - line 121
Out-Null Set-Content -Path $settings -Value $forced -Encoding utf8 $PW_RENDERFULLCONTENT = 2 $problems = @() $prints = @{} foreach ($tab in $tabs) { $proc = Start-Process -FilePath $Exe -ArgumentList "--tab", $tab -PassThru # Wait for this - line 121
process's own window, by process id rather than by title # alone: another VeilVoice left open would otherwise be photographed instead. [Shot]::Want = [uint32]$proc.Id [Shot]::Found = [IntPtr]::Zero $h = [IntPtr]::Zero for ($i = 0; $i -lt - line 121
60 -and $h -eq [IntPtr]::Zero; $i++) { Start-Sleep -Milliseconds 400 [Shot]::Found = [IntPtr]::Zero [void][Shot]::EnumWindows([Shot+EnumProc]{ param($a, $b) [Shot]::Check($a, $b) }, [IntPtr]::Zero) $h = [Shot]::Found } if ($h -eq - line 121
[IntPtr]::Zero) { $problems += "$tab : the window never appeared" $proc | Stop-Process -Force -ErrorAction SilentlyContinue continue } # A fitted size rather than full screen. # # Maximising on a 4K display gave 3840x2088 captures whose - line 121
text was # unreadable at any size a page shows them, with wide empty margins down # either side where the layout had nothing to put. # # Tall rather than square, and taller than any tab needs: `tools/shots/ - line 161
# fit.py` trims each picture back to what it actually contains, so a window # bigger than the longest panel costs nothing and a window smaller than it # cuts a sentence in half. 1000 was that smaller window: the group panel is # 1288 - line 161
pixels of content and was published with its bottom third missing. [void][Shot]::SetWindowPos($h, [IntPtr]::Zero, 40, 40, 1400, 1900, 0x0004) # Long enough for the layout to settle and the first frames to be drawn. Start-Sleep - line 161
-Milliseconds 2500 $r = [Shot]::Frame($h) $w = $r.Right - $r.Left $hh = $r.Bottom - $r.Top if ($w -le 100 -or $hh -le 100) { $problems += "$tab : the window measured ${w}x${hh}" $proc | Stop-Process -Force -ErrorAction SilentlyContinue - line 161
continue } $bmp = New-Object System.Drawing.Bitmap $w, $hh $g = [System.Drawing.Graphics]::FromImage($bmp) $dc = $g.GetHdc() $drew = [Shot]::PrintWindow($h, $dc, $PW_RENDERFULLCONTENT) $g.ReleaseHdc($dc) $g.Dispose() if (-not $drew) { - line 161
$problems += "$tab : PrintWindow refused" $bmp.Dispose() $proc | Stop-Process -Force -ErrorAction SilentlyContinue continue } $path = Join-Path $Out "gui-$tab.png" $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) # A cheap - line 161
fingerprint, so two tabs coming out identical is caught rather than # published. That is the failure the coordinate versions of this script kept # producing, and it is invisible in a directory listing. $sb = New-Object - line 161
System.Text.StringBuilder for ($y = 60; $y -lt [Math]::Min(600, $bmp.Height); $y += 17) { for ($x = 20; $x -lt [Math]::Min(900, $bmp.Width); $x += 19) { - line 201
$c = $bmp.GetPixel($x, $y) [void]$sb.Append(($c.R -band 0xF0)); [void]$sb.Append(($c.B -band 0xF0)) } } $print = $sb.ToString() if ($prints.ContainsKey($print)) { $problems += "$tab : identical to $($prints[$print]) -- the tab did not - line 201
change" } else { $prints[$print] = $tab } $bmp.Dispose() Write-Output ("wrote gui-{0}.png ({1}x{2})" -f $tab, $w, $hh) $proc | Stop-Process -Force -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 300 } # The reader's own settings - line 201
back, exactly as they were. A screenshot script that # leaves a preference changed is one that edits somebody's configuration to take # a picture. if ($null -ne $saved) { Set-Content -Path $settings -Value $saved -Encoding utf8 } elseif - line 201
(Test-Path $settings) { Remove-Item $settings -Force } if ($problems.Count -gt 0) { Write-Output "" foreach ($p in $problems) { Write-Output " PROBLEM $p" } exit 1 } Write-Output "" Write-Output ("{0} captures in {1}" -f $tabs.Count, - line 201
(Resolve-Path $Out))
tools/shots/gui.sh
- line 1
#!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-or-later # # Photograph every tab of the desktop application, on Linux, with no display. # # tools/shots/gui.sh # # The counterpart to `gui.ps1`, which does the same job on Windows. - line 1
Both exist # because the captures have to be reproducible by whoever is holding the # repository, and until this script there was exactly one machine in the world # that could take them. # # What this does, and what it deliberately does - line 1
not do # ---------------------------------------------------- # # It does not click, for the same reason `gui.ps1` does not: three earlier # versions of that script drove the interface by clicking and each failed by # photographing the - line 1
wrong tab under the right name. `veilvoice-gui --tab <name>` # opens the window on a tab, and the application is started once per tab. There # are no coordinates, no scanning and no synthetic input. # # There is no window manager. That is - line 1
a choice rather than a limitation: # without one the window is mapped at the origin at exactly the size it asks # for, so the X root window *is* the application window, pixel for pixel. The # capture needs no cropping, cannot include a - line 1
strip of desktop down one edge, # and comes out the same size for every tab by construction rather than by # arithmetic afterwards. The Windows script has to ask the desktop compositor # for the window's real bounds to get the same result, - line 1
because there a window # manager owns the frame. # # The screen is the window size for the same reason. # # Nothing here touches the reader's own configuration. `gui.ps1` has to save # and restore `%APPDATA%\veilvoice\settings.conf`, - line 1
because that is where the # application looks on Windows. On Linux it looks under `XDG_CONFIG_HOME`, # which this points at a temporary directory, so the settings the captures need # exist only for as long as the captures take and the - line 1
reader's own are never # opened, let alone written. # # Requirements: Xvfb and xwd, and a release build of veilvoice-gui. - line 41
set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" exe="${VEILVOICE_GUI:-$here/target/release/veilvoice-gui}" out="${1:-$here/assets/screenshots}" # The size the captures are taken at. # # 1100x720 is what the - line 41
window opens at, and it is too small for this: the tab # strip does not fit and the longest tab is cut off partway down, so the # picture shows a panel with its bottom missing. 1400 across is the same width # `gui.ps1` uses, so the two - line 41
scripts produce comparable images. # # The height is 1600 and that is not the height of the finished pictures. # # It used to be 1000, which is the height the window is meant to be, and two # tabs are taller than that: `group` needs 1315 - line 41
and `install` needs 1095. Those # two came out with their bottoms cut off, and the committed pictures were the # right size only because whoever ran this last happened to know to pass a # bigger height. A tool that produces correct output - line 41
only for somebody who # already knows the answer is not a tool. # # So it captures tall enough for the longest tab and `fit.py` trims each one # back to its own content afterwards, with a floor of 1000 so the short ones # stay the size the - line 41
window actually is. Every tab gets a picture that fits it, # by measurement rather than by memory, and nothing here needs a per-tab table # that would go stale the next time a panel grows a paragraph. width="${SHOT_WIDTH:-1400}" - line 41
height="${SHOT_HEIGHT:-1600}" # The size every finished picture is, after `fit.py`. # # It used to be each tab's own content height, with a floor of 1000. That is # right for one picture and wrong for ten: the README and the website show - line 41
them # in a grid, and a grid of 1000s with a 1315 and a 1095 in it steps, so the row # containing `group` sits lower than the rows either side of it. # # So they are all the tallest, which is the only shared height that crops # nothing. - line 41
Trimming every picture to the shortest would cut the bottom off - line 81
# `group`, and scaling them to match would make the text in one picture a # different size from the text in the next. # # 1315 is `group`, measured. It is passed to `fit.py` rather than written into # it, and the check in `images.test.js` - line 81
reads the pictures rather than this # number, so a tab that grows past it fails the build instead of being cropped. shared_height="${SHOT_SHARED_HEIGHT:-1315}" # JetBrains Mono, or nothing. # # The window prefers it and falls back to - line 81
egui's own monospace when it is # absent. That fallback is right for somebody running the program and wrong # here: a capture taken with the built-in face looks subtly unlike every other # picture in the set, and **nothing about the run - line 81
says so**. Half a set of # screenshots in the wrong face is the kind of thing noticed on the website # weeks later. # # So it is asked first, and refused rather than fallen back on. The answer comes # from the program itself, through - line 81
`--typeface`, rather than from `fc-list`: # what matters is which face *this binary* would load, and the two can disagree # (a font installed somewhere the program does not look is on `fc-list` and is # not on its list of paths). if [ -x - line 81
"$exe" ]; then face="$("$exe" --typeface 2>/dev/null || true)" case "$face" in "JetBrains Mono"*) echo "typeface: ${face}" ;; *) cat >&2 <<'WHY' JetBrains Mono is not installed where the application looks for it, so these captures would be - line 81
taken in the built-in monospace face and would not match the ones already committed. Install it and run this again: Debian, Ubuntu sudo apt-get install fonts-jetbrains-mono Fedora sudo dnf install jetbrains-mono-fonts Arch sudo pacman -S - line 81
ttf-jetbrains-mono macOS brew install --cask font-jetbrains-mono - line 121
Windows winget install --id JetBrains.JetBrainsMono `veilvoice-gui --typeface` says which face the window would draw with, and is what this script just asked. WHY exit 1 ;; esac fi for tool in Xvfb xwd; do command -v "$tool" >/dev/null - line 121
2>&1 || { echo "missing $tool -- install xvfb and x11-apps" >&2 exit 1 } done [ -x "$exe" ] || { echo "no build at $exe -- run: cargo build --release -p veilvoice-gui" >&2 exit 1 } mkdir -p "$out" work="$(mktemp -d)" display=":$(( 90 + - line 121
RANDOM % 9 ))" trap 'kill "${xvfb:-}" 2>/dev/null || true; rm -rf "$work"' EXIT # `-nocursor` is the whole of the no-mouse-pointer guarantee on this side. # The Windows script gets it from `PrintWindow`, which asks the window to # draw - line 121
itself and so cannot include anything the compositor draws on top of # it. Here the capture is of the root window, which would include a pointer # if one were drawn, so the server is told to draw none at all. Both are # properties of how - line 121
the capture is taken rather than a hope about where the # mouse was, and `images.test.js` checks each script for its own. Xvfb "$display" -screen 0 "${width}x${height}x24" -nocursor -nolisten tcp >/dev/null 2>&1 & xvfb=$! for _ in $(seq - line 121
40); do DISPLAY="$display" xwd -root -silent >/dev/null 2>&1 && break sleep 0.25 done - line 161
# The configuration these pictures are taken under. It exists only for this # run, in a directory made a moment ago, so nothing here reads or writes the # settings of whoever is running it -- and, just as importantly, no policy # they have - line 161
set applies: policies live beside this file, under the same # `XDG_CONFIG_HOME`, and this one is empty. The captures are therefore of the # application as it is, not as one machine has been told to restrict it. # # configured skips the - line 161
first-run choice, which is a dialog over the # window rather than a tab, and would be photographed # instead of the tab that was asked for. # always_group group mode is off by default, which is correct and makes # for a picture of an empty - line 161
panel. # animations the mark in the header animates, so it is a different # shape in every photograph and any two captures of the same # tab differ. Stilled here, which is a setting the # application already has for people who want it. # - line 161
animated_icon the same, for the window icon. # toured_tabs every tab, so the first-run tour does not open over the # panel being photographed. Built from the same `--tabs` # list, so a tab added tomorrow is covered without anybody # - line 161
remembering to add it: a hand-written list here would # leave the tour open on exactly the new tab whose picture # was the reason for running this. # The tab names, asked of the application rather than written down here. # # This was a - line 161
hand-written list, and a hand-written list of another list is a # copy: it went stale the first time a tab was added, and it did it silently. # The run succeeded, every picture it took was correct, and the new tab simply # had none. - line 161
`--tabs` prints `Tab::ALL`, so there is one list and this reads it. mapfile -t tabs < <("$exe" --tabs) if [ "${#tabs[@]}" -eq 0 ]; then echo "the application listed no tabs, so there is nothing to photograph" >&2 exit 1 fi # Every tab, - line 161
comma separated, for `toured_tabs` below. toured="$(IFS=,; echo "${tabs[*]}")" mkdir -p "$work/config/veilvoice" cat > "$work/config/veilvoice/settings.conf" <<CONF - line 201
configured = true always_group = true animations = false animated_icon = false toured_tabs = $toured CONF problems=() declare -A prints=() for tab in "${tabs[@]}"; do DISPLAY="$display" XDG_CONFIG_HOME="$work/config" - line 201
LIBGL_ALWAYS_SOFTWARE=1 \ "$exe" --tab "$tab" --size "${width}x${height}" >"$work/$tab.log" 2>&1 & gui=$! # Long enough for the window to appear, the layout to settle and the first # frames to be drawn. Software rendering, so this is not - line 201
quick. sleep 14 if ! kill -0 "$gui" 2>/dev/null; then problems+=("$tab : the application exited before it was photographed") continue fi if ! DISPLAY="$display" xwd -root -silent > "$work/$tab.xwd" 2>/dev/null; then problems+=("$tab : xwd - line 201
could not read the screen") kill "$gui" 2>/dev/null || true wait "$gui" 2>/dev/null || true continue fi kill "$gui" 2>/dev/null || true wait "$gui" 2>/dev/null || true if ! python3 "$here/tools/shots/xwd.py" "$work/$tab.xwd" - line 201
"$out/gui-$tab.png"; then problems+=("$tab : the capture could not be converted") continue fi # A cheap fingerprint, so two tabs coming out identical is caught rather than - line 241
# published. That is the failure the clicking versions of the Windows script # kept producing, and it is invisible in a directory listing. print="$(python3 "$here/tools/shots/xwd.py" --fingerprint "$out/gui-$tab.png")" if [ -n - line 241
"${prints[$print]:-}" ]; then problems+=("$tab : identical to ${prints[$print]} -- the tab did not change") else prints[$print]="$tab" fi echo "wrote gui-$tab.png (${width}x${height})" done if [ "${#problems[@]}" -gt 0 ]; then echo for p - line 241
in "${problems[@]}"; do echo " PROBLEM $p"; done exit 1 fi echo echo "${#tabs[@]} captures in $out"
tools/shots/round.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Round the corners of every window capture, in the file rather than in CSS. python tools/shots/round.py # round, in place python tools/shots/round.py --check # verify - line 1
every corner is rounded # Why the corners are rounded in the picture The application draws a rounded window and the capture is a rectangle, so every committed screenshot had four square corners with a wedge of window chrome in each. On a - line 1
page that gives them a boxed-in look the running program does not have. CSS could round them on the website with one `border-radius`, and that is where it would belong if the website were the only reader. It is not: the README is rendered - line 1
by GitHub, which strips styles from images, and the release archives carry the same files. A picture that is only round in one of the three places it appears is not rounded, so the alpha channel carries it and every reader gets the same - line 1
thing. # What it does to the pixels Only the corners, and only their alpha. A pixel wholly outside the radius becomes transparent, a pixel wholly inside is untouched, and one the arc crosses is given partial alpha from how much of it the - line 1
arc covers, sampled on a 4x4 grid. Nothing is recoloured, nothing is moved, and nothing is resampled: the picture is the same pixels the application drew, minus some corner. That matters more than it sounds. `tools/shots/crop.py` says a - line 1
screenshot has to be the pixels the application drew for it to be worth committing, and this is the same argument. Antialiasing the arc changes coverage, not colour. # Transparent, not filled with the page colour Filling the corners with - line 1
the website's background would be a picture that only looks right on one theme, and the website has nine. Transparency is the only answer that is correct on all of them, on GitHub's light and dark renderings, and on whatever a reader has - line 1
set. - line 41
# It has to be safe to run twice Every generator here is checked by running it again and comparing, so this has to reach a fixed point. It does: rounding an already-rounded corner computes the same alpha from the same geometry and writes - line 41
the same bytes. `--check` re-derives the corners and fails if any pixel differs, which also catches a capture that was replaced without being rounded. Pure standard library. `zlib` and `struct`, the same as everything else that touches an - line 41
image here. """ from __future__ import annotations import argparse import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) sys.path.insert(0, HERE) from crop import read_png, - line 41
write_png, shots as all_shots # noqa: E402 # The radius, in pixels, at the size these are captured. # # 14 is what the application asks its own window for. Matching it means the # picture has the shape the program has, rather than a - line 41
rounding somebody liked # the look of. RADIUS = 14 # How finely the arc is sampled inside one pixel. 4x4 is sixteen samples and # seventeen possible alphas, which is past the point anyone can see a step on a # 14-pixel arc, and it keeps - line 41
the arithmetic in integers. SAMPLES = 4 def coverage(px, py, radius): """How much of pixel (px, py) lies inside the rounded corner, 0.0 to 1.0. - line 81
Coordinates are relative to the corner, with the arc centred at (radius, radius). Sampled rather than integrated: the exact area of a circle clipped to a square is a closed form nobody should have to read in a screenshot tool, and sixteen - line 81
samples is indistinguishable at this size. """ inside = 0 for sy in range(SAMPLES): for sx in range(SAMPLES): x = px + (sx + 0.5) / SAMPLES y = py + (sy + 0.5) / SAMPLES dx = radius - x dy = radius - y if dx <= 0 or dy <= 0: # Past the - line 81
centre on either axis: this pixel is in the straight # part of the edge, which the arc does not cut. inside += 1 continue if dx * dx + dy * dy <= radius * radius: inside += 1 return inside / float(SAMPLES * SAMPLES) def - line 81
with_alpha(channels, rows): """The same picture as RGBA rows, whatever it arrived as.""" if channels == 4: return [bytearray(row) for row in rows] if channels == 3: out = [] for row in rows: line = bytearray() for x in range(0, len(row), - line 81
3): line += row[x:x + 3] + b"\xff" out.append(line) return out raise SystemExit( "only RGB and RGBA captures are rounded; this one has %d channels" % channels) def rounded(path): - line 121
"""The RGBA rows this picture should have once its corners are rounded.""" width, height, channels, rows = read_png(path) if width < RADIUS * 2 or height < RADIUS * 2: raise SystemExit( "%s is %dx%d, which is too small for a %d-pixel - line 121
radius" % (path, width, height, RADIUS)) out = with_alpha(channels, rows) for cy in range(RADIUS): for cx in range(RADIUS): alpha = coverage(cx, cy, RADIUS) if alpha >= 1.0: continue value = int(alpha * 255 + 0.5) # The same corner, - line 121
reflected into all four. `min` guards the case # where a picture is barely wider than two radii and the corners # would otherwise overlap and fight. for x, y in ( (cx, cy), (width - 1 - cx, cy), (cx, height - 1 - cy), (width - 1 - cx, - line 121
height - 1 - cy), ): at = x * 4 + 3 out[y][at] = min(out[y][at], value) return [bytes(row) for row in out] def captures(): """The window captures, and only those. `crop.py` trims every PNG in the screenshot folders, which is right: a - line 121
capture border is a capture border whatever the picture is of. Rounding is not like that. It says "this is a picture of a rounded window", which is true of the `gui-*.png` captures and is not true of anything else that might be put beside - line 121
them. A diagram or a photograph rounded on the assumption it was a window would be quietly wrong, and nothing would say so, so the assumption is written down here instead of inherited. """ return [ - line 161
path for path in all_shots() if os.path.basename(path).startswith("gui-") ] def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", action="store_true", help="fail if any capture - line 161
still has square corners") args = parser.parse_args() pending = [] for path in captures(): want = rounded(path) _, _, channels, have = read_png(path) if channels != 4 or have != want: pending.append((path, want)) if args.check: if pending: - line 161
for path, _ in pending: print(" %s does not have its corners rounded" % os.path.relpath(path, ROOT).replace(os.sep, "/")) print("\n Run: python tools/shots/round.py") return 1 print("every window capture has rounded corners") return 0 if - line 161
not pending: print("nothing to round") return 0 for path, want in pending: write_png(path, 4, want) print(" rounded %s" % os.path.relpath(path, ROOT).replace(os.sep, "/")) print("\n rounded %d capture(s)" % len(pending)) return 0 if - line 161
__name__ == "__main__": raise SystemExit(main())
tools/shots/sessions.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Recordings of the programs actually doing something. tools/shots/sessions.py --record # run them and write the transcripts tools/shots/sessions.py --check # verify the - line 1
transcripts are current # Why this exists The website's demonstration replayed ten `--help` screens. Those are real, and they are the least interesting real thing the programs produce: a help screen tells a reader what the flags are called - line 1
and nothing about what happens when you use one. Somebody deciding whether to trust this needs to see it work. Everything here is a transcript of a real run. Not a plausible-looking imitation, not a designer's idea of what the output would - line 1
be: the bytes the program wrote, captured from a terminal, with the same passphrases typed at the same prompts a person would type them at. # Why a pty rather than a pipe The first attempt ran each command with its output piped, which is - line 1
how the help screens are captured, and three of the four sessions came out as refusals. `veilvoice keygen` and `veilvoice anonymise` ask for a passphrase and check whether there is a terminal to ask on; with a pipe there is not, so what - line 1
got recorded was the program correctly declining to run. That refusal is worth showing and it is one of the sessions below. It is not worth showing four times. So the recorder allocates a pseudo-terminal, which is what a person has, and - line 1
types at the prompts. # What cannot be the same twice A real run contains figures that are properties of the machine and the moment: how much faster than realtime it processed the audio, and the millisecond range the modulation seed rolls - line 1
at, which is drawn fresh each time on purpose. The committed transcript keeps the real ones, because it is a transcript. `--check` normalises those spans on both sides before comparing, so a re-run on a different machine does not fail for - line 1
being a different machine, and every other byte still has to match. - line 41
The list of what is normalised is short, explicit, and right here rather than in a comment somewhere: anything not on it must be identical. # The one that cannot be re-run offline The verifier session checks a published release, which - line 41
means the archive, the hash list and the signature have to exist and be downloaded. That is a maintainer step and it needs the network, so `--check` does not re-run it. That gives the release one ordering rule, and it is written here - line 41
because the check that enforces it cannot state it at the moment it fails. This transcript records the verifier checking a release that is **already published**, so it cannot be made until the release exists. The order is bump, tag, - line 41
publish, re-record, commit, and between the bump and the publication this check is expected to fail. `tools/verify.py` is the only thing that runs it, so a release commit still goes through CI green. Instead it holds the transcript to what - line 41
the repository can prove about it: the fingerprint in it must be the fingerprint of the signing key committed here, the version in it must be the workspace version, and it must contain the verdict lines the verifier is documented to print. - line 41
A stale or invented transcript fails all three. Pure standard library. """ from __future__ import annotations import argparse import difflib import io import os import pty import re import select import shutil import struct import - line 41
subprocess import sys import tempfile - line 81
import time import wave import math HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) OUT = os.path.join(ROOT, "assets", "screenshots") # The width a recorded terminal is. The same - line 81
figure the help-screen drawings # use, so the two sit beside each other without one of them wrapping oddly. COLUMNS = 88 # Spans that are a property of the machine or the moment rather than of the # program. Normalised on both sides before - line 81
`--check` compares; the committed # transcript keeps whatever the real run produced. VOLATILE = [ # "125.9x realtime" is how fast this processor happened to be. (re.compile(r"\d+(?:\.\d+)?x realtime"), "<speed>x realtime"), # "1371-1973 - line 81
ms" is drawn from the CSPRNG before every roll, on purpose: # a fixed period would be a period an observer could measure. (re.compile(r"\d+-\d+ ms, drawn fresh"), "<range> ms, drawn fresh"), # Argon2id timings, where a build prints them. - line 81
(re.compile(r"in \d+(?:\.\d+)? ?m?s\b"), "in <duration>"), ] def steady(text: str) -> str: """A transcript with the parts that cannot repeat replaced.""" for pattern, replacement in VOLATILE: text = pattern.sub(replacement, text) return - line 81
text def binary(name: str) -> str | None: target = os.environ.get("CARGO_TARGET_DIR") or os.path.join(ROOT, "target") exe = name + (".exe" if os.name == "nt" else "") for profile in ("release", "debug"): path = os.path.join(target, - line 81
profile, exe) if os.path.exists(path): return path - line 121
return None def sample_wav(path: str, seconds: float = 3.0, rate: int = 16000) -> None: """A synthetic voiced tone to veil. Generated rather than committed, for the same reason every other picture in this repository is generated: a binary - line 121
blob in the tree is a thing nobody can check. It is a harmonic series over a wobbling fundamental, which is close enough to a voiced vowel for the engine to find a pitch in and is nobody's actual voice, which matters for a file that ships. - line 121
""" frames = [] for n in range(int(rate * seconds)): t = n / rate f0 = 120 + 12 * math.sin(2 * math.pi * 0.7 * t) value = 0.0 for harmonic, amplitude in enumerate([1.0, 0.6, 0.45, 0.3, 0.22, 0.15], start=1): value += amplitude * math.sin(2 - line 121
* math.pi * f0 * harmonic * t) edge = min(t, seconds - t) envelope = min(1.0, edge / 0.2) frames.append(int(max(-1.0, min(1.0, value / 3.0)) * envelope * 20000)) with wave.open(path, "w") as handle: handle.setnchannels(1) - line 121
handle.setsampwidth(2) handle.setframerate(rate) handle.writeframes(b"".join(struct.pack("<h", f) for f in frames)) def run_in_terminal(argv, cwd, typed=(), limit=90.0) -> str: """Run a command on a pseudo-terminal, typing at its prompts. - line 121
`typed` is sent one line at a time, each after the program has gone quiet, which is what "wait for the prompt" means without parsing the prompt. A program that asks for nothing gets nothing. """ pid, fd = pty.fork() if pid == 0: # pragma: - line 121
no cover - the child never returns os.chdir(cwd) os.environ["COLUMNS"] = str(COLUMNS) - line 161
os.environ["LINES"] = "40" # A terminal that claims no capabilities, so nothing writes cursor # movement or colour into a file meant to be read as text. os.environ["TERM"] = "dumb" os.environ["NO_COLOR"] = "1" os.execv(argv[0], argv) out = - line 161
bytearray() queue = list(typed) deadline = time.time() + limit quiet = 0.0 while time.time() < deadline: ready, _, _ = select.select([fd], [], [], 0.3) if ready: try: chunk = os.read(fd, 4096) except OSError: break if not chunk: break out - line 161
+= chunk quiet = 0.0 continue quiet += 0.3 if queue and quiet >= 0.9: os.write(fd, queue.pop(0).encode() + b"\n") quiet = 0.0 elif not queue and quiet >= 2.0: break try: os.waitpid(pid, 0) except ChildProcessError: pass os.close(fd) return - line 161
out.decode("utf-8", "replace").replace("\r\n", "\n") def clean_transcript(text: str) -> str: """Trailing spaces off, one trailing newline, no blank run longer than one.""" lines = [line.rstrip() for line in text.split("\n")] - line 201
tidied = [] for line in lines: if not line and tidied and not tidied[-1]: continue tidied.append(line) while tidied and not tidied[-1]: tidied.pop() return "\n".join(tidied) + "\n" PASSPHRASE = "correct horse battery staple" # Every - line 201
session: what it is called, what it is for, the steps, and how it is # checked. `rerun` sessions are reproduced here on demand; `witnessed` ones # need a published release and are held to what this repository can prove. SESSIONS = [ { - line 201
"name": "anonymise", "programme": "veilvoice", "title": "Veiling one recording", "note": "The whole point of the program, on a three second recording, " "with the result sealed to a key so nothing is typed.", "how": "rerun", "steps": [ - line 201
{"show": "veilvoice keygen", "argv": ["keygen"], "typed": [PASSPHRASE, PASSPHRASE]}, {"show": "veilvoice anonymise interview.wav -o veiled.veil --encrypt-to veilvoice.pub", "argv": ["anonymise", "interview.wav", "-o", "veiled.veil", - line 201
"--encrypt-to", "veilvoice.pub"]}, ], "files": {"interview.wav": "sample"}, }, { "name": "refusal", "programme": "veilvoice", "title": "What it does with no terminal to ask on", "note": "The same command in a script, where nobody can type - line 201
a " "passphrase. It stops, says why, and writes nothing.", "how": "rerun", - line 241
"pipe": True, "steps": [ {"show": "veilvoice anonymise interview.wav -o veiled.wav", "argv": ["anonymise", "interview.wav", "-o", "veiled.wav"]}, ], "files": {"interview.wav": "sample"}, }, { "name": "unencrypted", "programme": - line 241
"veilvoice", "title": "Asking for it in the clear, and being told what that means", "note": "The escape hatch exists. It says in full what you are giving " "up before it uses it.", "how": "rerun", "pipe": True, "steps": [ {"show": - line 241
"veilvoice anonymise interview.wav -o veiled.wav --encrypt false --yes", "argv": ["anonymise", "interview.wav", "-o", "veiled.wav", "--encrypt", "false", "--yes"]}, ], "files": {"interview.wav": "sample"}, }, { "name": "info", "programme": - line 241
"veilvoice", "title": "What this build can do", "note": "Every version, whether live audio is available on this " "machine, and the network answer.", "how": "rerun", "pipe": True, "steps": [ {"show": "veilvoice info", "argv": ["info"]}, ], - line 241
"files": {}, }, { "name": "verify", # The verifier stopped being a binary of its own in 0.1.18 and became # `veilvoice verify`. The recording follows it rather than being # dropped: this is the session a nervous user most wants to see - line 241
before - line 281
# they run anything. "programme": "veilvoice", "title": "Checking a download", "note": "The published release, its signed hash list, and the same " "question asked again of your own GnuPG.", "how": "witnessed", "pipe": True, "steps": [ - line 281
{"show": "veilvoice verify auto .", "argv": ["verify", "auto", "."]}, ], "files": {}, }, ] def make_files(where, wanted): for name, kind in wanted.items(): if kind == "sample": sample_wav(os.path.join(where, name)) else: raise - line 281
SystemExit("unknown fixture kind %r" % kind) def record_one(session, release_dir=None): """Run one session and return its transcript.""" exe = binary(session["programme"]) if exe is None: raise SystemExit( "no `%s` build found. Run:\n" " - line 281
cargo build --release -p veilvoice-cli" % session["programme"]) if session["how"] == "witnessed": if not release_dir: raise SystemExit( "the %s session checks a published release.\n" " Download the archive, SHA256SUMS, SHA256SUMS.asc and - line 281
the\n" " signing key into one folder and pass --release <FOLDER>." % session["name"]) where = release_dir - line 321
temporary = None else: temporary = tempfile.mkdtemp(prefix="veilvoice-session-") where = temporary make_files(where, session["files"]) try: parts = [] for step in session["steps"]: parts.append("$ " + step["show"]) argv = [exe] + - line 321
step["argv"] if session.get("pipe"): # No terminal on purpose: this is what a script sees, and for # three of these it is also simply the shorter way to the same # bytes because nothing is asked. done = subprocess.run(argv, cwd=where, - line 321
capture_output=True, check=False) text = (done.stdout or b"") + (done.stderr or b"") parts.append(text.decode("utf-8", "replace").replace("\r\n", "\n")) else: parts.append(run_in_terminal(argv, where, step.get("typed", ()))) return - line 321
clean_transcript("\n".join(parts)) finally: if temporary: shutil.rmtree(temporary, ignore_errors=True) def path_for(name): return os.path.join(OUT, "session-%s.txt" % name) def read(path): with io.open(path, encoding="utf-8") as handle: - line 321
return handle.read() def workspace_version(): text = read(os.path.join(ROOT, "Cargo.toml")) found = re.search(r'(?m)^version = "([0-9]+\.[0-9]+\.[0-9]+)"$', text) return found.group(1) if found else None - line 361
def signing_fingerprint(): """The fingerprint of the key this project signs with. Read from `FINGERPRINT` in `veilvoice_verify::check`, which is the constant the verifier itself compares the embedded key against. That is the source rather - line 361
than a copy of it: a transcript naming a different key fails against the same value the program uses, not against a second written down somewhere for humans. """ text = read(os.path.join(ROOT, "crates", "veilvoice-verify", "src", "check", - line 361
"mod.rs")) found = re.search(r'pub const FINGERPRINT: &str = "([0-9A-F]{40})"', text) return found.group(1) if found else None def check_witnessed(name, transcript): """What this repository can prove about a transcript it cannot re-run.""" - line 361
problems = [] fingerprint = signing_fingerprint() version = workspace_version() if not fingerprint: problems.append("no 40-character fingerprint found in README.md to compare against") elif fingerprint not in transcript: - line 361
problems.append("does not name the signing key %s that README.md publishes" % fingerprint) if version and version not in transcript: # Expected between a version bump and the publication of that version, # and the message says so rather - line 361
than leaving a maintainer to work out # whether they have broken something. This transcript is a recording of # the verifier checking a *published* release, so it cannot be made # until the release exists. The order is: bump, tag, publish, - line 361
re-record # here, commit. `tools/verify.py` is the only thing that runs this # check, so a release commit still goes through CI green. problems.append( "does not mention the workspace version %s, so it is from an older " "release. If %s - line 361
has not been published yet, this is the expected " "state: download the release and re-record with\n" " python tools/shots/sessions.py --record --release <folder>" % (version, version)) for required in ("ok signature over the hash list is - line 361
good", "ok sha256 matches", - line 401
"INTACT."): if required not in transcript: problems.append("does not contain %r, which the verifier prints on a pass" % required) return problems def record(release_dir): os.makedirs(OUT, exist_ok=True) for session in SESSIONS: if - line 401
session["how"] == "witnessed" and not release_dir: print(" skipped %-12s needs --release <FOLDER>" % session["name"]) continue transcript = record_one(session, release_dir) with io.open(path_for(session["name"]), "w", encoding="utf-8", - line 401
newline="\n") as handle: handle.write(transcript) print(" recorded %-12s %d lines" % (session["name"], transcript.count("\n"))) return 0 def difference(committed, fresh, most=24): """The lines that differ, normalised, as a diff somebody - line 401
can read. Written because this check once failed on a loaded machine, once in twenty-odd runs, and said only that the transcript "is not what the program prints now". That is not enough to act on: a failure nobody can explain is a failure - line 401
that gets re-run until it passes, which is how a real change in the program's output would be dismissed as a flake. The comparison is on the *normalised* text, because that is what `check` compares, so what is printed here is exactly what - line 401
it disagreed about rather than a second opinion. Capped, because a transcript that has diverged entirely is a wall of output that says less than its first ten lines. """ diff = list(difflib.unified_diff( steady(committed).split("\n"), - line 401
steady(fresh).split("\n"), fromfile="committed", tofile="now", lineterm="", )) - line 441
if not diff: # Equal here and unequal in `check` is impossible, and saying so is # better than printing nothing and looking like there was no difference. return " (the normalised texts compare equal here, which cannot happen)\n" shown = - line 441
diff[:most] out = "".join(" %s\n" % line for line in shown) if len(diff) > most: out += " ... and %d more lines\n" % (len(diff) - most) return out def check(): problems = [] for session in SESSIONS: path = path_for(session["name"]) if not - line 441
os.path.exists(path): problems.append("%s: never recorded" % os.path.relpath(path, ROOT)) continue committed = read(path) if session["how"] == "witnessed": for problem in check_witnessed(session["name"], committed): problems.append("%s: - line 441
%s" % (os.path.relpath(path, ROOT), problem)) continue fresh = record_one(session) if steady(fresh) != steady(committed): problems.append( "%s is not what the program prints now.\n%s" " Run: tools/shots/sessions.py --record" % - line 441
(os.path.relpath(path, ROOT), difference(committed, fresh))) if problems: for problem in problems: print(" " + problem, file=sys.stderr) return 1 print(" %d recorded sessions match the programs" % len(SESSIONS)) return 0 def main(): parser - line 441
= argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--record", action="store_true", help="run the sessions and write them down") - line 481
parser.add_argument("--check", action="store_true", help="verify the transcripts are current") parser.add_argument("--release", metavar="FOLDER", help="a folder holding a downloaded release, for the verifier session") args = - line 481
parser.parse_args() if args.record: return record(args.release) return check() if __name__ == "__main__": raise SystemExit(main())
tools/shots/terminal.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Pictures of the command line, drawn from its real output. python tools/shots/terminal.py --capture # run the commands, save the text python tools/shots/terminal.py # - line 1
draw the SVGs from that text python tools/shots/terminal.py --check # verify the SVGs match the text # Two steps, on purpose `--capture` runs `veilvoice` and writes what it printed into `assets/screenshots/cli-<name>.txt`. That step needs - line 1
a build, a machine, and a person deciding the output is right; it is not something CI can do. Everything after that is a pure function of those text files, so the drawings **are** reproducible and CI checks them exactly the way it checks - line 1
the banners: regenerate into memory, compare, fail on a difference. A picture of a command line that disagrees with the command line is documentation that lies, and this is the arrangement that makes that impossible to commit by accident. - line 1
The text is committed beside the picture and is the thing to read in a diff. An SVG diff is unreadable; a diff of what the program printed is the review. # Why SVG and not a screenshot The same reason the website's banner is text: it - line 1
follows a palette rather than being baked in one, it can be selected, searched and read aloud, and it stays sharp at any size. Finding F-37 was this project's own claims rendered illegibly inside an image. Claims stay out of images. # What - line 1
is captured, and what is deliberately not Only commands that print the program's own help and its own findings. Nothing that names this machine's devices, users or paths: a demonstration picture is published, and a device list is a - line 1
description of somebody's hardware. Pure standard library. """ import argparse - line 41
import io import os import re import struct import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) OUT = os.path.join(ROOT, "assets", "screenshots") # The site - line 41
serves only what is under `website/`, so every picture has to exist # there as well. That copy is made *here*, by the tool that owns the pictures, # and it is checked here too -- including the window captures, which this tool # does not - line 41
draw but does keep in step. # # Finding F-41 was exactly this: website assets drifting from the generator that # produced them, silently, with nothing able to tell. A second copy kept by hand # is a second copy that will be wrong. WEB = - line 41
os.path.join(ROOT, "website", "assets", "screenshots") MARKER = "GENERATED by tools/shots/terminal.py" # Tokyo Night, the same hexes `website/css/themes.css` declares. Written out # rather than parsed, exactly as the documentation - line 41
generator does it, and for # the same reason: a tool should not need the website on disk to draw a box. BG = "#16161e" BG_BAR = "#1f2335" BORDER = "#2f3549" FG = "#c0caf5" MUTED = "#737aa2" GREEN = "#9ece6a" BLUE = "#7aa2f7" YELLOW = - line 41
"#e0af68" RED = "#f7768e" MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" # One character at 13.4px in this stack. # # 0.62 of the font size rather than the 0.60 this had, and the difference - line 81
# matters: `tools/site-tests/images.test.js` measures the result with 0.62, and # a generator that lays out with the smaller number produces drawings that # suite then reports as overflowing by a few pixels. The larger number is also # the - line 81
safer one, because the font is whichever of the stack the reader has. TEXT_RATIO = 0.62 CHAR_W = 13.4 * TEXT_RATIO LINE_H = 19.0 FONT = 13.4 PAD_X = 18.0 PAD_TOP = 46.0 # room for the title bar PAD_BOTTOM = 16.0 # Nothing here writes a - line 81
file, starts a device, or names this machine. COMMANDS = [ ("help", ["--help"], "what the command line offers"), ("anonymise", ["anonymise", "--help"], "veiling one recording"), ("live", ["live", "--help"], "scrambling a microphone as it - line 81
runs"), ("conversation", ["conversation", "--help"], "several people in one recording"), ("render", ["conversation", "render", "--help"], "a plan, a recording, and a page"), ("preview", ["conversation", "preview", "--help"], "what the - line 81
video will look like"), ("fix", ["conversation", "fix", "--help"], "correcting a plan before rendering it"), ("companions", ["companions", "--help"], "software VeilVoice can use but never bundles"), ("capture", ["capture", "--help"], - line 81
"which screen recorders are running"), ("guard", ["guard", "--help"], "noticing that a file changed"), ("clean", ["clean", "--help"], "metadata, EXIF and GPS"), ] # The widest a line may be drawn. A line longer than this is **wrapped**, - line 81
not # cut. # # It used to be cut, with an ellipsis marking the cut, and the reasoning was # that a picture two thousand pixels wide to show the end of one path is a # worse picture. That part is still true and it is why the width is - line 81
bounded at # all. What was wrong was the other half: a help screen that says # # -o, --output <OUTPUT> Output device name. Defaults to a virtual cable if one is fo… # # has not shown the reader what the flag does. It has shown them that - line 81
there is # more and they cannot have it, in a picture whose entire job is explaining the # flag. Three of the committed drawings ended a sentence mid-word that way. - line 121
# # So the picture gets taller instead of wider, which is the axis a page can # afford, and the text is all there. MAX_COLUMNS = 96 # There is no cap on how many lines a capture may have. # # There was, at 44, with an ellipsis on the end - line 121
to mark the cut, and the # reasoning was that these are illustrations rather than manuals. It is the # same mistake the width had: a picture of `--help` whose whole job is showing # what the flags are, ending three flags early, is not an - line 121
illustration of # anything. The height is the axis a page can afford, and `draw` already sizes # the canvas from the number of lines, so a longer capture simply produces a # taller picture. def binary(): """Where the built `veilvoice` - line 121
is.""" target = os.environ.get("CARGO_TARGET_DIR") or os.path.join(ROOT, "target") name = "veilvoice.exe" if os.name == "nt" else "veilvoice" for profile in ("release", "debug"): path = os.path.join(target, profile, name) if - line 121
os.path.exists(path): return path return None def capture(): """Run each command and write down what it printed.""" exe = binary() if exe is None: print(" no `veilvoice` build found. Run:") print(" cargo build --release -p veilvoice-cli") - line 121
return 1 os.makedirs(OUT, exist_ok=True) for name, argv, _ in COMMANDS: # `--help` exits non-zero on some argument parsers and zero on others; # what is wanted is what it printed either way, so the status is not # checked. A command that - line 121
printed nothing is what fails, below. # Bytes, decoded as UTF-8 explicitly. `text=True` decodes with the # locale encoding, which on Windows is CP1252 -- and the help screens - line 161
# are full of em dashes. The first capture wrote every one of them as # three CP1252 characters instead, and the repository's own # stray-character suite is what noticed, three checks after this one # ran. Worth the note: the mojibake is - line 161
invisible in a terminal that # is itself CP1252, which is where this was run. done = subprocess.run([exe] + argv, capture_output=True, cwd=ROOT, check=False) raw = done.stdout or done.stderr text = raw.decode("utf-8", "replace") if not - line 161
text.strip(): print(" %s printed nothing" % " ".join(argv)) return 1 path = os.path.join(OUT, "cli-%s.txt" % name) with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text.replace("\r\n", "\n")) print(" captured - line 161
%-14s %d lines" % (name, text.count("\n") + 1)) print() print(" now run this again without --capture to draw them") return 0 def esc(text): return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', - line 161
""") ) def colour_of(line): """A colour for one line, from its shape rather than from an escape code. The CLI turns colour off when it is not writing to a terminal, which is what makes the captured text clean and diffable -- and - line 161
leaves the drawing with no colour information at all. Rather than re-run with colour forced and then parse ANSI, the shape of a help screen is read directly: it is a small, stable grammar and it is the same one for every subcommand. """ - line 161
stripped = line.strip() if not stripped: - line 201
return FG if stripped.endswith(":") and stripped == stripped.upper(): return BLUE # USAGE:, OPTIONS:, COMMANDS: if stripped.startswith("Usage:"): return BLUE if stripped.startswith("-") or stripped.startswith("--"): return GREEN # a flag - line 201
if stripped.startswith("!"): return YELLOW # a warning the CLI prints if stripped.startswith("x") and stripped[1:2] == " ": return RED return FG # A help screen's second column: leading space, the flag group, a run of two or # more spaces, - line 201
then the description. # # The flag group is `\S(?:.*?\S)?` rather than one token, and that is the whole # difficulty. `-o, --output <OUTPUT>` is four tokens separated by single spaces, # so a pattern that matched one token found `-o,` and - line 201
then looked for the column # gap immediately after it, failed, and fell through to the prose branch, which # collapsed the column and left `-o, --output <OUTPUT> Output device name`. The # non-greedy form takes everything up to the *first* - line 201
run of two spaces, which is # what the column gap actually is. OPTION_LINE = re.compile(r"^(\s*)(\S(?:.*?\S)?)(\s{2,})(\S.*)$") def wrap_line(line, width): """One captured line as one or more drawn lines, wrapped on words. Three things - line 201
this has to get right, each of which it got wrong first: * **The second column stays a column.** A wrapped option indents to where its description started, so the flag column is still readable down the page. Wrapping to zero turns a tidy - line 201
help screen into a paragraph. * **Nothing comes out wider than `width`.** Including a word that is on its own longer than the line, which for these captures means a path or a URL: it is broken at the width, because a line that overflows - line 201
the picture is the thing being fixed. * **A word is never broken otherwise**, and no line is left blank in the - line 241
middle of a help screen. The joining rule is worth stating because it is where the column lives: the prefix already ends in the column's spaces, so a word is appended to it directly, and a space is added only when the line so far ends in - line 241
something other than a space. """ if len(line) <= width: return [line] match = OPTION_LINE.match(line) if match: head = match.group(1) + match.group(2) + match.group(3) rest = match.group(4) else: lead = len(line) - len(line.lstrip(" ")) - line 241
head = " " * lead rest = line.strip() indent = " " * len(head) # A column gap wider than the line leaves nothing to wrap into. if len(indent) > width // 2: indent = " " * min(len(indent), max(2, width // 4)) out = [] current = head for - line 241
word in rest.split(): if len(indent) + len(word) > width: # Longer than a line of its own: break it at the width. if current.strip(): out.append(current.rstrip()) current = indent while len(current) + len(word) > width: room = width - - line 241
len(current) out.append(current + word[:room]) word = word[room:] current = indent current += word continue joiner = "" if current.endswith(" ") or not current else " " if len(current) + len(joiner) + len(word) > width: - line 281
out.append(current.rstrip()) current = indent + word else: current = current + joiner + word if current.strip(): out.append(current.rstrip()) return out or [line[:width]] def laid_out(title, text): """The wrapped lines of one drawing, and - line 281
how many columns they need.""" lines = text.replace("\r\n", "\n").rstrip("\n").split("\n") body = [] for line in lines: body.extend(wrap_line(line.rstrip(), MAX_COLUMNS)) return body, max([len(line) for line in body] + [len(title) + 4]) - line 281
def draw(name, title, note, text, columns=None): """One terminal window, as SVG. `columns` is the width the whole set shares. Passing None draws this one at its own content width, which is what a single drawing on its own would want and is - line 281
kept for callers testing one in isolation. # Why the set shares a width Each drawing used to be exactly as wide as its own longest line, which gave eleven pictures at five different widths: 651, 800, 817, 825 and 834. The README stacks all - line 281
eleven vertically, one after another, and the website shows them in the same order, so what a reader sees is a column of terminal windows whose right edges do not line up. Height differing is content and is right: a longer help screen is a - line 281
taller picture, which is the axis a page can afford. Width differing is the frame, and a frame that changes size between pictures reads as the pictures being wrong rather than the commands being different. The other argument is what these - line 281
actually are. They draw a **terminal window**, with a title bar and rounded corners, and a terminal window does - line 321
not shrink to fit whichever command printed the least. One that did would be the odd thing. The shared width is the widest one's content, for the same reason the window captures take the tallest one's: it is the only shared size that - line 321
re-wraps nothing. Narrowing to the mean would push text in the widest drawings onto extra lines to make the picture tidier, and this file already argues at length that text is not a thing to lose for a better-looking picture. """ body, own - line 321
= laid_out(title, text) if columns is None: columns = own width = PAD_X * 2 + columns * CHAR_W height = PAD_TOP + len(body) * LINE_H + PAD_BOTTOM out = [] add = out.append add( '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.0f - line 321
%.0f" ' 'width="%.0f" height="%.0f" style="max-width:100%%;height:auto" ' 'role="img" aria-label="%s">' % (width, height, width, height, esc(note)) ) add("<!-- %s from assets/screenshots/cli-%s.txt. Do not edit. -->" % (MARKER, name)) - line 321
add("<title>%s</title>" % esc(note)) add('<rect x="0" y="0" width="%.0f" height="%.0f" rx="9" fill="%s"/>' % (width, height, BG)) add('<rect x="0" y="0" width="%.0f" height="30" rx="9" fill="%s"/>' % (width, BG_BAR)) add('<rect x="0" - line 321
y="21" width="%.0f" height="9" fill="%s"/>' % (width, BG_BAR)) add('<rect x="0.5" y="0.5" width="%.0f" height="%.0f" rx="9" fill="none" ' 'stroke="%s"/>' % (width - 1, height - 1, BORDER)) # Three dots, because a box with three dots in the - line 321
corner reads as a # terminal without a word being spent saying so. for index, colour in enumerate((RED, YELLOW, GREEN)): add('<circle cx="%.0f" cy="15" r="4.5" fill="%s"/>' % (18 + index * 16, colour)) add('<text x="%.0f" y="20" - line 321
font-family="%s" font-size="12" fill="%s" ' 'text-anchor="middle">%s</text>' % (width / 2.0, MONO, MUTED, esc(title))) for number, line in enumerate(body): if not line: continue - line 361
add('<text x="%.1f" y="%.1f" font-family="%s" font-size="%.1f" ' 'fill="%s" xml:space="preserve">%s</text>' % (PAD_X, PAD_TOP + number * LINE_H, MONO, FONT, colour_of(line), esc(line))) add("</svg>") return "\n".join(out) + "\n" def - line 361
outputs(): """Every drawing this tool owns, as {relative path: text}.""" files = {} missing = [] # Read every capture first, because the width they share is measured across # all of them and cannot be known while drawing the first. - line 361
Measured on each # run rather than written down, so a command whose help grows moves the # whole set together instead of becoming the one exception. captured = [] for name, argv, note in COMMANDS: path = os.path.join(OUT, "cli-%s.txt" % - line 361
name) if not os.path.exists(path): missing.append(name) continue with io.open(path, encoding="utf-8") as handle: text = handle.read() captured.append((name, "veilvoice " + " ".join(argv), note, text)) shared = max( [laid_out(title, - line 361
text)[1] for _, title, _, text in captured] or [MAX_COLUMNS] ) for name, title, note, text in captured: files["assets/screenshots/cli-%s.svg" % name] = draw( name, title, note, text, shared ) # The website's copies, from the same run - line 361
rather than by hand. for rel in list(files): files["website/" + rel] = files[rel] return files, missing - line 401
def window_captures(): """The `gui-*.png` files, which this tool copies but does not make. Taken by `tools/shots/gui.ps1` against a running application, so they cannot be regenerated from anything in the tree. What *can* be kept true is - line 401
that the website's copy is the same file, which is what this returns. """ if not os.path.isdir(OUT): return [] return sorted( name for name in os.listdir(OUT) if name.startswith("gui-") and name.endswith(".png") ) def png_size(path): - line 401
"""Width and height out of a PNG's header, without a decoder. IHDR is always the first chunk and its first eight bytes are the two dimensions, big-endian. Sixteen bytes in, eight bytes long -- which is why this is four lines rather than a - line 401
dependency. """ with open(path, "rb") as handle: head = handle.read(24) if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n": raise ValueError("%s is not a PNG" % path) return struct.unpack(">II", head[16:24]) def check_declared_sizes(): - line 401
"""The gallery declares each capture's size; verify it is the real one. `width` and `height` on an `<img>` are what stop the page reflowing as each picture arrives -- the browser reserves the right box before the bytes come. A declared - line 401
size that has gone stale is worse than none: the page reserves the wrong box and then jumps anyway, under a reader who is mid-sentence. - line 441
It went stale the first time within an hour of being written, because the capture window was made taller so a longer tab would fit. Hence a check rather than a note asking somebody to remember. """ page = os.path.join(ROOT, "website", - line 441
"index.html") if not os.path.exists(page): return [] with io.open(page, encoding="utf-8") as handle: html = handle.read() problems = [] for name in window_captures(): width, height = png_size(os.path.join(OUT, name)) found = re.search( - line 441
r'src="assets/screenshots/%s"[^>]*?width="(\d+)" height="(\d+)"' % re.escape(name), html, ) if found is None: # Not every capture has to be in the gallery; one that is not # simply has nothing to check. continue if (int(found.group(1)), - line 441
int(found.group(2))) != (width, height): problems.append( "website/index.html declares %s as %sx%s and it is %dx%d" % (name, found.group(1), found.group(2), width, height) ) return problems def mirror_captures(check): """Copy, or verify, - line 441
the window captures under `website/`.""" problems = [] for name in window_captures(): source = os.path.join(OUT, name) target = os.path.join(WEB, name) with open(source, "rb") as handle: blob = handle.read() if check: try: with - line 441
open(target, "rb") as handle: if handle.read() != blob: - line 481
problems.append( "website/assets/screenshots/%s differs from assets/" % name ) except OSError as error: problems.append( "website/assets/screenshots/%s: cannot read (%s)" % (name, error) ) else: os.makedirs(WEB, exist_ok=True) with - line 481
open(target, "wb") as handle: handle.write(blob) return problems def check_captures_are_current(): """**F-103.** Do the committed captures still say what the program says? Nothing asked this. `--check` compared each drawing against the - line 481
text file beside it, and the text file against nothing at all: it is written by `--capture`, which is a separate command nobody runs by accident. So a string could be rewritten in `veilvoice-cli` and every check in this repository would - line 481
pass while `assets/screenshots/` went on showing the old wording, on the website, in the README and in the gallery. That is not hypothetical. It happened during the sweep that took the em dashes out of the interface text: the code changed, - line 481
the whole of `tools/verify.py` passed, and `cli-help.txt` still contained a dash the program no longer prints. The audit had recorded the re-capture as a manual step and this is the half that was missing, which is that nothing said when - line 481
the manual step was due. Every command captured here is a `--help` screen, so its output is a function of the binary and not of the machine: two people with the same commit get the same bytes. That is what makes this checkable at all, and - line 481
it is why the list stays help screens. A capture of something that reads the machine -- what is installed, what is plugged in -- could not be checked this way and would have to be marked as such. Skipped where there is no build, which is - line 481
the state of the CI job that runs the other checks here. It is not skipped where it matters: `verify.py` runs - line 521
after `cargo build`, on the machine where the strings were just edited. """ exe = binary() if exe is None: return [], False problems = [] for name, argv, _ in COMMANDS: path = os.path.join(OUT, "cli-%s.txt" % name) if not - line 521
os.path.exists(path): continue done = subprocess.run([exe] + argv, capture_output=True, cwd=ROOT, check=False) raw = done.stdout or done.stderr live = raw.decode("utf-8", "replace").replace("\r\n", "\n") with io.open(path, - line 521
encoding="utf-8") as handle: saved = handle.read() if live.strip() != saved.strip(): problems.append( "assets/screenshots/cli-%s.txt is not what `veilvoice %s` prints" % (name, " ".join(argv)) ) return problems, True def main(): parser = - line 521
argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--capture", action="store_true", help="run the commands and save what they printed") parser.add_argument("--check", action="store_true", help="verify the - line 521
drawings match the captured text") args = parser.parse_args() if args.capture: return capture() files, missing = outputs() if missing: print(" no captured output for: %s" % ", ".join(missing)) print(" run: python tools/shots/terminal.py - line 521
--capture") return 1 - line 561
if args.check: stale, ran = check_captures_are_current() problems = mirror_captures(check=True) + check_declared_sizes() + stale for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", os.sep)) try: with - line 561
io.open(path, encoding="utf-8") as handle: if handle.read() != text: problems.append("%s differs from its captured text" % rel) except OSError as error: problems.append("%s: cannot read (%s)" % (rel, error)) if problems: for line in - line 561
problems: print(" MISMATCH %s" % line) print() if stale: print(" Run 'python tools/shots/terminal.py --capture', then") print(" 'python tools/shots/terminal.py', and commit the result.") else: print(" Run 'python tools/shots/terminal.py' - line 561
and commit the result.") return 1 print( " %d terminal drawings match their captured text, and %d window " "captures match their copies" % (len(files), len(window_captures())) ) print( " and the captures %s" % ("are what the built program - line 561
prints" if ran else "were not compared against a build: there is none here") ) return 0 os.makedirs(OUT, exist_ok=True) os.makedirs(WEB, exist_ok=True) for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", - line 561
os.sep)) with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) mirror_captures(check=False) print( - line 601
" wrote %d terminal drawings and mirrored %d window captures" % (len(files), len(window_captures())) ) return 0 if __name__ == "__main__": sys.exit(main())
tools/shots/xwd.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Turn an X window dump into a PNG, and fingerprint a PNG. python tools/shots/xwd.py capture.xwd out.png python tools/shots/xwd.py --fingerprint out.png Used by - line 1
`tools/shots/gui.sh`, which captures the screen with `xwd` because that is the one screen-grabbing tool that is reliably present beside Xvfb. # Why this exists rather than a call to ImageMagick The same reason the rest of the image tooling - line 1
here is written out: `convert` is not installed everywhere, it is a large dependency to add for a format conversion, and the format in question is a header and a block of pixels. Pure standard library, like `tools/shots/round.py` and - line 1
`assets/generate.py`. # The fingerprint `gui.sh` photographs every tab by starting the application once per tab, and the failure it has to catch is two of them coming out identical, which means a tab did not open and the previous picture - line 1
was taken again under a new name. That is invisible in a directory listing and obvious in a hash. It samples on a grid and masks the low bits of each channel, so a difference of one level in software rendering does not read as a different - line 1
tab. """ import struct import sys import zlib # The X11 window dump header: 25 big-endian 32-bit words, then the colour map, # then the pixels. These are the words this needs, by their position in that # structure (`XWDFileHeader` in - line 1
<X11/XWDFile.h>). HEADER_WORDS = 25 COLOUR_MAP_ENTRY = 12 (HEADER_SIZE, WIDTH, HEIGHT, BYTE_ORDER, BITS_PER_PIXEL, BYTES_PER_LINE, RED_MASK, GREEN_MASK, BLUE_MASK, NCOLORS) = (0, 4, 5, 7, 11, 12, 14, 15, 16, 19) - line 41
def channel_offset(mask, depth_bytes, msb_first): """Which byte of a stored pixel holds the channel this mask selects. Read from the header rather than assumed. The first version of this file assumed 32 bits per pixel in BGRX order, which - line 41
is what one machine happened to write; the Xvfb here writes 24-bit pixels and every capture was refused. A dump says what it contains, so this asks. """ if mask == 0: raise SystemExit("the capture declares no mask for one of its channels") - line 41
shift = (mask & -mask).bit_length() - 1 index = shift // 8 if index >= depth_bytes: raise SystemExit("a channel mask falls outside the pixel") return depth_bytes - 1 - index if msb_first else index def decode(path): """(width, height, rows - line 41
of RGB bytes) from an xwd file.""" with open(path, "rb") as handle: data = handle.read() if len(data) < HEADER_WORDS * 4: raise SystemExit("%s: too short to be an xwd capture" % path) field = struct.unpack(">%dI" % HEADER_WORDS, - line 41
data[:HEADER_WORDS * 4]) width, height = field[WIDTH], field[HEIGHT] bits, line_bytes = field[BITS_PER_PIXEL], field[BYTES_PER_LINE] if bits not in (24, 32): raise SystemExit("%s: %d bits per pixel; this reads 24 and 32" % (path, bits)) - line 41
depth_bytes = bits // 8 msb_first = field[BYTE_ORDER] == 1 red = channel_offset(field[RED_MASK], depth_bytes, msb_first) green = channel_offset(field[GREEN_MASK], depth_bytes, msb_first) blue = channel_offset(field[BLUE_MASK], depth_bytes, - line 41
msb_first) start = field[HEADER_SIZE] + field[NCOLORS] * COLOUR_MAP_ENTRY pixels = data[start:] if len(pixels) < height * line_bytes: raise SystemExit("%s: %d bytes of pixels, expected %d" % (path, len(pixels), height * line_bytes)) - line 81
# Sliced rather than looped over. A 1400x1000 capture is 1.4 million # pixels, and a Python loop over them takes long enough that nine of them # is a coffee break; three strided slices per row is the same work inside # the interpreter. - line 81
span = width * depth_bytes rows = [] for y in range(height): base = y * line_bytes row = pixels[base:base + span] out = bytearray(width * 3) out[0::3] = row[red::depth_bytes] out[1::3] = row[green::depth_bytes] out[2::3] = - line 81
row[blue::depth_bytes] rows.append(bytes(out)) return width, height, rows def chunk(kind, body): return (struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xffffffff)) def write_png(path, width, - line 81
height, rows): raw = bytearray() for row in rows: raw.append(0) # filter: none raw += row head = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) with open(path, "wb") as handle: handle.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", - line 81
head) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) def read_png(path): """Enough of a PNG reader for the files this tool wrote.""" with open(path, "rb") as handle: data = handle.read() - line 121
if data[:8] != b"\x89PNG\r\n\x1a\n": raise SystemExit("%s: not a PNG" % path) at = 8 width = height = None body = b"" while at + 8 <= len(data): length = struct.unpack(">I", data[at:at + 4])[0] kind = data[at + 4:at + 8] payload = data[at - line 121
+ 8:at + 8 + length] if kind == b"IHDR": width, height, depth, colour = struct.unpack(">IIBB", payload[:10]) if (depth, colour) not in ((8, 2), (8, 6)): raise SystemExit("%s: not 8-bit RGB or RGBA" % path) channels = 3 if colour == 2 else - line 121
4 elif kind == b"IDAT": body += payload elif kind == b"IEND": break at += 12 + length stride = width * channels flat = zlib.decompress(body) rows = [] previous = bytearray(stride) at = 0 for _ in range(height): filter_kind = flat[at] line - line 121
= bytearray(flat[at + 1:at + 1 + stride]) at += 1 + stride for i in range(stride): left = line[i - channels] if i >= channels else 0 up = previous[i] if filter_kind == 1: line[i] = (line[i] + left) & 0xff elif filter_kind == 2: line[i] = - line 121
(line[i] + up) & 0xff elif filter_kind == 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff elif filter_kind == 4: upper_left = previous[i - channels] if i >= channels else 0 - line 161
estimate = left + up - upper_left da = abs(estimate - left) db = abs(estimate - up) dc = abs(estimate - upper_left) nearest = left if da <= db and da <= dc else (up if db <= dc else upper_left) line[i] = (line[i] + nearest) & 0xff - line 161
rows.append(bytes(line)) previous = line return width, height, channels, rows def fingerprint(path): width, height, channels, rows = read_png(path) out = [] for y in range(60, min(600, height), 17): row = rows[y] for x in range(20, - line 161
min(900, width), 19): at = x * channels out.append(row[at] & 0xf0) out.append(row[at + 2] & 0xf0) return "%08x" % (zlib.crc32(bytes(out)) & 0xffffffff) def main(): args = sys.argv[1:] if args[:1] == ["--fingerprint"]: if len(args) != 2: - line 161
raise SystemExit("usage: xwd.py --fingerprint <png>") print(fingerprint(args[1])) return 0 if len(args) != 2: raise SystemExit(__doc__.split("\n\n")[0]) width, height, rows = decode(args[0]) write_png(args[1], width, height, rows) return 0 - line 161
if __name__ == "__main__": sys.exit(main())
tools/sign/manifest.py
- line 1
#!/usr/bin/env python3 """Generate APPMANIFEST.json: a signed-list VeilVoice can carry about itself. python3 tools/sign/manifest.py DIR # write DIR/APPMANIFEST.json python3 tools/sign/manifest.py DIR --check # verify an existing one # What - line 1
this is, beside the OpenPGP one Every release already publishes `SHA256SUMS`, signed with the project's OpenPGP key, and that remains the real check. This is a second, self-contained record -- a small JSON manifest naming each binary, its - line 1
size and its SHA-256, plus the project and version -- meant to be signed with the project's **self-signed code certificate** (see `tools/sign/selfsign.sh`) rather than the OpenPGP key. The point of a self-signed certificate is narrow and - line 1
honest: it is not a certificate authority vouching for anyone, and Windows SmartScreen will not trust it on sight. What it is good for is a user or an organisation who chooses to import it once, after checking its fingerprint, so that this - line 1
publisher becomes *known* on their machines. From then on files carrying this manifest can be checked against a certificate they decided to trust, which is the same shape as the OpenPGP flow: compare a fingerprint by hand once, then let - line 1
the tools do the rest. The manifest is detached from the binaries -- it describes them, it is not embedded in them -- so signing it changes nothing about the binaries and does not touch reproducibility. That is the same reason the OpenPGP - line 1
signature is over `SHA256SUMS` and never over a binary in place. SPDX-License-Identifier: GPL-3.0-or-later """ from __future__ import annotations import argparse import hashlib import json import re import sys from pathlib import Path - line 41
ROOT = Path(__file__).resolve().parents[2] MANIFEST = "APPMANIFEST.json" #: The files a manifest describes: the shipped programs, by the names they have #: on each platform. A file that is not there is simply not listed, so one #: manifest - line 41
generator serves every platform's archive. BINARIES = ["veilvoice", "veilvoice-gui", "veilvoice.exe", "veilvoice-gui.exe"] def workspace_version() -> str: text = (ROOT / "Cargo.toml").read_text(encoding="utf-8") match = - line 41
re.search(r'^version = "([^"]+)"', text, re.M) if not match: raise SystemExit("no workspace version in Cargo.toml") return match.group(1) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in - line 41
iter(lambda: handle.read(1 << 20), b""): digest.update(block) return digest.hexdigest() def build(directory: Path) -> dict: """The manifest for the binaries found in `directory`. Deterministic: the file list is sorted, and the JSON is - line 41
written with sorted keys and a fixed separator, so the same inputs produce byte-identical output on any machine. A manifest that varied run to run could not be signed once and checked later. """ files = [] for name in - line 41
sorted(set(BINARIES)): path = directory / name if not path.is_file(): continue files.append( { - line 81
"name": name, "size": path.stat().st_size, "sha256": sha256(path), } ) if not files: raise SystemExit( f"no VeilVoice binaries found in {directory}. " f"Point this at a built or unpacked release." ) return { "manifest_version": 1, - line 81
"project": "VeilVoice", "version": workspace_version(), "files": files, } def render(manifest: dict) -> str: # Sorted keys and a trailing newline, so the file is stable and diff-clean. return json.dumps(manifest, indent=2, sort_keys=True) - line 81
+ "\n" def write(directory: Path) -> int: manifest = build(directory) (directory / MANIFEST).write_text(render(manifest), encoding="utf-8") print(f"wrote {directory / MANIFEST} ({len(manifest['files'])} file(s))") return 0 def - line 81
check(directory: Path) -> int: path = directory / MANIFEST if not path.exists(): print(f"no {MANIFEST} in {directory}") return 1 stored = json.loads(path.read_text(encoding="utf-8")) problems: list[str] = [] for entry in - line 81
stored.get("files", []): binary = directory / entry["name"] if not binary.is_file(): - line 121
problems.append(f"{entry['name']}: listed but not present") continue got = sha256(binary) if got != entry["sha256"]: problems.append( f"{entry['name']}: manifest {entry['sha256'][:16]}…, file {got[:16]}…" ) if binary.stat().st_size != - line 121
entry["size"]: problems.append(f"{entry['name']}: size differs from the manifest") if stored.get("version") != workspace_version(): problems.append( f"manifest version {stored.get('version')}, workspace {workspace_version()}" ) if - line 121
problems: for line in problems: print(f" {line}") print(f"\n{len(problems)} manifest problem(s).") return 1 print(f"{MANIFEST} matches the {len(stored['files'])} binary(ies) beside it") return 0 def main() -> int: parser = - line 121
argparse.ArgumentParser(description=__doc__) parser.add_argument("directory", type=Path, help="where the binaries are") parser.add_argument("--check", action="store_true", help="verify, do not write") args = parser.parse_args() directory = - line 121
args.directory if not directory.is_dir(): raise SystemExit(f"not a directory: {directory}") return check(directory) if args.check else write(directory) if __name__ == "__main__": sys.exit(main())
tools/sign/selfsign.ps1
- line 1
# Generate VeilVoice's self-signed code certificate on Windows, and sign the app # manifest with it. For the maintainer, at release time. # # powershell -File tools/sign/selfsign.ps1 -NewCert # once # powershell -File - line 1
tools/sign/selfsign.ps1 -Sign DIR # sign DIR\APPMANIFEST.json # # Same design as tools/sign/selfsign.sh: a self-signed certificate is trust on # first use, not a certificate authority, and it does not replace the OpenPGP # signature over - line 1
SHA256SUMS. The manifest is detached, so signing it does not # touch the binaries or their reproducibility. The private key stays in the # user's certificate store and is never exported to the repository. # # On Windows the certificate is - line 1
created with codeSigning usage so it can also be # used with signtool to Authenticode-sign the .exe files for a local or # enterprise build -- but note that signing a binary in place changes it and so # breaks reproducible builds, which is - line 1
why the detached manifest is the default # and the in-place path is opt-in and off the reproducible route. # # SPDX-License-Identifier: GPL-3.0-or-later param( [switch]$NewCert, [string]$Sign, [switch]$Fingerprint ) $ErrorActionPreference - line 1
= "Stop" function New-VVCert { $cert = New-SelfSignedCertificate ` -Type CodeSigningCert ` -Subject "CN=tilas01, O=VeilVoice, OU=Code Signing" ` -KeyAlgorithm ECDSA_nistP256 ` -CertStoreLocation "Cert:\CurrentUser\My" ` -NotAfter - line 1
(Get-Date).AddYears(10) # Export only the public certificate. The private key stays in the store. Export-Certificate -Cert $cert -FilePath "veilvoice-code-cert.cer" | Out-Null Write-Host "Created certificate in Cert:\CurrentUser\My and - line 1
wrote veilvoice-code-cert.cer (public, publish this)." Write-Host "Thumbprint (publish beside the OpenPGP fingerprint):" Write-Host " $($cert.Thumbprint)" Write-Host "The private key is in your certificate store and was not exported." - line 41
} function Get-VVCert { $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -like "*CN=tilas01*VeilVoice*" -or $_.Subject -like "*O=VeilVoice*" } | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not - line 41
$cert) { throw "no VeilVoice code certificate found; run -NewCert first" } return $cert } function Sign-Manifest { param($Dir) $manifest = Join-Path $Dir "APPMANIFEST.json" if (-not (Test-Path $manifest)) { throw "no APPMANIFEST.json in - line 41
$Dir; run tools/sign/manifest.py first" } $cert = Get-VVCert # A detached CMS/PKCS7 signature over the manifest bytes. $bytes = [System.IO.File]::ReadAllBytes($manifest) $content = New-Object - line 41
System.Security.Cryptography.Pkcs.ContentInfo(,$bytes) $signed = New-Object System.Security.Cryptography.Pkcs.SignedCms($content, $true) $signer = New-Object System.Security.Cryptography.Pkcs.CmsSigner($cert) - line 41
$signed.ComputeSignature($signer) [System.IO.File]::WriteAllBytes("$manifest.p7s", $signed.Encode()) Write-Host "wrote $manifest.p7s" } if ($NewCert) { New-VVCert } elseif ($Fingerprint) { Write-Host (Get-VVCert).Thumbprint } elseif - line 41
($Sign) { Sign-Manifest $Sign } else { Write-Host "usage: selfsign.ps1 -NewCert | -Fingerprint | -Sign DIR" }
tools/sign/selfsign.sh
- line 1
#!/bin/sh # Generate VeilVoice's self-signed code certificate, and sign an app manifest # with it. For the maintainer, at release time; a user never runs this. # # tools/sign/selfsign.sh --new-cert # once: make the key and cert # - line 1
tools/sign/selfsign.sh sign DIR # sign DIR/APPMANIFEST.json # # What this is, and is honest about not being # -------------------------------------------- # A self-signed certificate is not a certificate authority vouching for anyone. # - line 1
Windows SmartScreen will not trust it on sight, and it does not replace the # OpenPGP signature over SHA256SUMS, which stays the primary check. What it adds # is an identity a user or an organisation can choose to import once, after # - line 1
checking its fingerprint by hand -- the same trust-on-first-use shape as the # OpenPGP key -- so that this publisher becomes known on their machines. # # The manifest is detached: it describes the binaries, it is not embedded in # them, so - line 1
signing it changes nothing about the binaries and does not touch # reproducibility. That is deliberate and matches how the OpenPGP signature is # over the hash list rather than over any binary in place. # # The private key never leaves the - line 1
maintainer's machine and is never committed. # Only the public certificate (veilvoice-code-cert.pem) and its fingerprint are # published. # # SPDX-License-Identifier: GPL-3.0-or-later set -eu KEY="veilvoice-code-key.pem" - line 1
CERT="veilvoice-code-cert.pem" SUBJECT="/CN=tilas01/O=VeilVoice/OU=Code Signing" DAYS=3650 have() { command -v "$1" >/dev/null 2>&1; } have openssl || { echo "openssl is required" >&2; exit 1; } new_cert() { if [ -f "$KEY" ]; then echo - line 1
"refusing to overwrite an existing $KEY" >&2 - line 41
echo "delete it yourself if you really mean to make a new identity." >&2 exit 1 fi # A code-signing certificate: an EC key (P-256), self-signed, with the # codeSigning extended key usage so a verifier can tell what it is for. openssl req - line 41
-x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ -keyout "$KEY" -out "$CERT" -days "$DAYS" -nodes \ -subj "$SUBJECT" \ -addext "keyUsage=critical,digitalSignature" \ -addext "extendedKeyUsage=codeSigning" chmod 600 "$KEY" echo echo - line 41
"Wrote $KEY (keep this secret, never commit it) and $CERT (publish this)." echo "Its fingerprint, to publish beside the OpenPGP one:" fingerprint } fingerprint() { openssl x509 -in "$CERT" -noout -fingerprint -sha256 \ | sed 's/.*=//' } - line 41
sign() { dir="$1" manifest="$dir/APPMANIFEST.json" [ -f "$manifest" ] || { echo "no APPMANIFEST.json in $dir; run tools/sign/manifest.py first" >&2; exit 1; } [ -f "$KEY" ] || { echo "no $KEY; run --new-cert first" >&2; exit 1; } # A - line 41
detached CMS signature over the manifest, carrying the certificate so a # verifier needs only the manifest and the signature. openssl cms -sign -binary -in "$manifest" -signer "$CERT" -inkey "$KEY" \ -outform PEM -out "$manifest.sig" - line 41
-nodetach echo "wrote $manifest.sig" echo "verify with: tools/sign/verify.sh $dir" } case "${1:-}" in --new-cert) new_cert ;; --fingerprint) fingerprint ;; sign) [ $# -ge 2 ] || { echo "usage: $0 sign DIR" >&2; exit 1; }; sign "$2" ;; *) - line 41
echo "usage: $0 {--new-cert | --fingerprint | sign DIR}" >&2; exit 1 ;; - line 81
esac
tools/sign/selftest.py
- line 1
#!/usr/bin/env python3 """Prove the app-manifest tooling still works: round trip, and catch a tamper. The self-signing scripts need OpenSSL and a private key, so they cannot run in CI unattended. The manifest generator can, and it is the - line 1
part a mistake would most quietly break -- a manifest that no longer matches the binaries it describes verifies nothing. This exercises it end to end against a temporary directory, so `tools/verify.py` fails if the generator or its checker - line 1
drifts. SPDX-License-Identifier: GPL-3.0-or-later """ from __future__ import annotations import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[2] GEN = ROOT / "tools" / "sign" / - line 1
"manifest.py" def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, str(GEN), *args], capture_output=True, text=True, cwd=ROOT, ) def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = - line 1
Path(tmp) (d / "veilvoice").write_bytes(b"pretend binary one") (d / "veilvoice-gui").write_bytes(b"pretend binary two, longer") if run(str(d)).returncode != 0: print(" the manifest generator failed on a clean directory") - line 41
return 1 if run(str(d), "--check").returncode != 0: print(" a freshly written manifest did not verify against its own files") return 1 # Tamper with a binary; the check must now fail. (d / "veilvoice").write_bytes(b"tampered") if - line 41
run(str(d), "--check").returncode == 0: print(" a tampered binary passed the manifest check -- the check is broken") return 1 print("app-manifest tooling round-trips and catches a tampered binary") return 0 if __name__ == "__main__": - line 41
sys.exit(main())
tools/sign/verify.ps1
- line 1
# Verify a VeilVoice release against its self-signed code certificate, on Windows. # # powershell -File tools/sign/verify.ps1 DIR # # The code-certificate counterpart to `veilvoice verify` (which uses OpenPGP). # Checks the detached - line 1
manifest signature against the published certificate, then # every binary against the manifest. # # SPDX-License-Identifier: GPL-3.0-or-later param( [Parameter(Mandatory=$true)][string]$Dir, [string]$Cert = "veilvoice-code-cert.cer", - line 1
[string]$ExpectedThumbprint = "" ) $ErrorActionPreference = "Stop" $manifest = Join-Path $Dir "APPMANIFEST.json" $sig = "$manifest.p7s" foreach ($needed in @($Cert, $manifest, $sig)) { if (-not (Test-Path $needed)) { Write-Error "missing: - line 1
$needed"; exit 2 } } # 1. The detached signature over the manifest. $bytes = [System.IO.File]::ReadAllBytes($manifest) $content = New-Object System.Security.Cryptography.Pkcs.ContentInfo(,$bytes) $signed = New-Object - line 1
System.Security.Cryptography.Pkcs.SignedCms($content, $true) $signed.Decode([System.IO.File]::ReadAllBytes($sig)) try { $signed.CheckSignature($true) # verify the signature; do not chain to a CA Write-Host "ok signature over - line 1
APPMANIFEST.json is valid" } catch { Write-Error "FAIL the signature over APPMANIFEST.json did not verify" exit 2 } # 2. The certificate thumbprint, if one was given to check against. $certObj = New-Object - line 1
System.Security.Cryptography.X509Certificates.X509Certificate2($Cert) if ($ExpectedThumbprint) { $a = ($certObj.Thumbprint -replace '[: ]','').ToUpper() - line 41
$b = ($ExpectedThumbprint -replace '[: ]','').ToUpper() if ($a -eq $b) { Write-Host "ok certificate thumbprint matches the one you trust" } else { Write-Error "FAIL thumbprint mismatch: expected $b, found $a"; exit 2 } } else { Write-Host - line 41
"note thumbprint is $($certObj.Thumbprint)" Write-Host " compare it against the website before trusting." } # 3. Every binary against the manifest, via the shared Python checker. if (Get-Command python3 -ErrorAction SilentlyContinue) { - line 41
python3 tools/sign/manifest.py $Dir --check if ($LASTEXITCODE -ne 0) { exit 2 } } elseif (Get-Command python -ErrorAction SilentlyContinue) { python tools/sign/manifest.py $Dir --check if ($LASTEXITCODE -ne 0) { exit 2 } } else { - line 41
Write-Host "note skipping the per-binary check (needs Python and the repo checkout)" }
tools/sign/verify.sh
- line 1
#!/bin/sh # Verify a VeilVoice release against its self-signed code certificate. # # tools/sign/verify.sh DIR # # Checks, in order: # 1. the manifest's signature, against the published certificate # 2. the certificate's fingerprint, - line 1
against the one you trust # 3. every binary, against the now-trusted manifest # # This is the code-certificate counterpart to `veilvoice verify`, which uses the # OpenPGP key. Either one on its own establishes the download; both together - line 1
is # two independent identities agreeing. The OpenPGP one is primary. # # SPDX-License-Identifier: GPL-3.0-or-later set -eu DIR="${1:-.}" CERT="${VV_CERT:-veilvoice-code-cert.pem}" MANIFEST="$DIR/APPMANIFEST.json" SIG="$MANIFEST.sig" # - line 1
Published fingerprint of the code certificate. Compare what you import against # this and against the copy on the website before trusting it. EXPECTED_FPR="${VV_CERT_FPR:-}" have() { command -v "$1" >/dev/null 2>&1; } have openssl || { - line 1
echo "openssl is required" >&2; exit 1; } [ -f "$CERT" ] || { echo "no certificate at $CERT (set VV_CERT)" >&2; exit 1; } [ -f "$MANIFEST" ] || { echo "no APPMANIFEST.json in $DIR" >&2; exit 1; } [ -f "$SIG" ] || { echo "no - line 1
APPMANIFEST.json.sig in $DIR" >&2; exit 1; } # 1. The signature over the manifest. if openssl cms -verify -binary -in "$SIG" -inform PEM -content "$MANIFEST" \ -certfile "$CERT" -CAfile "$CERT" -purpose any -out /dev/null 2>/dev/null; then - line 1
echo "ok signature over APPMANIFEST.json is valid" else echo "FAIL the signature over APPMANIFEST.json did not verify" >&2 exit 2 - line 41
fi # 2. The certificate fingerprint, if one to check against was given. GOT_FPR=$(openssl x509 -in "$CERT" -noout -fingerprint -sha256 | sed 's/.*=//') if [ -n "$EXPECTED_FPR" ]; then norm() { printf '%s' "$1" | tr -d ': ' | tr 'a-f' - line 41
'A-F'; } if [ "$(norm "$GOT_FPR")" = "$(norm "$EXPECTED_FPR")" ]; then echo "ok certificate fingerprint matches the one you trust" else echo "FAIL certificate fingerprint does not match" >&2 echo " expected $EXPECTED_FPR" >&2 echo " found - line 41
$GOT_FPR" >&2 exit 2 fi else echo "note fingerprint is $GOT_FPR" echo " set VV_CERT_FPR, or compare it against the website, before trusting." fi # 3. Every binary against the manifest, reusing the Python checker so there is # one - line 41
implementation of "does this file match the manifest". if have python3 && [ -f "tools/sign/manifest.py" ]; then python3 tools/sign/manifest.py "$DIR" --check else echo "note skipping the per-binary check (needs python3 and the repo - line 41
checkout)" fi
tools/site-tests/addresses.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Every page says where it lives, and a crawler is told it may read the site. // // # The defect this exists to stop coming back // // `og:image` was `assets/banner.png` on every page: a - line 1
relative URL, typed by // hand. The crawlers that read these tags do not resolve relative URLs against // the page, so every link to this site, in every chat application and every // social network, showed no picture at all. Nothing on the - line 1
page looked wrong, // which is why it survived: the tag was there, it was spelled correctly, and // it named a file that exists. // // There was no `og:url` and no canonical link either, so a page reachable at // more than one address is - line 1
more than one page as far as an index is concerned, // and two of them, `404.html` and `wiki.html`, carried no tags at all. // // # Why this is here as well as `tools/site/seo.py --check` // // That check asks "is each page exactly what - line 1
the generator would write". This // one asks "is what the generator writes correct". They fail differently: a // generator that starts emitting relative image URLs passes the first check on // every page and fails this one on every page. - line 1
"use strict"; const fs = require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); const SITE = path.join(ROOT, "website"); function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } - line 1
/** Every page of the site, relative to `website/`, sorted. */ function pages(dir = SITE, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { const full = path.join(dir, - line 1
entry.name); - line 41
if (entry.isDirectory()) { pages(full, out); } else if (entry.name.endsWith(".html")) { out.push(path.relative(SITE, full).replace(/\\/g, "/")); } } return out; } function run() { let failures = 0; const fail = (why) => { failures += 1; - line 41
console.log(`FAIL ${why}`); }; const pass = (what) => console.log(` ok ${what}`); const all = pages(); // robots.txt: readable, and it says where the sitemap is. let robots = null; try { robots = read("website/robots.txt"); } catch (e) { - line 41
/* reported below */ } if (robots === null) { fail("website/robots.txt is missing, so a crawler is told nothing at all"); } else if (!/^\s*User-agent:\s*\*/m.test(robots) || !/^\s*Allow:\s*\/\s*$/m.test(robots)) { fail("website/robots.txt - line 41
does not allow every crawler to read the whole site"); } else if (!/^Sitemap:\s*https:\/\/\S+\/sitemap\.xml\s*$/m.test(robots)) { fail("website/robots.txt names no sitemap, so a crawler has to guess at the pages"); } else { - line 41
pass("robots.txt allows the whole site and names the sitemap"); } // The sitemap lists every page, and nothing that is not one. let sitemap = null; try { sitemap = read("website/sitemap.xml"); } catch (e) { /* reported below */ } if - line 41
(sitemap === null) { fail("website/sitemap.xml is missing"); } else { const listed = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]); const base = listed.length ? listed[0].replace(/[^/]*$/, "") : ""; const tails = new - line 41
Set(listed.map(url => url.slice(base.length) || "index.html")); const missing = all.filter(rel => !tails.has(rel) && !tails.has(rel.replace(/index\.html$/, ""))); if (!listed.length) { fail("website/sitemap.xml lists no pages"); - line 81
} else if (missing.length) { fail(`${missing.length} page(s) are not in the sitemap, so nothing points a ` + `crawler at them: ${missing.slice(0, 5).join(", ")}` + (missing.length > 5 ? ", ..." : "")); } else { pass(`the sitemap lists all - line 81
${all.length} pages`); } } // Each page: one canonical, one og:url, and an absolute preview picture. const noCanonical = []; const manyCanonical = []; const relativeImage = []; const noTitle = []; for (const rel of all) { const html = - line 81
read("website/" + rel); const canonical = [...html.matchAll(/<link rel="canonical" href="([^"]*)"/g)]; if (!canonical.length) { noCanonical.push(rel); } else if (canonical.length > 1) { manyCanonical.push(rel); } else if - line 81
(!/^https:\/\//.test(canonical[0][1])) { relativeImage.push(`${rel} (canonical)`); } for (const m of html.matchAll(/<meta (?:property|name)="(?:og:image|twitter:image|og:url)" content="([^"]*)"/g)) { if (!/^https:\/\//.test(m[1])) { - line 81
relativeImage.push(`${rel}: ${m[1]}`); } } if (!/<meta property="og:title" content="[^"]+"/.test(html)) { noTitle.push(rel); } } if (noCanonical.length) { fail(`${noCanonical.length} page(s) have no canonical link, so an index cannot ` + - line 81
`tell which address is the page's own: ${noCanonical.slice(0, 5).join(", ")}`); } else if (manyCanonical.length) { fail(`${manyCanonical.length} page(s) declare more than one canonical address: ` + manyCanonical.slice(0, 5).join(", ")); } - line 81
else { pass(`all ${all.length} pages name one canonical address`); } if (relativeImage.length) { fail(`${relativeImage.length} address(es) are relative. A crawler reading these ` + `tags does not resolve them against the page, so the link - line 81
preview has no ` + - line 121
`picture: ${relativeImage.slice(0, 5).join("; ")}`); } else { pass("every address and preview picture is absolute"); } if (noTitle.length) { fail(`${noTitle.length} page(s) carry no og:title, so a link to them shows the ` + `URL: - line 121
${noTitle.slice(0, 5).join(", ")}`); } else { pass("every page carries a preview title"); } return failures; } module.exports = { name: "every page says where it lives", run }; if (require.main === module) { process.exit(run() === 0 ? 0 : - line 121
1); }
tools/site-tests/alignment.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Buttons line up with the buttons beside them, and a new feature cannot // quietly add a row that does not. // // # The defect this exists to stop coming back // // Buttons that appear only - line 1
after setup were not aligned with the ones that // are there from the start. In the desktop application the cause was labels // padded with trailing spaces to fake a column, which lines nothing up in a // proportional font; that is guarded - line 1
in `veilvoice-gui` by a test beside the // code. On the website the cause was the other half of the same habit: // `.row`, which holds the download buttons on the front page and the download // page, declared `display: flex` and said - line 1
nothing about `align-items`. // // A flex container that says nothing gets `stretch`. While every button in a // row happens to be one line tall that is invisible, and the first button // whose label wraps on a narrow viewport makes every - line 1
button beside it taller. // It is invisible right up until it is on somebody's phone. // // # Why this checks the containers and not the buttons // // The buttons are fine: `.btn` is an `inline-flex` that centres its own // contents, and - line 1
it was fine before this test existed. Alignment between // siblings is a property of the thing that holds them, so that is what is // read. A check that measured the buttons would pass while the row that // arranges them said nothing at - line 1
all. // // # What "holds buttons" means // // The pages are read to find out, rather than a list of class names being // kept here. A list would go stale the first time somebody adds a row, which // is precisely the case this is for: the - line 1
point is to catch the *next* feature, // not to describe the current ones. "use strict"; const fs = require("fs"); const path = require("path"); - line 41
const ROOT = path.resolve(__dirname, "..", ".."); /** Anything a reader would call a button. */ const BUTTON_CLASS = /\b(btn|term-btn|walk-tab)\b/; function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } function - line 41
pages() { const out = []; for (const dir of ["website", "website/nojs"]) { const full = path.join(ROOT, dir); if (!fs.existsSync(full)) { continue; } for (const name of fs.readdirSync(full)) { if (name.endsWith(".html")) { - line 41
out.push(path.posix.join(dir, name)); } } } return out; } /** * Every class that directly wraps a button, per page. * * Deliberately crude: the nearest preceding opening tag that carries a class. * It is not a parser and does not need to - line 41
be, because it is looking for * containers to ask the stylesheet about, and a wrong guess costs a lookup * that finds no flex rule and moves on. */ function containersHoldingButtons() { const found = new Map(); const opener = - line 41
/<(?:div|nav|section|p|header|footer|form)\b[^>]*class="([^"]*)"[^>]*>/g; const button = /<(?:a|button)\b[^>]*class="([^"]*)"[^>]*>/g; for (const page of pages()) { const html = read(page); let last = null; const marks = []; let m; - line 41
opener.lastIndex = 0; - line 81
while ((m = opener.exec(html)) !== null) { marks.push({ at: m.index, cls: m[1].trim() }); } button.lastIndex = 0; while ((m = button.exec(html)) !== null) { if (!BUTTON_CLASS.test(m[1])) { continue; } last = null; for (const mark of marks) - line 81
{ if (mark.at < m.index) { last = mark.cls; } else { break; } } if (!last) { continue; } for (const cls of last.split(/\s+/).filter(Boolean)) { if (!found.has(cls)) { found.set(cls, new Set()); } found.get(cls).add(page); } } } return - line 81
found; } /** Every CSS rule whose selector mentions this class, with its body. */ function rulesFor(css, cls) { const out = []; const rule = /([^{}]+)\{([^{}]*)\}/g; let m; while ((m = rule.exec(css)) !== null) { const selector = - line 81
m[1].replace(/\/\*[\s\S]*?\*\//g, "").trim(); if (new RegExp(`\\.${cls}(?![\\w-])`).test(selector)) { out.push({ selector, body: m[2] }); } } return out; } function run() { let failures = 0; const fail = (why) => { failures += 1; - line 81
console.log(`FAIL ${why}`); }; const pass = (what) => console.log(` ok ${what}`); const css = read("website/css/main.css"); - line 121
const holders = containersHoldingButtons(); if (holders.size === 0) { fail("no container holding a button was found on any page, so this suite " + "is reading the pages wrongly and is checking nothing"); return failures; } let checked = 0; - line 121
for (const [cls, where] of [...holders].sort()) { for (const { selector, body } of rulesFor(css, cls)) { if (!/display:\s*(inline-)?flex/.test(body)) { continue; } checked += 1; if (!/align-items:/.test(body)) { fail(`\`${selector}\` holds - line 121
buttons on ${[...where].join(", ")} and is ` + "a flex container that does not declare `align-items`, so it " + "falls back to `stretch`: one button whose label wraps makes " + "every button beside it taller. Say what you mean, even if you - line 121
" + "mean stretch."); } else { pass(`\`${selector}\` decides how its buttons line up`); } } } if (checked === 0) { fail("every container that holds a button was found, and not one of them " + "is a flex row, which is not credible: the - line 121
selectors or the " + "stylesheet path have moved and this suite is passing without " + "reading anything"); } return failures; } module.exports = { run, name: "buttons line up with the buttons beside them" };
tools/site-tests/anchors.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Following a link into a page lands on the thing it names, with that thing // visible. // // # The defect this exists to stop coming back // // The header is `position: sticky`, so whatever - line 1
a fragment jump puts at the // top of the viewport is underneath it. `scroll-margin-top` is the property // for that, and the stylesheet set it to a flat `90px`. // // The header is not 90px tall, at any width. Measured in a browser it is - line 1
// 133px at desktop widths, 171px where the navigation wraps to three rows, // 115px on a phone, 143px at 320px, and 81px on the reference pages. At the // common desktop width that is 43px in the direction that puts the heading // behind - line 1
the header: the reader follows `download` and arrives at a page that // appears to start mid-sentence. // // The rule also named `section`, `h2` and `h3` and nothing else, so an `h4` // or a list item with an id got no offset at all. That - line 1
is every entry on the // releases page and every entry on the roadmap. // // # What is checked, and why it is these things // // A written-down height is the defect, so the check is that the offset is not // written down: the stylesheet - line 1
has to hold a variable, `js/teleport.js` has // to measure the header, and every page whose header is sticky has to load // it. None of that can be measured without a browser, which this suite does // not have, but all of it can be read. - line 1
// // The selector is checked too. A list of element names is how the releases // page and the roadmap ended up with no offset at all, and the next id to be // added to something not on the list would go the same way. "use strict"; const - line 1
fs = require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); - line 41
const SITE = path.join(ROOT, "website"); function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } /** Every page under `website/`, repository-relative, sorted. */ function pages(dir = SITE, out = []) { for (const entry - line 41
of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { pages(full, out); } else if (entry.name.endsWith(".html")) { - line 41
out.push(path.relative(ROOT, full)); } } return out; } function run() { let failures = 0; const fail = (why) => { failures += 1; console.log(`FAIL ${why}`); }; const pass = (what) => console.log(` ok ${what}`); const css = - line 41
read("website/css/main.css"); // Every `scroll-margin-top` that exists to clear the header. const offsets = [...css.matchAll(/scroll-margin-top:\s*([^;]+);/g)].map(m => m[1].trim()); if (!offsets.length) { fail("main.css declares no - line 41
scroll-margin-top, so every fragment jump " + "lands underneath the sticky header"); } else { const written = offsets.filter(value => !value.includes("var(--anchor-offset)")); if (written.length) { fail(`main.css clears the header with - line 41
${written.join(", ")} rather than ` + "the measured offset. The header is 81, 115, 133, 143 or 171px " + "tall depending on the page and the width, so any number written " + "here is wrong somewhere."); } else { pass(`all ${offsets.length} - line 41
anchor offsets come from the measured variable`); } } - line 81
// The selector. Anything with an id can be a fragment target. if (!/\[id\]\s*\{\s*scroll-margin-top:/.test(css)) { fail("main.css applies the anchor offset to a list of element names " + "rather than to `[id]`. An h4 or a list item with - line 81
an id is a " + "fragment target too, and every roadmap and release entry is one."); } else { pass("the anchor offset applies to every id, not to a list of tag names"); } // The starting value exists, so a page lands somewhere sensible in - line 81
the frame // before the measurement lands. if (!/--anchor-offset:\s*\d+px/.test(css)) { fail("main.css declares no starting value for --anchor-offset, so the " + "offset is zero until js/teleport.js has run"); } else { - line 81
pass("--anchor-offset has a starting value for the frame before it is measured"); } // The script measures rather than assumes. const js = read("website/js/teleport.js"); if (!/getBoundingClientRect\(\)\.height/.test(js)) { - line 81
fail("js/teleport.js no longer measures the header's height, which is " + "the whole reason it exists"); } else { pass("js/teleport.js takes the header's height from the header"); } // Nothing above a landing may change size after the - line 81
jump. // // An image with no width and height has no size until it arrives, so // everything below it moves down when it does. Measured in a browser, a // link to the demonstration section landed and then kept moving for over a // second - line 81
while six drawings of help screens and a screen photograph loaded // above it. The offset was right the whole time; the page was not finished. const unsized = []; for (const rel of pages()) { const html = read(rel); for (const tag of - line 81
html.match(/<img\s[^>]*>/g) || []) { if (!/\swidth=/.test(tag) || !/\sheight=/.test(tag)) { unsized.push(`${rel}: ${tag.slice(0, 70)}`); - line 121
} } } if (unsized.length) { fail(`${unsized.length} image(s) declare no width and height, so everything ` + `below them moves when they load: ${unsized.slice(0, 5).join("; ")}` + (unsized.length > 5 ? ", ..." : "")); } else { pass("every - line 121
image declares its size, so nothing below it moves when it loads"); } // Every page with a sticky header loads it. const missing = pages().filter(rel => { const html = read(rel); return /<header class="top">/.test(html) && - line 121
!/js\/teleport\.js/.test(html); }); if (missing.length) { fail(`${missing.length} page(s) carry the sticky header without loading ` + `js/teleport.js, so their anchors land at whatever the starting ` + `value happens to be: - line 121
${missing.slice(0, 5).join(", ")}` + (missing.length > 5 ? ", ..." : "")); } else { pass("every page with a sticky header loads the script that measures it"); } return failures; } module.exports = { name: "fragment links land on what they - line 121
name", run }; if (require.main === module) { process.exit(run() === 0 ? 0 : 1); }
tools/site-tests/audit.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The audit's own arithmetic, and whether every finding it hands out a number // to actually has an entry. // // # The two defects this exists to stop coming back // // F-105. The verdict - line 1
opened with "One hundred and four defects found and fixed // across eighteen audit rounds (F-1 to F-104)" and then broke that down as // "eight in the first two, twenty-eight in the third, eleven in the fourth, // twelve in the fifth, one - line 1
in the sixth, five in the seventh". Those sum to // sixty-five. The headline had been maintained round after round; the // explanation underneath it had not been touched in eleven rounds. Two numbers // about the same thing, one of them - line 1
being kept. // // F-93 and F-94. Both were real, both were fixed, and both were described in // full in the commit that fixed them. Neither was ever given an entry in // `docs/AUDIT.md`, so the finding numbers ran 1 to 104 with a hole at - line 1
94 and // nothing said so. A later round then referred a reader back to "F-93" and // there was nothing to refer them to. // // # Why this compares against a measured number and not against prose // // F-71 is the reason. A guard already - line 1
existed that compared the front page's // test count against `docs/AUDIT.md`, and it passed the whole time both // numbers were wrong, because both were hand-typed and drifted together. A // check that compares one copy of a claim against - line 1
another copy agrees with // itself. // // So the count comes from `docs/MEASURED.md`, which // `tools/measured/generate.py` derives by reading the audit's finding headings // out of the document. The documents that make claims are then - line 1
checked against // that, never against each other. // // # What a gap means, and why it is a failure rather than a note // // The generator records two numbers: how many findings have an entry, and the // highest number handed out. They - line 1
are equal exactly when no number has been // skipped. A gap means one of two things, and both are worth failing a build // over: a finding was fixed and never written up, which is F-94, or a number - line 41
// was allocated to something that turned out not to be a finding and the // document does not say so. Either way the audit is claiming a completeness it // does not have. "use strict"; const fs = require("fs"); const path = - line 41
require("path"); const ROOT = path.resolve(__dirname, "..", ".."); function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } /** A row out of the measured table, as a number. */ function measured(label) { const table = - line 41
read("docs/MEASURED.md"); const row = new RegExp(`^\\| ${label} \\| (\\d+) \\|$`, "m").exec(table); return row ? Number(row[1]) : null; } /** * "One hundred and five" as 105. * * The verdict states its total in words, and a check that - line 41
skipped it because * parsing words is fiddly would be checking the easy half of the sentence and * reporting on the whole of it. That is the shape of mistake this file exists * to catch, so the words are parsed. Scale is deliberately - line 41
limited to hundreds: * a project claiming a thousand findings has a different problem. */ function wordsToNumber(words) { const UNITS = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, - line 41
eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19 }; const TENS = { twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, - line 81
eighty: 80, ninety: 90 }; let total = 0; let current = 0; let saw = false; for (const word of words.toLowerCase().split(/[\s-]+/)) { if (word === "and" || word === "") { continue; } if (word === "hundred") { current = (current || 1) * 100; - line 81
saw = true; continue; } if (Object.prototype.hasOwnProperty.call(UNITS, word)) { current += UNITS[word]; saw = true; continue; } if (Object.prototype.hasOwnProperty.call(TENS, word)) { current += TENS[word]; saw = true; continue; } return - line 81
null; } total += current; return saw ? total : null; } function run() { let failures = 0; const fail = (why) => { failures += 1; console.log(`FAIL ${why}`); }; const pass = (what) => console.log(` ok ${what}`); const writtenUp = - line 81
measured("Findings written up in the audit"); const highest = measured("Highest finding number used"); if (writtenUp === null || highest === null) { fail("docs/MEASURED.md has no finding rows; regenerate it with " + - line 81
"tools/measured/generate.py"); return failures; } // A gap in the numbering. F-94's whole life as a defect was that nothing // said this. if (writtenUp !== highest) { const missing = highest - writtenUp; fail(`the audit hands out numbers - line 81
up to F-${highest} but writes up only ` + `${writtenUp} of them, so ${missing} finding number(s) have no entry. ` + "A finding fixed in code and described only in its commit message is " + "the case this checks for: see F-94."); } else { - line 121
pass(`every finding from F-1 to F-${highest} has an entry of its own`); } // The README's headline, in digits. // // It used to carry the range as well, as "(F-1 to F-112)", and this checked // both halves. The numbers have come out of the - line 121
README: they are working // references for the audit and for the code, and a reader of the front page // is being told an index number for a defect they cannot look up from // there. So only the count is claimed, and only the count is - line 121
checked. // // The range is still checked, in the audit's own verdict below, which is // where somebody who wants a finding by number is already standing. const readme = read("README.md"); const claim = /\*\*(\d+) - line 121
defects\*\*/.exec(readme); if (!claim) { fail("README.md no longer states its defect count as '**N defects**', " + "so nothing here can check it against the audit"); } else if (Number(claim[1]) !== writtenUp) { fail(`README.md claims - line 121
${claim[1]} defects; the audit writes up ${writtenUp}`); } else { pass(`README.md's ${claim[1]} matches the audit`); } // And no finding numbers anywhere in it. They are for the audit. const numbered = readme.match(/F-\d+/g); if (numbered) - line 121
{ fail(`README.md carries finding numbers (${[...new Set(numbered)].join(", ")}). ` + "Those are working references for the audit and for the code; on the " + "front page they are an index into a document the reader is not in."); } else { - line 121
pass("README.md carries no finding numbers"); } // The audit's own verdict, which states the total in words and the range in // digits. Both halves are checked: F-105 was a sentence whose two halves // disagreed with each other. const - line 121
audit = read("docs/AUDIT.md"); const verdict = /\*\*([A-Za-z][A-Za-z\s-]*?) defects found and fixed \(F-1 to (\d+|F-\d+)\)/.exec(audit); - line 161
if (!verdict) { fail("docs/AUDIT.md's verdict no longer opens with " + "'**<words> defects found and fixed (F-1 to F-N)', so nothing here " + "can check it"); } else { const spelled = wordsToNumber(verdict[1]); const end = - line 161
Number(String(verdict[2]).replace(/^F-/, "")); if (spelled === null) { fail(`the verdict's total, "${verdict[1]}", is not a number this suite ` + "can read; write it in words it can, or the check is decorative"); } else if (spelled !== - line 161
writtenUp) { fail(`the verdict says ${spelled} defects ("${verdict[1]}"); the audit ` + `writes up ${writtenUp}`); } else { pass(`the verdict's "${verdict[1]}" is ${writtenUp}, which is what is written up`); } if (end !== highest) { - line 161
fail(`the verdict's range ends at F-${end}; the last number used is F-${highest}`); } else { pass(`the verdict's range ends at F-${highest}`); } } // The breakdown that caused F-105. It is gone, and this keeps it gone: any // per-round - line 161
split written there is unmaintainable, because sixty of the // findings sit in a shared section rather than under a round each. // // Scoped to the verdict section rather than the whole file, and that is not // a detail. The first version - line 161
of this check searched the document and failed // on F-105's own write-up, which quotes the breakdown it is about. A guard // that cannot tell a quotation from a reintroduction fails honest edits and // teaches people to route around it. - line 161
const section = /\n## 6\. Verdict\n([\s\S]*?)(?=\n## |$)/.exec(audit); const verdictText = section ? section[1] : ""; if (!section) { fail("docs/AUDIT.md has no '## 6. Verdict' section, so the breakdown " + "check has nothing to read"); } - line 161
else if (/\b(?:eight|twelve|eleven|five|one) in the (?:first|second|third|fourth|fifth|sixth|seventh)\b/.test(verdictText)) { fail("the verdict has grown a per-round breakdown again. There is nothing " + "in the document to derive one - line 161
from, which is why F-105 went " + - line 201
"unnoticed for eleven rounds."); } else { pass("the verdict states a total and no hand-maintained per-round split"); } return failures; } module.exports = { run, name: "the audit's arithmetic and its finding numbers" };
tools/site-tests/characters.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // No stray characters, anywhere in the repository. // // # Why this is a test and not a tidy-up // // Four separate defects in this project were stray characters nobody could see: // // - - line 1
The Markdown renderer parked fragments behind **NUL bytes**, which lived // as literal control characters in the source. They were invisible in every // editor, made the file read as binary to `grep`, and could not be matched // by - line 1
ordinary string-replacing tools. // - When those were replaced with **private-use characters**, one escaped // into the rendered page, where browsers draw it as nothing at all. Every // README link whose label was inline code came out - line 1
empty: "see // [`docs/AUDIT.md`](docs/AUDIT.md)." was published as "see .". // - A **NUL from hostile input** passed straight through the renderer. // - Writing the fix for that one put literal control characters straight back // into the - line 1
source, because the character class was typed out instead of // escaped. Twice. // // The pattern is identical every time: a character that is invisible causes a // fault that is also invisible. Ordinary review does not catch these, so - line 1
they // are checked mechanically. // // # This file is deliberately pure ASCII // // Every pattern below is built with `new RegExp` from a string of `\uXXXX` // escapes, rather than written as a literal character class. A checker for // - line 1
invisible characters that contains invisible characters is not a joke worth // making twice: the first two attempts at this file did exactly that, and one // of them turned the checker itself into a file `grep` reported as binary. // // # - line 1
What counts as stray // // Not "unusual". This repository is full of deliberate non-ASCII prose - em // dashes, arrows, accented words - and all of that is wanted. Stray means // characters carrying no meaning a reader can see. "use - line 1
strict"; - line 41
const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); const ROOT = path.resolve(__dirname, "..", ".."); const NUL = String.fromCharCode(0); const BOM = String.fromCharCode(0xfeff); /** - line 41
Every file git tracks, which is exactly the set that ships. */ function trackedFiles() { return execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "buffer" }) .toString("utf8") .split(NUL) .filter(Boolean); } /** Legitimately - line 41
binary, and not text anyone reads. */ const BINARY = /\.(png|jpg|jpeg|gif|ico|icns|rgba|woff2?|ttf|otf|pdf|zip|gz|asc|wav|mp3|flac)$/i; /** * Directories whose contents are bytes rather than text, whatever they are * named. * * The fuzzing - line 41
seed corpus is deliberately hostile input: truncated headers, * impossible lengths, control characters by the hundred. That is what it is * for. Its files are named after their own hash and carry no extension, so the * list above cannot - line 41
reach them, and a checker demanding that a fuzzing corpus * be clean readable text would be asking it to stop being a fuzzing corpus. */ const BINARY_DIRS = [/^fuzz\/seeds\//]; const RULES = [ { // Tab and newline are fine, and so is - line 41
carriage return: this repository is // developed on Windows and git may hand back CRLF. name: "control character", pattern: "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]" }, { - line 81
name: "Unicode replacement character (always a decoding accident)", pattern: "\\uFFFD" }, { name: "private-use character (invisible, no agreed appearance)", pattern: "[\\uE000-\\uF8FF]" }, { // Trojan Source: these reorder how text - line 81
*displays* without changing what it // says, so source can read one way and mean another. A project asking // people to read its source has a specific reason to refuse them. name: "bidirectional override or isolate", pattern: - line 81
"[\\u202A-\\u202E\\u2066-\\u2069]" }, { name: "zero-width character", pattern: "[\\u200B-\\u200D\\u2060]" }, { // UTF-8 read as CP1252 and written back out. An em dash becomes the three // perfectly valid characters "a-hat, euro, quote", - line 81
so nothing here is // *invalid* - which is exactly why it survives review and why it needs a // pattern of its own rather than a validity check. name: "mojibake (UTF-8 decoded as CP1252)", pattern: - line 81
"[\\u00C2\\u00C3\\u00E2\\u00C5\\u00C4][\\u0080-\\u00BF\\u20AC\\u2019\\u201C" + "\\u201D\\u2013\\u2014\\u2026\\u02DC\\u2122\\u0161\\u0153\\u017E]" } ].map(rule => ({ name: rule.name, re: new RegExp(rule.pattern, "g") })); /** * Files that - line 81
must be pure ASCII, and why. * * These are the files the site hands over **raw**, to be read on their own * terms rather than rendered inside a page: * * - `website/js/*.js`, because the site tells readers to open `verify.js` and * confirm - line 81
for themselves that nothing is uploaded. * - `website/user-agreements/*.txt`, the licence and the liability waiver, * which are linked directly and are the documents someone reads before * deciding whether to trust any of this. - line 121
* * GitHub Pages sends `charset=utf-8` for all of them, so a browser following * the header is fine. The trouble is everything that does not: an editor, a * downloaded copy, a terminal with a CP1252 locale. There, one prose em dash * - line 121
becomes "a-hat, euro, quote" in the middle of the sentence making the * promise - which was reported twice, from two different files, before this * rule existed. * * ASCII removes the question. Non-ASCII that genuinely has to survive is * - line 121
written as a `\\uXXXX` escape: ASCII on disk, correct on screen. See the * theme name in `theme.js`. * * Markdown and HTML are deliberately exempt. They are prose, they declare their * own encoding, and em dashes in them are wanted. */ - line 121
const ASCII_ONLY = /^website[/\\](js[/\\].*\.js|user-agreements[/\\].*\.txt)$/; function describe(ch) { return "U+" + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0"); } function positionOf(text, index) { const before = - line 121
text.slice(0, index); return before.split("\n").length + ":" + (index - before.lastIndexOf("\n")); } function run() { let failures = 0; let scanned = 0; for (const rel of trackedFiles()) { const full = path.join(ROOT, rel); if ( - line 121
BINARY.test(rel) || BINARY_DIRS.some((dir) => dir.test(rel)) || !fs.existsSync(full) || fs.statSync(full).isDirectory() ) { continue; } const text = fs.readFileSync(full, "utf8"); - line 161
scanned++; const problems = []; for (const rule of RULES) { rule.re.lastIndex = 0; let m; while ((m = rule.re.exec(text)) !== null) { problems.push(rule.name + " " + describe(m[0]) + " at " + positionOf(text, m.index)); if (problems.length - line 161
> 6) { break; } } } // A byte-order mark is unwanted anywhere: at the start it is noise in a // repository that is UTF-8 throughout, and anywhere else it is a mistake. if (text.charCodeAt(0) === 0xfeff) { problems.push("byte-order mark at - line 161
the start of the file"); } const stray = text.indexOf(BOM, 1); if (stray !== -1) { problems.push("byte-order mark mid-file at " + positionOf(text, stray)); } if (ASCII_ONLY.test(rel)) { for (let i = 0; i < text.length; i++) { if - line 161
(text.charCodeAt(i) > 127) { problems.push( "non-ASCII " + describe(text[i]) + " at " + positionOf(text, i) + " - this file is served raw and must be ASCII, so no viewer can" + " mis-decode it; use a \\uXXXX escape if the character must - line 161
survive" ); break; } } } if (problems.length) { failures++; console.log("FAIL " + rel); problems.slice(0, 6).forEach(p => console.log(" " + p)); } - line 201
} console.log(" " + scanned + " tracked text files scanned"); return failures; } module.exports = { run, name: "no stray characters in the repository" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/complexity-probe.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Renders one adversarial document and prints how long it took, in // milliseconds, on stdout. // // A separate process on purpose. A regular-expression match is *synchronous* // and cannot - line 1
be interrupted: once the engine starts backtracking there is no // timer, no signal and no `await` that will get control back. A suite that // measured these in-process would therefore hang rather than fail when a // quadratic reappeared - line 1
-- which is the worst outcome, because a hung CI job // looks like an infrastructure problem and gets retried, while a failing one // gets read. Measured here and killed from outside, a catastrophic case becomes // a timeout the parent can - line 1
report as the failure it is. // // node complexity-probe.js <shape> <size> "use strict"; const fs = require("fs"); const path = require("path"); const vm = require("vm"); const ROOT = path.resolve(__dirname, "..", ".."); const sandbox = { - line 1
window: {} }; vm.createContext(sandbox); vm.runInContext(fs.readFileSync(path.join(ROOT, "website", "js", "markdown.js"), "utf8"), sandbox); const MD = sandbox.window.MD; /** * Every one of these is a real shape, found by reading the - line 1
patterns for two * runs that can both match the same character, or for an unbounded scan * repeated at every position in the document. */ const SHAPES = { "image target that never closes": (n) => ", "link target that - line 1
never closes": (n) => "[a](" + "b".repeat(n), "an opening bracket at every position": (n) => "[".repeat(n) + "]".repeat(n), "a bracket then an open parenthesis, repeated": (n) => "[".repeat(n) + "](".repeat(n), "inline code that never - line 1
closes": (n) => "`" + "a".repeat(n), "a backtick at every position": (n) => "`x".repeat(Math.floor(n / 2)), - line 41
"an unterminated string in a code block": (n) => '```rust\nlet s = "' + "a".repeat(n) + "\n```", "a quote at every position in a code block": (n) => "```rust\n" + '"x'.repeat(Math.floor(n / 2)) + "\n```", "emphasis markers only": (n) => - line 41
"*".repeat(n), "an underscore at every position": (n) => "_x".repeat(Math.floor(n / 2)), "a table row at every line": (n) => "|a|b|\n|-|-|\n" + "|x|y|\n".repeat(Math.floor(n / 6)), "a list item at every line": (n) => "- - line 41
x\n".repeat(Math.floor(n / 4)), "a heading at every line": (n) => "# x\n".repeat(Math.floor(n / 4)), "a parenthesis at every position": (n) => "](".repeat(Math.floor(n / 2)), "an exclamation bracket at every position": (n) => - line 41
"![".repeat(Math.floor(n / 2)) }; module.exports = { SHAPES }; if (require.main === module) { const shape = process.argv[2]; const size = Number(process.argv[3]); const build = Object.prototype.hasOwnProperty.call(SHAPES, shape) ? - line 41
SHAPES[shape] : null; if (!build || !isFinite(size)) { console.error("usage: complexity-probe.js <shape> <size>"); process.exit(2); } const source = build(size); // Median of three: one sample on a shared machine is noise. const samples = - line 41
[]; for (let i = 0; i < 3; i++) { const started = process.hrtime.bigint(); MD.render(source); samples.push(Number(process.hrtime.bigint() - started) / 1e6); } samples.sort((a, b) => a - b); process.stdout.write(String(samples[1])); }
tools/site-tests/css.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Cross-engine invariants for the stylesheets. // // The site is served to whatever browser somebody has, and this project has no // build step -- no autoprefixer, no PostCSS, no - line 1
browserslist. That is a // deliberate choice (what is in `website/` is exactly what GitHub Pages serves, // which is what makes "read the file yourself" a real invitation), and the // price of it is that nothing adds vendor prefixes or - line 1
fallbacks on your behalf. // This suite is the thing that notices when one is missing. // // Every rule below corresponds to a real degradation found by reading the CSS // against what each engine actually shipped, not to a general - line 1
preference: // // - `backdrop-filter` was unprefixed only. Safari did not support the // unprefixed property until version 18 (late 2024), so on every iPhone // running iOS 17 or earlier the translucent header had no blur at all. // // - - line 1
`color-mix()` arrived in Chrome 111, Safari 16.2 and Firefox 113, all in // 2023. An engine older than that discards the whole declaration -- and // three of the four uses had no preceding fallback, so the element was left // with *no - line 1
background*. The worst was the legal gate: a fixed overlay shown // with `body { overflow: hidden }`, which without its background is an // invisible modal that silently stops the page scrolling. // // - `:focus-visible` arrived in Safari - line 1
15.4. An unsupported pseudo-class // makes the entire selector list invalid, so a single rule combining // `:focus-visible` selectors left older Safari with no focus ring anywhere // -- the page unnavigable by keyboard. // // - - line 1
`color-scheme` is the only way to reach the browser's *native* controls. // Without it the theme `<select>`'s dropdown and the verifier's // `<progress>` bar render light on a near-black page. "use strict"; const fs = require("fs"); const - line 1
path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); - line 41
const CSS_DIR = path.join(ROOT, "website", "css"); function read(name) { return fs.readFileSync(path.join(CSS_DIR, name), "utf8"); } /** Strip comments, so prose about a property is never mistaken for a use. */ function code(text) { return - line 41
text.replace(/\/\*[\s\S]*?\*\//g, ""); } /** * Split a stylesheet into declaration blocks, keeping the selector. * Crude, and sufficient: this stylesheet is hand-written and shallow. */ function blocks(css) { const found = []; const - line 41
pattern = /([^{}]+)\{([^{}]*)\}/g; let match; while ((match = pattern.exec(css)) !== null) { found.push({ selector: match[1].trim().replace(/\s+/g, " "), body: match[2] }); } return found; } function run() { let failures = 0; const fail = - line 41
(message) => { console.log("FAIL " + message); failures++; }; const pass = (message) => console.log("ok " + message); const files = fs.readdirSync(CSS_DIR).filter((f) => f.endsWith(".css")); // --- 1. backdrop-filter must always be paired - line 41
with the WebKit prefix ------ let backdropUses = 0; for (const file of files) { for (const block of blocks(code(read(file)))) { const unprefixed = /(^|[;\s])backdrop-filter\s*:/.test(block.body); const prefixed = - line 41
/-webkit-backdrop-filter\s*:/.test(block.body); if (unprefixed) { backdropUses++; - line 81
if (!prefixed) { fail( `${file}: \`${block.selector}\` uses backdrop-filter without ` + "-webkit-backdrop-filter, so it does nothing on Safari before 18" ); } } if (prefixed && !unprefixed) { fail(`${file}: \`${block.selector}\` has only - line 81
the prefixed backdrop-filter`); } } } if (backdropUses > 0) { pass(`all ${backdropUses} backdrop-filter uses carry the -webkit- prefix`); } // --- 2. every color-mix() must have a plain fallback before it ------------ let mixUses = 0; for - line 81
(const file of files) { for (const block of blocks(code(read(file)))) { // Declarations in order, so "before" is meaningful. const declarations = block.body .split(";") .map((d) => d.trim()) .filter(Boolean); - line 81
declarations.forEach((declaration, index) => { if (!/color-mix\s*\(/.test(declaration)) { return; } mixUses++; const property = declaration.split(":")[0].trim(); const hasFallback = declarations .slice(0, index) .some((earlier) => - line 81
earlier.split(":")[0].trim() === property && !/color-mix\s*\(/.test(earlier) ); if (!hasFallback) { fail( `${file}: \`${block.selector}\` sets ${property} with color-mix() and ` + "no earlier plain fallback -- engines before 2023 drop it - line 81
entirely" ); } - line 121
}); } } if (mixUses > 0) { pass(`all ${mixUses} color-mix() declarations have a plain fallback first`); } // --- 3. :focus-visible must not be the only focus rule ------------------- // // And it must not share a selector list with plain - line 121
`:focus`, since one // unsupported pseudo-class invalidates the whole list. for (const file of files) { const css = code(read(file)); const usesFocusVisible = /:focus-visible/.test(css); if (!usesFocusVisible) { continue; } for (const - line 121
block of blocks(css)) { if (!/:focus-visible/.test(block.selector)) { continue; } // `:focus:not(:focus-visible)` is the standard progressive-enhancement // form and is *meant* to be dropped by an engine that does not know // - line 121
`:focus-visible` -- being dropped is what leaves the plain `:focus` // ring in place. Only a list that mixes a bare `:focus` selector with a // `:focus-visible` one is a problem, because then the fallback and the // enhancement fall - line 121
together. const parts = block.selector.split(",").map((s) => s.trim()); const bare = parts.filter( (s) => /:focus(?![-a-z])/.test(s) && !/:not\(\s*:focus-visible\s*\)/.test(s) ); const enhanced = parts.filter((s) => - line 121
/:focus-visible/.test(s) && !/:not\(/.test(s)); if (bare.length && enhanced.length) { fail( `${file}: \`${block.selector}\` mixes a bare :focus selector with a ` + ":focus-visible one -- an engine that knows neither drops both" ); } } - line 121
const hasPlainFocusRule = blocks(css).some( (b) => /:focus(?![-a-z])/.test(b.selector) && /outline\s*:/.test(b.body) && !/outline\s*:\s*none/.test(b.body) ); if (!hasPlainFocusRule) { - line 161
fail( `${file}: :focus-visible is used with no plain :focus fallback, so Safari ` + "before 15.4 shows no focus ring at all" ); } else { pass(`${file}: :focus-visible has a plain :focus fallback`); } } // --- 4. every theme declares a - line 161
color-scheme ------------------------------ const themes = code(read("themes.css")); const themeBlocks = blocks(themes).filter((b) => /--bg\s*:/.test(b.body)); if (themeBlocks.length < 5) { fail(`only ${themeBlocks.length} theme blocks - line 161
found -- the parser is wrong`); } for (const block of themeBlocks) { if (!/color-scheme\s*:\s*(light|dark)/.test(block.body)) { fail( `themes.css: \`${block.selector}\` defines colours but no color-scheme, so ` + "native controls will not - line 161
match it" ); continue; } // And the declared scheme must agree with the background it sits beside, // or the native controls are wrong in the other direction. const declared = /color-scheme\s*:\s*(light|dark)/.exec(block.body)[1]; const bg - line 161
= /--bg\s*:\s*#([0-9a-fA-F]{6})/.exec(block.body); if (bg) { const v = bg[1]; const luminance = parseInt(v.slice(0, 2), 16) * 0.299 + parseInt(v.slice(2, 4), 16) * 0.587 + parseInt(v.slice(4, 6), 16) * 0.114; const expected = luminance > - line 161
127 ? "light" : "dark"; if (declared !== expected) { fail( `themes.css: \`${block.selector}\` says color-scheme: ${declared} but its ` + `background #${v} is ${expected}` ); } - line 201
} } pass(`all ${themeBlocks.length} themes declare a color-scheme matching their background`); // --- 5. no fixed viewport-height units ----------------------------------- // // `100vh` on iOS Safari is the height of the viewport *without* - line 201
the browser // chrome, so a full-height element is taller than the screen and its bottom // is unreachable. Not currently used; this keeps it that way. // // A `vh` value is allowed only as the *fallback* immediately before the same // - line 201
property in `dvh`, which is the documented way to support both. let vhProblems = 0; for (const file of files) { for (const block of blocks(code(read(file)))) { const declarations = block.body.split(";").map((d) => - line 201
d.trim()).filter(Boolean); declarations.forEach((declaration, index) => { if (!/(?:^|[\s:(])\d+(?:\.\d+)?vh\b/.test(declaration)) { return; } const property = declaration.split(":")[0].trim(); const upgraded = declarations .slice(index + - line 201
1) .some((later) => later.split(":")[0].trim() === property && /dvh\b/.test(later)); if (!upgraded) { vhProblems++; fail( `${file}: \`${block.selector}\` sets ${property} in vh with no dvh after ` + "it -- on iOS Safari vh is not the - line 201
height you can see, so the bottom " + "of the element can be unreachable" ); } }); } } if (vhProblems === 0) { pass("every vh length is followed by a dvh upgrade"); } // --- 6. the mobile header must not be allowed to grow - line 201
--------------------- // // Nine navigation links wrapped onto four rows at 375 px and, because the - line 241
// header is sticky, cost 165 px of an 812 px screen at every scroll position. // The fix is a single scrolling row; this asserts the parts of it that a // future edit could remove without anyone noticing on a desktop. const main = - line 241
code(read("main.css")); const mobileHeader = /@media[^{]*max-width:\s*7\d\dpx[^{]*\{([\s\S]*?)\n\}/.exec(main); if (!mobileHeader) { fail("main.css: no narrow-viewport media query for the header was found"); } else { const body = - line 241
mobileHeader[1]; const required = [ [/nav\.links[\s\S]*?overflow-x:\s*auto/, "the nav must scroll rather than wrap"], [/nav\.links[\s\S]*?min-width:\s*0/, "min-width: 0 is what lets a flex item scroll"], - line 241
[/nav\.links[\s\S]*?white-space:\s*nowrap/, "links must not break mid-word"] ]; for (const [pattern, why] of required) { if (!pattern.test(body)) { fail(`main.css, narrow viewport: ${why}`); } } pass("the narrow-viewport header keeps its - line 241
single scrolling nav row"); } // --- 7. tap targets ------------------------------------------------------ if (!/nav\.links a\s*\{[^}]*min-height:\s*2[4-9]px/.test(main)) { fail("main.css: nav links must be at least 24px tall (WCAG - line 241
2.5.8)"); } else { pass("nav links declare a 24px minimum tap target"); } // --- 8. the cycling fact line ------------------------------------------- // // The keyframe percentages are one slot of N, so `--fact-count` in the // stylesheet - line 241
and the number of `.fact` elements in the page must agree. // Adding a fact without widening the cycle overlaps two messages and makes // both unreadable -- on the strip carrying this project's own claims, which // is the neighbourhood - line 241
finding F-37 lived in. const indexHtml = fs.readFileSync( path.join(ROOT, "website", "index.html"), "utf8"); const declared = /--fact-count:\s*(\d+)/.exec(main); const present = (indexHtml.match(/class="fact"/g) || []).length; if - line 241
(!declared) { fail("main.css: --fact-count is not declared, so nothing pins the cycle"); - line 281
} else if (Number(declared[1]) !== present) { fail(`the fact line disagrees with itself: main.css says ${declared[1]}, ` + `index.html has ${present}`); } else if (present < 20) { fail(`only ${present} facts; the strip was specified as at - line 281
least 20`); } else { pass(`the fact line's ${present} entries match --fact-count`); } // Every fact needs its own index and colour, or they all animate together. const indices = - line 281
[...indexHtml.matchAll(/class="fact"\s+style="--f:(\d+);--c:([^"]+)"/g)]; if (indices.length !== present) { fail(`${present - indices.length} fact(s) are missing --f or --c`); } else { const seen = new Set(indices.map(m => m[1])); if - line 281
(seen.size !== present) { fail("two facts share an --f index, so they appear at the same moment"); } else if (indices.some(m => !/^var\(--[a-z0-9-]+\)$/.test(m[2]))) { fail("a fact's colour is not a palette token, so it will not follow the - line 281
theme"); } else { pass("each fact has a distinct slot and a palette colour"); } } // The resting state of a `.fact` is invisible, and the global // reduced-motion rule collapses animations to 0.01ms -- which would leave // the strip - line 281
permanently blank for a reader who asked for less motion. That // needs handling explicitly rather than by the blanket rule. const reduced = main.slice(main.indexOf(".facts")); if - line 281
(!/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{[^}]*\.fact\s*\{[^}]*animation:\s*none/.test(reduced)) { fail("main.css: .fact must stop animating under prefers-reduced-motion"); } else if - line 281
(!/\.fact:first-child\s*\{[^}]*opacity:\s*1/.test(reduced)) { fail("main.css: with motion reduced, one fact must remain visible " + "-- otherwise the strip is blank and nobody can tell"); } else { pass("with motion reduced, the cycle stops - line 281
and the first fact stays"); } // --- 9. the fact strip states numbers, and numbers go stale ------------ // - line 321
// It said "336 tests" and "47 defects across four audit rounds" while the // tree had 354 and 59. Both were true when written. Everything else in this // repository that makes a claim is generated and checked; this was the one // place - line 321
claims were hand-typed with nothing watching them. // // `docs/AUDIT.md` is the authority: it is the document that has to be // correct for any of the rest to mean anything. const audit = fs.readFileSync(path.join(ROOT, "docs", - line 321
"AUDIT.md"), "utf8"); const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); // F-71. This used to compare the front page against docs/AUDIT.md, and it // passed for four rounds while both were wrong: the page said 354 tests - line 321
and // "the nine crates", the audit said 354 across 9, and the tree held 890 // across 19. Two hand-typed copies of a claim agreeing with each other is // not a check -- it is the same defect as F-61 and F-63, in a third place. // // Both - line 321
are now compared against docs/MEASURED.md, which is generated by // running the tests and reading Cargo.toml. Neither number is typed by // anybody, so neither can drift. const measuredPath = path.join(ROOT, "docs", "MEASURED.md"); if - line 321
(!fs.existsSync(measuredPath)) { fail("docs/MEASURED.md is missing; run python tools/measured/generate.py"); } else { const measured = fs.readFileSync(measuredPath, "utf8"); const number = (label) => { const row = new RegExp("\\|\\s*" + - line 321
label + "[^|]*\\|\\s*(\\d+)\\s*\\|").exec(measured); return row ? row[1] : null; }; const trueTests = number("Tests, measured by running them"); const trueCrates = number("Crates in the workspace"); const trueSuites = number("Website - line 321
suites"); const trueLines = number("Functional lines of Rust"); if (!trueTests || !trueCrates || !trueSuites || !trueLines) { fail("docs/MEASURED.md does not carry the four numbers it should"); } else { // Every place each number is - line 321
claimed, against the measurement. const claims = [ ["the front page's test count", /(\d+) tests, and \d+ more suites/.exec(indexHtml), trueTests], - line 361
["the front page's suite count", /\d+ tests, and (\d+) more suites/.exec(indexHtml), trueSuites], ["the front page's crate count", /in any of the (\d+) crates/.exec(indexHtml), trueCrates], // Anchored on the "Test suite" row, not on the - line 361
first loose match in the // document. The audit *discusses* past numbers -- F-71's own write-up // quotes "354 tests across 9 crates" -- and a check that takes the // first match reads the history instead of the claim. It did exactly // - line 361
that on the first run, which is the second time in this file that a // regex has found an older number further up the page. ["the audit's test count", /\| Test suite \| (\d+) tests across \d+ crates/.exec(audit), trueTests], ["the audit's - line 361
crate count", /\| Test suite \| \d+ tests across (\d+) crates/.exec(audit), trueCrates], ["the audit's suite count", /\| Test suite \|[^|]*?and (\d+) site-test suites/.exec(audit), trueSuites], // The README carries the same counts and - line 361
nothing was watching them. // They said 1,215 tests and 17 suites against a measured 1389 and 18: // the front page and the audit were checked here from the day this // file was written, and the document most readers actually open was // - line 361
not. Added with the line count rather than after it drifts too. ["the README's test count", /\((\d+) tests across \d+\ncrates/.exec(readme), trueTests], ["the README's crate count", /\(\d+ tests across (\d+)\ncrates/.exec(readme), - line 361
trueCrates], ["the README's suite count", /and (\d+) website suites/.exec(readme), trueSuites], ["the README's line count", /\*\*(\d+) functional lines of Rust\*\*/.exec(readme), trueLines] ]; let drifted = 0; for (const [what, found, - line 361
truth] of claims) { if (!found) { fail(`${what} could not be found, so nothing is watching it`); drifted += 1; } else if (found[1] !== truth) { fail(`${what} says ${found[1]}, the tree measures ${truth}`); drifted += 1; } } if (drifted === - line 361
0) { pass(`every stated count matches the tree (${trueTests} tests, ` + `${trueCrates} crates, ${trueSuites} suites, ` + `${trueLines} functional lines)`); } // A spelled-out number cannot be compared, so it is not allowed. "the // nine - line 361
crates" is exactly how this drifted without anything noticing. - line 401
const spelled = /in any of the (nine|ten|eleven|twelve|nineteen|twenty) crates/.exec(indexHtml); if (spelled) { fail(`the crate count is spelled out ("${spelled[1]}"), which no check can compare`); } else { pass("counts on the page are - line 401
digits, so they can be checked"); } } } // Anchored on the **bold** verdict, which is the audit's own current claim. // // It used to match `audit rounds (F-1 to F-N)` in plain text, and that is a // sentence the audit contains twice: once - line 401
as its verdict and once *quoted*, // inside F-105's write-up, which exists precisely because that verdict had // gone stale. The quotation comes first in the file, so this compared the // front page against a number F-105 was written to - line 401
condemn, and reported it // as agreement. The page said 104 for six rounds with this check passing. // // The bold markers are what makes the new pattern safe: a quotation of a // sentence is not bold, and the verdict always is. const - line 401
verdict = /\*\*[A-Za-z- ]+ defects found and fixed \(F-1 to F-(\d+)\), across\s+([a-z-]+) rounds\.\*\*/.exec(audit); const pageFindings = /(\d+) defects found and fixed across ([a-z-]+) audit rounds/.exec(indexHtml); if (!verdict || - line 401
!pageFindings) { fail("the defect-count claim could not be found in the audit or on the page"); } else if (verdict[1] !== pageFindings[1]) { fail(`the front page claims ${pageFindings[1]} defects, ` + `docs/AUDIT.md's verdict says F-1 to - line 401
F-${verdict[1]}`); } else if (verdict[2] !== pageFindings[2]) { // The round count drifted separately from the defect count once already. fail(`the front page says ${pageFindings[2]} audit rounds, ` + `docs/AUDIT.md's verdict says - line 401
${verdict[2]}`); } else { pass(`the front page's defect count (${pageFindings[1]}) and round count ` + `(${pageFindings[2]}) match the audit's verdict`); } // --- 10. tooltips -------------------------------------------------------- // // - line 401
Three rules, each of which is a way tooltips are routinely got wrong and // none of which shows up as a visible fault on the developer's own desktop. - line 441
const tips = main.slice(main.indexOf("[data-tip]")); if (main.indexOf("[data-tip]") === -1) { fail("main.css: the tooltip styles are gone"); } else { // Keyboard reachable. `:hover` alone means a pointer is required, which // is invisible - line 441
to anybody testing with a mouse in their hand. if (!/\[data-tip\]:focus-visible::after/.test(tips)) { fail("tooltips must appear on :focus-visible, not only on :hover"); } else { pass("tooltips appear on keyboard focus as well as hover"); - line 441
} // `visibility` has to be in the transition. Without it the box stays in // the accessibility tree and stays hoverable while fully transparent, so a // pointer crossing empty space triggers a tooltip that is not visible. if - line 441
(!/transition:[^;]*visibility/.test(tips)) { fail("the tooltip transition must include visibility, or an invisible " + "box stays hoverable and stays in the accessibility tree"); } else { pass("the tooltip transition covers visibility"); } - line 441
// There must be an affordance. A hover-only annotation with nothing // indicating it exists is a secret, not a tooltip. if (!/\[data-tip\]\s*\{[^}]*text-decoration:\s*underline dotted/.test(tips)) { fail("[data-tip] needs a visible - line 441
affordance, or nothing suggests there " + "is anything to hover"); } else { pass("tooltips carry a visible affordance"); } } // Every tooltip on the pages must also be announced once, and only once: // `data-tip` for sighted readers, - line 441
`aria-label` carrying the same words, and // no `title` on the same element -- which some screen readers announce in // addition, and some desktops draw as a second box. const tipUses = - line 441
[...indexHtml.matchAll(/<span([^>]*\bdata-tip="([^"]*)"[^>]*)>/g)]; if (tipUses.length === 0) { fail("no tooltip is actually used on the front page"); } else { - line 481
const problems = []; for (const [, attrs, text] of tipUses) { if (!/\baria-label="/.test(attrs)) { problems.push(`a tooltip has no aria-label: ${text.slice(0, 40)}`); } if (/\btitle="/.test(attrs)) { problems.push(`a tooltip also carries - line 481
title=, so it is announced twice`); } } if (problems.length) { problems.forEach(fail); } else { pass(`${tipUses.length} tooltips are announced exactly once`); } } // ---- the page must not scroll sideways ---------------------------------- - line 481
// // Every rule below was written because a viewport was measured with // `tools/render/probe.py overflow` and found to scroll horizontally. A phone // that scrolls sideways is not a cosmetic complaint: it moves the text out // from under - line 481
the reader on every swipe. None of it is visible from the source // -- each looked correct until a number came back -- so each is pinned here. const mobile = [ [/\.wiki-layout\s*>\s*\*\s*\{[^}]*min-width:\s*0/, "a grid item defaults to - line 481
min-width:auto, so the reference column refused " + "to be narrower than its widest table (658px) and took the page with it"], [/(^|\})\s*table\s*\{[^}]*display:\s*block[^}]*overflow-x:\s*auto/m, "a table has to be its own sideways - line 481
scroller, or a column of code names " + "wider than the page drags the whole page along"], [/td code,\s*th code\s*\{[^}]*overflow-wrap:\s*anywhere/, "break-word does not shrink a table's intrinsic width; anywhere does, " + "and without it - line 481
the items table scrolled inside a desktop column too"], [/\bcode\s*\{[^}]*overflow-wrap:\s*break-word/, "one identifier can be 385px of unbreakable word in a 300px column"], [/pre code\s*\{[^}]*overflow-wrap:\s*normal/, "a code block - line 481
scrolls on purpose; breaking its lines changes what it says"], [/\.hero\s*\{[^}]*padding:\s*\d+px\s+[1-9]/, "the hero is the one section not inside .wrap, so it needs its own gutter " + "or the tagline touches both edges of a phone"], - line 481
[/\.search-page\s*\{\s*padding:\s*\d+px\s+[1-9]/, "a padding shorthand on .wrap.search-page replaces .wrap's side padding " + "rather than adding to it, which took the gutters away"], - line 521
[/\.diagram\s*\{[^}]*overflow-x:\s*auto/, "a drawing wider than its column must scroll inside itself"] ]; for (const [pattern, why] of mobile) { if (!pattern.test(main)) fail(`${why}: the rule for it is gone`); } if - line 521
(mobile.every(([pattern]) => pattern.test(main))) { pass(`${mobile.length} measured horizontal-overflow fixes are still in place`); } // The tooltip is the subtle one. It is `position: absolute`, anchored to the // left of the word it - line 521
annotates, and `visibility: hidden` still takes part in // layout -- so a closed tooltip near the right of a narrow column pushed the // front page 82px sideways with nobody hovering anything. The fix pins it to // the viewport below - line 521
900px, which is where the columns stop narrowing; a // query written at the site's usual 760 left a tablet at 768 still 75px over. const pinned = main.match( /@media \(max-width:\s*(\d+)px\)\s*\{\s*\[data-tip\]::after\s*\{([^}]*)\}/); if - line 521
(!pinned) { fail("[data-tip]::after is not pinned to the viewport on a narrow screen, " + "so a closed tooltip can push the page sideways"); } else if (Number(pinned[1]) < 900) { fail(`the tooltip is only pinned below ${pinned[1]}px; a - line 521
tablet at 768 was ` + `still 75px over, so this has to reach 900`); } else if (!/position:\s*fixed/.test(pinned[2])) { fail("the pinned tooltip must be position:fixed, because an absolute box still " + "counts toward the page's scrollable - line 521
width"); } else { pass(`tooltips are pinned to the viewport below ${pinned[1]}px`); } // ---- `inset` needs its longhands, and one place needs them badly -------- // // `inset` arrived in Safari 14.1 (early 2021). An engine older than that - line 521
// treats the declaration as invalid and drops it, and a `position: fixed` // element with no offsets sits wherever it fell in the flow at its own // content size. // // For the legal gate that is not a cosmetic failure. It is shown with - line 521
// `body { overflow: hidden }`, so an overlay that does not cover the page - line 561
// leaves a reader unable to scroll with nothing visible stopping them -- // exactly the shape of the `color-mix` degradation this file already guards. { const insetRules = [...main.matchAll(/\{[^}]*\}/g)] .map((m) => m[0]) .filter((block) - line 561
=> /(^|[\s;{])inset\s*:/.test(block)); if (insetRules.length === 0) { pass("no bare `inset` to guard"); } else { const bare = insetRules.filter( (block) => !(/(^|[\s;{])top\s*:/.test(block) && /(^|[\s;{])left\s*:/.test(block)) ); if - line 561
(bare.length) { bare.forEach((block) => fail( "`inset` with no longhand fallback (Safari 14.0 and earlier drop " + "it): " + block.replace(/\s+/g, " ").slice(0, 70) ) ); } else { pass(`${insetRules.length} \`inset\` rules carry longhand - line 561
fallbacks`); } } } // ---- the page must not describe its own layout by direction ------------- // // The demonstration's caption said "the bars on the left" and "on the // right". Below 640 px `.demo-flow` stacks, so on every phone those - line 561
words // named the wrong thing -- and they never meant anything to a reader using a // screen reader at any width. // // The fix is not a second sentence behind a media query. It is to name the // thing rather than where it happens to be, - line 561
which is true in every layout // and to every reader. This checks nobody puts the directions back. { const pages = fs .readdirSync(path.join(ROOT, "website")) .filter((name) => name.endsWith(".html")) // `releases.html` reproduces - line 561
CHANGELOG.md, which is a record of what was - line 601
// said at the time each version went out. One of those notes describes // where something sat in the window in 2026. The rule here is that the // site must not describe *its own* layout by direction, and rewriting a // released note to - line 601
satisfy it would be editing a record rather than // fixing a page. Every other page, including every hand-written one, is // still checked. .filter((name) => name !== "releases.html"); const directions = /\b(?:on|to) the - line 601
(?:left|right)\b|\b(?:left|right)-hand (?:column|side|panel)\b/i; const guilty = []; for (const name of pages) { const html = fs.readFileSync(path.join(ROOT, "website", name), "utf8"); // Prose only: a `float: left` in an inline style is - line 601
not a claim about // where something is on a phone. const prose = html.replace(/<style[\s\S]*?<\/style>/g, "").replace(/<[^>]+>/g, " "); const found = prose.match(directions); if (found) guilty.push(`${name}: "${found[0]}"`); } if - line 601
(guilty.length) { guilty.forEach((where) => fail( "the page describes its own layout by direction, which is wrong " + "wherever it stacks and meaningless to a screen reader: " + where ) ); } else { pass(`${pages.length} pages name things - line 601
rather than directions`); } } return failures; } module.exports = { name: "stylesheets, cross-engine invariants", run };
tools/site-tests/diagrams.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The generated flowcharts must be drawn at a size a person can read. // // # The defect this exists to stop coming back // // `tools/docs/generate.py` laid a graph out by rank and put every - line 1
node of one // rank on a single line, so the canvas was as wide as the busiest rank: // `veilvoice-core/chain.rs` reached **4490 px**. The drawing then went into the // page as `width="100%"` with no intrinsic size, inside a 630 px column. - line 1
The // browser did the only thing it could and scaled it to **0.147**, which renders // a 13 px label at under two pixels tall. // // That was measured, not guessed: driving the reference page over the DevTools // protocol reported the - line 1
SVG's rendered box against its `viewBox`. The same // measurement on a 390 px viewport reported a `scrollWidth` of 561, so the wide // drawings were also part of what pushed the reference pages sideways on a // phone. // // So there are - line 1
three things to hold, and each is checked below: // // 1. A rank **wraps**, so the canvas never grows past what a column can show. // 2. The drawing carries its **own** `width` and `height`, so it renders at // its own size rather than - line 1
being scaled to whatever box it lands in. // 3. `max-width: 100%` with `height: auto`, so a narrow screen scales it // *down* -- the direction that keeps the aspect ratio and loses nothing // but room. // // A ceiling in pixels is a blunt - line 1
instrument, and it is the right one here: the // number is what the reference column actually is, and a diagram wider than the // column is the bug. "use strict"; const fs = require("fs"); const path = require("path"); const ROOT = - line 1
path.resolve(__dirname, "..", ".."); const REFERENCE = path.join(ROOT, "website", "reference"); const DIAGRAMS = path.join(ROOT, "assets", "diagrams"); - line 41
// The reference pages put the drawing in a 630 px column, measured. The // generator lays out to 640 and a single box may be wider than the budget when // one name is very long, so the ceiling has headroom for that and for nothing // - line 41
else. Before the fix the widest was 4490. const MAX_CANVAS_W = 900; function walk(dir, ext, found = []) { if (!fs.existsSync(dir)) return found; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = - line 41
path.join(dir, entry.name); if (entry.isDirectory()) walk(full, ext, found); else if (entry.name.endsWith(ext)) found.push(full); } return found; } /** Every `<svg …>` opening tag that carries `aria-label="flowchart"`. */ function - line 41
flowchartTags(text) { return [...text.matchAll(/<svg\b[^>]*>/g)] .map((m) => m[0]) .filter((tag) => /aria-label="flowchart"/.test(tag)); } function attr(tag, name) { const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`)); return m ? m[1] - line 41
: null; } function run() { let failures = 0; const fail = (message) => { failures++; console.log(` FAIL ${message}`); }; const pass = (message) => console.log(` ok ${message}`); const pages = walk(REFERENCE, ".html"); if (pages.length === - line 41
0) { fail("no reference pages were found at all"); return failures; } - line 81
let widest = { width: 0, where: null }; let drawings = 0; const problems = []; const check = (tag, where) => { drawings++; const viewBox = attr(tag, "viewBox"); if (!viewBox) { problems.push(`${where}: a flowchart has no viewBox`); return; - line 81
} const width = Number(viewBox.split(/\s+/)[2]); const height = Number(viewBox.split(/\s+/)[3]); if (!Number.isFinite(width) || !Number.isFinite(height)) { problems.push(`${where}: viewBox "${viewBox}" is not four numbers`); return; } if - line 81
(width > widest.width) widest = { width, where }; if (width > MAX_CANVAS_W) { problems.push( `${where}: the canvas is ${width}px, past the ${MAX_CANVAS_W}px ceiling` + ` -- a rank is not wrapping, and the drawing will be scaled down to - line 81
fit`); } // An intrinsic size, or the browser has nothing to render it at but the // width of whatever box it lands in. This is the exact attribute whose // absence caused the 0.147 scale. const w = attr(tag, "width"); const h = attr(tag, - line 81
"height"); if (w === null || h === null || w.endsWith("%") || h === null) { problems.push( `${where}: a flowchart has no intrinsic width and height` + ` (width=${w}, height=${h}), so it is scaled to its container`); } else if (Number(w) - line 81
!== width || Number(h) !== height) { problems.push( `${where}: width/height (${w}x${h}) disagree with the viewBox` + ` (${width}x${height}), so the drawing is scaled before it is drawn`); } const style = attr(tag, "style") || ""; - line 121
if (!/max-width:\s*100%/.test(style) || !/height:\s*auto/.test(style)) { problems.push( `${where}: a flowchart needs "max-width:100%;height:auto" or it cannot` + ` scale down on a narrow screen (style="${style}")`); } }; for (const page of - line 121
pages) { const rel = path.relative(ROOT, page).replace(/\\/g, "/"); for (const tag of flowchartTags(fs.readFileSync(page, "utf8"))) check(tag, rel); } // The same drawings are written out as files for the repository and the wiki, // which - line 121
is what makes those two show the same picture the site does rather // than leaving the layout to GitHub's Mermaid. const files = walk(DIAGRAMS, ".svg"); if (files.length === 0) { fail("assets/diagrams/ is empty, so the repository has no - line 121
drawing to show"); } else { for (const file of files) { const rel = path.relative(ROOT, file).replace(/\\/g, "/"); const text = fs.readFileSync(file, "utf8"); const tags = flowchartTags(text); if (tags.length === 0) { // A file with - line 121
nothing to draw is legitimate and says so. if (!/aria-label="no items"/.test(text)) { problems.push(`${rel}: neither a flowchart nor an empty one`); } continue; } tags.forEach((tag) => check(tag, rel)); } } if (problems.length) { - line 121
problems.slice(0, 20).forEach(fail); if (problems.length > 20) fail(`… and ${problems.length - 20} more`); } else { pass(`${drawings} flowcharts carry their own size and scale down, not up`); pass(`the widest canvas is ${widest.width}px - line 121
(${widest.where}), ` + - line 161
`under the ${MAX_CANVAS_W}px ceiling`); } // The stylesheet's half of the deal: a container that scrolls rather than a // page that does. Without it a single box wider than the column takes the // whole reference page sideways with it. - line 161
const css = fs.readFileSync( path.join(ROOT, "website", "css", "main.css"), "utf8") .replace(/\/\*[\s\S]*?\*\//g, ""); if (!/\.diagram\s*\{[^}]*overflow-x:\s*auto/.test(css)) { fail(".diagram must scroll on its own, or a wide drawing - line 161
scrolls the page"); } else { pass(".diagram scrolls on its own rather than widening the page"); } return failures; } module.exports = { name: "flowcharts, drawn at a readable size", run };
tools/site-tests/html.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Structural checks on the published pages. // // These catch the mistakes hand-edited HTML actually makes, such as an unclosed // section that silently swallows the rest of the page, a - line 1
navigation link // pointing at an anchor that no longer exists, an id used twice so the browser // jumps to the wrong one. None of them are security problems; all of them ship // a broken page to everybody. // // Also enforced here, rather - line 1
than only in the deploy workflow, so a mistake is // caught before the push rather than after: no third-party asset references, // no inline event handlers, and the signing-key fingerprint on every page. "use strict"; const fs = - line 1
require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); const SITE = path.join(ROOT, "website"); /** * Every HTML page the site publishes, **discovered** rather than listed. * * This was a hardcoded - line 1
list of three files, under a comment saying the checks * applied to "every page". They did not. `search.html` was added and was never * checked at all -- not for the signing-key fingerprint, not for balanced tags, * not for third-party - line 1
assets, not for inline event handlers. It shipped * without the fingerprint, which is the one thing on these pages that lets a * reader tell a real release from a forged one. * * That is section 4.5 of `docs/AUDIT.md` happening to the - line 1
tests themselves: * *a finished scope is only as wide as the list it was drawn from.* The * defence is to enumerate from the directory rather than from memory, so a new * page is covered the moment it exists rather than whenever somebody - line 1
remembers * to add it here. */ function discoverPages(dir = SITE, found = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - line 41
const full = path.join(dir, entry.name); if (entry.isDirectory()) { discoverPages(full, found); } else if (entry.name.endsWith(".html")) { found.push(full); } } return found.sort(); } const PAGES = discoverPages(); // Hosts the site is - line 41
allowed to link to. Not fetch from, link to. Nothing on // these pages may *load* from anywhere but the same origin. const ALLOWED_LINK_HOSTS = [ "github.com", "raw.githubusercontent.com", "api.github.com", "www.audacityteam.org", - line 41
"vb-audio.com", "creativecommons.org", "www.gnu.org", // This project's own published site, which the release notes link to when // they tell somebody where to compare a key fingerprint, and the advisory // database the audit cites by - line 41
name. Both are links a reader follows on // purpose; neither is an asset the page loads, which the separate check // below still refuses outright. "tilas01.github.io", "rustsec.org" ]; const VOID = new Set(["area", "base", "br", "col", - line 41
"embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", "!doctype"]); function balance(html) { const problems = []; const stack = []; const re = /<(\/?)([a-zA-Z!][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g; let m; while ((m = - line 41
re.exec(html)) !== null) { const closing = m[1] === "/"; const name = m[2].toLowerCase(); if (VOID.has(name) || m[3] === "/") { continue; } if (!closing) { stack.push({ name, at: m.index }); continue; } const top = stack.pop(); if (!top) { - line 41
problems.push(`stray </${name}> at offset ${m.index}`); } else if (top.name !== name) { problems.push(`</${name}> at ${m.index} closes <${top.name}> opened at ${top.at}`); } - line 81
} for (const left of stack) { problems.push(`unclosed <${left.name}> at offset ${left.at}`); } return problems; } function run() { let failures = 0; const fingerprint = fs .readFileSync(path.join(SITE, "assets", "fingerprint.txt"), "utf8") - line 81
.replace(/\s/g, "") .toUpperCase(); for (const page of PAGES) { const rel = path.relative(ROOT, page).replace(/\\/g, "/"); const raw = fs.readFileSync(page, "utf8"); const html = raw.replace(/<!--[\s\S]*?-->/g, ""); const problems = []; - line 81
problems.push(...balance(html)); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]); const duplicated = [...new Set(ids.filter((v, i) => ids.indexOf(v) !== i))]; if (duplicated.length) { problems.push(`duplicate ids: - line 81
${duplicated.join(", ")}`); } const known = new Set(ids); const dangling = [...new Set([...html.matchAll(/href="#([^"]+)"/g)].map(m => m[1]))] .filter(a => !known.has(a)); if (dangling.length) { problems.push(`anchors with no target: - line 81
#${dangling.join(", #")}`); } // A privacy tool's site loading a CDN would undercut the whole claim. for (const m of html.matchAll(/(?:src|href)="(https?:\/\/[^"]+)"/g)) { const host = m[1].replace(/^https?:\/\//, "").split(/[/?#]/)[0]; if - line 81
(!ALLOWED_LINK_HOSTS.includes(host)) { problems.push(`reference to an unexpected host: ${host}`); } } // A `<link>` only fetches for some values of `rel`. `canonical` is one of // the ones that does not: it tells an index which address to - line 81
treat as this // page's own, and an address has to be absolute to say that at all. The // rule is about requests the reader's browser makes, so it is scoped to - line 121
// the rels that make one. const FETCHING_REL = /\brel="(?:stylesheet|icon|shortcut icon|apple-touch-icon|preload|prefetch|preconnect|dns-prefetch|modulepreload|manifest)"/; for (const m of - line 121
html.matchAll(/<(script|link|img)\b([^>]*)\b(?:src|href)="(\/\/|https?:)/g)) { if (m[1] === "link" && !FETCHING_REL.test(m[2])) { continue; } problems.push(`asset loaded from a third party: ${m[3]}`); } // Inline handlers are the thing the - line 121
renderer's escaping exists to prevent; // the pages themselves must not undo that by hand. for (const m of html.matchAll(/<[^>]*\son[a-z]+\s*=/gi)) { problems.push(`inline event handler: ${m[0].trim().slice(0, 60)}`); } // The fingerprint - line 121
is how a reader tells a real release from a forged one. if (!raw.replace(/\s/g, "").toUpperCase().includes(fingerprint)) { problems.push("the signing-key fingerprint is missing from this page"); } if (problems.length) { failures++; - line 121
console.log(`FAIL ${rel}`); problems.slice(0, 10).forEach(p => console.log(` ${p}`)); } } // Every script the pages reference must exist and parse. for (const page of PAGES) { const dir = path.dirname(page); const html = - line 121
fs.readFileSync(page, "utf8"); for (const m of html.matchAll(/<script[^>]+src="([^"]+)"/g)) { const file = path.resolve(dir, m[1]); if (!fs.existsSync(file)) { failures++; console.log(`FAIL ${path.relative(ROOT, page)} references a missing - line 121
script: ${m[1]}`); } } } console.log(` ${PAGES.length} pages checked`); return failures; - line 161
} module.exports = { run, name: "published pages, structure" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/images.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // No generated picture may put text outside its own canvas. // // # The defect this exists to stop coming back // // A drawing that carries its own explanation has to be tall enough for it. - line 1
The // first version of the diagram footer worked the height out in one function and // drew it in another, the two disagreed by one row of the colour key, and the // last line of every note was clipped by the bottom edge of the picture it - line 1
was // explaining. It rendered fine in a browser, every existing check passed, and // the only way to see it was to look at one. // // That is the third time this repository has cut its own text off. F-37 deleted // more than half the - line 1
pixel rows of the banner carrying this project's licence // and authorship. The terminal drawings ended sentences mid-word with an // ellipsis for as long as they had existed. Both were found by looking, and // looking does not scale to - line 1
300 pictures. // // So this measures instead. Every `<text>` in every generated SVG, against the // canvas it is drawn on. // // # How the width is estimated, and why an estimate is enough // // These drawings are monospace by - line 1
construction: one font stack, set in // `generate.py`, chosen so a character's width is predictable. A character is // about 0.585 of the font size in it. This uses **0.62**, deliberately high, // so the estimate errs towards reporting a - line 1
problem that is not there rather // than missing one that is. A false alarm costs somebody a look at a picture; // a miss costs a reader the end of a sentence. // // It is not a rendering. A rendering would need a browser and this suite - line 1
runs // without one. What it catches is the class of fault that has actually // happened here: a canvas sized by arithmetic that does not match the drawing. "use strict"; const fs = require("fs"); const path = require("path"); - line 41
const ROOT = path.resolve(__dirname, "..", ".."); // Where the generated drawings are. `website/assets` holds copies of the same // files; checking both means a copy that went stale is caught here too. const PLACES = [ "assets/diagrams", - line 41
"assets/banners", "assets/screenshots", "website/assets/banners", "website/assets/screenshots" ]; const SINGLES = [ "assets/roadmap.svg", "website/assets/roadmap.svg", "assets/roadmap-film.svg", "website/assets/roadmap-film.svg" ]; // A - line 41
character is about this much of the font size, in the monospace stack // every one of these drawings uses. Rounded up on purpose: see the header. // // `TEXT_RATIO` in `tools/docs/generate.py` is the same number and has to stay // the same - line 41
number. The generator lays text out with it and this checks the // result: if the generator were the more optimistic of the two, every drawing // would be laid out to a width this suite then refuses. const CHAR_RATIO = 0.62; // Room for a - line 41
descender under the baseline, as a fraction of the font size. const DESCENDER = 0.28; // A pixel of slack, because these are floating-point layouts. const SLACK = 1.5; function walk(dir, found = []) { const full = path.join(ROOT, dir); if - line 41
(!fs.existsSync(full)) return found; for (const entry of fs.readdirSync(full, { withFileTypes: true })) { const next = path.join(dir, entry.name); if (entry.isDirectory()) walk(next, found); else if (entry.name.endsWith(".svg")) - line 41
found.push(next); } return found; } - line 81
/** The drawing area, from the `viewBox` rather than from `width`. */ function canvas(svg) { const box = /viewBox="([-\d.]+)\s+([-\d.]+)\s+([\d.]+)\s+([\d.]+)"/.exec(svg); if (!box) return null; return { width: parseFloat(box[3]), height: - line 81
parseFloat(box[4]) }; } /** * A copy with the clipped groups removed. * * Text inside a `clip-path` is *meant* to be outside the frame: the roadmap * film is a list that scrolls through a window, so at any one instant most of * it is above - line 81
or below the visible area, on purpose, and the clip is what * makes that work. Measuring it against the canvas would report sixty rows as * overflowing and would be wrong about every one of them. * * What is still measured in such a - line 81
drawing is everything outside the clip: the * heading, the countdown, the rule. Those are the parts that have to fit. */ function unclipped(svg) { return svg.replace(/<g[^>]*\bclip-path="[^"]*"[^>]*>[\s\S]*?<\/g>\s*<\/g>/g, "") - line 81
.replace(/<g[^>]*\bclip-path="[^"]*"[^>]*>[\s\S]*?<\/g>/g, ""); } /** Every `<text>` element, with what decides where its box lands. */ function texts(svg) { const found = []; const re = /<text\b([^>]*)>([\s\S]*?)<\/text>/g; let match; - line 81
while ((match = re.exec(svg)) !== null) { const attrs = match[1]; const get = (name) => { const found = new RegExp(name + '="([^"]*)"').exec(attrs); return found ? found[1] : null; }; // Entities count as one character each, which is what - line 81
they render as. // An entity is one character on screen and up to six in the file. The // hexadecimal form was missed at first, so every apostrophe counted as // five characters and four banners were reported as overflowing when they // - line 81
were not. - line 121
const body = match[2] .replace(/&#x[0-9a-f]+;/gi, "x") .replace(/&#\d+;/g, "x") .replace(/&[a-z]+;/gi, "x"); found.push({ x: parseFloat(get("x") || "0"), y: parseFloat(get("y") || "0"), size: parseFloat(get("font-size") || "13"), anchor: - line 121
get("text-anchor") || "start", length: body.length, text: body }); } return found; } function run() { let failures = 0; const fail = (message) => { failures++; console.log(`FAIL ${message}`); }; const pass = (message) => console.log(`ok - line 121
${message}`); let files = []; for (const place of PLACES) { files = files.concat(walk(place)); } for (const single of SINGLES) { if (fs.existsSync(path.join(ROOT, single))) { files.push(single); } } files.sort(); if (files.length === 0) { - line 121
fail("no generated drawings were found at all, so nothing was checked"); return failures; } const problems = []; let checked = 0; for (const rel of files) { const svg = fs.readFileSync(path.join(ROOT, rel), "utf8"); const box = - line 121
canvas(svg); if (!box) { problems.push(`${rel}: no viewBox, so nothing can be measured against it`); - line 161
continue; } for (const item of texts(unclipped(svg))) { checked++; const width = item.length * item.size * CHAR_RATIO; let left = item.x; if (item.anchor === "middle") { left = item.x - width / 2; } else if (item.anchor === "end") { left = - line 161
item.x - width; } const right = left + width; const bottom = item.y + item.size * DESCENDER; const shown = item.text.trim().slice(0, 44); if (left < -SLACK) { problems.push(`${rel}: text starts ${(-left).toFixed(0)}px left of the canvas: - line 161
"${shown}"`); } if (right > box.width + SLACK) { problems.push(`${rel}: text runs ${(right - box.width).toFixed(0)}px past the right edge ` + `(canvas ${box.width}): "${shown}"`); } if (bottom > box.height + SLACK) { problems.push(`${rel}: - line 161
text runs ${(bottom - box.height).toFixed(0)}px below the bottom edge ` + `(canvas ${box.height}): "${shown}"`); } if (item.y - item.size < -SLACK) { problems.push(`${rel}: text sits above the canvas: "${shown}"`); } } } if - line 161
(problems.length) { problems.slice(0, 20).forEach(fail); if (problems.length > 20) { fail(`and ${problems.length - 20} more`); } } else { pass(`${checked} pieces of text in ${files.length} drawings are inside their canvas`); } failures += - line 161
windowCaptureChecks(fail, pass); failures += roadmapChecks(fail, pass); return failures; } - line 201
/** * The window captures: no mouse pointer, and no text cut off with an ellipsis. * * # Why the pointer is checked by reading the capture script * * The obvious check is to look for a pointer in the pixels, and it is the * wrong one. A - line 201
cursor is a small arbitrary shape over arbitrary content, so * any detector is a guess, and a guess over nine screenshots will eventually * call a mouse pointer out of a scrollbar and fail a build for a picture that * is fine. * * There is - line 201
an exact answer available instead, and there is one for each of * the two scripts that take these pictures. * * `tools/shots/gui.ps1` captures with `PrintWindow`, which asks the window to * draw itself into a bitmap. The pointer is drawn - line 201
by the compositor on top of * the screen and is not part of any window's own rendering, so a * `PrintWindow` capture cannot contain one. A screen copy would include * whatever is over the window, a pointer among it. * * - line 201
`tools/shots/gui.sh` captures the root window of an Xvfb display, which * *would* include a pointer, so it starts that server with `-nocursor` and * there is no pointer to include. Different mechanism, same kind of * guarantee: a property - line 201
of how the capture is taken, not a hope about where * the mouse happened to be. * * Both are checked, and both have to exist. Two scripts producing one set of * files is exactly the arrangement where one of them quietly stops being * - line 201
equivalent to the other, and the pictures do not say which took them. * * This is the same reasoning as F-103, which found that comparing a drawing * against a file written by the same command proves nothing. Check the thing * that makes - line 201
the claim true. */ function windowCaptureChecks(fail, pass) { let failures = 0; const before = failures; // Each capture script, and the thing in it that makes a mouse pointer - line 241
// impossible rather than unlikely. const guarantees = [ { script: "tools/shots/gui.ps1", needs: /PrintWindow\s*\(/, what: "calls PrintWindow", why: "That call is the whole reason a mouse pointer cannot appear in " + "a screenshot: it asks - line 241
the window to draw itself, rather than " + "copying whatever is on screen over it." }, { script: "tools/shots/gui.sh", needs: /Xvfb\b[^\n]*-nocursor\b/, what: "starts Xvfb with -nocursor", why: "This script captures the root window, which - line 241
would include a " + "pointer if the server drew one, so it tells the server to draw " + "none at all." } ]; for (const { script, needs, what, why } of guarantees) { const full = path.join(ROOT, script); if (!fs.existsSync(full)) { - line 241
fail(`${script} is missing, so nothing here can say how the window ` + "captures it takes are free of a mouse pointer. Both capture " + "scripts have to exist: they produce the same nine files, and the " + "files do not record which one - line 241
took them."); failures += 1; continue; } const code = fs.readFileSync(full, "utf8") .split("\n") .filter((line) => !line.trim().startsWith("#")) .join("\n"); if (!needs.test(code)) { fail(`${script} no longer ${what}. ${why}`); failures += - line 241
1; } else { pass(`${script} ${what}, so no pointer can be in what it captures`); } - line 281
// The other half of the same property: nothing that copies the screen // instead of asking a window to draw itself. for (const forbidden of ["CopyFromScreen", "BitBlt", "CAPTUREBLT"]) { if (new RegExp(`${forbidden}\\s*\\(`).test(code)) { - line 281
fail(`${script} calls ${forbidden}, which copies the screen rather ` + "than the window. Whatever is over the window at the time lands " + "in the picture, and the mouse pointer usually is."); failures += 1; } } } // The corners are in the - line 281
alpha channel, so the stylesheet must not draw // them a second time. // // `border-radius` on an `img` clips the content box. The gallery shows these // at roughly a third of their captured width, so the file's 14-pixel radius // arrives - line 281
as about five, and a fixed radius here larger than that cuts into // the picture past the corner the file already rounded. A `background` is // worse: it shows through the transparent corners as a wedge of colour in // each one. Both - line 281
looked like a bug in the screenshot rather than in the page. const css = fs.readFileSync(path.join(ROOT, "website/css/main.css"), "utf8"); const rule = /([^{}]+)\{([^{}]*)\}/g; let m; while ((m = rule.exec(css)) !== null) { const selector - line 281
= m[1].replace(/\/\*[\s\S]*?\*\//g, "").trim(); if (!/(^|,|\s)\.(shot|viewer)\s+img\b/.test(selector)) { continue; } for (const property of ["border-radius", "background"]) { if (new RegExp(`(^|;|\\s)${property}\\s*:`).test(m[2])) { - line 281
fail(`\`${selector}\` sets \`${property}\`. The window captures carry ` + "their own rounded corners in the alpha channel, so the page " + "must not round or fill them again: a radius here clips the " + "picture past its own corner, and a - line 281
background shows through " + "the corners the file made transparent."); } } } return failures - before; - line 321
} /** * The roadmap picture says what each square is, and does not say a number. * * # What a grid of numbers tells a reader * * Nothing. Ninety-six squares each carrying an index into a document the * reader is not looking at, and the one - line 321
thing they want to know -- what this * square is -- was the thing the square did not say. The name lives in the * `<title>`, which every browser shows on hover and a screen reader reads * out, and that is now all a square carries. * * Both - line 321
halves are checked, because either one alone would pass while the * picture was wrong: numbers with no names, or names with numbers still * printed over them. */ function roadmapChecks(fail, pass) { let failures = 0; const drawing = - line 321
"website/assets/roadmap.svg"; const full = path.join(ROOT, drawing); if (!fs.existsSync(full)) { fail(`${drawing} is missing, so the roadmap has no picture`); return 1; } const svg = fs.readFileSync(full, "utf8"); const squares = - line 321
(svg.match(/class="rm-square"/g) || []).length; const named = (svg.match(/<title>[^<]+<\/title>/g) || []).length; if (squares === 0) { fail(`${drawing} has no squares, so this suite is reading it wrongly`); failures += 1; } else if (named - line 321
< squares) { fail(`${drawing} draws ${squares} squares and names ${named} of them. A ` + "square with no title is a coloured box a reader cannot identify."); failures += 1; } else { pass(`all ${squares} roadmap squares say what they are`); - line 321
} - line 361
// A bare number drawn as text: the index that used to sit in each square. const numbered = svg.match(/>\s*\d{1,3}\s*<\/text>/g); if (numbered) { fail(`${drawing} prints ${numbered.length} bare number(s) as text. Those ` + "are indexes - line 361
into ROADMAP.md, which is not where the reader is."); failures += 1; } else { pass("the roadmap picture prints no bare item numbers"); } // The reveal, and the promise that somebody who asked for less movement // gets a still picture - line 361
rather than a faster animation. if (!/@keyframes\s+rm-in/.test(svg)) { fail(`${drawing} has no reveal animation`); failures += 1; } else if (!/prefers-reduced-motion/.test(svg)) { fail(`${drawing} animates without honouring - line 361
prefers-reduced-motion, so a ` + "reader who asked their system for less movement gets it anyway"); failures += 1; } else { pass("the roadmap reveal stops for anybody who asked for less movement"); } // A filling animation is never - line 361
finished as far as the compositor is // concerned, and a transform animation earns its element a GPU layer. The // roadmap picture has one animated square per marker, so `both` gave the // browser 146 permanent layers for a drawing that - line 361
stops moving in under half // a second. Measured through Chromium's own layer tree: 162 composited // layers on the roadmap page with `both`, 8 with `backwards`, and the film // scrolling beside them shares the compositor with every one of - line 361
them. // // `forwards` is what costs the layer and it was never needed: the animation // ends at `opacity:1;transform:none`, which is the square's own state, so // holding it changes nothing on screen. `backwards` keeps the half that // - line 361
matters, the state before the animation starts. if (/animation:\s*rm-in[^;}]*\b(both|forwards)\b/.test(svg)) { fail(`${drawing} fills its reveal animation forwards, which keeps a GPU ` + "layer alive per square for as long as the page is - line 361
open. The end " + "state is the square's own state, so use `backwards`"); failures += 1; - line 401
} else { pass("the roadmap reveal releases its layers when it finishes"); } return failures; } module.exports = { run, name: "generated pictures, with all their words inside" };
tools/site-tests/links.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Every local link in the documentation and the website has to point at // something that exists. // // # Why this is a test // // `docs/INSTALL.md` shipped a link to - line 1
`REPRODUCIBLE-BUILDS.md`. The file is // called `REPRODUCIBLE_BUILDS.md`. One character, and the link was dead on // GitHub and on the site -- written by somebody who had just read the real // filename, in a document whose whole subject is - line 1
telling people where to go and // check things for themselves. // // That is the same shape as every other defect this repository keeps finding: // silent, invisible to every existing check, and specifically corrosive to a // project whose - line 1
argument is "go and read it yourself". A dead link is not a // cosmetic problem here. It is the argument failing. // // # What counts as a link worth checking // // Only links that name something in this repository. External `http(s)` URLs - line 1
// are not fetched -- a test suite that reaches the network is a test suite that // fails when somebody else's server is down, and this project's CI has no // business making requests anyway. Fragments (`#section`) are checked for the // - line 1
file they hang off, not for the anchor: heading anchors are generated // differently by GitHub, by this site's renderer and by the browser, and // asserting one of those spellings would fail on the other two. "use strict"; const fs = - line 1
require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); const ROOT = path.resolve(__dirname, "..", ".."); function trackedFiles() { return execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, - line 1
encoding: "buffer" }) .toString("utf8") .split("\0") - line 41
.filter(Boolean); } // Generated by `tools/search-index/generate.py` from every other file, so any // link inside them is a copy of one already checked at its source. Checking // them again would report the same fault many times over and, - line 41
worse, would // report excerpt text that merely *looks* like a link. const GENERATED = new Set([ "website/search-index.json", "website/nojs/search.html" ]); const MD_LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const HREF = - line 41
/(?:href|src)\s*=\s*["']([^"']+)["']/gi; /** * Remove quoted code before looking for links. * * Text inside a fence or backticks is an *example of* a link, not a link, and * this repository is full of both -- `docs/AUDIT.md` alone quotes a - line 41
README * link to show how it once rendered wrongly, and quotes * `src="a"onerror=x"` to explain why a naive scanner called it an attack. * * The first version of this file reported both as broken links: the * documentation describing - line 41
a bug correctly, flagged as a bug. That is the * mistake `docs/AUDIT.md` section 4.4 records, where five of six "findings" * were the checker rather than the code. A checker has to read the way the * thing it checks is read. * * Replaced - line 41
with spaces rather than deleted, so byte offsets -- and therefore * any future line reporting -- stay meaningful. */ function stripQuotedCode(text, isHtml) { const blank = m => " ".repeat(m.length); let out = text - line 41
.replace(/```[\s\S]*?```/g, blank) // fenced, backticks .replace(/~~~[\s\S]*?~~~/g, blank); // fenced, tildes if (isHtml) { out = out .replace(/<pre\b[\s\S]*?<\/pre>/gi, blank) - line 81
.replace(/<code\b[\s\S]*?<\/code>/gi, blank); } // Inline code last: a fence's own content may contain single backticks. return out.replace(/`[^`\n]*`/g, blank); } /** Is this a link into this repository, rather than out of it? */ function - line 81
isLocal(target) { if (!target) { return false; } if (/^[a-z][a-z0-9+.-]*:/i.test(target)) { return false; } // http:, mailto:, data: if (target.startsWith("//")) { return false; } // protocol-relative if (target.startsWith("#")) { return - line 81
false; } // same-page fragment return true; } /** Where a link resolves to on disk, or null if it leaves the repository. */ function resolveTarget(fromFile, target) { const clean = target.split("#")[0].split("?")[0]; if (!clean) { return - line 81
null; } const base = target.startsWith("/") ? ROOT : path.dirname(path.join(ROOT, fromFile)); const resolved = path.resolve(base, target.startsWith("/") ? "." + clean : clean); if (!resolved.startsWith(ROOT)) { return null; } return - line 81
resolved; } function run() { let fails = 0; const check = (name, ok) => { console.log((ok ? "ok " : "FAIL ") + name); if (!ok) { fails++; } }; const files = trackedFiles().filter( f => (/\.(md|html)$/i.test(f)) && !GENERATED.has(f) ); - line 121
const broken = []; let checked = 0; for (const rel of files) { let text; try { text = fs.readFileSync(path.join(ROOT, rel), "utf8"); } catch { continue; } const prose = stripQuotedCode(text, /\.html$/i.test(rel)); const targets = []; for - line 121
(const m of prose.matchAll(MD_LINK)) { targets.push(m[1]); } for (const m of prose.matchAll(HREF)) { targets.push(m[1]); } for (const target of targets) { if (!isLocal(target)) { continue; } const resolved = resolveTarget(rel, target); if - line 121
(resolved === null) { broken.push(`${rel} -> ${target} (escapes the repository)`); continue; } checked++; if (!fs.existsSync(resolved)) { broken.push(`${rel} -> ${target}`); } } } check(`every local link in ${files.length} documents - line 121
resolves ` + `(${checked} checked)`, broken.length === 0); for (const line of broken.slice(0, 20)) { console.log(" broken: " + line); } if (broken.length > 20) { console.log(` ...and ${broken.length - 20} more`); } - line 161
// The check must not be vacuous: if the extraction silently matched nothing, // the suite above would pass while testing exactly zero links. check("the link extraction actually found links", checked > 50); return fails; } module.exports = - line 161
{ run, name: "documentation links" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/markdown.complexity.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The renderer must stay *linear* in the size of its input. // // Separate from both the correctness and the hostile-input suites, because // neither could see this class of bug: the output - line 1
was perfectly correct and // contained nothing dangerous. It just took eight seconds to produce. // // That matters here specifically. `repo.js` fetches README.md over the network // and renders it on the main thread, so a document that - line 1
makes the renderer // quadratic is a frozen tab -- a denial of service against the reader, from // text this page went and asked for. Two separate quadratics were measured // during the audit and both are fixed; this suite is what stops a - line 1
third // arriving unnoticed. // // # Why a ratio rather than a stopwatch // // Asserting "renders in under N milliseconds" fails on a slow or busy machine, // which teaches people to ignore the suite. What is actually being checked is // - line 1
the *shape* of the curve: four times the input must not take sixteen times // the work. Each case is therefore run at two sizes and the ratio compared // against a generous allowance -- linear predicts 4, quadratic predicts 16, and // the - line 1
allowance sits between them at 12. A small absolute ceiling is checked // too, since a ratio alone would pass something uniformly slow. // // # Why each measurement is a separate process // // A regular-expression match is synchronous and - line 1
cannot be interrupted. Once the // engine starts backtracking, no timer, signal or `await` gets control back -- // so a suite that measured in-process would **hang** rather than fail when a // quadratic reappeared. That was not - line 1
theoretical: reverting the fixes and // running an earlier, in-process version of this file produced no output at all // for over fifteen minutes, where the fixed renderer finishes the same work in // about a second. A hung CI job looks - line 1
like an infrastructure problem and gets // retried; a failing one gets read. Each measurement is therefore spawned and // killed from outside, so catastrophe arrives as a reportable timeout. "use strict"; const { execFileSync } = - line 1
require("child_process"); - line 41
const fs = require("fs"); const path = require("path"); const vm = require("vm"); const PROBE = path.join(__dirname, "complexity-probe.js"); const { SHAPES } = require("./complexity-probe.js"); const ROOT = path.resolve(__dirname, "..", - line 41
".."); const sandbox = { window: {} }; vm.createContext(sandbox); vm.runInContext(fs.readFileSync(path.join(ROOT, "website", "js", "markdown.js"), "utf8"), sandbox); const MD = sandbox.window.MD; const SMALL = 8000; const LARGE = 32000; // - line 41
four times SMALL const RATIO_ALLOWANCE = 12; // linear predicts 4, quadratic 16 const CEILING_MS = 4000; const PROBE_TIMEOUT_MS = 30000; /** Render one shape at one size in a child process. `null` means it timed out. */ function - line 41
measure(shape, size) { try { const out = execFileSync(process.execPath, [PROBE, shape, String(size)], { timeout: PROBE_TIMEOUT_MS, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); return Number(out); } catch (e) { // `killed` is a - line 41
timeout; anything else is a genuine crash in the probe. if (e.killed || e.code === "ETIMEDOUT") { return null; } throw new Error("probe failed for " + shape + ": " + (e.stderr || e.message)); } } function run() { let failures = 0; for - line 41
(const shape of Object.keys(SHAPES)) { const small = measure(shape, SMALL); - line 81
if (small === null) { console.log(`FAIL ${shape}: ${SMALL} characters did not finish in ${PROBE_TIMEOUT_MS} ms`); failures++; continue; } const large = measure(shape, LARGE); if (large === null) { console.log( `FAIL ${shape}: ${LARGE} - line 81
characters did not finish in ${PROBE_TIMEOUT_MS} ms ` + `(${SMALL} took ${small.toFixed(1)} ms) -- this is worse than quadratic` ); failures++; continue; } // A floor on the denominator: dividing by a 0.02 ms measurement gives a // - line 81
meaningless ratio and a flaky test. const ratio = large / Math.max(small, 0.5); if (large > CEILING_MS) { console.log(`FAIL ${shape}: ${LARGE} characters took ${large.toFixed(0)} ms`); failures++; } else if (ratio > RATIO_ALLOWANCE) { - line 81
console.log( `FAIL ${shape}: 4x the input took ${ratio.toFixed(1)}x the time ` + `(${small.toFixed(1)} ms -> ${large.toFixed(1)} ms) -- this looks quadratic` ); failures++; } else { console.log(`ok ${shape} (${small.toFixed(1)} ms -> - line 81
${large.toFixed(1)} ms)`); } } // --- recursion depth ----------------------------------------------------- // // A blockquote strips one `>` and calls `render` again, so the *document* // chose the recursion depth. Five thousand `>` - line 81
characters overflowed the // stack and threw a RangeError -- and because `repo.js` reports any rejection // from the README fetch as "could not reach api.github.com", the reader was // given a confident, wrong explanation for a page that - line 81
had loaded fine and - line 121
// then broken while rendering. for (const depth of [100, 5000, 50000]) { try { const out = MD.render(">".repeat(depth) + " hello"); if (typeof out !== "string" || out.indexOf("hello") === -1) { console.log(`FAIL nesting ${depth}: the - line 121
quoted text did not survive`); failures++; } else { console.log(`ok ${depth} nested blockquotes render without overflowing`); } } catch (e) { console.log(`FAIL nesting ${depth}: threw ${e.constructor.name}: ${e.message}`); failures++; } } - line 121
// --- the fence language must not reach Object.prototype ------------------- // // The info string is matched with `\w*`, and both `constructor` and // `__proto__` are `\w*`. On a plain object literal, `KEYWORDS[lang]` resolved // through - line 121
the prototype chain to `Object` and to `Object.prototype`. Neither // did any harm as the code stood, which is exactly the point: that is a // description of a bug that has not gone off, not of a safe lookup. const plain = - line 121
MD.render("```\nlet x = 1;\n```"); for (const lang of ["constructor", "__proto__", "valueOf", "hasOwnProperty", "toString"]) { let out; try { out = MD.render("```" + lang + "\nlet x = 1;\n```"); } catch (e) { console.log(`FAIL fence - line 121
language ${lang}: threw ${e.message}`); failures++; continue; } if (out !== plain) { console.log(`FAIL fence language ${lang}: highlighted unlike an unknown language`); failures++; } else { console.log(`ok a fence language of "${lang}" - line 121
reaches no prototype property`); } } - line 161
// --- the bounds stay generous enough for real documents ------------------- // // The repetition bounds that make the link patterns linear also decide what // still renders as a link. Tightened too far they would refuse ordinary // - line 161
Markdown, which is F-8 all over again: safe, wrong, and quietly wrong. const realistic = [ "[the whitepaper](docs/WHITEPAPER.md)", "[a link](https://tilas01.github.io/veilvoice/#verify)", "", - line 161
'[titled](https://example.org/a "with a title")', "[" + "long label ".repeat(20) + "](docs/AUDIT.md)", "[deep](a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z.md)" ]; for (const source of realistic) { const html = MD.render(source); if - line 161
(html.indexOf("<a ") === -1 && html.indexOf("<img ") === -1) { console.log(`FAIL ordinary Markdown stopped rendering as a link: ${source.slice(0, 50)}`); failures++; } else { console.log(`ok still renders: ${source.slice(0, 46)}`); } } - line 161
return failures; } module.exports = { name: "markdown renderer, complexity and recursion", run };
tools/site-tests/markdown.hostile.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Hostile-input tests for the site's Markdown renderer. // // # Why this exists // // `js/repo.js` fetches README.md over the network and assigns the rendered // result straight to - line 1
`innerHTML`. Every byte of that path's safety rests on one // claim in `js/markdown.js`: that the source is escaped first and only tags the // renderer itself emits are ever introduced. `docs/AUDIT.md` listed that claim // as *asserted but - line 1
not tested*. This file is the test. // // The threat is not hypothetical hand-waving. The README is fetched from // raw.githubusercontent.com; anyone who could alter it, whether a compromised // token, a bad merge or a mistaken commit, - line 1
would be writing directly into the page unless // the renderer holds. // // Two kinds of check: // // 1. A corpus of deliberately hostile documents, each aimed at a specific // escape route. // 2. A randomised campaign that builds - line 1
documents from dangerous fragments, on // the theory that the bug worth finding is the one nobody thought to write // a case for. // // Both assert the same invariant, and it is an allowlist rather than a // blocklist: the output may only - line 1
contain tags and attributes this renderer is // supposed to produce. A blocklist of "no <script>" would pass a document that // found some other way in. "use strict"; const fs = require("fs"); const path = require("path"); const vm = - line 1
require("vm"); const ROOT = path.resolve(__dirname, "..", ".."); function loadRenderer() { const src = fs.readFileSync(path.join(ROOT, "website", "js", "markdown.js"), "utf8"); - line 41
const sandbox = { window: {} }; vm.createContext(sandbox); vm.runInContext(src, sandbox); return sandbox.window.MD; } const MD = loadRenderer(); // Everything the renderer is allowed to emit. Anything else in the output is a // failure by - line 41
definition, whether or not it happens to be exploitable today. const ALLOWED_TAGS = new Set([ "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "strong", "em", "code", "pre", "blockquote", "ul", "ol", "li", "a", "img", "span", "table", - line 41
"thead", "tbody", "tr", "th", "td" ]); const ALLOWED_ATTRS = new Set(["href", "src", "alt", "class", "rel"]); // The check has to parse the way a browser parses, or it reports things that // are not true. `<script>` in the output is - line 41
*text*, which is the renderer // doing its job, and `src="a"onerror=x"` is a single attribute whose value // happens to contain a quote character, because entity references are decoded // *after* the value has been delimited. A naive - line 41
scan of the raw string calls // both of those attacks and hides the real bug in the noise. /** Decode the entities the renderer can emit, for inspecting a URL value. */ function decodeEntities(value) { return value .replace(/"/g, '"') - line 41
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); } /** Split a tag's attribute text the way an HTML parser would. */ function parseAttributes(text) { - line 41
const attrs = []; const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g; - line 81
let m; while ((m = re.exec(text)) !== null) { if (!m[0].trim()) { break; } attrs.push({ name: m[1].toLowerCase(), value: m[2] ?? m[3] ?? m[4] ?? "" }); } return attrs; } /** A URL scheme that can run code, checked after entity decoding. */ - line 81
function dangerousUrl(value) { // Strip characters an HTML parser ignores before the scheme, which is the // classic way `java\tscript:` sneaks past a naive prefix test. const url = decodeEntities(value) .replace(new - line 81
RegExp("[\\u0000-\\u0020]", "g"), "") .toLowerCase(); return url.startsWith("javascript:") || url.startsWith("vbscript:") || (url.startsWith("data:") && !url.startsWith("data:image/")); } function findViolations(html) { const bad = []; // - line 81
No internal machinery may reach the page. Placeholders are private-use // characters, which browsers draw as nothing at all, so one escaping is not // a visible glitch, it is content that silently disappears. Exactly that // shipped: every - line 81
README link whose label was inline code rendered empty, // because the un-parking pass did not recurse into its own output. const escaped = [...html].filter(c => c.charCodeAt(0) >= 0xe000 && c.charCodeAt(0) <= 0xf8ff); if (escaped.length) - line 81
{ bad.push(`${escaped.length} un-parked placeholder character(s) reached the output`); } // Control characters have no business in rendered HTML either. if (new RegExp("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]").test(html)) { - line 81
bad.push("a control character reached the output"); } // Built rather than typed: a file that checks for stray characters must not // contain one. `tools/site-tests/characters.test.js` enforces that repo-wide. if - line 81
(html.includes(String.fromCharCode(0xfffd))) { bad.push("a Unicode replacement character reached the output"); } - line 121
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:\s[^>]*)?)\/?>/g; let m; while ((m = tagRe.exec(html)) !== null) { const tag = m[1].toLowerCase(); if (!ALLOWED_TAGS.has(tag)) { bad.push(`disallowed tag <${tag}>`); continue; } for (const attr - line 121
of parseAttributes(m[2])) { if (attr.name.startsWith("on")) { bad.push(`event handler ${attr.name} on <${tag}>`); } else if (!ALLOWED_ATTRS.has(attr.name)) { bad.push(`disallowed attribute ${attr.name} on <${tag}>`); } if ((attr.name === - line 121
"href" || attr.name === "src") && dangerousUrl(attr.value)) { bad.push(`executable URL in ${attr.name} on <${tag}>: ${attr.value.slice(0, 60)}`); } } } return bad; } // --- the corpus - line 121
------------------------------------------------------------ // Each entry names the escape route it is trying to take. const HOSTILE = [ ["a bare script tag", '<script>alert(1)</script>'], ["a script tag mid-paragraph", 'text before - line 121
<script>alert(1)</script> text after'], ["an img with an error handler", '<img src=x onerror=alert(1)>'], ["an svg with an onload", '<svg onload=alert(1)></svg>'], ["an iframe", '<iframe src="https://evil.example"></iframe>'], ["a - line 121
javascript: link", '[click me](javascript:alert(1))'], ["a javascript: link, mixed case", '[click me](JaVaScRiPt:alert(1))'], ["a data: URL link", '[click me](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)'], ["a javascript: - line 121
image", ')'], ["a quote break-out in an image source", ')'], ["a quote break-out in image alt text", ''], ["a quote break-out in a - line 121
link label", '[" onmouseover="alert(1)](http://example.com)'], ["an entity-encoded javascript URL", '[x](javascript:alert(1))'], ["a tab-split scheme", '[x](java\tscript:alert(1))'], ["a null-byte-split scheme", - line 121
'[x](java\u0000script:alert(1))'], ["raw html inside a heading", '# <img src=x onerror=alert(1)>'], - line 161
["raw html inside a list item", '- <script>alert(1)</script>'], ["raw html inside a table cell", 'a | b\n--- | ---\n<script>alert(1)</script> | c'], ["raw html inside a blockquote", '> <script>alert(1)</script>'], ["raw html inside a - line 161
fenced block", '```\n<script>alert(1)</script>\n```'], ["raw html inside inline code", '`<script>alert(1)</script>`'], ["a style block", '<style>body{display:none}</style>'], ["a base tag", '<base href="https://evil.example/">'], ["a form - line 161
and input", '<form action="https://evil.example"><input name="p"></form>'], ["an unclosed tag swallowing the rest", '<div onclick="alert(1)"'], ["a comment that never closes", '<!-- ' + 'x'.repeat(200)], ["angle brackets in a table - line 161
header", '<script> | b\n--- | ---\nc | d'], ["a link label containing a closing anchor", '[</a><script>alert(1)</script>](http://example.com)'], ["nested emphasis around markup", '**<script>alert(1)</script>**'], ["an image whose alt - line 161
closes the tag", ''], ["a protocol-relative link", '[x](//evil.example/path)'], ["a very long link target", '[x](http://example.com/' + 'a'.repeat(5000) + ')'], ["deep blockquote - line 161
nesting", '>'.repeat(60) + ' hello'], ["a fence that is never closed", '```rust\nfn main() { println!("hi"); }'], ["backtick soup", '`'.repeat(200)], ["asterisk soup", '*'.repeat(200)], ["pipe soup", '|'.repeat(200) + '\n' + - line 161
'-|'.repeat(100)], ["mixed markers", '#'.repeat(50) + ' <script>x</script>'], ["an anchor inside inline code inside a link", '[`</code><script>alert(1)</script>`](http://example.com)'] ]; // --- randomised campaign - line 161
--------------------------------------------------- const FRAGMENTS = [ "<script>", "</script>", "<img", "src=x", "onerror=alert(1)", "javascript:", "data:text/html", '"', "'", "<", ">", "&", """, "j", "`", "```", "[", "]", "(", - line 161
")", "!", "*", "**", "_", "#", ">", "|", "---", "\n", " ", "0", " 1 ", " 2 ", "\t", "\u0000", "\\", "%", "%22", "//evil.example", "http://example.com", "</a>", "</code>", "<svg", "onload=", "-->", "<!--" ]; // A tiny deterministic PRNG, so - line 161
a failure can be reproduced from its seed // rather than being a story about a run nobody can repeat. function rng(seed) { let s = seed >>> 0; return function () { - line 201
s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } function generate(random) { const pieces = 3 + Math.floor(random() * 40); let doc = ""; for (let i = 0; i < pieces; i++) { doc += - line 201
FRAGMENTS[Math.floor(random() * FRAGMENTS.length)]; } return doc; } // --- runner ---------------------------------------------------------------- function run() { let failures = 0; for (const [name, source] of HOSTILE) { let html; try { - line 201
html = MD.render(source); } catch (e) { console.log(`FAIL ${name}: renderer threw ${e.message}`); failures++; continue; } const bad = findViolations(html); if (bad.length) { failures++; console.log(`FAIL ${name}`); console.log(` - line 201
${bad.join("; ")}`); console.log(` output: ${html.slice(0, 200)}`); } } console.log(` corpus: ${HOSTILE.length} hostile documents`); - line 241
const ROUNDS = Number(process.env.MD_FUZZ_ROUNDS || 20000); let firstBad = null; for (let seed = 1; seed <= ROUNDS; seed++) { const source = generate(rng(seed)); let html; try { html = MD.render(source); } catch (e) { firstBad = { seed, - line 241
source, why: `threw ${e.message}` }; break; } const bad = findViolations(html); if (bad.length) { firstBad = { seed, source, why: bad.join("; ") }; break; } } if (firstBad) { failures++; console.log(`FAIL randomised campaign at seed - line 241
${firstBad.seed}: ${firstBad.why}`); console.log(` source: ${JSON.stringify(firstBad.source).slice(0, 300)}`); } console.log(` randomised: ${ROUNDS} generated documents`); return failures; } module.exports = { run, name: "markdown - line 241
renderer, hostile input" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/markdown.render.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Rendering-correctness tests for the site's Markdown renderer. // // Separate from the hostile-input suite because the failure being guarded // against here is different in kind. Nothing - line 1
below is a security bug; they are // all cases where the page would display something that is *not what the // source says*. On a project whose entire pitch is "every claim is checkable, // go and read the source", publishing altered - line 1
source on the front page is its // own kind of serious. "use strict"; const fs = require("fs"); const path = require("path"); const vm = require("vm"); const ROOT = path.resolve(__dirname, "..", ".."); const sandbox = { window: {} }; - line 1
vm.createContext(sandbox); vm.runInContext(fs.readFileSync(path.join(ROOT, "website", "js", "markdown.js"), "utf8"), sandbox); const MD = sandbox.window.MD; const CASES = [ // --- the placeholder regression - line 1
----------------------------------------- // Finished markup is parked while later passes run. The placeholder used to // be a NUL-delimited *decimal index*, which the number highlighter (\b\d+\b) // then wrapped in a span, after which the - line 1
un-parking pass no longer // recognised it and the parked content was dropped entirely. Every string // literal in every code block on the site rendered as a stray digit. { name: "a string literal in a code block survives the number - line 1
highlighter", source: '```rust\nlet s = "hello";\n```', want: ['tok-str', '"hello"'], reject: ['tok-num">0<'] }, { name: "a string and a number in one line both survive", source: '```rust\nlet n = 42; let s = "x";\n```', want: - line 1
['tok-num">42<', 'tok-str', '"x"'] - line 41
}, { name: "a comment in a shell block survives", source: '```bash\ncargo build # deliberately slow\n```', want: ['tok-com', 'deliberately slow'] }, { name: "several parked items in one block all come back", source: '```rust\nlet a = - line 41
"one"; let b = "two"; let c = "three";\n```', want: ['"one"', '"two"', '"three"'] }, // Adjacent placeholders used to share a delimiter, so alternate items were // silently lost. { name: "adjacent inline code - line 41
spans both survive", source: '`alpha``beta`', want: ['<code>alpha</code>', '<code>beta</code>'] }, { name: "a digit in ordinary prose is not mistaken for a placeholder", source: 'Section 0 has `code` in it, and section 1 does too.', want: - line 41
['Section 0 has', '<code>code</code>', 'section 1 does too'] }, { name: "a link, a bare digit and another link all render", source: '[a](http://x.test) 0 [b](http://y.test)', want: ['>a</a>', '>b</a>', '> 0 <'] }, // --- ordinary rendering - line 41
-------------------------------------------------- { name: "headings", source: '## Title', want: ['<h2>Title</h2>'] }, { name: "bold and italic", source: 'a **b** and *c*', want: ['<strong>b</strong>', '<em>c</em>'] }, { name: "an external - line 41
link gets rel=noopener", source: '[x](https://example.com)', want: ['rel="noopener noreferrer"'] }, { name: "a relative link does not", source: '[x](./docs/AUDIT.md)', want: ['href="./docs/AUDIT.md"'], reject: ['rel='] }, { name: "a table - line 41
renders as a table", source: 'a | b\n--- | ---\n1 | 2', want: ['<table>', '<th>a</th>', '<td>1</td>'] }, { name: "a blockquote renders", source: '> quoted', want: ['<blockquote>', 'quoted'] }, { name: "a list renders", source: '- one\n- - line 41
two', want: ['<ul>', '<li>one</li>'] }, - line 81
{ name: "an ordered list renders", source: '1. one\n2. two', want: ['<ol>', '<li>two</li>'] }, { name: "an image renders", source: '', want: ['<img src="assets/x.png" alt="alt">'] }, // --- nested placeholders - line 81
-------------------------------------------------- // A link whose label is inline code parks the code, then parks an anchor // whose label *is* that placeholder. One un-parking pass left the inner one // in the output as a private-use - line 81
character, which browsers draw as nothing: // every such link in the README rendered as an empty link, so // "see [`docs/AUDIT.md`](docs/AUDIT.md)." came out as "see .". { name: "a link whose label is inline code keeps its label", source: - line 81
'See [`docs/AUDIT.md`](docs/AUDIT.md).', want: ['<a href="docs/AUDIT.md"><code>docs/AUDIT.md</code></a>'], // The broken output was `<a href="docs/AUDIT.md"></a>`, so the quote has to // be part of the pattern, because `></a>` alone also - line 81
matches `</code></a>`. reject: ['"></a>'] }, { name: "an image nested in a link keeps both", source: '[](https://example.com)', want: ['<img src="a.png" alt="alt">', '<a href="https://example.com"'] }, { name: "inline code in - line 81
a heading and a list item survives", source: '## The `veilvoice` binary\n- run `veilvoice lock set`', want: ['<code>veilvoice</code>', '<code>veilvoice lock set</code>'] }, { name: "bold around an inline-code link keeps everything", - line 81
source: '**[`HANDOFF.md`](HANDOFF.md)**', want: ['<strong>', '<code>HANDOFF.md</code>', 'href="HANDOFF.md"'] }, // A protocol-relative target looks relative and behaves external, so it is // refused outright rather than emitted without - line 81
rel="noopener noreferrer". { name: "a protocol-relative link is refused", source: '[label](//evil.example)', want: ['label'], reject: ['<a href'] }, { name: "a backslash-prefixed target is refused", source: '[label](\\\\evil.example)', - line 81
want: ['label'], reject: ['<a href'] }, - line 121
// Ordinary relative Markdown links, which the old scheme test rejected. { name: "a bare relative link renders", source: '[whitepaper](docs/WHITEPAPER.md)', want: ['href="docs/WHITEPAPER.md"', '>whitepaper</a>'] }, { name: "an anchor-only - line 121
link renders", source: '[top](#what)', want: ['href="#what"'] }, { name: "a mailto link is still refused", source: '[mail](mailto:someone@example.com)', want: ['mail'], reject: ['<a href'] }, // --- the real README - line 121
------------------------------------------------------ { name: "the project README renders without losing its code", source: null } ]; function run() { let failures = 0; for (const test of CASES) { if (test.source === null) { continue; } - line 121
const html = MD.render(test.source); const missing = (test.want || []).filter(w => !html.includes(w)); const present = (test.reject || []).filter(r => html.includes(r)); if (missing.length || present.length) { failures++; console.log(`FAIL - line 121
${test.name}`); if (missing.length) { console.log(` missing: ${missing.join(" | ")}`); } if (present.length) { console.log(` should not contain: ${present.join(" | ")}`); } console.log(` output: ${html.slice(0, 240)}`); } } // The - line 121
renderer's actual job: the project's own README, which is what // js/repo.js fetches and injects. Every fenced block in the source must come // back with its contents intact. const readme = fs.readFileSync(path.join(ROOT, "README.md"), - line 121
"utf8"); const html = MD.render(readme); // Nothing internal may survive into the page, and no link may come out with // no text in it. Both were true of the deployed site. const stray = [...html].filter(c => c.charCodeAt(0) >= 0xe000 && - line 121
c.charCodeAt(0) <= 0xf8ff); if (stray.length) { failures++; - line 161
console.log(`FAIL the README leaves ${stray.length} placeholder character(s) in the output`); } const empty = html.match(/<a [^>]*><\/a>/g) || []; if (empty.length) { failures++; console.log(`FAIL the README renders ${empty.length} link(s) - line 161
with no text: ${empty[0]}`); } const fences = readme.match(/```[\s\S]*?```/g) || []; let lost = 0; for (const fence of fences) { for (const literal of fence.match(/"[^"\n]{3,40}"/g) || []) { const escaped = literal.replace(/"/g, """); - line 161
if (!html.includes(escaped)) { if (lost === 0) { console.log("FAIL the README loses code-block content"); } if (lost < 5) { console.log(` dropped: ${literal}`); } lost++; } } } if (lost) { failures++; } console.log(` ${CASES.length - 1} - line 161
rendering cases, ${fences.length} README code blocks`); return failures; } module.exports = { run, name: "markdown renderer, rendering correctness" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/nav.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Every page on the site carries the same header navigation, and every link in // it points at something that exists. // // # Why this is a test // // The header was not the same on every - line 1
page. Nine hand-written pages carried // the full thirteen links. `wiki.html` carried six, `search.html` nine, // `404.html` four, and the twelve hundred generated pages under // `website/reference/` carried five. // // So clicking - line 1
`reference` -- the link whose whole job is to send a reader into // the source -- landed them on a page whose header had silently dropped // `what`, `download`, `guide`, `verify`, `security`, `faq`, `roadmap` and // `releases`. Eight ways - line 1
out of the page, gone, with no way back to any of them // except `home`. Nothing was broken in the sense a link checker understands: // every link that was there worked. The fault was the links that were not // there, which is exactly the - line 1
kind of thing no existing check could see. // // A header that changes shape as a reader moves through the site is worse than // a long one. It reads as though they have left the site, and on a project // whose argument is "go and read it - line 1
yourself" the reference pages are the last // place that should feel like somewhere else. // // # What is checked // // `index.html` is the definition: whatever nav it has is what every other page // must have, label for label, in order. - line 1
That way the test cannot drift out of // date with a deliberate change to the menu -- add a link to `index.html` and // every other page is required to grow it too, which is the property actually // wanted. // // Links are then resolved - line 1
for real, relative to the page that carries them, so // the `../../` prefixes the generator computes for depth are checked rather // than assumed. Fragments are resolved too, against the `id` attributes of the // target page: - line 1
`index.html#crypto` from a page two directories down has three // separate ways to be wrong, and F-37's shape was exactly a depth prefix that // no test looked at. - line 41
"use strict"; const fs = require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); const SITE = path.join(ROOT, "website"); /** Every `.html` file under `website/`, repository-relative, sorted. */ - line 41
function pages(dir = SITE, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { pages(full, out); } - line 41
else if (entry.name.endsWith(".html")) { out.push(path.relative(ROOT, full)); } } return out; } const NAV = /<nav class="links">([\s\S]*?)<\/nav>/; const LINK = /<a\s+href="([^"]+)"[^>]*>([^<]*)<\/a>/g; /** The [href, label] pairs of a - line 41
page's header nav, or null if it has none. */ function navOf(text) { const block = NAV.exec(text); if (!block) { return null; } return [...block[1].matchAll(LINK)].map(m => [m[1], m[2].trim()]); } const idsCache = new Map(); function - line 41
idsOf(rel) { if (!idsCache.has(rel)) { const text = fs.readFileSync(path.join(ROOT, rel), "utf8"); idsCache.set(rel, new Set([...text.matchAll(/\bid="([^"]+)"/g)].map(m => m[1]))); } return idsCache.get(rel); } function run() { let fails = - line 41
0; const check = (name, ok) => { - line 81
console.log(` ${ok ? "ok " : "FAIL"} ${name}`); if (!ok) { fails++; } return ok; }; const all = pages(); // `nojs/` is the deliberately scriptless mirror. It has its own minimal // shell and no header nav at all, which is the point of it, - line 81
so it is not // held to the shape of the main site's header. const withNav = all.filter(rel => !rel.startsWith(path.join("website", "nojs"))); const home = "website/index.html"; const want = navOf(fs.readFileSync(path.join(ROOT, home), - line 81
"utf8")); check("index.html defines a header nav", want !== null && want.length > 0); if (!want) { return fails; } const wantLabels = want.map(([, label]) => label).join(" "); const wrong = []; const broken = []; let resolved = 0; for - line 81
(const rel of withNav) { const text = fs.readFileSync(path.join(ROOT, rel), "utf8"); const nav = navOf(text); if (nav === null) { wrong.push(`${rel}: no header nav at all`); continue; } const labels = nav.map(([, label]) => label).join(" - line 81
"); if (labels !== wantLabels) { wrong.push(`${rel}: [${labels}]`); continue; } for (const [href] of nav) { if (/^https?:/i.test(href)) { continue; } const [target, frag] = href.split("#"); // A bare `#section` is a link into the page that - line 81
carries it, which is // how the home page's own header is written. // Anything else resolves relative to the directory of the page carrying - line 121
// it, which is what makes the generator's depth prefixes testable. const targetRel = target === "" ? rel : path.relative(ROOT, path.resolve(path.dirname(path.join(ROOT, rel)), target)); if (targetRel.startsWith("..")) { - line 121
broken.push(`${rel} -> ${href} (escapes the repository)`); continue; } const abs = path.join(ROOT, targetRel); if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { broken.push(`${rel} -> ${href} (no such page)`); continue; } if (frag - line 121
&& !idsOf(targetRel).has(frag)) { broken.push(`${rel} -> ${href} (no such anchor)`); continue; } resolved++; } } check(`all ${withNav.length} pages carry the same header as index.html ` + `(${want.length} links)`, wrong.length === 0); for - line 121
(const line of wrong.slice(0, 10)) { console.log(" differs: " + line); } if (wrong.length > 10) { console.log(` ...and ${wrong.length - 10} more`); } check(`every header link resolves, anchors included (${resolved} checked)`, broken.length - line 121
=== 0); for (const line of broken.slice(0, 10)) { console.log(" broken: " + line); } if (broken.length > 10) { console.log(` ...and ${broken.length - 10} more`); } // The generated reference tree is the half that regressed, and it is the - line 121
// half a hand-written fix cannot reach. If the walk stopped finding it, both // checks above would pass while testing only the pages that were never // wrong. const refs = withNav.filter(rel => rel.includes(path.join("website", - line 121
"reference"))); check(`the generated reference pages are covered (${refs.length} found)`, refs.length > 100); // Non-vacuity: a nav regex that matched nothing anywhere would report a // clean run over zero links. check("header links were - line 121
actually resolved", resolved > 1000); return fails; } module.exports = { run, name: "header navigation" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/packaging.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The package definitions, against the version the workspace is actually at. // // # The defect this exists to stop coming back // // F-81. Every definition in `packaging/` named v0.1.9 - line 1
while the workspace was // at v0.1.14. Five releases had gone by and nothing noticed, because nothing // was looking: the Homebrew formula would have fetched and built the v0.1.9 // tarball, the Flatpak manifest would have checked out the - line 1
v0.1.9 tag, and the // AppStream metadata told a software centre that 0.1.9 was the newest release // there is. // // That is exactly the shape this repository keeps finding and keeps writing // guards for: a claim in a file, correct on - line 1
the day it was typed, with nothing // watching it. F-71 was two hand-typed numbers agreeing with each other; this // is six files agreeing with a number that had moved. // // # Why a version and not a build // // A build would be better - line 1
and is not available here. `rpmbuild`, `wix`, // `flatpak-builder` and `brew` are four different platforms' toolchains, and // `docs/PACKAGING.md` says plainly which of these has been built and which has // not. What this suite can do - line 1
without any of them is make sure the number in // each file is the number the workspace is at, which is the part that went // wrong. // // # The Debian package is the one with a rule of its own // // F-80. `dpkg-buildpackage` refuses to - line 1
start without `debian/changelog`, and // runs `debian/rules` directly, so that file has to be executable. Neither was // true, so the recipe printed in `docs/PACKAGING.md` could not run at all. // Both are checked here, the mode through - line 1
`git ls-files -s` rather than // through the filesystem, because a checkout on a filesystem with no // permission bits still records the mode in the index and that is what other // people clone. "use strict"; const fs = require("fs"); - line 41
const path = require("path"); const { execFileSync } = require("child_process"); const ROOT = path.resolve(__dirname, "..", ".."); function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } /** The version every one of - line 41
these has to agree with. */ function workspaceVersion() { const manifest = read("Cargo.toml"); const block = /\[workspace\.package\]([\s\S]*?)(\n\[|$)/.exec(manifest); if (!block) { return null; } const found = - line 41
/^\s*version\s*=\s*"([^"]+)"/m.exec(block[1]); return found ? found[1] : null; } function run() { let failures = 0; const fail = (message) => { failures++; console.log(`FAIL ${message}`); }; const pass = (message) => console.log(`ok - line 41
${message}`); const version = workspaceVersion(); if (!version) { fail("Cargo.toml has no [workspace.package] version, so nothing can be checked"); return failures; } // Each entry: the file, a pattern whose first group is a version, and - line 41
what // that version means to somebody who runs the definition. const claims = [ ["packaging/debian/changelog", /^veilvoice \(([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+\)/m, "the version dpkg-buildpackage stamps into the .deb"], - line 41
["packaging/rpm/veilvoice.spec", /%global vv_version %\{\?vv_version\}%\{!\?vv_version:([0-9]+\.[0-9]+\.[0-9]+)\}/, "what rpmbuild builds when no version is passed"], ["packaging/rpm/veilvoice.spec", /%changelog\n\* [^\n]*? - - line 41
([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+/, - line 81
"the newest entry in the spec's own changelog"], ["packaging/homebrew/veilvoice.rb", /url "https:\/\/github\.com\/tilas01\/veilvoice\/archive\/refs\/tags\/v([0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz"/, "the tarball brew downloads and compiles"], - line 81
["packaging/flatpak/io.github.tilas01.VeilVoice.yml", /^\s*tag: v([0-9]+\.[0-9]+\.[0-9]+)\s*$/m, "the tag flatpak-builder checks out"], ["packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml", /<release - line 81
version="([0-9]+\.[0-9]+\.[0-9]+)"/, "the newest release a software centre will show"], ["packaging/wix/veilvoice.wxs", /-d Version=([0-9]+\.[0-9]+\.[0-9]+)/, "the version in the documented wix command"], // The commands a reader copies - line 81
out of the documentation are claims too, // and two of them were stale in exactly the same way the files were. ["docs/PACKAGING.md", /--define "vv_version ([0-9]+\.[0-9]+\.[0-9]+)"/, "the version in the rpmbuild command somebody will - line 81
copy"], ["docs/PACKAGING.md", /-d Version=([0-9]+\.[0-9]+\.[0-9]+)/, "the version in the wix command somebody will copy"] ]; let drifted = 0; for (const [file, pattern, what] of claims) { let text; try { text = read(file); } catch (error) - line 81
{ fail(`${file} is missing, so ${what} cannot be checked`); drifted++; continue; } const found = pattern.exec(text); if (!found) { fail(`${file}: no version found where one is expected (${what})`); drifted++; } else if (found[1] !== - line 81
version) { fail(`${file} says ${found[1]}, the workspace is at ${version}. That is ${what}.`); drifted++; - line 121
} } if (drifted === 0) { pass(`all ${claims.length} version claims in packaging/ are at ${version}`); } // ---- one version per release, in order, with no gaps -------------------- // // Roadmap item 95. The version claims above only check - line 121
that everything agrees with // the workspace. They say nothing about whether the workspace number is the // right *next* one, and a release that skips 0.1.15 or repeats 0.1.14 is a // release nobody can reason about afterwards: a user - line 121
asking "have I got the // one with the lock fix" is asking a question about ordering. const changelog = read("CHANGELOG.md"); const released = [...changelog.matchAll(/^## v(\d+)\.(\d+)\.(\d+)\s*$/gm)] .map((m) => [Number(m[1]), - line 121
Number(m[2]), Number(m[3])]); if (released.length < 2) { fail("CHANGELOG.md lists fewer than two releases, so nothing can be ordered"); } else { // Newest first is how the file is written and how anybody reads it. let outOfOrder = 0; for - line 121
(let i = 1; i < released.length; i++) { const [aMaj, aMin, aPat] = released[i - 1]; const [bMaj, bMin, bPat] = released[i]; const newer = aMaj > bMaj || (aMaj === bMaj && aMin > bMin) || (aMaj === bMaj && aMin === bMin && aPat > bPat); if - line 121
(!newer) { fail(`CHANGELOG.md has v${aMaj}.${aMin}.${aPat} above ` + `v${bMaj}.${bMin}.${bPat}; releases are listed newest first`); outOfOrder++; } } if (outOfOrder === 0) { pass(`${released.length} releases in CHANGELOG.md are in order, - line 121
newest first`); } // The workspace is either the newest release, or exactly one patch past // it while that release is still being prepared under Unreleased. - line 161
const [maj, min, pat] = version.split(".").map(Number); const [nMaj, nMin, nPat] = released[0]; const isReleased = maj === nMaj && min === nMin && pat === nPat; const isNextPatch = maj === nMaj && min === nMin && pat === nPat + 1; const - line 161
isNextMinor = maj === nMaj && min === nMin + 1 && pat === 0; const isNextMajor = maj === nMaj + 1 && min === 0 && pat === 0; if (isReleased || isNextPatch || isNextMinor || isNextMajor) { pass(`the workspace at ${version} follows - line 161
v${nMaj}.${nMin}.${nPat} without a gap`); } else { fail(`the workspace is at ${version} and the newest release is ` + `v${nMaj}.${nMin}.${nPat}. A release goes up by one: ` + `${nMaj}.${nMin}.${nPat + 1}, ${nMaj}.${nMin + 1}.0 or ${nMaj + - line 161
1}.0.0.`); } } // ---- every archive the release builds is linked on the releases page ---- // // **F-101.** The page hand-listed five archives and the workflow built // eleven. Two of the five names had never existed -- `macos-aarch64` - line 161
and // `linux-aarch64`, where the workflow says `arm64` -- so every release entry // carried two dead links, and six published platforms had no link at all. // // `tools/site/releases.py` derives the list from the workflow now, which is // - line 161
the fix. This is the check that the derivation still works: a workflow // rewritten into a shape the generator cannot read would produce a page with // fewer downloads on it and nothing else would notice, because a missing // link looks - line 161
exactly like a platform that was never built. const workflow = read(".github/workflows/release.yml"); const releasesPage = read("website/releases.html"); const labels = new Set(); for (const match of - line 161
workflow.matchAll(/^\s*label:\s*(\S+)\s*$/gm)) { labels.add(match[1]); } for (const match of workflow.matchAll(/out="veilvoice-\$\{\{[^}]*\}\}-(\S+?)"/g)) { labels.add(match[1]); } if (labels.size < 5) { fail(`only ${labels.size} archive - line 161
labels could be read out of ` + "release.yml, so this check is not checking anything"); } else { - line 201
const missing = [...labels].filter( (label) => !releasesPage.includes(`-${label}.`) ); if (missing.length) { fail(`the releases page links no file for ${missing.join(", ")}, ` + "which the release workflow builds"); } else { pass(`all - line 201
${labels.size} archives the workflow builds are linked on ` + "the releases page"); } } // ---- the two things dpkg-buildpackage needs before it will start -------- const index = execFileSync("git", ["ls-files", "-s", "packaging/debian/"], - line 201
{ cwd: ROOT, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); const modes = new Map( index.split("\n").filter(Boolean).map((line) => { const parts = line.split("\t"); return [parts[1], parts[0].split(" ")[0]]; }) ); if - line 201
(!modes.has("packaging/debian/changelog")) { fail("packaging/debian/changelog is not tracked, and dpkg-buildpackage " + "refuses to start without it"); } else { pass("packaging/debian/changelog is there, so dpkg-buildpackage can start"); } - line 201
const rules = modes.get("packaging/debian/rules"); if (rules !== "100755") { fail(`packaging/debian/rules is mode ${rules || "absent"} in the index; ` + "dpkg-buildpackage runs it directly, so it has to be 100755"); } else { - line 201
pass("packaging/debian/rules is executable in the index"); } failures += changelogDates(fail, pass); failures += releaseDatesAgree(fail, pass); - line 241
// --- the site is published after a release --------------------------------- // // The site is not only documentation. The download page names the current // version, the releases page is generated from CHANGELOG.md, and the verify // - line 241
page describes files a release publishes. Cutting a release changed all of // that and deployed none of it: a tag push touches no path in the pages // workflow's filter, so the site stayed on the previous release until // somebody happened - line 241
to edit a file under `website/`. // // Checked here rather than trusted, because the failure is silent and slow: // the site is simply out of date, and looks fine. const pages = "\.github/workflows/pages.yml"; const pagesPath = - line 241
path.join(ROOT, ".github", "workflows", "pages.yml"); if (!fs.existsSync(pagesPath)) { fail(`${pages} is missing, so nothing publishes the site`); failures += 1; } else { const workflow = fs.readFileSync(pagesPath, "utf8"); if - line 241
(!/workflow_run:/.test(workflow) || !/workflows:\s*\[?\s*release/.test(workflow)) { fail(`${pages} does not run after the release workflow, so a release ` + "publishes new archives and leaves the site describing the previous " + "one until - line 241
somebody edits a page by hand"); failures += 1; } else if (!/conclusion\s*==\s*'success'/.test(workflow)) { fail(`${pages} runs after the release workflow without checking that it ` + "succeeded, so a release that failed halfway would - line 241
still redeploy " + "the site"); failures += 1; } else { pass("the site is published again after a release succeeds"); } } // --- what the README claims the release builds ---------------------------- // // It said "nine targets" and the - line 241
release publishes eleven. A number typed // into prose beside a list is a copy of that list's length, and this one had // been wrong across at least two releases without anything noticing, because // nothing compared the sentence to the - line 241
workflow that does the building. // - line 281
// Counted from the workflow rather than from a list here, so the only way to // change the number is to change what is actually built. const releaseYml = path.join(ROOT, ".github", "workflows", "release.yml"); if - line 281
(!fs.existsSync(releaseYml)) { fail(".github/workflows/release.yml is missing, so nothing builds a release"); failures += 1; } else { const workflow = fs.readFileSync(releaseYml, "utf8"); // One archive per `label:` in the build matrix, - line 281
plus the BSD jobs, which // are separate entries in the file because they run inside a VM rather // than on a runner. const matrix = new Set( [...workflow.matchAll(/^\s*label:\s*([a-z0-9][a-z0-9.\-_]*)\s*$/gm)] .map((m) => m[1])); for - line 281
(const os of ["freebsd", "openbsd", "netbsd"]) { if (new RegExp(`^ ${os}:`, "m").test(workflow)) { matrix.add(os); } } const targets = matrix.size; const words = { 9: "nine", 10: "ten", 11: "eleven", 12: "twelve", 13: "thirteen" }; const - line 281
readme = read("README.md"); const claimed = /built for (\w+)\s*\n?targets/.exec(readme.replace(/\s+/g, " ")) || /built for (\w+) targets/.exec(readme.replace(/\s+/g, " ")); if (targets < 5) { fail(`only ${targets} release target(s) were - line 281
found in release.yml, which ` + "is not credible: this check is reading the workflow wrongly"); failures += 1; } else if (!claimed) { fail("README.md no longer says how many targets a release builds for, " + "so nothing here can check it - line 281
against the workflow"); failures += 1; } else if (claimed[1] !== words[targets]) { fail(`README.md says a release is built for "${claimed[1]}" targets; ` + `release.yml builds ${targets} (${words[targets] || targets})`); failures += 1; } - line 281
else { pass(`README.md's "${claimed[1]} targets" matches the ${targets} in release.yml`); } } - line 321
return failures; } /** * A release is dated the same day in every file that dates it. * * Three files record when each version came out, by hand and separately: the * Debian changelog, the RPM spec's `%changelog`, and the AppStream - line 321
metainfo * the Flatpak ships. Nothing derives any of them from the others. * * They agree today. This is here because the weekday check above exists: that * defect was one wrong date copied into two of these three files, which is * proof - line 321
that they are edited together, by hand, and can part company. A * software centre shows the metainfo date and a distribution shows the * changelog, so a reader can be told two different days a release happened. * * Only versions a file - line 321
actually mentions are compared. The RPM spec's history * reaches further back than the others, and that is not drift. */ function releaseDatesAgree(fail, pass) { let failures = 0; const MONTHS = { Jan: "01", Feb: "02", Mar: "03", Apr: - line 321
"04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }; const dated = new Map(); const note = (version, where, date) => { if (!dated.has(version)) { dated.set(version, new Map()); } - line 321
dated.get(version).set(where, date); }; // AppStream: <release version="0.1.16" date="2026-09-01"/> const meta = read("packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml"); for (const m of - line 321
meta.matchAll(/<release\s+version="([\d.]+)"\s+date="([\d-]+)"/g)) { note(m[1], "the Flatpak metainfo", m[2]); } // RPM: * Tue Sep 01 2026 Name <mail> - 0.1.16-1 const spec = read("packaging/rpm/veilvoice.spec"); const changelog = - line 321
spec.slice(spec.indexOf("%changelog")); - line 361
for (const m of changelog.matchAll( /^\* \w{3} (\w{3}) (\d{2}) (\d{4})[^\n]*?-\s*([\d.]+)-\d/gm )) { note(m[4], "the RPM spec", `${m[3]}-${MONTHS[m[1]] || "??"}-${m[2]}`); } // Debian: the version heading, then the ` -- ` line that closes - line 361
that entry. const deb = read("packaging/debian/changelog"); const versions = [...deb.matchAll(/^veilvoice \(([\d.]+)-\d\)/gm)].map((m) => m[1]); const stamps = [...deb.matchAll(/^ -- .*?>\s+\w{3}, (\d{2}) (\w{3}) (\d{4})/gm)]; - line 361
versions.forEach((version, at) => { const m = stamps[at]; if (m) { note(version, "the Debian changelog", `${m[3]}-${MONTHS[m[2]] || "??"}-${m[1]}`); } }); let compared = 0; for (const [version, places] of dated) { if (places.size < 2) { - line 361
continue; } compared += 1; const days = new Set(places.values()); if (days.size > 1) { failures += 1; const said = [...places] .map(([where, day]) => `${where} says ${day}`) .join(", and "); fail(`version ${version} is dated differently in - line 361
each file: ${said}. A ` + "software centre shows one of these and a distribution shows " + "another, so a reader is told two days a release happened."); } } if (compared === 0) { failures += 1; fail("no version is dated in more than one - line 361
packaging file, so this check " + "is comparing nothing"); } else if (failures === 0) { pass(`${compared} release(s) are dated the same day in every file that dates them`); } return failures; } - line 401
/** * Every changelog entry's weekday matches its date. * * # The defect this exists to stop coming back * * `Sun, 31 Aug 2026` in the Debian changelog and `Sun Aug 31 2026` in the RPM * spec. 31 August 2026 was a Monday. One wrong - line 401
weekday, copied into two * packaging files, sitting in both for as long as they had existed. * * Neither is cosmetic. `rpmbuild` prints `bogus date in %changelog` and * lintian carries `debian-changelog-has-wrong-day-of-week`, so both - line 401
would be * bounced by a distribution's review. Nothing here noticed, because nothing * here built the packages: `docs/PACKAGING.md` said these formats "parse", and * a date with the wrong weekday parses perfectly. * * # Why this check and - line 401
not the build * * Building both packages takes a full release compile and two toolchains, and * it already happens by hand and is written up. This is the part that can run * on every commit, on any machine, in milliseconds. It does not - line 401
replace the * build; it catches the one class of defect that a build found and that * nothing else was looking for. */ function changelogDates(fail, pass) { let failures = 0; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - line 401
const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }; const sources = [ { file: "packaging/debian/changelog", // ` -- Name <mail> Tue, 01 Sep 2026 00:00:00 +0000` pattern: /^ - line 401
-- .*?>\s+(\w{3}), (\d{2}) (\w{3}) (\d{4})/gm, order: (m) => [m[1], m[2], m[3], m[4]] }, { - line 441
file: "packaging/rpm/veilvoice.spec", // `* Tue Sep 01 2026 Name <mail> - 0.1.16-1` pattern: /^\* (\w{3}) (\w{3}) (\d{2}) (\d{4})/gm, order: (m) => [m[1], m[3], m[2], m[4]] } ]; let checked = 0; for (const { file, pattern, order } of - line 441
sources) { const text = read(file); let m; pattern.lastIndex = 0; while ((m = pattern.exec(text)) !== null) { const [dow, day, mon, year] = order(m); if (!(mon in MONTHS)) { failures += 1; fail(`${file}: "${mon}" is not a month name`); - line 441
continue; } checked += 1; const date = new Date(Date.UTC(Number(year), MONTHS[mon], Number(day))); const actual = DAYS[date.getUTCDay()]; if (actual !== dow) { failures += 1; fail(`${file}: "${dow}, ${day} ${mon} ${year}" is wrong; that - line 441
date is ` + `a ${actual}. rpmbuild calls this a bogus date and lintian has ` + "debian-changelog-has-wrong-day-of-week, so a distribution's " + "review would bounce it."); } } } if (checked < 2) { failures += 1; fail("no changelog dates - line 441
were found in either packaging file, so this " + "check is reading nothing"); } else if (failures === 0) { pass(`${checked} packaging changelog dates have the right weekday`); } return failures; - line 481
} module.exports = { run, name: "package definitions, against the workspace version" };
tools/site-tests/repo.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // `repo.js` is the only module on this site that puts data from a *third party* // into the page. It fetches the GitHub API and raw README.md, and everything it // does with those answers is - line 1
a decision about how far a remote response is // trusted. // // The audit's standing objection was "trusted by omission": download links were // assigned straight from `browser_download_url` with no check, on the reasoning // that GitHub's - line 1
own API for this repository always returns a github.com URL. // That reasoning is true and it is not a control. This suite is the control. // // # How the module is exercised without a browser // // `repo.js` is an IIFE that reaches for - line 1
`document`, `fetch` and `window`. // Rather than pull in a DOM library -- this project has no dependencies and // that is deliberate -- the suite builds the smallest DOM and `fetch` the // module actually touches and drives it through the - line 1
same path a reader does: // the module registers a `DOMContentLoaded` handler, that handler attaches a // click listener to the button, and the click is what starts the fetches. All // three are wired here rather than short-circuited, - line 1
because a stub that models // only the happy path cannot express a failure -- the mistake the reveal suite // made once already, and recorded in the audit. "use strict"; const fs = require("fs"); const path = require("path"); const vm = - line 1
require("vm"); const ROOT = path.resolve(__dirname, "..", ".."); const REPO_JS = fs.readFileSync(path.join(ROOT, "website", "js", "repo.js"), "utf8"); const MD_JS = fs.readFileSync(path.join(ROOT, "website", "js", "markdown.js"), "utf8"); - line 1
// --- the smallest DOM the module uses --------------------------------------- function makeElement(tag) { return { tagName: String(tag).toUpperCase(), children: [], - line 41
attributes: Object.create(null), listeners: Object.create(null), style: { setProperty() {} }, classList: { add() {}, remove() {} }, disabled: false, _text: "", _html: null, offsetWidth: 0, get textContent() { return this._text; }, set - line 41
textContent(v) { this._text = String(v); this._html = null; }, get innerHTML() { return this._html; }, set innerHTML(v) { this._html = String(v); }, setAttribute(name, value) { this.attributes[name] = String(value); if (name === "href") { - line 41
this.href = String(value); } }, getAttribute(name) { if (name === "href" && typeof this.href === "string") { return this.href; } return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }, - line 41
appendChild(child) { this.children.push(child); return child; }, remove() {}, querySelector(selector) { const found = this.querySelectorAll(selector); return found.length ? found[0] : null; }, /** * Enough of a selector engine to see the - line 41
anchors and images in whatever was * assigned to `innerHTML`, and to write attributes back into it. * * Returning an empty list here instead would have quietly skipped the * link-rewriting pass in `repo.js` altogether -- the test would - line 41
have * "passed" while exercising nothing, which is precisely the failure the * reveal suite already made once (a test double must * model the platform, not the happy path). This is not a real parser and * does not pretend to be; it handles - line 41
the double-quoted attributes this * renderer emits, which is all the renderer can produce. - line 81
*/ querySelectorAll(selector) { const html = this._html; if (typeof html !== "string") { return []; } const tag = /^img/.test(selector) ? "img" : "a"; const attr = tag === "img" ? "src" : "href"; if (selector.indexOf(tag) !== 0) { return - line 81
[]; } const self = this; const found = []; const pattern = new RegExp("<" + tag + "\\b([^>]*)>", "g"); let match; while ((match = pattern.exec(html)) !== null) { const attrs = match[1]; const value = - line 81
/(?:^|\s)(?:href|src)="([^"]*)"/.exec(attrs); if (!value) { continue; } found.push(makeAnchorView(self, match[0], attr, value[1])); } return found; }, addEventListener(type, fn) { this.listeners[type] = fn; } }; } /** * A live view onto - line 81
one tag inside a parent's `innerHTML` string. Reading an * attribute reads the string; writing one rewrites it in place, so assertions * afterwards see what a browser would have. */ function makeAnchorView(parent, originalTag, primary, - line 81
primaryValue) { let currentTag = originalTag; return { tagName: primary === "src" ? "IMG" : "A", getAttribute(name) { const m = new RegExp('(?:^|\\s)' + name + '="([^"]*)"').exec(currentTag); return m ? m[1] : null; }, setAttribute(name, - line 81
value) { const escaped = String(value).replace(/"/g, """); const existing = new RegExp('(\\s' + name + '=")[^"]*(")'); - line 121
const updated = existing.test(currentTag) ? currentTag.replace(existing, "$1" + escaped + "$2") : currentTag.replace(/>$/, " " + name + '="' + escaped + '">'); parent._html = parent._html.replace(currentTag, updated); currentTag = updated; - line 121
}, remove() { parent._html = parent._html.replace(currentTag, ""); }, get href() { return primary === "href" ? this.getAttribute("href") : undefined; }, get src() { return primary === "src" ? this.getAttribute("src") : undefined; }, - line 121
_primaryValue: primaryValue }; } function makeDocument() { const nodes = Object.create(null); for (const id of [ "stars", "forks", "issues", "repo-desc", "repo-license", "latest-tag", "asset-list", "readme", "repo-status", "repo", - line 121
"load-repo" ]) { nodes[id] = makeElement(id === "load-repo" ? "button" : "div"); } const listeners = Object.create(null); return { nodes, listeners, getElementById(id) { return Object.prototype.hasOwnProperty.call(nodes, id) ? nodes[id] : - line 121
null; }, createElement: makeElement, createTextNode: (t) => ({ text: String(t) }), querySelectorAll: () => [], addEventListener(type, fn) { listeners[type] = fn; } }; } function jsonReply(body) { return { ok: true, - line 161
status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve("") }; } function textReply(body) { return { ok: true, status: 200, text: () => Promise.resolve(body), json: () => Promise.resolve({}) }; } /** * Run the panel end - line 161
to end against scripted responses, and return the DOM it * produced along with anything that escaped as an unhandled rejection. */ async function drive(responses) { const document = makeDocument(); const escaped = []; const sandbox = { - line 161
window: { matchMedia: () => ({ matches: true }), // reduced motion: skip the animations requestAnimationFrame: (fn) => fn(0) }, document, URL, Promise, Array, Math, isFinite, String, Error, console, setTimeout, fetch(url) { for (const - line 161
[pattern, reply] of responses) { if (String(url).indexOf(pattern) !== -1) { return Promise.resolve(reply); } - line 201
} return Promise.reject(new Error("no stub for " + url)); } }; sandbox.window.document = document; sandbox.window.fetch = sandbox.fetch; vm.createContext(sandbox); vm.runInContext(MD_JS, sandbox); // repo.js reuses MD.safeUrl - line 201
vm.runInContext(REPO_JS, sandbox); const onRejection = (e) => escaped.push(e instanceof Error ? e : new Error(String(e))); process.on("unhandledRejection", onRejection); try { // Same three steps a reader's browser takes. if - line 201
(!document.listeners.DOMContentLoaded) { throw new Error("repo.js did not register a DOMContentLoaded handler"); } document.listeners.DOMContentLoaded(); const button = document.getElementById("load-repo"); if (!button.listeners.click) { - line 201
throw new Error("repo.js did not attach a click handler to the load button"); } const started = Date.now(); button.listeners.click(); // `Promise.allSettled` over three stubbed fetches settles in a few ticks. for (let i = 0; i < 50; i++) { - line 201
await new Promise((r) => setTimeout(r, 1)); } return { document, escaped, elapsed: Date.now() - started }; } finally { process.removeListener("unhandledRejection", onRejection); } } const EMPTY = [ - line 201
["api.github.com/repos/tilas01/veilvoice/releases", jsonReply({})], ["README.md", textReply("")], ["api.github.com/repos/tilas01/veilvoice", jsonReply({})] ]; /** Replace one stubbed response, leaving the rest empty. */ function - line 201
only(pattern, reply) { - line 241
return [[pattern, reply]].concat(EMPTY); } // --- the checks -------------------------------------------------------------- const CHECKS = []; const check = (name, fn) => CHECKS.push({ name, fn }); check("a download URL that is not https - line 241
is named but never made clickable", async () => { const hostile = [ "javascript:alert(1)", "data:text/html,<script>x</script>", "//attacker.example/x", "http://insecure.example/x", "\\\\attacker.example\\x", "VBSCRIPT:msgbox 1", - line 241
"jAvAsCrIpT:alert(1)", "" ]; const assets = [{ name: "veilvoice-x86_64-linux.tar.gz", browser_download_url: "https://github.com/tilas01/veilvoice/releases/download/v9.9.9/a.tar.gz", size: 1048576 }]; hostile.forEach((url, i) => { - line 241
assets.push({ name: "hostile-" + i + ".bin", browser_download_url: url, size: 1 }); }); const { document } = await drive( only("/releases/latest", jsonReply({ tag_name: "v9.9.9", assets })) ); const problems = []; let linked = 0; for - line 241
(const li of document.getElementById("asset-list").children) { const label = li.children[0]; const name = label.textContent; const href = typeof label.href === "string" ? label.href : null; if (name.indexOf("hostile-") === 0) { - line 281
if (href !== null) { problems.push(`${name} became a link to ${href}`); } if (label.tagName === "A") { problems.push(`${name} was rendered as an anchor`); } if (!name) { problems.push("a refused asset lost its name"); } } else if (href) { - line 281
linked++; if (href.indexOf("https://github.com/") !== 0) { problems.push("the legitimate asset was linked to " + href); } } } if (linked !== 1) { problems.push(`expected exactly one usable link, got ${linked}`); } return problems; }); - line 281
check("a malformed release payload is survived rather than thrown on", async () => { const problems = []; const payloads = [ { tag_name: 42, assets: [{ name: 7, browser_download_url: 9 }] }, { tag_name: null, assets: "not an array" }, { - line 281
assets: [null, undefined, {}, { name: "ok", browser_download_url: null }] }, { assets: [{ name: "a", browser_download_url: "https://github.com/x", size: "big" }] }, { assets: [{ name: "a", browser_download_url: "https://github.com/x", - line 281
size: NaN }] }, {} ]; for (const payload of payloads) { const { escaped } = await drive(only("/releases/latest", jsonReply(payload))); for (const e of escaped) { problems.push("unhandled rejection: " + e.message); } } return problems; }); - line 281
check("the number of list items a single response can create is bounded", async () => { const assets = []; for (let i = 0; i < 5000; i++) { assets.push({ name: "a" + i, - line 321
browser_download_url: "https://github.com/tilas01/veilvoice/" + i, size: 1 }); } const { document } = await drive( only("/releases/latest", jsonReply({ tag_name: "v1", assets })) ); const n = - line 321
document.getElementById("asset-list").children.length; return n > 200 ? [`${n} list items were created from one response`] : []; }); check("a README's own banner markup is not shown as text", async () => { // This is what was live on the - line 321
site: the project's README opens with a // centred banner, GitHub's own idiom for one, and the panel rendered its // source code as a paragraph above the word VeilVoice. // // Nothing was broken in isolation. `markdown.js` escapes raw HTML - line 321
because // that is what makes its output safe to hand to `innerHTML`, and a README is // entitled to contain presentational markup. The two correct behaviours met // and produced tag soup at the top of the page -- which is the same shape - line 321
as // F-37, a thing that was wrong on every viewport for as long as it existed // and that every test passed straight through. const { document } = await drive( only("README.md", textReply( '<!-- a comment that must not appear either - line 321
-->\n' + '<p align="center">\n' + ' <picture>\n' + ' <source srcset="assets/banner-animated.png">\n' + ' <img src="assets/banner.png" alt="VeilVoice">\n' + ' </picture>\n' + '</p>\n' + '\n' + '# VeilVoice\n' + '\n' + 'Irreversible voice - line 321
de-identification.\n' + '\n' + '```html\n' + '<picture>this one is an example and must survive</picture>\n' + '```\n')) ); - line 361
const html = document.getElementById("readme").innerHTML || ""; const problems = []; if (/<picture>|<source|<p align/.test(html.replace(/<code[\s\S]*?<\/code>/g, ""))) { problems.push("raw HTML was rendered as escaped text - line 361
outside a code block"); } if (/a comment that must not appear/.test(html)) { problems.push("an HTML comment from the README was shown to the reader"); } if (!/VeilVoice/.test(html)) { problems.push("the prose was stripped along with the - line 361
markup"); } // A fenced example is being shown on purpose and must not be swept up. if (!/this one is an example/.test(html)) { problems.push("markup inside a fenced block was stripped; it is an example"); } return problems; }); check("an - line 361
enormous README is refused, and the reader is told why", async () => { const { document } = await drive( only("README.md", textReply("x".repeat(4 * 1024 * 1024))) ); const readme = document.getElementById("readme"); const problems = []; if - line 361
(readme.innerHTML !== null) { problems.push("a four-megabyte document was passed to innerHTML"); } if (!/unusually large/.test(readme.textContent)) { problems.push("no explanation was shown: " + JSON.stringify(readme.textContent.slice(0, - line 361
60))); } return problems; }); check("a README shaped to hang the tab renders promptly", async () => { // This exact document took eight seconds before the renderer's two // quadratics were fixed, on the main thread, from text fetched over - line 361
the // network. const { document, elapsed } = await drive( only("README.md", textReply(")) ); - line 401
const problems = []; if (elapsed > 3000) { problems.push(`rendering took ${elapsed} ms -- the quadratic is back`); } if (document.getElementById("readme").innerHTML === null) { problems.push("nothing was rendered at all"); } return - line 401
problems; }); check("a repo-relative README link resolves to where it says it points", async () => { const { document } = await drive( only("README.md", textReply( "See [the whitepaper](docs/WHITEPAPER.md) and [up](../../../elsewhere).\n" - line 401
)) ); const html = document.getElementById("readme").innerHTML || ""; const problems = []; if (html.indexOf("https://github.com/tilas01/veilvoice/blob/main/docs/WHITEPAPER.md") === -1) { problems.push("an ordinary repo-relative link was - line 401
not rewritten: " + html.slice(0, 160)); } // The climbing link must not have been rewritten into a URL that normalises // somewhere other than where it appears to point. if (/blob\/main\/\.\.\//.test(html)) { problems.push("a `..` segment - line 401
was left in an href for the browser to resolve"); } return problems; }); check("every rendered href is http(s) or a fragment", async () => { const { document } = await drive( only("README.md", textReply( "[a](javascript:alert(1)) - line 401
[b](data:text/html,x) [c](vbscript:x)\n\n" + "[d](https://example.org/) [e](#anchor) [f](docs/AUDIT.md)\n\n" + ") \n" )) ); const html = document.getElementById("readme").innerHTML || ""; - line 401
const problems = []; for (const scheme of ["javascript:", "data:", "vbscript:"]) { - line 441
// Present as escaped *text* is the renderer working; present inside an // attribute is the bug. const inAttribute = new RegExp('(?:href|src)="\\s*' + scheme, "i"); if (inAttribute.test(html)) { problems.push(`${scheme} reached an - line 441
attribute`); } } if (html.indexOf('href="https://example.org/"') === -1) { problems.push("an ordinary absolute link stopped working"); } return problems; }); // --- harness ----------------------------------------------------------------- - line 441
// // Asynchronous, unlike the other suites, because the module under test is. The // runner awaits whatever `run` returns. async function run() { let failures = 0; for (const c of CHECKS) { let problems; try { problems = (await c.fn()) || - line 441
[]; } catch (e) { problems = ["threw " + e.constructor.name + ": " + e.message]; } if (problems.length) { failures++; console.log(`FAIL ${c.name}`); for (const p of problems) { console.log(` ${p}`); } } else { console.log(`ok ${c.name}`); - line 441
} } return failures; } module.exports = { name: "repository panel, remote data", run };
tools/site-tests/reveal.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Behaviour tests for the scroll-reveal effect. // // The invariant is narrow and absolute: **every path must end with the content // visible.** A reveal that hides text it then fails to - line 1
show is worse than no // effect at all. // // The previous version of this file passed while the deployed page had three // permanently invisible paragraphs, one of which was the box explaining that // the app lock is not tamper-proof. It - line 1
passed because its stub only ever // modelled the observer firing, and the real failure was the observer *not* // firing, when a viewport jump carries an element from below the fold to above // it between two frames without the - line 1
intersection ratio ever leaving zero. // // So the stub here models a viewport with a position, and the tests drive it // the way a browser does: gradual scrolling, anchor jumps, and a restored // scroll position on load. "use strict"; - line 1
const fs = require("fs"); const path = require("path"); const vm = require("vm"); const SOURCE = fs.readFileSync( path.resolve(__dirname, "..", "..", "website", "js", "reveal.js"), "utf8" ); /** A fake page: nodes at fixed document - line 1
offsets, and a viewport over them. */ function makePage({ count = 6, spacing = 400, viewportHeight = 800, reducedMotion = false, hasIO = true } = {}) { const page = { scrollY: 0, viewportHeight, frames: [], listeners: {}, observed: new - line 1
Set() }; page.nodes = Array.from({ length: count }, (_, i) => { const classes = new Set(["reveal"]); return { _top: i * spacing, classList: { - line 41
add: v => classes.add(v), contains: v => classes.has(v) }, getBoundingClientRect: () => ({ top: (i * spacing) - page.scrollY }) }; }); let observerCallback = null; const sandbox = { document: { addEventListener: (ev, fn) => { if (ev === - line 41
"DOMContentLoaded") { page.ready = fn; } }, querySelectorAll: () => page.nodes }, window: { matchMedia: () => ({ matches: reducedMotion }), get innerHeight() { return page.viewportHeight; }, requestAnimationFrame: fn => { - line 41
page.frames.push(fn); }, addEventListener: (ev, fn) => { (page.listeners[ev] = page.listeners[ev] || []).push(fn); }, removeEventListener: (ev, fn) => { page.listeners[ev] = (page.listeners[ev] || []).filter(f => f !== fn); } } }; if - line 41
(hasIO) { sandbox.window.IntersectionObserver = function (cb) { observerCallback = cb; this.observe = n => page.observed.add(n); this.unobserve = n => page.observed.delete(n); this.disconnect = () => { page.observed.clear(); - line 41
observerCallback = null; }; }; // In a browser `window` *is* the global object, so `IntersectionObserver` // and `window.IntersectionObserver` are the same binding. The stub has to // model that, or code that feature-detects on `window` - line 41
and then constructs // from the global, which is ordinary, idiomatic browser code, fails here // for a reason that could never happen in a browser. sandbox.IntersectionObserver = sandbox.window.IntersectionObserver; } - line 41
sandbox.requestAnimationFrame = sandbox.window.requestAnimationFrame; vm.createContext(sandbox); - line 81
vm.runInContext(SOURCE, sandbox); page.ready(); /** Run any animation frames the code asked for. */ page.flush = () => { while (page.frames.length) { page.frames.shift()(); } }; /** Move the viewport and fire a scroll event, as a browser - line 81
would. */ page.scrollTo = y => { page.scrollY = y; (page.listeners.scroll || []).forEach(fn => fn()); page.flush(); }; /** * Move the viewport **without** the observer noticing, such as an anchor jump that * carries elements from below the - line 81
fold to above it in one step. This is the * case that shipped broken. */ page.jumpPast = y => page.scrollTo(y); /** Deliver an observer callback for whatever is currently intersecting. */ page.settle = () => { if (!observerCallback) { - line 81
return; } const entries = [...page.observed] .map(target => { const top = target.getBoundingClientRect().top; return { target, isIntersecting: top >= 0 && top <= page.viewportHeight }; }) .filter(e => e.isIntersecting); if (entries.length) - line 81
{ observerCallback(entries); } page.flush(); }; page.shown = () => page.nodes.filter(n => n.classList.contains("in")).length; page.hidden = () => page.nodes.filter(n => !n.classList.contains("in")).length; page.listenerCount = () => - line 81
(page.listeners.scroll || []).length + (page.listeners.resize || []).length; return page; } function run() { - line 121
let fails = 0; const check = (name, ok) => { console.log((ok ? "ok " : "FAIL ") + name); if (!ok) { fails++; } }; // 1. Reduced motion: shown at once, nothing observed, nothing listening. { const p = makePage({ reducedMotion: true }); - line 121
check("reduced motion reveals everything at once", p.hidden() === 0); check("reduced motion observes nothing", p.observed.size === 0); check("reduced motion attaches no listeners", p.listenerCount() === 0); } // 2. No IntersectionObserver - line 121
at all. { const p = makePage({ hasIO: false }); check("missing IntersectionObserver still reveals everything", p.hidden() === 0); } // 3. The ordinary path: hidden at the top, revealed as it comes into view. { const p = makePage({ count: - line 121
6, spacing: 400, viewportHeight: 800 }); p.flush(); const initial = p.shown(); check("only what is already on screen starts revealed", initial > 0 && initial < 6); p.scrollTo(400); p.settle(); check("scrolling reveals more", p.shown() > - line 121
initial); } // 4. **The regression.** An anchor jump straight past content, with the // observer never reporting those elements as intersecting. { const p = makePage({ count: 6, spacing: 400, viewportHeight: 800 }); p.flush(); - line 121
p.jumpPast(100000); // far below every element; all are now above the fold check("a jump past content still reveals all of it", p.hidden() === 0); } - line 161
// 5. A restored scroll position on load, before any scroll event fires. { const p = makePage({ count: 6, spacing: 400, viewportHeight: 800 }); p.scrollY = 100000; p.flush(); // only the frame the code queued for itself at startup check("a - line 161
restored scroll position reveals what it skipped", p.hidden() === 0); } // 6. Teardown: once everything is shown, nothing is left running. { const p = makePage({ count: 4, spacing: 400, viewportHeight: 800 }); p.scrollTo(100000); - line 161
check("everything is revealed", p.hidden() === 0); check("listeners detach once there is no work left", p.listenerCount() === 0); check("the observer disconnects too", p.observed.size === 0); } // 7. Scroll bursts are coalesced rather than - line 161
handled one by one. { const p = makePage({ count: 6, spacing: 4000, viewportHeight: 800 }); // Run the frame the module queues at startup first. Discarding it instead // would leave the coalescing flag stuck on, and this test would then - line 161
pass // for the wrong reason, by observing no frames at all. p.flush(); p.frames.length = 0; p.scrollY = 10; (p.listeners.scroll || []).forEach(fn => fn()); (p.listeners.scroll || []).forEach(fn => fn()); (p.listeners.scroll || - line 161
[]).forEach(fn => fn()); check("three scroll events queue one frame, not three", p.frames.length === 1); p.flush(); } // 8. A page with no reveals must be a complete no-op. { let threw = false; try { const sandbox = { document: { - line 161
addEventListener: (e, f) => e === "DOMContentLoaded" && (sandbox._r = f), - line 201
querySelectorAll: () => [] }, window: {} }; vm.createContext(sandbox); vm.runInContext(SOURCE, sandbox); sandbox._r(); } catch (e) { threw = true; } check("a page with no reveals is a no-op", !threw); } return fails; } module.exports = { - line 201
run, name: "scroll reveal" }; if (require.main === module) { process.exit(run() ? 1 : 0); }
tools/site-tests/run.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Runs every site test. No framework, no dependencies, no package.json, because the // same rule the site itself follows, for the same reason: a test suite that // pulls a hundred packages - line 1
off a registry is a supply chain nobody has read. // // node tools/site-tests/run.js // // `MD_FUZZ_ROUNDS` sets the size of the randomised Markdown campaign; the // default is small enough to run on every commit and the audit runs it far - line 1
// larger by hand. "use strict"; const SUITES = [ require("./characters.test.js"), require("./html.test.js"), require("./css.test.js"), require("./markdown.render.test.js"), require("./markdown.hostile.test.js"), - line 1
require("./markdown.complexity.test.js"), require("./repo.test.js"), require("./reveal.test.js"), require("./search.test.js"), require("./links.test.js"), require("./nav.test.js"), require("./anchors.test.js"), - line 1
require("./addresses.test.js"), require("./diagrams.test.js"), require("./source.test.js"), require("./packaging.test.js"), require("./images.test.js"), require("./audit.test.js"), require("./alignment.test.js"), - line 1
require("./scripts.test.js") ]; // `run` may be synchronous or return a promise: the repository-panel suite // drives an async module, and awaiting a number is harmless for the rest. (async function () { - line 41
let failures = 0; for (const suite of SUITES) { console.log(`\n${suite.name}`); failures += await suite.run(); } console.log(failures === 0 ? "\nall site tests passed" : `\n${failures} failing check(s)`); process.exit(failures === 0 ? 0 : - line 41
1); })();
tools/site-tests/scripts.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // A page that carries a feature's markup loads the feature's code. // // # The defect this exists to stop coming back // // `website/verify.html` is the page this project points people to - line 1
when it // tells them to check a download before running it. It had the drop zone, the // expected-hash field, the progress bar and the verdict line, all of it, and // it did not load `js/verify.js`. Dropping a file on it did nothing at - line 1
all: // the digest line sat at "no file hashed yet" for ever, on the page whose // entire subject is proving a download is genuine (finding F-111). // // The cause was in `tools/site/split.py`, which generates the section pages // out of - line 1
`index.html`. It copied the head, and the six scripts `index.html` // loads in its head came along inside it. The three it loads at the *end of // its body* did not. `shell()` even collected all nine into a `scripts` key, // and nothing - line 1
ever read it -- a dead variable is why nobody noticed. // // # Why this reads the scripts instead of keeping a list // // A table here saying "verify.html needs verify.js" would be a second copy of // a fact, which is the shape of half the - line 1
findings in this repository. So each // module is read for the ids it will not start without, and each page for the // ids it has, and the two are compared. A section that grows a feature is // covered the day it grows it, by nobody doing - line 1
anything. // // # Activation ids, not every id // // Which ids count is the whole difficulty. `repo.js` touches `#asset-list`, // and the download page has an `#asset-list` -- but `repo.js` returns // immediately unless `#load-repo` is on - line 1
the page, and that button lives on // the front page only. So the download page holds that markup legitimately // and needs no script, while the verify page holds `#drop` and `#file`, which // `verify.js` does demand, and needed one badly. - line 1
// // Every module here is written the same way: a `DOMContentLoaded` handler // looks its elements up and returns early if the ones it cannot work without // are missing. That guard is the answer, and it is read out of the module // - line 1
rather than restated here. Counting ids gets the download page wrong; - line 41
// reading the guard gets both right. // // # The second check // // Splitting a section onto its own page also falsifies the words "above" and // "below" in it. "The verifier below" was true on the front page and became a // link to a - line 41
different page. A claim about where something sits on this page // cannot be made about a link that leaves it, so that pairing is refused. "use strict"; const fs = require("fs"); const path = require("path"); const ROOT = - line 41
path.resolve(__dirname, "..", ".."); function read(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } function htmlIn(dir) { const full = path.join(ROOT, dir); if (!fs.existsSync(full)) { return []; } return - line 41
fs.readdirSync(full) .filter((name) => name.endsWith(".html")) .map((name) => path.posix.join(dir, name)) .sort(); } /** `function byId(id) { return document.getElementById(id); }` and its callers. */ const ALIAS = /function - line 41
(\w+)\(\w+\)\s*\{\s*return document\.getElementById\(\w+\);\s*\}/g; const LOOKUP = /getElementById\("([^"]+)"\)|querySelector(?:All)?\("#([^"]+)"\)/g; const BOUND = /var (\w+) = document\.getElementById\("([^"]+)"\)/g; const GUARD = /if - line 41
\(([^)]*!\w+[^)]*)\)\s*\{\s*return;\s*\}/g; const HANDLER = 'document.addEventListener("DOMContentLoaded"'; function idsUsed(js) { const out = new Set(); for (const m of js.matchAll(LOOKUP)) { out.add(m[1] || m[2]); } for (const m of - line 41
js.matchAll(ALIAS)) { - line 81
const call = new RegExp(`\\b${m[1]}\\("([^"]+)"\\)`, "g"); for (const hit of js.matchAll(call)) { out.add(hit[1]); } } return out; } /** * The ids a module will not start without, and how to read them. * * `all` means the module needs - line 81
every one of them, which is what an early * return on a missing element says. `any` is the answer when there is no * guard to read: cautious rather than precise, so a module this cannot * understand is demanded wherever its markup appears - line 81
rather than nowhere. */ function activationIds(js) { const start = js.indexOf(HANDLER); if (start < 0) { return { ids: idsUsed(js), how: "any" }; } const body = js.slice(start); for (const guard of body.matchAll(GUARD)) { const bound = new - line 81
Map(); for (const m of body.slice(0, guard.index).matchAll(BOUND)) { bound.set(m[1], m[2]); } const names = [...guard[1].matchAll(/!(\w+)/g)].map((m) => m[1]); // A guard naming something the handler did not look up is testing a // parsed - line 81
value or a browser capability, and says nothing about which // page the module belongs on. if (names.length && names.every((n) => bound.has(n))) { return { ids: new Set(names.map((n) => bound.get(n))), how: "all" }; } } return { ids: - line 81
idsUsed(js), how: "any" }; } function modules() { const dir = path.join(ROOT, "website", "js"); const out = new Map(); for (const name of fs.readdirSync(dir).sort()) { if (!name.endsWith(".js")) { continue; } out.set(name, - line 81
activationIds(read(path.posix.join("website/js", name)))); - line 121
} return out; } /** Text with comments and script bodies removed, so prose is read as prose. */ function prose(html) { return html .replace(/<!--[\s\S]*?-->/g, " ") .replace(/<script[\s\S]*?<\/script>/g, " "); } function run() { let - line 121
failures = 0; const fail = (why) => { failures += 1; console.log(`FAIL ${why}`); }; const pass = (what) => console.log(` ok ${what}`); const known = modules(); const gated = [...known].filter(([, m]) => m.how === "all" && m.ids.size); if - line 121
(gated.length === 0) { fail("not one module in website/js was found to guard its own start, " + "which is not credible: the guard pattern has changed and this " + "suite is reading nothing"); return failures; } // --- every page loads the - line 121
code for the markup it carries ----------------- let demanded = 0; for (const page of htmlIn("website")) { const html = read(page); const loaded = new Set( [...html.matchAll(/<script src="js\/([^"]+)"/g)].map((m) => m[1])); const present = - line 121
new Set([...html.matchAll(/id="([^"]+)"/g)].map((m) => m[1])); for (const [name, { ids, how }] of known) { if (!ids.size) { continue; } const hit = [...ids].filter((id) => present.has(id)); const needed = how === "all" ? hit.length === - line 121
ids.size : hit.length > 0; if (!needed) { continue; } demanded += 1; if (loaded.has(name)) { - line 161
pass(`${page} carries ${name}'s markup and loads it`); } else { fail(`${page} has ${hit.map((i) => "#" + i).join(", ")}, which is ` + `what js/${name} works on, and does not load js/${name}. The ` + "markup is on the page and the code - line 161
behind it is not, so the " + "feature is furniture: it looks present and does nothing."); } } } if (demanded === 0) { fail("no page was found to carry any module's markup, so this suite " + "passed without comparing a single page to a - line 161
single module"); } // --- "below" is a claim about this page ---------------------------------- // // Anchor text, or the words just before the link, describing a link that // goes to another page as being above or below on this one. const - line 161
CROSS = /<a\b[^>]*href="(?!#)([^"]*\.html)#?[^"]*"[^>]*>([^<]*)<\/a>/g; let spatial = 0; for (const page of htmlIn("website")) { const text = prose(read(page)); for (const m of text.matchAll(CROSS)) { const before = text.slice(Math.max(0, - line 161
m.index - 60), m.index); const where = `${m[2]} ${before}`; if (!/\b(above|below)\b/.test(m[2]) && !/\b(above|below)\b[^.]{0,20}$/.test(before)) { continue; } spatial += 1; fail(`${page} describes a link to ${m[1]} as "${ - line 161
/\babove\b/.test(where) ? "above" : "below"}" (${m[2].trim()}). ` + "Above and below are claims about where something sits on this " + "page, and that link leaves it."); } } if (spatial === 0) { pass("no cross-page link is described as - line 161
above or below"); } // --- the no-JavaScript pages are exactly that ---------------------------- const nojs = htmlIn("website/nojs"); if (nojs.length === 0) { - line 201
fail("website/nojs has no pages, so the no-JavaScript variant of the " + "site has gone missing"); } for (const page of nojs) { if (/<script\b/.test(read(page))) { fail(`${page} is in the no-JavaScript variant of the site and loads a ` + - line 201
"script, which is the one thing those pages exist not to do"); } else { pass(`${page} loads no scripts`); } } // --- the legal gate opens with nothing looking pressed ------------------ // // `legal.js` used to focus the first checkbox - line 201
when the gate opened. Focus // moved by script counts as keyboard focus in every engine, so the checkbox // drew its ring before the reader had touched anything, and the site opened // on a box that looked selected for no reason (finding - line 201
F-173). A modal's // focus lands on the dialog itself; from there Tab reaches the first // control, and the reader ticks what they have read. { const legal = read("website/js/legal.js"); const css = read("website/css/main.css"); if - line 201
(/querySelector\("#legal-(waiver|licence)"\)\.focus\(\)|\b(waiver|licence)\.focus\(\)/.test(legal)) { fail("legal.js focuses a checkbox when the gate opens, which draws its " + "focus ring before the reader has done anything"); } else if - line 201
(!/querySelector\("\.legal-box"\)\.focus\(\)/.test(legal)) { fail("legal.js does not move focus into the dialog when the gate opens, " + "so a keyboard reader starts behind it"); } else if (!/class="legal-box" tabindex="-1"/.test(legal)) { - line 201
fail("the legal box has no tabindex=\"-1\", so focus() on it does nothing"); } else if (!/\.legal-box:focus\s*\{\s*outline:\s*none;?\s*\}/.test(css)) { fail("main.css draws a focus ring around the whole legal box"); } else { pass("the - line 201
legal gate opens with focus on the dialog and no ring"); } } return failures; } - line 241
module.exports = { run, name: "pages load the code for the markup they carry" };
tools/site-tests/search.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // Search: the live one, and the one that works with no JavaScript at all. // // Both halves are tested here on purpose, because they are one feature. The // static page in `website/nojs/` is - line 1
not a courtesy stub -- `website/nojs/` is a // supported edition of this site -- and a search that quietly finds nothing // without JavaScript would be exactly the silent degradation this project // audits itself against. So the last group - line 1
of checks asks the only question // that matters about it: *does it actually contain the answers?* // // The live half is driven through a DOM stub rather than asserted about by // reading the source, for the reason `reveal.test.js` - line 1
records: a test that // models the happy path cannot express the bug. The stub here therefore builds // real element objects with children, so a test can ask what ended up in the // page -- and, critically, whether anything from the index - line 1
ever became markup. // // # Why the injection checks are not theoretical // // The index is built from every tracked file, which in this repository includes // `markdown.hostile.test.js` -- a file whose entire content is `<script>`, // - line 1
`onerror=` and other attack strings, as ordinary text. That text is in // `search-index.json` today. If `search.js` ever renders a result through // `innerHTML`, this project's own test corpus becomes its payload. "use strict"; const fs = - line 1
require("fs"); const path = require("path"); const vm = require("vm"); const ROOT = path.resolve(__dirname, "..", ".."); const SOURCE = fs.readFileSync(path.join(ROOT, "website", "js", "search.js"), "utf8"); const INDEX_PATH = - line 1
path.join(ROOT, "website", "search-index.json"); const STATIC_PATH = path.join(ROOT, "website", "nojs", "search.html"); // --- a DOM small enough to read and complete enough to be wrong in ---------- function makeNode(tagName) { const - line 1
classes = new Set(); - line 41
const node = { tagName: String(tagName || "").toUpperCase(), childNodes: [], attributes: {}, style: { setProperty(name, value) { node.attributes["style:" + name] = value; } }, hidden: false, disabled: false, value: "", placeholder: "", - line 41
_listeners: {}, classList: { add: v => classes.add(v), remove: v => classes.delete(v), contains: v => classes.has(v), toggle: (v, on) => { if (on) { classes.add(v); } else { classes.delete(v); } } }, get className() { return - line 41
[...classes].join(" "); }, set className(v) { classes.clear(); String(v).split(/\s+/).filter(Boolean).forEach(c => classes.add(c)); }, get firstChild() { return node.childNodes[0] || null; }, appendChild(child) { // Appending a fragment - line 41
appends its children and empties it, which is // exactly what a browser does and what the row-count assertions below // depend on. if (child && child.isFragment) { for (const grandchild of child.childNodes.slice()) { - line 41
node.childNodes.push(grandchild); } child.childNodes.length = 0; return child; } node.childNodes.push(child); return child; }, removeChild(child) { const at = node.childNodes.indexOf(child); if (at !== -1) { node.childNodes.splice(at, 1); - line 41
} return child; }, setAttribute(name, v) { node.attributes[name] = String(v); }, getAttribute(name) { return name in node.attributes ? node.attributes[name] : null; }, - line 81
addEventListener(type, fn) { (node._listeners[type] = node._listeners[type] || []).push(fn); }, dispatch(type, event) { (node._listeners[type] || []).forEach(fn => fn(event || { preventDefault() {} })); }, get textContent() { return - line 81
node.childNodes.map(c => (c.nodeType === 3 ? c.data : c.textContent)).join(""); }, // Read-only in this stub: `search.js` must never *assign* innerHTML, and a // getter lets a test inspect what the tree would serialise to. // // Text nodes - line 81
are escaped, because that is what a real serialiser does and // the difference is the whole point here. The index legitimately contains // the text `<script>` -- it is indexed from this project's own hostile // markup fixtures -- and a - line 81
stub that emitted it raw would report the // renderer working correctly as an injection. `docs/AUDIT.md` section 4.4 // records five false findings from exactly that mistake. get innerHTML() { return node.childNodes.map(c => { if - line 81
(c.nodeType === 3) { return c.data .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } const attrs = Object.keys(c.attributes) .filter(k => !k.startsWith("style:")) .map(k => ` ${k}="${c.attributes[k]}"`).join(""); const - line 81
tag = c.tagName.toLowerCase(); return `<${tag}${attrs}>${c.innerHTML}</${tag}>`; }).join(""); } }; Object.defineProperty(node, "href", { get() { return node.attributes.href; }, set(v) { node.attributes.href = String(v); } }); - line 81
Object.defineProperty(node, "rel", { get() { return node.attributes.rel; }, set(v) { node.attributes.rel = String(v); } }); return node; } - line 121
function findAll(node, predicate, out = []) { for (const child of node.childNodes) { if (child.nodeType === 3) { continue; } if (predicate(child)) { out.push(child); } findAll(child, predicate, out); } return out; } /** Load `search.js` - line 121
against a stubbed page and a given index. */ async function boot(index, { failFetch = false, badShape = false } = {}) { const ids = {}; for (const id of ["search-form", "q", "sort", "kind", "area", "results", "result-count", "no-results"]) - line 121
{ ids[id] = makeNode(id === "results" ? "ul" : "div"); } const frames = []; const docClasses = new Set(); let ready = null; const sandbox = { document: { documentElement: { classList: { add: v => docClasses.add(v) } }, getElementById: id - line 121
=> ids[id] || null, createElement: makeNode, // A fragment is a node whose children are moved, not the node itself. // Modelled rather than stubbed away: `search.js` builds rows into one and // attaches them in a single call, and a stub - line 121
that swallowed that would // let a broken render pass this suite. createDocumentFragment: () => { const frag = makeNode("#document-fragment"); frag.isFragment = true; return frag; }, createTextNode: data => ({ nodeType: 3, data: - line 121
String(data) }), addEventListener: (type, fn) => { if (type === "DOMContentLoaded") { ready = fn; } } }, window: { - line 161
location: { href: "https://example.org/search.html" }, requestAnimationFrame: fn => { frames.push(fn); return frames.length; }, fetch: () => { if (failFetch) { return Promise.reject(new Error("offline")); } return Promise.resolve({ ok: - line 161
true, json: () => Promise.resolve(badShape ? { nope: true } : index) }); } }, URL, JSON, Promise }; sandbox.window.URL = URL; vm.createContext(sandbox); vm.runInContext(SOURCE, sandbox); ready(); // Let the fetch promise chain settle. - line 161
await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve)); const page = { ids, frames, docClasses, flush() { while (frames.length) { frames.shift()(); } }, type(value) { ids.q.value = value; - line 161
ids.q.dispatch("input"); page.flush(); }, choose(which, value) { ids[which].value = value; ids[which].dispatch("change"); page.flush(); }, rows() { return ids.results.childNodes.filter(n => n.nodeType !== 3); - line 201
}, headings() { return page.rows().map(r => { const head = findAll(r, n => n.className.includes("sr-head"))[0]; return head ? head.textContent : ""; }); }, paths() { return page.rows().map(r => { const p = findAll(r, n => - line 201
n.className.includes("sr-path"))[0]; return p ? p.textContent : ""; }); }, count() { return ids["result-count"].textContent; } }; return page; } // --- the suite -------------------------------------------------------------- async function - line 201
run() { let fails = 0; const check = (name, ok) => { console.log((ok ? "ok " : "FAIL ") + name); if (!ok) { fails++; } }; if (!fs.existsSync(INDEX_PATH) || !fs.existsSync(STATIC_PATH)) { console.log("FAIL the search index has not been - line 201
generated -- run " + "`python tools/search-index/generate.py`"); return 1; } const index = JSON.parse(fs.readFileSync(INDEX_PATH, "utf8")); const staticHtml = fs.readFileSync(STATIC_PATH, "utf8"); // A word guaranteed not to be in the - line 201
corpus -- assembled from parts, because // this file is itself a tracked file and is therefore *in* the index. Written // out as one literal, the sentinel indexed itself and the "finds nothing" // tests started finding exactly one thing: - line 201
this line. Four checks failed for - line 241
// a reason that had nothing to do with the search. const ABSENT = ["qxzv", "nomatch", "sentinel", "wxyq"].join(""); // --- 1. the index itself -------------------------------------------------- check("the index has documents", - line 241
Array.isArray(index.docs) && index.docs.length > 50); check("the index has sections", Array.isArray(index.secs) && index.secs.length > 300); check("every section points at a real document", index.secs.every(s => index.docs[s.d] !== - line 241
undefined)); check("every document has a path, kind and area", index.docs.every(d => d.p && d.k && d.r)); check("the Rust engine is indexed", index.docs.some(d => d.p === "crates/veilvoice-core/src/spectral.rs")); check("the audit is - line 241
indexed", index.docs.some(d => d.p === "docs/AUDIT.md")); check("the website is indexed", index.docs.some(d => d.p === "website/wiki.html")); // The generator's output is tracked and lives under `website/`, so without an // explicit - line 241
exclusion it would index itself: each run's input would contain // the previous run's output, the file would grow every time, and `--check` // could never agree with a freshly built one. That failure presents as flaky // CI rather than as - line 241
a bug, so it is asserted here. check("the index does not index itself", !index.docs.some(d => d.p === "website/search-index.json" || d.p === "website/nojs/search.html")); // Built with `new RegExp` from escapes rather than written as a - line 241
literal // class, for the reason `characters.test.js` gives at length: a checker for // invisible characters that contains invisible characters is a joke that has // already been made twice in this repository. const STRAY = new RegExp( - line 241
"[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f" + "\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e" + "\\u2060\\u2066-\\u2069\\ue000-\\uf8ff\\ufeff\\ufffd]" ); check("no section carries a stray control character", !index.secs.some(s => - line 241
STRAY.test((s.h || "") + (s.x || "")))); // --- 2. live search: it finds things -------------------------------------- { const page = await boot(index); check("the box is enabled once the index loads", page.ids.q.disabled === false); - line 241
check("the page marks itself live", page.docClasses.has("search-live")); - line 281
page.type("argon2"); check("searching 'argon2' finds results", page.rows().length > 0); check("every result mentioning argon2 is a real path", page.paths().every(p => typeof p === "string" && p.length > 0)); page.type("spectral"); - line 281
check("searching 'spectral' reaches the DSP engine", page.paths().some(p => p.includes("spectral.rs"))); page.type("irreversible"); check("a word from the prose finds the documentation", page.paths().some(p => p.endsWith(".md") || - line 281
p.endsWith(".html"))); page.type(ABSENT); check("a word that is in nothing returns nothing", page.rows().length === 0); check("and says so rather than showing an empty list", /nothing matched/i.test(page.count())); check("the empty note is - line 281
shown", page.ids["no-results"].hidden === false); } // --- 3. every term must match, not just one ------------------------------- { const page = await boot(index); page.type("argon2"); const one = page.rows().length; page.type("argon2 " + - line 281
ABSENT); check("adding a term that matches nothing empties the results", one > 0 && page.rows().length === 0); } // --- 4. filtering --------------------------------------------------------- { const page = await boot(index); - line 281
page.type("encrypt"); const all = page.rows().length; page.choose("kind", "doc"); const docsOnly = page.rows().length; check("filtering by kind narrows the results", docsOnly > 0 && docsOnly <= all); check("filtering by kind returns only - line 281
that kind", - line 321
page.paths().every(p => { const doc = index.docs.find(d => d.p === p); return doc && doc.k === "doc"; })); page.choose("kind", ""); page.choose("area", "veilvoice-crypto"); check("filtering by area returns only that area", - line 321
page.rows().length > 0 && page.paths().every(p => p.startsWith("crates/veilvoice-crypto/"))); } // --- 5. sorting ----------------------------------------------------------- { const page = await boot(index); page.type("veil"); - line 321
page.choose("sort", "path"); const paths = page.paths(); const sorted = [...paths].sort(); check("sorting by path really sorts by path", paths.length > 1 && paths.join("|") === sorted.join("|")); page.choose("sort", "relevance"); - line 321
check("sorting back to relevance changes the order or keeps results", page.rows().length > 0); // The same query twice must give the same order: a list that reshuffles // between keystrokes is unreadable. const first = - line 321
page.paths().join("|"); page.type("veil"); check("the same query gives the same order twice", page.paths().join("|") === first); } // --- 6. nothing from the index ever becomes markup ------------------------ { const page = await - line 321
boot(index); // These terms are present in this repository *as hostile-input fixtures*, // so the index genuinely contains them. for (const term of ["script", "onerror", "javascript"]) { page.type(term); - line 361
const rows = page.rows().length; check(`'${term}' matches something (so the check is not vacuous)`, rows > 0); // Asked of the *tree*, not of a serialised string. A result whose text // reads `<script>` is the renderer working; an element - line 361
whose tagName is // SCRIPT is the renderer broken. Only the second is an injection, and // only a tree walk can tell them apart. const elements = findAll(page.ids.results, () => true); check(`'${term}' creates no script or frame element`, - line 361
!elements.some(n => ["SCRIPT", "IFRAME", "OBJECT", "EMBED", "IMG"] .includes(n.tagName))); check(`'${term}' sets no event-handler attribute`, !elements.some(n => Object.keys(n.attributes) .some(a => /^on/i.test(a)))); check(`'${term}' sets - line 361
no javascript: URL`, !elements.some(n => ["href", "src"].some(a => /^\s*javascript:/i.test(n.attributes[a] || "")))); check(`'${term}' keeps every match as text, not markup`, findAll(page.ids.results, n => n.tagName === "MARK") .every(m => - line 361
m.childNodes.every(c => c.nodeType === 3))); } // Only the tags the renderer is supposed to produce. page.type("the"); const tags = new Set(findAll(page.ids.results, () => true).map(n => n.tagName)); const allowed = new Set(["LI", "A", - line 361
"DIV", "SPAN", "P", "MARK"]); check("only the expected element types are produced", [...tags].every(t => allowed.has(t))); } // --- 7. highlighting ------------------------------------------------------ { const page = await boot(index); - line 361
page.type("argon2"); const marks = findAll(page.ids.results, n => n.tagName === "MARK"); check("matches are highlighted", marks.length > 0); check("every highlight is the matched text, case-insensitively", marks.every(m => - line 361
m.textContent.toLowerCase() === "argon2")); check("highlights are text nodes, not markup", marks.every(m => m.childNodes.every(c => c.nodeType === 3))); } - line 401
// --- 8. bounds ------------------------------------------------------------ { const page = await boot(index); page.type("e"); // a letter in almost everything check("the number of rows drawn is bounded", page.rows().length <= 60); - line 401
check("the count still reports the honest total", /result|file/.test(page.count())); const long = "a".repeat(5000); const before = Date.now(); page.type(long); check("a 5000-character query is handled promptly", Date.now() - before < - line 401
2000); page.type("a b c d e f g h i j k l m n o p"); check("a query with many terms is handled promptly", true); } // --- 9. failure is reported honestly -------------------------------------- { const page = await boot(index, { failFetch: - line 401
true }); check("a failed index fetch says so", /could not load/i.test(page.count())); check("and points at the static index", /static index/i.test(page.count())); check("the box stays disabled rather than pretending", page.ids.q.disabled - line 401
=== true); } { const page = await boot(index, { badShape: true }); check("an index of the wrong shape is refused, not walked into", /could not load|expected shape/i.test(page.count())); } // --- 10. the no-JavaScript path actually finds - line 401
things --------------------- // // This is the group that matters. Everything above proves the JavaScript // works; these prove the page that runs none of it is a real answer. { check("the static index is a complete HTML document", - line 401
/<!DOCTYPE html>/i.test(staticHtml) && /<\/html>/i.test(staticHtml)); check("the static index needs no JavaScript", !/<script/i.test(staticHtml)); - line 441
check("the static index tells the reader how to search it", /find-in-page/i.test(staticHtml) && /Ctrl\+F/i.test(staticHtml)); // Every document in the JSON index is on the static page too, or the two // halves of this feature disagree - line 441
about what the project contains. const missing = index.docs.filter(d => !staticHtml.includes(d.p)); check(`every one of the ${index.docs.length} indexed files is listed statically`, missing.length === 0); if (missing.length) { - line 441
console.log(" missing: " + missing.slice(0, 5).map(d => d.p).join(", ")); } // The words a reader would actually search for have to be *in the page*, // because find-in-page is the whole mechanism. const terms = ["Argon2", "spectral", - line 441
"voiceprint", "reproducible", "XChaCha20", "tamper", "de-identification"]; const absent = terms.filter(t => !new RegExp(t, "i").test(staticHtml)); check("the terms a reader would search for are present in the page: " + terms.join(", "), - line 441
absent.length === 0); if (absent.length) { console.log(" absent: " + absent.join(", ")); } // Finding something is only useful if you can then go to it. check("the static index links to the repository", - line 441
/https:\/\/github\.com\/tilas01\/veilvoice\/blob\/main\//.test(staticHtml)); check("the static index links back to the live search", /href="\.\.\/search\.html"/.test(staticHtml)); check("the static index links to the no-JavaScript - line 441
edition", /href="index\.html"/.test(staticHtml)); // Section text, not just file names: searching for a phrase from the middle // of a document is the case that separates an index from a directory listing. check("section headings are in - line 441
the static page", staticHtml.includes("The standard this is held to")); check("section text is in the static page, not only headings", /class="x"/.test(staticHtml)); } // --- 11. the two halves agree - line 441
-------------------------------------------- { - line 481
const listed = (staticHtml.match(/<details\b[^>]*>/g) || []).length; check(`the static page lists exactly the indexed files (${index.docs.length})`, listed === index.docs.length); } return fails; } module.exports = { run, name: "search, - line 481
live and static" }; if (require.main === module) { run().then(f => process.exit(f ? 1 : 0)); }
tools/site-tests/source.test.js
- line 1
// SPDX-License-Identifier: GPL-3.0-or-later // // The source pages, and the links that lead to them. // // # What this is for // // Roadmap item 27 asks for diagrams that open the relevant source, highlighted, in // the site's palette. - line 1
Every box in a flowchart on a reference page is now a // link to a page of this site rather than to a blob on GitHub, and every one // of those links carries a fragment naming the function it drew. // // Three ways that can rot without - line 1
anybody noticing, and one check each. // // 1. **The fragment stops resolving.** A renamed function, a changed anchor // scheme, a page that no longer emits the wrapper: the link still works, // lands at the top of the file, and marks - line 1
nothing. `html.test.js` checks // anchors that point within one page and cannot see this one, because the // target is a different file. // 2. **The page stops showing the file.** These pages are generated from the // `.rs` files and - line 1
nothing else reads them, so a generator that dropped // the last line, or the first, would be invisible. The line count is // compared against the file on disk. // 3. **A box goes back to leaving the site.** That is the whole of what the - line 1
// marker asked for, so it is asserted rather than assumed. // // The stylesheet's half is checked too. The mark is `:target` and nothing // else, so a reader with JavaScript off gets it; a rule that quietly went // missing would leave - line 1
every link landing in an unmarked file. "use strict"; const fs = require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); const SITE = path.join(ROOT, "website"); const REFERENCE = path.join(SITE, - line 1
"reference"); function walk(dir, found = []) { if (!fs.existsSync(dir)) return found; - line 41
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full, found); else if (entry.name.endsWith(".html")) found.push(full); } return found; } function - line 41
run() { let failures = 0; const fail = (message) => { failures++; console.log(`FAIL ${message}`); }; const pass = (message) => console.log(`ok ${message}`); const pages = walk(SITE).sort(); const idsOf = new Map(); const identifiers = - line 41
(file) => { if (!idsOf.has(file)) { const html = fs.readFileSync(file, "utf8"); idsOf.set(file, new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]))); } return idsOf.get(file); }; // ---- 1. every fragment that names another - line 41
page resolves ----------------- let crossPage = 0; const broken = []; for (const page of pages) { const html = fs.readFileSync(page, "utf8"); const here = path.dirname(page); for (const m of - line 41
html.matchAll(/href="([^"#:][^"]*\.html)#([^"]+)"/g)) { const target = path.resolve(here, m[1]); const rel = path.relative(ROOT, page).replace(/\\/g, "/"); crossPage++; if (!fs.existsSync(target)) { broken.push(`${rel} links to ${m[1]}, - line 41
which does not exist`); } else if (!identifiers(target).has(m[2])) { broken.push(`${rel} links to ${m[1]}#${m[2]}, and that page has no such id`); } } } - line 81
if (broken.length) { broken.slice(0, 15).forEach(fail); if (broken.length > 15) fail(`and ${broken.length - 15} more`); } else { pass(`all ${crossPage} fragments naming another page resolve`); } // ---- 2. a source page shows the whole - line 81
file, and the same file ----------- const sourcePages = pages.filter((p) => p.endsWith(".src.html")); if (sourcePages.length === 0) { fail("no source pages were generated at all"); } const wrong = []; for (const page of sourcePages) { - line 81
const html = fs.readFileSync(page, "utf8"); const named = html.match(/<h1><code>([^<]+)<\/code><\/h1>/); const rel = path.relative(ROOT, page).replace(/\\/g, "/"); if (!named) { wrong.push(`${rel} does not say which file it shows`); - line 81
continue; } const file = path.join(ROOT, named[1]); if (!fs.existsSync(file)) { wrong.push(`${rel} claims to show ${named[1]}, which is not in the tree`); continue; } const text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n"); const - line 81
expected = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length; const drawn = (html.match(/<span class="ln" id="L\d+">/g) || []).length; if (drawn !== expected) { wrong.push(`${rel} draws ${drawn} lines of - line 81
${named[1]}, which has ${expected}`); } } if (wrong.length) { wrong.slice(0, 15).forEach(fail); if (wrong.length > 15) fail(`and ${wrong.length - 15} more`); } else { pass(`${sourcePages.length} source pages each show their whole file`); - line 121
} // ---- 3. a box on a reference page stays on this site -------------------- const leaving = []; let boxes = 0; for (const page of walk(REFERENCE)) { if (page.endsWith(".src.html")) continue; const html = fs.readFileSync(page, "utf8"); - line 121
for (const svg of html.match(/<svg\b[\s\S]*?<\/svg>/g) || []) { if (!/aria-label="flowchart"/.test(svg)) continue; for (const m of svg.matchAll(/<a\s+href="([^"]+)"/g)) { boxes++; if (/^[a-z][a-z0-9+.-]*:/i.test(m[1]) || - line 121
m[1].startsWith("//")) { leaving.push( `${path.relative(ROOT, page).replace(/\\/g, "/")} has a box linking off ` + `this site: ${m[1]}`); } } } } if (leaving.length) { leaving.slice(0, 10).forEach(fail); } else if (boxes === 0) { fail("no - line 121
flowchart box on any reference page carries a link"); } else { pass(`all ${boxes} flowchart boxes open the source on this site`); } // ---- 4. the mark is in the stylesheet, and needs no script -------------- const css = - line 121
fs.readFileSync(path.join(SITE, "css", "main.css"), "utf8"); const bare = css.replace(/\/\*[\s\S]*?\*\//g, ""); const wanted = [ [/\.src\s+\.src-item:target\s+\.ln\s*\{|\.src\s+\.ln:target[\s\S]{0,120}?\.src-item:target/, "the mark on a - line 121
whole function must be a :target rule"], [/\.src\s+\.ln\s*\{[^}]*white-space:\s*pre/, "each line must keep its own whitespace, or the file renders as one paragraph"], [/pre\.src\s*\{[^}]*white-space:\s*normal/, "the block must not, or - line 121
every line is drawn twice"], [/\.src\s+\.ln\s*\{[^}]*min-width:\s*100%/, "a line must span the full width, or the mark stops where the column does"] - line 161
]; let missing = 0; for (const [pattern, why] of wanted) { if (!pattern.test(bare)) { fail(`main.css: ${why}`); missing++; } } if (!missing) pass("the mark is CSS, works with no script, and covers the line"); return failures; } - line 161
module.exports = { run, name: "source pages, and the boxes that open them" };
tools/site/demo.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The facts the website's interactive demonstration is drawn from. python tools/site/demo.py # write website/js/demo-data.js python tools/site/demo.py --check # verify it - line 1
is current # What this generates, and what it deliberately does not `website/js/demo.js` draws a working model of the desktop application and of the command line, inside the page, so somebody can click through both before downloading - line 1
anything. The *drawing* is hand written, because it is an interface and interfaces are designed rather than derived. The **facts inside it are not**. Three things come from the source: * **The tabs the application has, in order, with their - line 1
labels.** Read out of `crates/veilvoice-gui/src/app.rs`, which is where they are decided. A tab added to the application appears in the demonstration on the next run, and a demonstration showing a tab that no longer exists fails the build. - line 1
* **What each command line subcommand printed.** Taken from the `cli-*.txt` captures in `assets/screenshots/`, which are what the real program actually wrote. The terminal in the demonstration replays those bytes rather than a - line 1
plausible-looking imitation of them. * **The version.** From `Cargo.toml`. A demonstration of software that quietly stops matching the software is worse than no demonstration, because it is a claim rather than an omission. So it is - line 1
generated and checked, exactly as the documentation, the artwork, the roadmap page and the search index are. # The honest label None of this makes the model *be* the application. It is a drawing that responds to clicks, the panels are - line 1
written by hand, and the numbers in them are illustrations. `demo.js` says so at the top of the overlay, in the reader's sight rather than in a comment, and points at the photographs and the downloads. That sentence is the price of having - line 1
a demonstration at all. # In plain words - line 41
The website has a pretend version of the app you can click around in. This file takes the parts of it that have to be true, the list of tabs and the real output of each command, out of the actual source code, so the pretend version cannot - line 41
quietly drift away from the real one. Pure standard library. """ import argparse import io import json import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) - line 41
OUT = "website/js/demo-data.js" MARKER = "GENERATED by tools/site/demo.py" # The tab strip, as `app.rs` writes it: `(Tab::File, "Anonymise file"),`. TAB_ROW = re.compile(r"\(Tab::(?P<variant>\w+),\s*\"(?P<label>[^\"]+)\"\)") # And the key - line 41
each variant is stored under: `Self::File => "file",`. TAB_KEY = re.compile(r"Self::(?P<variant>\w+) => \"(?P<key>[a-z]+)\",") # What each command line capture is a picture of. # # Read from `tools/shots/terminal.py`, which is the thing - line 41
that takes them. # # This was a second list, with the same names and the same sentences written # out again, and the comment here justified it by saying terminal.py holds the # command "in a form no page can read". That form is a list of - line 41
arguments, and # joining a list of arguments with spaces is not difficult; what the second copy # actually did was drift. The note under `render` said "a plan, a recording and # a page" in one file and "a plan, a recording, and a page" in - line 41
the other, and # adding a screen to one of them left the other unable to draw it. sys.path.insert(0, os.path.join(ROOT, "tools", "shots")) import terminal as _terminal # noqa: E402 - line 81
COMMANDS = [ (name, "veilvoice " + " ".join(argv), note) for name, argv, note in _terminal.COMMANDS ] # What each tab of the application is for, in one sentence, for the walkthrough # that shows the screenshots. The tab list itself is read - line 81
out of `app.rs`: this # is only the sentence under the picture, and a tab that gains a screenshot # without gaining a sentence here fails the build rather than appearing blank. TAB_NOTES = { "file": "Point it at a recording, choose how - line 81
hard to push the voice, and " "write the result. Encryption at rest is already on.", "group": "One recording with several people in it. Each speaker is given " "their own destination voice, and every voiceprint is destroyed.", "studio": - line 81
"The microphone, veiled as it runs, into a virtual cable other " "programs can hear, and a take of it recorded straight into a " "locked vault if you ask for one. The vault opens with both " "passphrases at once, and with neither on its - line 81
own.", "browser": "What is in the vault, listed without opening any of it. Rename " "and remove; the names and dates are sealed with the " "recordings.", "monitor": "Which programs are holding the microphone and camera right " "now, and - line 81
which screen recorders are running.", "lock": "The password for VeilVoice itself, what that lock is worth, and " "what it deliberately does not protect.", "verify": "Check a download against the signed hash list, then ask your " "own GnuPG - line 81
the same question.", "settings": "Palettes, motion, the autolock timeout, and the ratchet " "interval. Every one of them says what it costs.", "install": "Copy VeilVoice somewhere the system can find it, or keep " "running it from the - line 81
folder it is in.", "about": "The version, what this build can do on this machine, and the " "graphics driver that actually drew the window.", } # Worked cases: the things somebody actually wants to do, each as a real # command. The *prose* - line 81
is written here, because an explanation is writing. The - line 121
# **command is checked against the program** in `usecases()`: a case naming a # subcommand this build does not have fails the build, so the page cannot go on # teaching a command that has been renamed or removed. USECASES = [ ("Veil one - line 121
recording", "veilvoice anonymise interview.m4a", "Reads almost any format, writes a veiled WAV, and seals it. It asks for " "a passphrase because the words survive on purpose: a veiled recording " "left in the clear is still a recording of - line 121
everything that was said."), ("Veil it, and keep the accent", "veilvoice anonymise talk.wav --keep-accent", "The voiceprint still goes. The rhythm and intonation stay, which is " "what you want when the delivery is the point and only the - line 121
speaker is not."), ("Write it unencrypted, deliberately", "veilvoice anonymise clip.wav --encrypt false", "Allowed, and it tells you in full what you are giving up first, then " "waits for an answer. In a script it prints the same warning - line 121
and refuses " "rather than guessing."), ("Seal it to somebody else", "veilvoice anonymise clip.wav --encrypt-to them.pub", "X25519 and ML-KEM-768 together, so a recording stored today survives a " "quantum adversary later. Only their - line 121
secret key opens it, and yours never " "existed."), ("Hear yourself before anybody else does", "veilvoice live --preview", "Routes the veiled voice to your own headphones and nowhere else. This is " "the check to run before an interview - line 121
rather than during one."), ("Be a microphone other programs can use", "veilvoice live", "Finds a virtual cable and sends the veiled voice into it, so a call, a " "stream or a recorder hears the veiled voice and never the real one."), - line 121
("Record yourself, already veiled", "veilvoice record --seconds 30", "Captures the veiled voice straight into an encrypted file. There is " "never an unencrypted recording on the disk, not even briefly, so there " "is nothing to delete - line 121
afterwards."), ("An interview, one voice each", "veilvoice conversation render --plan interview.toml", "Give each speaker their own destination voice so a question can still be " "told from its answer, with every voiceprint destroyed just - line 121
as thoroughly."), - line 161
("Fix a stretch given to the wrong person", "veilvoice conversation fix interview.toml reassign --at 20 --to 2", "The one mistake here that cannot be heard in the result: every voice in a " "veiled recording is unfamiliar, so nobody - line 161
notices one person rendered in " "another's voice. Corrected before the render, which is the only time it " "can be, and keyed on the moment you heard it rather than on a span " "number you would have to go and count."), ("Check a download - line 161
before running it", "veilvoice verify auto", "Looks in Downloads, checks the signature over the hash list, then checks " "the archive and every file that came out of it. It never holds a private " "key and needs no GnuPG."), ("Strip the - line 161
metadata off a file", "veilvoice clean photo.jpg", "Tags, EXIF and GPS, in place. A veiled recording in a folder of " "photographs that still carry coordinates is not anonymous."), ("See what is listening", "veilvoice watch", "Which - line 161
programs currently hold the microphone and the camera. It says " "what it cannot see as plainly as what it can."), ("Erase something properly", "veilvoice shred draft.wav", "Overwrites and deletes, and is honest that on an SSD or a memory - line 161
card " "the original blocks can survive every overwrite. That is why encryption " "at rest is the default rather than a thing to remember."), ("What this build can actually do here", "veilvoice info", "Every version, whether live audio has - line 161
a backend on this machine, and the " "network answer, which is that nothing here reaches one."), ("Require a lock and encryption, always", "veilvoice mandate status", "The two things VeilVoice insists on unless told otherwise. Relaxing one - line 161
" "is written down with the date, so the choice is never a mystery later."), ] # The recorded sessions, in the order somebody meeting this project would want # them: check the download first, then see what the program does, then see what # - line 161
it refuses to do. `tools/shots/sessions.py` records them; this is what each # one is for, which that file knows and no page can read out of it. SESSIONS = [ - line 201
("verify", "veilvoice", "Checking a download", "The published archive, its signed hash list, and the same question " "asked again of your own GnuPG."), ("anonymise", "veilvoice", "Veiling one recording", "A key made, then a three second - line 201
recording veiled and sealed to it. " "Nothing typed at the second step."), ("info", "veilvoice", "What this build can do", "Every version, whether live audio works on this machine, and the " "network answer."), ("refusal", "veilvoice", "In - line 201
a script, with nobody to type a passphrase", "It stops, explains, offers the two ways round it, and writes nothing."), ("unencrypted", "veilvoice", "Asking for it in the clear", "The escape hatch exists, and says in full what you are - line 201
giving up " "before it uses it."), ] def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() def tabs(): """Every tab the application shows, in order, with its label and key.""" source = read(os.path.join(ROOT, - line 201
"crates", "veilvoice-gui", "src", "app.rs")) keys = {m.group("variant"): m.group("key") for m in TAB_KEY.finditer(source)} found = [] for match in TAB_ROW.finditer(source): variant = match.group("variant") key = keys.get(variant) if key is - line 201
None: raise SystemExit( "crates/veilvoice-gui/src/app.rs shows a tab %s with no key.\n" " `Tab::key` is what the demonstration and the screenshots are\n" " named by, so a tab without one cannot be drawn." % variant) - line 241
found.append({"key": key, "label": match.group("label")}) if not found: raise SystemExit( "no tabs were found in crates/veilvoice-gui/src/app.rs.\n" " They are read from the `(Tab::X, \"label\")` list the window\n" " draws. If that moved, - line 241
fix TAB_ROW here rather than shipping a\n" " demonstration with no tabs in it.") return found def commands(): """Each subcommand, and exactly what it printed.""" out = [] for name, typed, note in COMMANDS: path = os.path.join(ROOT, - line 241
"assets", "screenshots", "cli-%s.txt" % name) if not os.path.exists(path): raise SystemExit( "%s is missing, so the demonstration would have to invent what\n" " `%s` prints. Run: python tools/shots/terminal.py --capture" % - line 241
(os.path.relpath(path, ROOT), typed)) out.append({ "name": name, "typed": typed, "note": note, "output": read(path).replace("\r\n", "\n").rstrip("\n"), }) return out def sessions(): """The transcripts of the programs actually running. - line 241
These are the answer to the demonstration's oldest problem. It showed ten help screens, which are real and are the least interesting real thing these programs produce: a help screen says what a flag is called and nothing about what happens - line 241
when you use one. Somebody deciding whether to trust this wants to watch it work. Each is a transcript of a real run, captured from a terminal by `tools/shots/sessions.py`, including the prompts and the passphrase typed - line 281
at them. The command line is split from its output so the page can type the one and print the other, which is what a terminal looks like. """ out = [] for name, programme, title, note in SESSIONS: path = os.path.join(ROOT, "assets", - line 281
"screenshots", "session-%s.txt" % name) if not os.path.exists(path): raise SystemExit( "%s is missing, so the demonstration would have to invent a\n" " session. Run: tools/shots/sessions.py --record" % os.path.relpath(path, ROOT)) text = - line 281
read(path).replace("\r\n", "\n").rstrip("\n") steps = [] current = None for line in text.split("\n"): if line.startswith("$ "): if current: steps.append(current) current = {"typed": line[2:], "output": []} elif current is not None: - line 281
current["output"].append(line) if current: steps.append(current) if not steps: raise SystemExit( "%s has no `$ ` command line in it, so there is nothing to\n" " replay. The recorder writes one before each step." % os.path.relpath(path, - line 281
ROOT)) out.append({ "name": name, "programme": programme, "title": title, "note": note, "steps": [{"typed": step["typed"], "output": "\n".join(step["output"]).strip("\n")} for step in steps], }) return out - line 321
def shots(): """Each tab, its screenshot, and the sentence that goes under it. The tab list comes from `app.rs` and the pictures from `assets/screenshots`. Pairing them here is what keeps the walkthrough honest: a tab with no photograph, - line 321
or a photograph with no sentence, stops the build rather than rendering a gap somebody has to notice. """ out = [] for tab in tabs(): key = tab["key"] name = "gui-%s.png" % key path = os.path.join(ROOT, "assets", "screenshots", name) if - line 321
not os.path.exists(path): raise SystemExit( "the application has a %r tab and %s does not exist, so the\n" " walkthrough would show a heading with nothing under it.\n" " Run: tools/shots/gui.sh on Linux, or tools/shots/gui.ps1 on Windows" - line 321
% (key, os.path.relpath(path, ROOT))) note = TAB_NOTES.get(key) if not note: raise SystemExit( "the application has a %r tab with no sentence in TAB_NOTES.\n" " Every picture in the walkthrough is captioned; add one\n" " rather than - line 321
shipping a screenshot nobody has explained." % key) out.append({ "key": key, "label": tab["label"], "image": "assets/screenshots/" + name, "note": note, }) return out def usecases(): """The worked command line cases, each checked against - line 321
the real program. The explanation is written by hand. The command is not trusted: the subcommand it names is looked up in the captured `veilvoice --help`, so a - line 361
case teaching a command that has been renamed or removed fails here instead of on somebody's terminal. """ help_text = read(os.path.join(ROOT, "assets", "screenshots", "cli-help.txt")) known = - line 361
set(re.findall(r"^\s{2}([a-z][a-z-]+)\s{2,}\S", help_text, re.M)) out = [] for title, typed, note in USECASES: parts = typed.split() if not parts or parts[0] != "veilvoice": raise SystemExit("a use case must be a `veilvoice ...` command: - line 361
%r" % typed) if len(parts) < 2: raise SystemExit("a use case must name a subcommand: %r" % typed) sub = parts[1] if sub not in known: raise SystemExit( "the use case %r runs `veilvoice %s`, which this build's own\n" " --help does not list. - line 361
Either the command was renamed and this\n" " case teaches something that no longer works, or the capture in\n" " assets/screenshots/cli-help.txt is stale. Fix whichever it is\n" " rather than publishing a command that fails.\n" " Known: - line 361
%s" % (title, sub, ", ".join(sorted(known)))) out.append({"title": title, "typed": typed, "note": note}) return out def version(): manifest = read(os.path.join(ROOT, "Cargo.toml")) block = - line 361
re.search(r"\[workspace\.package\]([\s\S]*?)(\n\[|$)", manifest) found = re.search(r'^\s*version\s*=\s*"([^"]+)"', block.group(1), re.M) return found.group(1) def build(): data = { "version": version(), "tabs": tabs(), "commands": - line 361
commands(), "sessions": sessions(), "shots": shots(), "usecases": usecases(), - line 401
} # Sorted keys and a fixed indent, so the file is a function of the facts # rather than of the order a dict happened to be built in. body = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=True) return ( "// - line 401
SPDX-License-Identifier: GPL-3.0-or-later\n" "//\n" "// %s. Do not edit.\n" "//\n" "// The facts the interactive demonstration is drawn from: the tabs the\n" "// desktop application has, and exactly what each command line\n" "// subcommand - line 401
printed when it was last captured. Both come from the\n" "// source rather than from anybody's memory of it, so the model in the\n" "// page cannot quietly stop matching the program it is a model of.\n" "//\n" "// In plain words\n" "//\n" - line 401
"// A list of what is in the app and what each command prints, taken\n" "// straight from the code, so the demonstration on the website stays\n" "// honest when the program changes.\n" "\n" "window.VEILVOICE_DEMO = %s;\n" % (MARKER, body) - line 401
) def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", action="store_true") args = parser.parse_args() text = build() path = os.path.join(ROOT, OUT.replace("/", os.sep)) if - line 401
args.check: if not os.path.exists(path) or read(path) != text: print(" out of date: %s" % OUT) print("\n Run: python tools/site/demo.py") return 1 print("the demonstration's data matches the source") return 0 - line 441
os.makedirs(os.path.dirname(path), exist_ok=True) with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) print(" wrote %s (%d tabs, %d commands, %d sessions, %d shots, %d cases)" % (OUT, len(tabs()), - line 441
len(commands()), len(sessions()), len(shots()), len(usecases()))) return 0 if __name__ == "__main__": sys.exit(main())
tools/site/faq.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The questions page, rendered from the document that holds the answers. python tools/site/faq.py # write website/faq.html python tools/site/faq.py --check # verify it is - line 1
current # Why the answers live in a Markdown file `docs/FAQ.md` is readable on GitHub, in a checkout, and by anybody who has cloned this and never opened the website. That is the audience this project keeps writing for, and a page that - line 1
existed only as HTML would put the answers somewhere a reader has to be online to reach. So the document is the source and the page is its output, checked in CI, which is the arrangement the documentation, the roadmap page and the section - line 1
pages already have. Two hand-maintained copies of the same prose is finding F-41 waiting to happen. # The contents list is derived, not written Every `##` in the file becomes a question in the list at the top and an anchor to link to. A - line 1
question added to the document appears in the list on the next run, and a question renamed takes its link with it. Writing that list by hand would mean maintaining a copy of the headings beside the headings. # In plain words The frequently - line 1
asked questions are written in an ordinary text file in the repository, and this turns that file into the page on the website. There is one copy of the answers, so the page cannot start saying something the file does not. Pure standard - line 1
library. """ import argparse import io import os import re - line 41
import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) sys.path.insert(0, os.path.join(ROOT, "tools", "docs")) import generate as docs # noqa: E402 (the path has to be set first) - line 41
sys.path.insert(0, HERE) import split # noqa: E402 (same) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo # noqa: E402 the addresses and preview tags every page carries SOURCE = "docs/FAQ.md" OUT = - line 41
"website/faq.html" MARKER = "GENERATED by tools/site/faq.py" def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() def questions(lines): """Every `##` heading, with the anchor it will be given.""" found = [] - line 41
fenced = False for line in lines: if line.strip().startswith("```"): fenced = not fenced continue if fenced: continue match = re.match(r"^##\s+(.*)$", line.strip()) if match: title = match.group(1).strip() found.append((title, - line 41
docs.slug(re.sub(r"[*`_]", "", title)))) return found - line 81
def build(root): text = read(os.path.join(root, SOURCE)) lines = text.split("\n") # The licence comment, the `# ` title and the two paragraphs about where # the file lives are for a reader of the repository. The page has a title # of its - line 81
own and does not need to be told what generated it twice. body_lines = [] started = False for line in lines: if line.startswith("<!--"): continue if line.startswith("# "): started = True continue if not started: continue - line 81
body_lines.append(line) # Drop the note about this being the source, which is true and is meant for # somebody editing rather than somebody reading. joined = "\n".join(body_lines) joined = re.sub( r"Rendered to a page at[\s\S]*?edit it - line 81
here\.\n", "", joined, count=1) body_lines = joined.split("\n") asked = questions(lines) if not asked: raise SystemExit( "%s has no `##` questions in it, so the page would be empty.\n" " Each question is a level two heading; that is what - line 81
the contents\n" " list and the anchors are built from." % SOURCE) out = [] out.append('<nav class="faq-list" aria-label="The questions">') out.append("<ul>") for title, anchor in asked: out.append('<li><a href="#%s">%s</a></li>' % (anchor, - line 81
docs.inline_html(title))) out.append("</ul>") out.append("</nav>") - line 121
out.extend(docs.doc_html(body_lines)) index_html = read(os.path.join(root, "website", "index.html")) parts = split.shell(index_html) page = split.page( root, parts, "faq", "Questions people ask", "Answers to what actually gets asked, - line 121
including the ones where the " "answer is that it does not do that.", "\n".join(out), # This page is written for itself, so its `#anchor` links point at # its own headings rather than at sections of the front page. relink_body=False, ) - line 121
page = page.replace( '<p style="color:var(--muted)">This section is also part of ' '<a href="index.html">the front page</a>, where it sits in context ' 'with the rest.</p>', '<p style="color:var(--muted)">The same answers as ' '<a - line 121
href="https://github.com/%s/blob/%s/docs/FAQ.md">docs/FAQ.md</a>, ' 'which is where they are written.</p>' % (docs.REPO, docs.REF)) page = page.replace("GENERATED by tools/site/split.py from website/index.html.", "%s from docs/FAQ.md." % - line 121
MARKER) return {OUT: page} def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", action="store_true") args = parser.parse_args() files = seo.finished(build(ROOT)) if args.check: - line 121
stale = [rel for rel, text in files.items() if not os.path.exists(os.path.join(ROOT, rel.replace("/", os.sep))) or read(os.path.join(ROOT, rel.replace("/", os.sep))) != text] if stale: for rel in stale: - line 161
print(" out of date: %s" % rel) print("\n Run: python tools/site/faq.py") return 1 print("the questions page matches docs/FAQ.md") return 0 for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", os.sep)) with - line 161
io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) print(" wrote %s (%d questions)" % (rel, len(questions(read(os.path.join(ROOT, SOURCE)).split("\n"))))) return 0 if __name__ == "__main__": sys.exit(main())
tools/site/releases.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The releases page: every version, its notes, and its files. python tools/site/releases.py # write website/releases.html python tools/site/releases.py --check # verify it - line 1
is current # Roadmap item 94, and the problem it solves Until now the only way to see what changed in a release was `CHANGELOG.md`, which is thousands of lines and is written newest-first for somebody reading top to bottom, and the only - line 1
way to reach the files was GitHub's releases page, which is a different site with a different layout. Somebody who wants "what is in the newest one, and where do I download it" had to visit two places and scroll a lot in one of them. This - line 1
page is both, in one, with every release collapsed to its heading so the list is readable at a glance and any one of them opening in place. # Why the notes come from CHANGELOG.md The same reason the questions page comes from `docs/FAQ.md`: - line 1
that file is readable on GitHub, in a checkout, and by anybody who has never opened the website, and it is where the notes are actually written. A second hand-kept copy of release notes is F-41 waiting to happen, and F-71 says what happens - line 1
when two hand-typed things are only ever compared against each other. # The download links are derived from the naming, not from the network Nothing here fetches anything: this project's build talks to no servers, and a generator that - line 1
asked GitHub what files a release has would make the website un-buildable offline and un-reproducible. The archive names follow a fixed pattern per platform, so the links are constructed from the version and the pattern, and the page says - line 1
plainly that it is linking rather than listing. That has one honest consequence, stated on the page: a link here is a link to where a file *should* be. GitHub answers with a 404 for a platform a given release did not build, and the release - line 1
page itself is one click away for anybody who wants the definitive list. - line 41
# In plain words Every version of VeilVoice, newest first, with what changed in it and where to get it. Click a version to open its notes without leaving the page. The download links are worked out from the version number rather than - line 41
fetched, so this page builds with no network, and the release page on GitHub is always one click away if a file is not where the pattern says. """ import argparse import os import re import sys HERE = - line 41
os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) sys.path.insert(0, os.path.join(ROOT, "tools", "docs")) sys.path.insert(0, os.path.join(ROOT, "tools", "site")) import generate as docs # - line 41
noqa: E402 import split # noqa: E402 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo # noqa: E402 the addresses and preview tags every page carries SOURCE = "CHANGELOG.md" OUT = os.path.join("website", - line 41
"releases.html") MARKER = "GENERATED by tools/site/releases.py" #: How a platform's label reads to somebody choosing a download. #: #: The labels themselves come from `release.yml` and are made for a build #: matrix; - line 41
"linux-arm64-musl-static" is exact and is not what somebody scanning #: a list of downloads is looking for. Anything not named here falls back to #: its label, so a platform added to the matrix appears on the page with an #: ugly name - line 41
rather than not at all. PLATFORMS = { "windows-x86_64": "Windows, 64-bit", - line 81
"macos-arm64": "macOS, Apple silicon", "macos-x86_64": "macOS, Intel", "linux-x86_64": "Linux, 64-bit", "linux-arm64": "Linux, ARM 64-bit", "linux-armv7-pi": "Raspberry Pi OS, 32-bit (command line only)", "linux-x86_64-musl-static": - line 81
"Linux, 64-bit, static (command line only)", "linux-arm64-musl-static": "Linux, ARM 64-bit, static (command line only)", "freebsd-x86_64": "FreeBSD, 64-bit (command line only)", "openbsd-x86_64": "OpenBSD, 64-bit (command line only)", - line 81
"netbsd-x86_64": "NetBSD, 64-bit (command line only)", } #: The workflow that decides what a release actually contains. WORKFLOW = os.path.join(".github", "workflows", "release.yml") #: The first release that publishes a contents list. See - line 81
roadmap item 97. CONTENTS_FROM = (0, 1, 15) # --- checking a download ---------------------------------------------------- # # The releases page is where somebody lands with a file they have just # downloaded, and it was the one page that - line 81
told them what changed without # telling them how to check it. `docs/INSTALL.md` has always carried the # instructions; a reader on this page had to know that and go and find it. # # So the tutorial is here as well, and it is - line 81
**generated**, for the reason # everything else on this site is: two hand-kept copies of a command are two # copies that drift, and the copy on the page nobody edits is the one that goes # wrong. Every command below is checked against the - line 81
verifier's own help text, # read out of `crates/veilvoice-verify/src/lib.rs`, when this page is built. A # subcommand that is renamed or dropped fails the build here rather than # teaching somebody to type something that no longer works. - line 81
VERIFIER_SOURCE = os.path.join("crates", "veilvoice-verify", "src", "lib.rs") # Where the Verify tab's capture goes. A line of its own in the Markdown, swapped # for markup after rendering. FIGURE = "<!--verify-tab-figure-->" - line 121
# Where the documentation is, linked twice: to the file on GitHub, which is # where it is written and where somebody with a checkout already has it, and to # the page on this site that covers the same ground where there is one. # # Two - line 121
links rather than a choice between them. The file is the source and reads # the same in a terminal, a checkout and a release archive; the page is styled, # searchable and does not require leaving the site. A reader wants whichever of # - line 121
those they are already in the middle of. DOCS = [ ("USER_GUIDE.md", "The whole application, screen by screen", "guide.html"), ("GUIDE_CLI.md", "Every command line command, with worked examples", None), ("GUIDE_GUI.md", "The desktop - line 121
application on its own", None), ("GUIDE_VERIFY.md", "Checking a download, at length", "verify.html"), ("INSTALL.md", "Installing and verifying, per operating system", "download.html"), ("FAQ.md", "The questions people actually ask", - line 121
"faq.html"), ("WHITEPAPER.md", "What the veiling does and why it cannot be undone", "crypto.html"), ("REPRODUCIBLE_BUILDS.md", "Building it yourself and comparing", None), ("SELF_SIGNING.md", "The code-signing certificate, and importing - line 121
it", None), ("PACKAGING.md", "The deb, the RPM, the AUR recipe and the rest", None), ("USING_THE_CRATES.md", "Using VeilVoice as a library", None), ("AUDIT.md", "Every defect found and fixed, in order", None), # GitHub reads a security - line 121
policy from the root, `.github/` or `docs/`, and # this is the one of the three where the rest of the documents already live, # so the table below needs no exception for it. The contributing guide sits # here for the same reason and is - line 121
recognised the same way. ("SECURITY.md", "Reporting a vulnerability, and what counts as one", None), ("CONTRIBUTING.md", "Building it, the standing rules, and the house style", None), ("WEBSITE.md", "Both editions of this site, and what - line 121
generates each page", None), ] FIGURE_HTML = ( '<figure class="shot">' '<img src="assets/screenshots/gui-verify.png" ' 'alt="the Verify tab, with a slot each for the archive, SHA256SUMS and ' 'SHA256SUMS.asc" loading="lazy" width="1400" - line 121
height="1000">' "<figcaption><strong>The Verify tab.</strong> All three slots are shown " "before anything is dropped, and each of the three answers is drawn on its " "own.</figcaption>" - line 161
"</figure>" ) # The signing key's fingerprint, read from the crate that carries it rather # than typed again. This is the one value in the whole chain that a person has # to compare by eye, so a wrong copy of it here would be the worst - line 161
possible # defect on a page about verification. FINGERPRINT_SOURCE = os.path.join("crates", "veilvoice-verify", "src", "check", "mod.rs") def verifier_usage(): """The verifier's own `--help` text, out of the source that prints it.""" - line 161
source = read(os.path.join(ROOT, VERIFIER_SOURCE)) start = source.index('const USAGE: &str = "') end = source.index('\n";', start) return source[start:end] def fingerprint(): """The signing key fingerprint, from the constant the programs - line 161
compare.""" source = read(os.path.join(ROOT, FINGERPRINT_SOURCE)) found = re.search(r'pub const FINGERPRINT: &str = "([0-9A-F]{40})";', source) if not found: raise SystemExit( "%s no longer declares FINGERPRINT, so the page cannot state - line 161
the " "fingerprint without typing it again" % FINGERPRINT_SOURCE ) digits = found.group(1) return " ".join(digits[i:i + 4] for i in range(0, 40, 4)) # Each step: the heading, the command, and what a pass actually proves. # # The order is - line 161
the order somebody meets the problem in, not the order the help # lists them: the whole check first, then the pieces of it, then the parts that # are about the *source* rather than the download. VERIFY_CLI = [ ( "The whole check, in one - line 161
command", "veilvoice verify", - line 201
"Run it in the folder you downloaded to. It finds the release, checks " "the OpenPGP signature over `SHA256SUMS`, checks every archive against " "that list, checks `CONTENTS.sha256` against it as well, and then " "checks **every file you - line 201
extracted** against `CONTENTS.sha256`. Each " "step runs only if the one before it passed. Entirely offline.", ), ( "The same, pointed at a folder", "veilvoice verify auto ~/Downloads", "A named directory is checked and nothing else is - line 201
searched. A path " "that is not there is refused by name rather than answered with a " "verdict about somewhere else.", ), ( "The signature over the hash list, on its own", "veilvoice verify sums SHA256SUMS SHA256SUMS.asc", "This is the - line 201
step everything else rests on. `SHA256SUMS` is a list of " "numbers; `SHA256SUMS.asc` is the detached OpenPGP signature over it. " "Until that signature checks out against the signing key, the list is " "just numbers somebody sent you.", - line 201
), ( "One file, against the signed list", "veilvoice verify file veilvoice-vVERSION-linux-x86_64.tar.gz " "--sums SHA256SUMS --sig SHA256SUMS.asc", "The signature is verified **first**, and the file is compared against " "the list only - line 201
after that passes. A pass means the download is " "**INTACT**: byte for byte the file that was published, signed by the " "VeilVoice key.", ), ( "One file, with no hash list at all", "veilvoice verify file - line 201
veilvoice-vVERSION-linux-x86_64.tar.gz " "--sha256 THE_HASH_YOU_WERE_GIVEN", "For when you have a hash rather than a signed list. What a match " "proves depends entirely on where that hash came from, and only you " "know that, so the tool - line 201
says so instead of guessing. If it came from " "somebody else's independent build of the same tag, a match means the " "release is **REPRODUCIBLE**, which is the stronger claim.", ), - line 241
( "Just the hash, verifying nothing", "veilvoice verify hash veilvoice-vVERSION-linux-x86_64.tar.gz", "Prints the SHA-256 and checks nothing, for when you want to compare " "it against something yourself.", ), ( "The same check through - line 241
your own GnuPG", "veilvoice verify gnupg", "The one thing no program can do for itself: the tool telling you a " "download is genuine came out of that download. This runs the `gpg` on " "your machine over the same signature, says it added - line 241
the public key " "and how to remove it again, and prints the commands so you can run " "them yourself.", ), ( "The key it is checking against", "veilvoice verify key", "Prints the signing key compiled into the program, and its " - line 241
"fingerprint. Compare it against the fingerprint at the top of this " "section, against `README.md`, and against the website. It is the one " "step nothing can do for you.", ), ( "What a pass actually proves", "veilvoice verify --explain", - line 241
"INTACT and REPRODUCIBLE are different claims, and this is the " "difference written out in full.", ), ] # The half about the source rather than the download. Separate because it # answers a different question and takes a great deal - line 241
longer. VERIFY_BUILD = [ ( "What a build needs on this machine", "veilvoice verify deps", "What is required to build VeilVoice here and which of it is already " "present. `--install` offers the missing pieces one at a time with the " - line 241
"exact command shown before each question.", - line 281
), ( "Build it, and compare against the published hashes", "veilvoice verify reproduce . --sums SHA256SUMS --sig SHA256SUMS.asc", "A signature says who made a file. Only a build says what it is made " "of. This compiles the workspace here - line 281
and compares what came out " "against the published hashes for this platform, with the signature " "verified before any hash from the list is read. A difference is a " "**finding**, not an accusation: both hashes are printed and the exit " - line 281
"status is deliberately not the one that means tampering.", ), ] # Doing it entirely with tools that are not VeilVoice's. Taken from the same # list `veilvoice verify gnupg` prints, in `veilvoice_verify::gnupg`, so the page and # the - line 281
program cannot come to say different things. BY_HAND = [ "gpg --import veilvoice-signing-key.asc", "gpg --verify SHA256SUMS.asc SHA256SUMS", "sha256sum -c SHA256SUMS --ignore-missing", ] GUI_STEPS = [ "Open **Verify** in the desktop - line 281
application. Three slots are shown before " "you drop anything, because verifying needs three files and an interface " "that discovers that after the drop teaches people it is fiddly.", "Drop the **archive** you downloaded, the - line 281
**`SHA256SUMS`** and the " "**`SHA256SUMS.asc`** on the window. A drop fills whichever slot the " "file's name says it is, in any order.", "Press the button once. It checks the signature over the hash list, then " "the archive against that - line 281
list, then every file extracted out of the " "archive against the signed contents list, and then runs the GnuPG on " "this machine over the same signature.", "Read the three answers, which are drawn separately on purpose so a pass " "on - line 281
one is never mistaken for a pass on another. A release published " "before v0.1.15 carries no contents list and simply has no such row. A " "GnuPG that will not run on this machine is drawn quietly: that is a fact " "about the computer and - line 281
says nothing about the download.", ] - line 321
def check_verify_commands(): """Every command shown on this page must exist in the verifier's help. The page is generated and the help is the source, so a subcommand renamed or dropped stops the build rather than leaving an instruction - line 321
that fails for somebody who followed it. `veilvoice-verify` was a separate program until 0.1.18 and nine places went on saying so for a release; this is the same class of drift, caught before it is published rather than after. """ usage = - line 321
verifier_usage() missing = [] for _title, command, _why in VERIFY_CLI + VERIFY_BUILD: words = command.split() # `veilvoice verify <sub>`, which is what the help lists. head = " ".join(words[:3]) if head not in usage: - line 321
missing.append(command) continue for word in words[3:]: if word.startswith("--") and word not in usage: missing.append("%s (the flag %s)" % (command, word)) if missing: raise SystemExit( "these commands are on the releases page and not in - line 321
the " "verifier's own help in %s:\n %s\n" " Either the help changed and the page has not, or the page is " "wrong. Fix whichever it is rather than removing this check." % (VERIFIER_SOURCE, "\n ".join(missing)) ) def - line 321
verify_markdown(version): """The tutorial, as Markdown, for the same renderer the notes use.""" check_verify_commands() out = [] add = out.append add("## Check a download before you run it") add("") - line 361
add("Every archive below is signed, and every release publishes the two " "files that let you check it: `SHA256SUMS`, a list of hashes, and " "`SHA256SUMS.asc`, the detached OpenPGP signature over that list. " "From v0.1.15 there is a - line 361
third, `CONTENTS.sha256`, which is itself " "covered by `SHA256SUMS` and lists every file **inside** each archive, " "so the binary you are about to run can be checked and not merely the " "zip it arrived in.") add("") add("The signing - line 361
key's fingerprint is:") add("") add("```") add(fingerprint()) add("```") add("") add("Comparing that against the copy in `README.md` and the copy on the " "front page is the one step no program can do for you, because a " "program that - line 361
came out of the download cannot vouch for the download.") add("") add("**The chain, end to end.** Every check below is one link in this, and " "each link is only worth anything if the one above it held:") add("") add("1. `SHA256SUMS.asc` - line 361
is a detached OpenPGP signature, made with the " "key above, **over `SHA256SUMS`**. Verifying it is what ties " "everything under it to a person rather than to whoever served you " "the files. Skip it and the hash list is numbers of - line 361
unknown origin, " "and comparing your file against numbers of unknown origin proves " "nothing at all.") add("2. `SHA256SUMS` holds the hash of **each archive** and the hash of " "**`CONTENTS.sha256`**, so the contents list is signed as - line 361
well, by " "being covered by the list the signature is over.") add("3. `CONTENTS.sha256` holds the hash of **every file inside each " "archive**: `veilvoice`, `veilvoice-gui`, and everything shipped " "beside them.") add("") add("So the - line 361
hash of the binary you are about to run traces back, link by " "link, to that one signature. Checking the archive stops at step 2 " "and tells you the *zip* was published; going on to step 3 tells you " "the *program* was, which is the - line 361
question somebody actually has. " "Releases before v0.1.15 publish no `CONTENTS.sha256` and stop at " "step 2, and every command here says so at the time rather than " - line 401
"quietly checking less.") add("") add("### With VeilVoice itself, no GnuPG required") add("") add("The verifier is part of `veilvoice`: it is not a separate download, " "and it carries the signing key compiled in, so it needs no GnuPG, no - line 401
" "keyring and no network.") add("") for title, command, why in VERIFY_CLI: add("**%s**" % title) add("") add("```") add(command.replace("VERSION", version)) add("```") add("") add(why) add("") add("### The same thing, with your own GnuPG - line 401
and nothing of ours") add("") add("Three commands, in the folder you downloaded to. This is what " "`veilvoice verify gnupg` runs and prints, and it is worth typing " "yourself: the second opinion is the whole point.") add("") add("```") - line 401
for command in BY_HAND: add(command) add("```") add("") add("`gpg --verify` answers whether the hash list is the one that was " "signed with the key above. `sha256sum -c` answers whether your files " "match that list. Both have to pass, - line 401
and in that order: a hash list " "nobody checked the signature of proves nothing at all. On macOS and " "the BSDs the last command is `shasum -a 256 -c SHA256SUMS` or " "`sha256 -c SHA256SUMS`; the first two are the same everywhere.") - line 401
add("") add("### In the desktop application") add("") - line 441
add("`veilvoice-gui` runs the same code with the same key, so the answer " "is the same one; only the typing is different.") add("") for index, step in enumerate(GUI_STEPS, 1): add("%d. %s" % (index, step)) add("") # The tab itself, as a - line 441
sentinel the builder replaces with markup: this # renderer escapes raw HTML, which is right for it and means a picture has # to be put in from outside. # # A capture rather than a drawing. The file is re-taken from the running # window on - line 441
every build, which is the difference between showing somebody # the program and showing them what it used to look like. add(FIGURE) add("") add("### Checking the source rather than the download") add("") for title, command, why in - line 441
VERIFY_BUILD: add("**%s**" % title) add("") add("```") add(command) add("```") add("") add(why) add("") add("### What each answer is worth") add("") add("- **INTACT** means your file is byte for byte the one that was " "published: not - line 441
truncated, not corrupted in transit, not swapped by " "whoever served it to you. The hash came from the signed list.") add("- **REPRODUCIBLE** is the stronger claim and needs a hash from " "somewhere else: somebody else's independent build - line 441
of the same tagged " "source. It says the published binary corresponds to the published " "source, which a signature alone cannot.") add("- **A GnuPG that will not run** is a fact about your computer. It is " "not a failed check and - line 441
nothing here treats it as one.") add("") - line 481
add("Full instructions per platform, including Windows and the BSDs, are " "in [`docs/INSTALL.md`](https://github.com/%s/blob/%s/docs/INSTALL.md)." % (docs.REPO, docs.REF)) return out def docs_html(): """Where every document is, on GitHub - line 481
and on this site. A release archive ships `docs/` inside it, the repository holds the same files, and this website renders several of them. Somebody who has just downloaded VeilVoice has all three and no reason to know that, so the list is - line 481
here, with both links where both exist. """ out = [] out.append('<details class="release" id="the-documentation">') out.append( "<summary><strong>The documentation</strong> " '<span class="muted">every guide, on GitHub and on this site, - line 481
and in ' "the archive you just downloaded</span></summary>" ) out.append( '<p class="muted">Each of these is a file in <code>docs/</code>. Every ' "release archive carries the whole folder, so once you have unpacked " "one you have all of - line 481
it offline. The first link is the file as it is " "written; the second, where there is one, is the same ground covered " "as a page of this site.</p>" ) out.append("<table><thead><tr><th>Document</th><th>What it covers</th>" "<th>On this - line 481
site</th></tr></thead><tbody>") for name, what, page in DOCS: here = ('<a href="%s">%s</a>' % (page, page[:-5])) if page else "—" out.append( '<tr><td><a href="https://github.com/%s/blob/%s/docs/%s" ' 'rel="noopener - line 481
noreferrer"><code>%s</code></a></td>' "<td>%s</td><td>%s</td></tr>" % (docs.REPO, docs.REF, name, name, docs.esc(what), here) ) out.append("</tbody></table>") out.append( - line 521
'<p class="muted">The generated reference for every crate and every ' 'source file is under <a href="wiki.html">the reference</a>, and the ' 'same pages are in <a href="https://github.com/%s/wiki" ' 'rel="noopener noreferrer">the - line 521
wiki</a>.</p>' % docs.REPO ) out.append("</details>") return out def archives(): """Every archive a release publishes, as `(label, filename pattern)`. **F-101.** This was a hand-written list of five, kept beside the build matrix by nothing - line 521
but attention, and the two had drifted: it linked `macos-aarch64` and `linux-aarch64`, which have never existed under those names, and it omitted six platforms that do. Two dead links and six missing ones, on every release entry, on the - line 521
page whose entire job is to be the list of downloads. That is F-71's shape again -- two things only ever compared against each other -- so the fix is not to correct the five names. `release.yml` is the file that decides what gets built and - line 521
what each archive is called, and this reads it. A platform added to the matrix appears here; a label renamed there cannot leave a dead link here. Parsed with a regular expression rather than a YAML library, because this project's website - line 521
build has no dependencies and adding one for eleven lines would be the larger cost. The shape being matched is the matrix's own `label:` and `archive:` pair, and the three BSD jobs name their output directly; both are asserted to be - line 521
present, so a workflow rewritten into a shape this cannot read fails the build rather than quietly publishing a page with no downloads on it. """ text = read(os.path.join(ROOT, WORKFLOW)) found = [] # The build matrix: a `label:` and, - line 521
below it, the `archive:` kind. for match in re.finditer( r"^\s*label:\s*(\S+)\s*$\n(?:^\s*#.*$\n)*^\s*archive:\s*(\S+)\s*$", text, - line 561
re.M, ): found.append((match.group(1), match.group(2))) # The BSD jobs, which are not in that matrix and name their archive in the # staging step. Always a tarball; there is no BSD zip. for match in - line 561
re.finditer(r'out="veilvoice-\$\{\{[^}]*\}\}-(\S+?)"', text): found.append((match.group(1), "tar.gz")) if len(found) < 5: raise SystemExit( "%s: only %d archives could be read out of %s.\n" " The page's download list is derived from that - line 561
workflow, so a\n" " shape this cannot read would publish a page with no downloads." % (OUT, len(found), WORKFLOW) ) out = [] seen = set() for label, kind in found: if label in seen: continue seen.add(label) out.append((PLATFORMS.get(label, - line 561
label), "veilvoice-v{v}-%s.%s" % (label, kind))) return out #: The three files every release carries beside its archives. BESIDE = [ ("SHA256SUMS", "the hash of every file above"), ("SHA256SUMS.asc", "the signature over that list"), - line 561
("veilvoice-signing-key.asc", "the public key it was signed with"), ] def read(path): with open(path, encoding="utf-8") as fh: return fh.read().replace("\r\n", "\n") - line 601
def releases(text): """Every `## vX.Y.Z` heading and the lines under it, newest first.""" lines = text.split("\n") found = [] current = None for line in lines: match = re.fullmatch(r"## v(\d+\.\d+\.\d+)\s*", line) if match: current = - line 601
{"version": match.group(1), "body": []} found.append(current) continue # A new top-level heading that is not a release ends the one before it, # so `## Unreleased` above them does not swallow the first release and # anything after the last - line 601
does not get appended to it. if line.startswith("## ") and current is not None: current = None continue if current is not None: current["body"].append(line) return found def earlier(text): """The versions covered by a combined `## vX.Y.Z - line 601
and earlier` heading. `CHANGELOG.md` keeps one section for the first six releases, which says in full: "See the release notes for each tag." That is honest and it left them off this page entirely, because the pattern above matches `## - line 601
vX.Y.Z` and nothing else. Six published versions with no entry, no summary and no download links, on the page whose whole job is to be the list. So the heading is read for what it says: every patch version of that series from zero up to - line 601
and including the one named. This project has released 0.1.0, 0.1.1 and so on with no gaps -- which is checked, by the version ordering guard added as roadmap item 95 -- so the enumeration is a fact about the file rather than a guess about - line 601
the world. Their notes genuinely are on their release pages and this page says so rather than inventing a summary for a section that has none. """ - line 641
match = re.search(r"^## v(\d+)\.(\d+)\.(\d+) and earlier\s*$", text, re.M) if not match: return [] major, minor, patch = (int(part) for part in match.groups()) return ["%d.%d.%d" % (major, minor, n) for n in range(patch, -1, -1)] def - line 641
unreleased(text): """The lines under `## Unreleased`, which is what the next version holds.""" lines = text.split("\n") out = [] taking = False for line in lines: if line.strip() == "## Unreleased": taking = True continue if taking and - line 641
line.startswith("## "): break if taking: out.append(line) return out def summary(body): """The first thing a release says about itself, for the closed heading. Three shapes appear in this file across nine releases, and taking the first - line 641
line works for none of them: * A paragraph, hard-wrapped at about eighty columns. Taking one line stops mid-sentence, which produced "a window that does" for v0.1.14. * A paragraph opening in bold. A naive bullet test skips it and then - line 641
takes the continuation, which produced "a console window and could fail with no message at all." for v0.1.11. * A `### Added` heading and then a bullet list, which is how the older releases are written. Skipping bullets takes the - line 641
*continuation* of the first one, which produced "SHA-256 manifest of VeilVoice's own files" starting mid-sentence for v0.1.7. So the whole first block is collected, whatever shape it is, joined, and cut - line 681
at a sentence. A bullet keeps its text and loses its marker: for those releases the first bullet genuinely is the first thing said. """ # A block that is nothing but a bold label is a heading written in bold # rather than with hashes, and - line 681
saying nothing more than that. `v0.1.17` # opens `**In short**`, and taking it gave a row in the list reading # "v0.1.17 In short", which is a label that labels nothing. Skip those and # take the block under them. Now that every release on - line 681
the page is closed # by default, this line is all a reader has to go on. LABEL = re.compile(r"^\*\*[^*]{1,40}\*\*[.:]?$") block = [] for line in body: text = line.strip() if not text: if block: break continue if text.startswith(("#", "|", - line 681
">", "```")) or LABEL.match(text): if block: break continue marker = re.match(r"(?:[-*+]\s+|\d+\.\s+)", text) if marker: if block: break text = text[marker.end():] block.append(text) if not block: return "" text = " ".join(block) text = - line 681
re.sub(r"\(https?://[^)]*\)", "", text) text = re.sub(r"[*`_\[\]]", "", text) text = re.sub(r"\s+", " ", text).strip() stop = text.find(". ") if stop > 30: text = text[: stop + 1] # Short enough to read as a label beside the version rather - line 681
than as a # paragraph. The notes themselves open below and begin with this same - line 721
# sentence, so a long one is printed twice on the release that is open by # default, which looks like a mistake. if len(text) > 72: text = text[:72].rsplit(" ", 1)[0] + "..." return text def beside_for(version): """The files a release of - line 721
this version carries beside its archives. `CONTENTS.sha256` is new in v0.1.15 and linking it on an older release would be a dead link among live ones, which teaches people that the links here are unreliable. So the version decides. """ out - line 721
= list(BESIDE) parts = tuple(int(n) for n in version.split(".")) if parts >= CONTENTS_FROM: out.insert( 1, ( "CONTENTS.sha256", "the hash of every file inside those archives", ), ) return out def files_html(version): """The download list - line 721
for one release.""" base = "https://github.com/%s/releases/download/v%s/" % (docs.REPO, version) out = ['<div class="release-files">'] out.append("<h4>Files</h4>") out.append("<ul>") for label, pattern in archives(): name = - line 721
pattern.format(v=version) out.append( '<li><a href="%s%s" rel="noopener noreferrer">%s</a> ' '<span class="muted">%s</span></li>' % (base, name, label, name) ) for name, what in beside_for(version): - line 761
out.append( '<li><a href="%s%s" rel="noopener noreferrer">%s</a> ' '<span class="muted">%s</span></li>' % (base, name, name, what) ) out.append("</ul>") out.append( '<p class="muted">These links are worked out from the version number ' - line 761
"rather than fetched, so this page builds with no network. A release " "that did not build for a platform answers with a not-found; " '<a href="https://github.com/%s/releases/tag/v%s" ' 'rel="noopener noreferrer">the release page</a> is - line 761
the definitive ' "list.</p>" % (docs.REPO, version) ) out.append("</div>") return out def build(): text = read(os.path.join(ROOT, SOURCE)) found = releases(text) if not found: raise SystemExit( "%s has no `## vX.Y.Z` headings, so the page - line 761
would be empty.\n" " Each release is a level two heading naming its version." % SOURCE ) # One anchor pool for the whole page, not one per release. Every release # since v0.1.6 has a `### Added` and most have a `### Testing`, so a fresh # - line 761
pool per release hands out the same `id` nine times over. `Anchors` # already suffixes repeats the way GitHub does; it just has to be told the # page is one document. anchors = docs.Anchors() def ids_for(lines): """The anchors `doc_html` - line 761
will pop, one per heading, in order. `doc_html` takes a list it pops from rather than an `Anchors`, so the headings are counted here and the pool is shared across every release. """ return [ - line 801
anchors.take(line.strip().lstrip("#").strip()) for line in lines if line.strip().startswith("#") ] out = [] out.append('<p class="muted">') out.append( "Every release, newest first. Open one to read what changed in it; " "its files are at - line 801
the end of the notes. The notes are " '<a href="https://github.com/%s/blob/%s/CHANGELOG.md">CHANGELOG.md</a>, ' "which is where they are written." % (docs.REPO, docs.REF) ) out.append("</p>") # The tutorial, above the list. Somebody - line 801
arrives here holding a file they # have just downloaded, and "what changed" is the second question they have. # Open by default rather than collapsed: a verification step behind a # disclosure triangle is a verification step most people - line 801
will not take. newest = found[0]["version"] tutorial = verify_markdown(newest) out.append('<details class="release" id="verify-a-download" open>') out.append( "<summary><strong>Check a download</strong> " '<span class="muted">the - line 801
signature, the hashes, and the files inside ' "the archive, from the command line or the window</span></summary>" ) # The tutorial's own `##` heading would repeat the summary above it, so the # renderer is given the body from the first - line 801
`###` down. body = tutorial[tutorial.index("### With VeilVoice itself, no GnuPG required"):] preamble = tutorial[1:tutorial.index("### With VeilVoice itself, no GnuPG required")] out.extend(docs.doc_html(preamble, ids_for(preamble))) # The - line 801
capture sits between two rendered halves rather than inside either, # because the renderer escapes markup and should go on doing so. at = body.index(FIGURE) before, after = body[:at], body[at + 1:] out.extend(docs.doc_html(before, - line 801
ids_for(before))) out.append(FIGURE_HTML) out.extend(docs.doc_html(after, ids_for(after))) out.append("</details>") - line 841
out.extend(docs_html()) pending = unreleased(text) # An `Unreleased` entry is worth showing when something is in it, and is # noise when it holds the placeholder that sits there between releases: # a dropdown at the top of the page - line 841
promising unreleased changes and # containing "Nothing yet" is worse than no dropdown. A heading or a bullet # is what a real entry has; a sentence on its own is the placeholder. if any(line.strip().startswith(("#", "-", "*", "+")) for - line 841
line in pending): out.append('<details class="release">') out.append( "<summary><strong>Unreleased</strong> " '<span class="muted">finished and waiting for the next version</span>' "</summary>" ) out.extend(docs.doc_html(pending, - line 841
ids_for(pending))) out.append("</details>") for release in found: version = release["version"] # **Every release is closed, including the newest.** # # The newest used to open by default, on the reasoning that somebody # arriving here - line 841
wants the one they are about to install. That was true # about *which* release they want and wrong about what they want from # it. These notes run to hundreds of lines, so an open newest release # put a wall of prose between the top of the - line 841
page and everything else # on it: the older releases, and the files. # # Closed, the page is a list of versions a reader can see the whole of, # and opening one is a decision rather than the default. out.append('<details class="release" - line 841
id="v%s">' % version) line = summary(release["body"]) out.append( "<summary><strong>v%s</strong> <span class=\"muted\">%s</span></summary>" % (version, docs.inline_html(line)) ) # **The files first, the notes under them.** # - line 881
# This was the other way round, with a comment arguing that the notes # are what the page is for. They are what the page is *made of*; the # files are what somebody came for. Putting several hundred lines of # prose in front of the - line 881
download links means scrolling past all of it # to reach them, every time, which is a poor trade for making a point # about what the page is for. out.extend(files_html(version)) out.append('<details class="notes">') out.append( - line 881
"<summary><strong>Release notes</strong> " '<span class="muted">everything that changed in v%s, in ' "full</span></summary>" % version ) out.extend(docs.doc_html(release["body"], ids_for(release["body"]))) out.append( '<p class="muted">The - line 881
same notes are in ' '<a href="https://github.com/%s/blob/%s/CHANGELOG.md" ' 'rel="noopener noreferrer"><code>CHANGELOG.md</code></a>, where ' "they are written, and at the top of " '<a href="https://github.com/%s/releases/tag/v%s" ' - line 881
'rel="noopener noreferrer">this release on GitHub</a>.</p>' % (docs.REPO, docs.REF, docs.REPO, version) ) out.append("</details>") out.append("</details>") # Every version that predates the first section with notes of its own. They # are - line 881
published releases and were missing from the list entirely. for version in earlier(text): out.append('<details class="release" id="v%s">' % version) out.append( "<summary><strong>v%s</strong> " '<span class="muted">notes on its own release - line 881
page</span></summary>' % version ) out.append( '<p class="muted">CHANGELOG.md keeps one section for v%s and ' "earlier, and it says to read the notes for each tag rather than " "repeating them. This entry is here so the list is the whole - line 881
list " "and the files are reachable; " - line 921
'<a href="https://github.com/%s/releases/tag/v%s" ' 'rel="noopener noreferrer">the release page for v%s</a> is where ' "its notes are.</p>" % (earlier(text)[0], docs.REPO, version, version) ) out.extend(files_html(version)) - line 921
out.append("</details>") index_html = read(os.path.join(ROOT, "website", "index.html")) parts = split.shell(index_html) page = split.page( ROOT, parts, "releases", "Releases", "Every version of VeilVoice, what changed in it, and where to - line 921
get it.", "\n".join(out), # Its `#anchor` links point at its own releases, not at sections of the # front page. relink_body=False, ) page = page.replace( '<p style="color:var(--muted)">This section is also part of ' '<a - line 921
href="index.html">the front page</a>, where it sits in context ' "with the rest.</p>", '<p style="color:var(--muted)">The same notes as ' '<a href="https://github.com/%s/blob/%s/CHANGELOG.md">CHANGELOG.md</a>.' "</p>" % (docs.REPO, - line 921
docs.REF), ) page = page.replace( "GENERATED by tools/site/split.py from website/index.html.", "%s from CHANGELOG.md." % MARKER, ) return {OUT: page} def main(): parser = argparse.ArgumentParser(description=__doc__) - line 921
parser.add_argument("--check", action="store_true", help="fail if the page is not what this would write") args = parser.parse_args() - line 961
written = seo.finished(build()) if args.check: for rel, want in written.items(): path = os.path.join(ROOT, rel) if not os.path.exists(path): print("missing: %s" % rel) return 1 if read(path) != want: print("out of date: %s" % rel) print(" - line 961
regenerate with: python tools/site/releases.py") return 1 print("the releases page matches %s" % SOURCE) return 0 for rel, body in written.items(): path = os.path.join(ROOT, rel) with open(path, "w", encoding="utf-8", newline="\n") as fh: - line 961
fh.write(body) print("wrote %s" % rel) return 0 if __name__ == "__main__": sys.exit(main())
tools/site/roadmap.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """The roadmap, as a page anybody can read and a picture of where it has got to. python tools/site/roadmap.py # write the page and the graphic python tools/site/roadmap.py - line 1
--check # verify they are current # Why this is generated `ROADMAP.md` is the file that decides what is done. It is also a two thousand line document in a repository, which is the wrong shape for the question people actually ask, which is - line 1
"is this finished, and if not, what is left". So this reads that file and writes two things: a picture of every roadmap item coloured by its state, and a page that puts the picture at the top and the list under it. Neither is typed. An - line 1
item whose status changes in `ROADMAP.md` changes here on the next run, and `--check` fails the build if it has not, which is the same arrangement the documentation, the artwork and the search index already have. A published roadmap that - line 1
quietly disagrees with the roadmap would be worse than not publishing one. # What the picture shows, and what it deliberately does not One square per item, in the order the roadmap lists them, grouped by the section headings that file - line 1
already has. Colour is state and nothing else: done, next, planned, blocked. **Area is not progress.** Every square is the same size, and an item is not a day: roadmap item 1 is the entire signal engine and roadmap item 68 is a page of - line 1
questions. Reading the coloured fraction as "how far along this is" would be wrong, and the page says so under the picture rather than leaving somebody to work it out. The estimates, which are the closest thing to an answer, are in the - line 1
text where they can carry the sentence that goes with them. **Blocked is its own colour, not a shade of unfinished.** Four items are blocked on a decision or on somebody else's rules rather than on effort, and showing them as "not done - line 1
yet" would promise work that is not going to happen by working harder. # In plain words - line 41
The roadmap is a long file in the source code. This turns it into a page with a picture at the top, so somebody can see at a glance what is built and what is not, without reading two thousand lines or taking anybody's word for it. The - line 41
picture is drawn from that file every time, so it cannot say something the roadmap does not. Pure standard library. """ import argparse import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = - line 41
os.path.abspath(os.path.join(HERE, "..", "..")) sys.path.insert(0, os.path.join(ROOT, "tools", "docs")) import generate as docs # noqa: E402 (the path has to be set first) sys.path.insert(0, HERE) import split # noqa: E402 (same) - line 41
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo # noqa: E402 the addresses and preview tags every page carries MARKER = "GENERATED by tools/site/roadmap.py" # A row in one of the roadmap's tables: number, item, - line 41
status, and sometimes an # estimate. The status is bold in the file, which is what `\*\*` is doing here. ROW = re.compile( r"^\|\s*(?P<number>\d+)\s*\|\s*(?P<item>.+?)\s*\|\s*" r"\*\*(?P<status>done|next|planned|blocked)\*\*\s*\|" - line 41
r"(?:\s*(?P<estimate>[^|]*?)\s*\|)?\s*$", re.M, ) - line 81
HEADING = re.compile(r"^## (?P<title>.+?)\s*$", re.M) # The four states, in the order a legend should list them, with the palette # token each is drawn in. STATES = [ ("done", "ok", "Built, tested, documented, in main"), ("next", "accent", - line 81
"Started or specified in detail, being worked on now"), ("planned", "warn", "Specified, not started"), ("blocked", "err", "Cannot proceed until something outside the code changes"), ] # Sections of the roadmap that are a list of items. - line 81
Anything else in the file # is prose and is linked to rather than redrawn. SKIP_SECTIONS = {"Legend"} def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() def items(text): """Every item in the roadmap, as - line 81
(section, number, item, status, estimate). Walked in file order, so the picture reads in the same order as the document, and a section added to `ROADMAP.md` appears here without anybody editing this file. """ sections = [(m.start(), - line 81
m.group("title")) for m in HEADING.finditer(text)] def section_of(position): title = "Markers" for start, name in sections: if start < position: title = name else: break return title # A line that looks like an item row and does not parse - line 81
is not skipped. - line 121
# # `63b` was such a row. It sat in a table between 63 and the next section, # written exactly like its neighbours, carrying a status like its # neighbours, and `ROW` requires a plain integer, so it matched nothing and # was dropped - line 121
without a word. It never appeared on the website, never # appeared in the picture, and was in no count of anything, for as long as # it existed. # # A parser that silently ignores what it cannot read turns a typo into an # invisible item, - line 121
which is the worst of both: the document says the work is # tracked and nothing tracks it. So anything shaped like a row and not # matched is a failure here, loudly, rather than a gap nobody sees. # # Two shapes are caught. The first is a - line 121
row carrying a bold status token, # `63b`'s shape: an item whose number is not a plain integer. The second is # a row that opens with a plain integer and does not parse, which is what # roadmap items 109 and 110 were for as long as their - line 121
status was written `planned` # rather than `**planned**`: the bold-token detector could not see them # because the only bold text on the line was the item's own title, so they # were numbered, present, and in no count of anything. A - line 121
numbered row is a # an item by definition, so a numbered row this cannot read is the same # invisible-item failure, and is raised the same way. looks_like_a_row = re.compile(r"^\|\s*[^|\s]+\s*\|.*\|\s*\*\*\w+\*\*\s*\|", re.M) numbered_row - line 121
= re.compile(r"^\|\s*\d+\s*\|.*\|", re.M) parsed_at = {m.start() for m in ROW.finditer(text)} candidates = {} for pattern in (looks_like_a_row, numbered_row): for match in pattern.finditer(text): candidates.setdefault(match.start(), match) - line 121
for candidate in (candidates[start] for start in sorted(candidates)): if candidate.start() in parsed_at: continue if section_of(candidate.start()) in SKIP_SECTIONS: continue raise SystemExit( "a row in ROADMAP.md looks like an item and - line 121
does not parse:\n" " %s\n" " A row needs a plain integer, an item, and a bold status of\n" " done, next, planned or blocked. Fix the row: a row this cannot\n" " read is a row that appears nowhere." - line 161
% candidate.group(0).strip()[:120]) out = [] for match in ROW.finditer(text): title = section_of(match.start()) if title in SKIP_SECTIONS: continue estimate = (match.group("estimate") or "").strip() if estimate in ("—", "-", "--"): - line 161
estimate = "" out.append({ "section": title, "number": int(match.group("number")), "item": match.group("item"), "status": match.group("status"), "estimate": estimate, }) return out def split_item(item): """An item's bold title, and the - line 161
rest of it. Every row in `ROADMAP.md` is written as `**A short name**: what that actually means`, and the page used to print the whole string as one line. In a list of a hundred that reads as a wall: the name and the explanation have the - line 161
same weight, so neither is scannable and the explanation is not reachable except by reading every word of it. Split here rather than in the template, because a handful of the oldest rows have no bold title at all and the caller should not - line 161
have to know which. Those come back with no title and their whole text as the detail, which renders as it always did. """ match = re.match(r"^\*\*(?P<title>.+?)\*\*\s*[:.]?\s*(?P<detail>.*)$", item, re.S) if not match: return "", item - line 161
return match.group("title"), match.group("detail").strip() - line 201
def anchor(title): """A stable `#id` for a section heading. The same shape GitHub gives a heading, so a link written against the document works against the page: lowercased, spaces to hyphens, everything else dropped. """ slug = - line 201
re.sub(r"[^a-z0-9\s-]", "", title.lower()) return re.sub(r"\s+", "-", slug.strip()) def prose_for(text, sections): """The paragraphs a section opens with, before its table. This is where the reasoning behind a group of items is written, - line 201
and the page threw all of it away: it kept the rows and dropped the sentences that say why the rows exist. A reader of the page saw a list; a reader of the document saw an argument. """ out = {} for index, (start, title) in - line 201
enumerate(sections): end = sections[index + 1][0] if index + 1 < len(sections) else len(text) chunk = text[start:end] # Everything between the heading and the first table row or the first # horizontal rule, whichever comes first. body_text - line 201
= chunk.split("\n", 1)[1] if "\n" in chunk else "" lines = [] for line in body_text.split("\n"): if line.startswith("|") or line.startswith("---"): break lines.append(line) paragraphs = [p.strip() for p in "\n".join(lines).split("\n\n") if - line 201
p.strip()] out[title] = paragraphs return out def plain(markup): """An item's text without its Markdown, for a tooltip. - line 241
Deliberately small: bold, code and links, which is everything the roadmap's item column actually uses. Anything else is left as it is rather than half-converted. """ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", markup) text = - line 241
text.replace("**", "").replace("`", "") return re.sub(r"\s+", " ", text).strip() # --- the picture ------------------------------------------------------------- SQUARE = 22.0 GAP = 6.0 LABEL_H = 30.0 BLOCK_GAP = 18.0 MARGIN = 24.0 WIDTH = - line 241
640.0 def graphic(colours, groups, counts): """Every item as a square, grouped by the roadmap's own sections.""" per_row = int((WIDTH - MARGIN * 2 + GAP) // (SQUARE + GAP)) # Height first, so the canvas is the size of what goes in it - line 241
rather than a # number that has to be kept in step by hand. y = MARGIN + 34.0 plan = [] for title, rows in groups: lines = (len(rows) + per_row - 1) // per_row plan.append((title, rows, y)) y += LABEL_H + lines * (SQUARE + GAP) + BLOCK_GAP - line 241
legend_y = y + 4.0 height = legend_y + 2 * (SQUARE - 2) + 22.0 + MARGIN out = [] add = out.append add('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.0f %.0f" ' 'width="%.0f" height="%.0f" style="max-width:100%%;height:auto" ' - line 241
'role="img" aria-label="every roadmap item, coloured by state">' % (WIDTH, height, WIDTH, height)) - line 281
add("<!-- %s from ROADMAP.md. Do not edit. -->" % MARKER) # The reveal, and the hover, written into the drawing. # # In the drawing rather than in `main.css` because this file is also # written to `assets/roadmap.svg` and opened on its - line 281
own, where no # stylesheet of ours is loaded. A picture that animates in one place and # not the other is the sort of difference nobody notices until it looks # broken. # # `transform-box: fill-box` is what makes `transform-origin: center` - line 281
mean # the square's own centre rather than the origin of the whole drawing. # Without it every square flies in from the top-left corner. # # **The fill mode is `backwards`, and it used to be `both`.** A filling # animation never finishes - line 281
as far as the compositor is concerned, and a # transform animation on an element gets that element its own GPU layer. # With `both`, all 146 squares kept theirs for as long as the page was # open: Chromium's layer tree showed 146 permanent - line 281
22x22 layers for a # picture that had stopped moving in under half a second, and every one of # them was a texture the compositor handled on every frame, next to the # film beside it that actually was moving. # # `forwards` was never - line 281
needed here. The animation ends at # `opacity:1;transform:none`, which is the state the square has anyway, so # holding it changes nothing on screen and costs a layer each. `backwards` # keeps the part that matters, the state before the - line 281
animation starts, so a # delayed square does not flash at full size before flying in. add('<style>' '.rm-square{transform-box:fill-box;transform-origin:center;' 'animation:rm-in .45s cubic-bezier(.2,.8,.3,1) backwards;' 'transition:filter - line 281
.15s ease}' '.rm-square:hover{filter:brightness(1.35)}' '@keyframes rm-in{from{opacity:0;transform:scale(.4)}' 'to{opacity:1;transform:none}}' # Somebody who has asked their system for less movement gets the # finished picture, - line 281
immediately, rather than a faster version of the # same animation. '@media (prefers-reduced-motion:reduce){' '.rm-square{animation:none}}' '</style>') - line 321
add('<rect x="0" y="0" width="%.0f" height="%.0f" rx="10" ' 'fill="var(--bg-inset, %s)"/>' % (WIDTH, height, colours["bg-inset"])) total = sum(counts.values()) add('<text x="%.1f" y="%.1f" font-family="%s" font-size="14" ' 'fill="var(--fg, - line 321
%s)">%d items</text>' % (MARGIN, MARGIN + 14, docs.MONO, colours["fg"], total)) add('<text x="%.1f" y="%.1f" font-family="%s" font-size="12" ' 'fill="var(--muted, %s)" text-anchor="end">%s</text>' % (WIDTH - MARGIN, MARGIN + 14, docs.MONO, - line 321
colours["muted"], docs.esc(", ".join("%d %s" % (counts.get(name, 0), name) for name, _, _ in STATES if counts.get(name))))) token = {name: colour for name, colour, _ in STATES} for title, rows, top in plan: add('<text x="%.1f" y="%.1f" - line 321
font-family="%s" font-size="12" ' 'fill="var(--muted, %s)">%s</text>' % (MARGIN, top + 16, docs.MONO, colours["muted"], docs.esc(title))) for index, row in enumerate(rows): column = index % per_row line = index // per_row x = MARGIN + - line 321
column * (SQUARE + GAP) square_y = top + LABEL_H + line * (SQUARE + GAP) colour = token[row["status"]] # No number drawn in the square. # # It used to carry one, and a grid of ninety-six numbered squares # tells a reader nothing: the - line 321
number is an index into a document # they are not in, and the one thing they want -- what this square # is -- was the thing it did not say. The name is in the `<title>`, # which every browser shows on hover and which a screen reader # - line 321
reads, and it is now the only thing the square carries. # # The status opacity moves to the group so the square's own # opacity is free for the reveal below. add('<g opacity="%s"><title>%s (%s)</title>' '<rect class="rm-square" - line 321
style="animation-delay:%dms" ' 'x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="4" ' 'fill="var(--%s, %s)"/></g>' % ("1" if row["status"] == "done" else "0.85", - line 361
docs.esc(plain(row["item"])[:90]), row["status"], # Staggered along each row, and capped: ninety-six squares # at 12 ms each would take a second and a quarter to # finish, which is a page that looks slow rather than one # that looks alive. - line 361
min(index * 12, 600), x, square_y, SQUARE, SQUARE, colour, colours[colour])) # The legend, two to a row, so a narrow screen does not scale the picture # down to fit one long line of it. for index, (name, colour, note) in enumerate(STATES): - line 361
column = index % 2 line = index // 2 x = MARGIN + column * ((WIDTH - MARGIN * 2) / 2) y = legend_y + line * (SQUARE - 2) add('<rect x="%.1f" y="%.1f" width="12" height="12" rx="3" ' 'fill="var(--%s, %s)"/>' % (x, y, colour, - line 361
colours[colour])) add('<text x="%.1f" y="%.1f" font-family="%s" font-size="11" ' 'fill="var(--muted, %s)">%s</text>' % (x + 18, y + 10, docs.MONO, colours["muted"], docs.esc("%s: %d" % (name, counts.get(name, 0))))) add("</svg>") return - line 361
"\n".join(out) + "\n" # --- the film ---------------------------------------------------------------- FILM_W = 640.0 FILM_H = 360.0 FILM_PAD = 20.0 FILM_ROW = 17.0 FILM_TOP = 46.0 # How fast the list moves, and how long it waits at the end - line 361
before repeating. FILM_SPEED = 42.0 FILM_PAUSE = 4.0 def film(colours, done): """Everything that is finished, scrolling, with a countdown before it repeats. - line 401
# Why this is an animation and not an MP4 A video was asked for, and a video is the right shape for this: something somebody watches rather than a picture with a long dead pause in it. What it is not is a reason to put an H.264 file in - line 401
this repository. This project ships no codec and does not bundle `ffmpeg`, and `ROADMAP.md` already settled what that means for video: render here, and *always write a self-contained animation that needs nothing else installed*. An encoded - line 401
file would also be a committed binary whose bytes depend on which build of which encoder made it, so it could not be regenerated and compared the way every other picture in this repository is. So it is an SVG that animates itself. It plays - line 401
in any browser with no plugin, no codec and no download, it is a few kilobytes rather than a few megabytes, it takes the reader's colour scheme, and it is generated from `ROADMAP.md`, so it cannot show an item as finished that is not. - line 401
`docs/PACKAGING.md` is not the place for it; the command to turn it into an actual file, for anybody who wants one, is under the picture on the page. # The countdown A loop with no warning restarts under the reader while they are still - line 401
reading the last line. The ring in the corner fills during the pause, so the restart is something you can see coming rather than something that happens to you. Four seconds: long enough to finish a line, short enough that nobody is - line 401
waiting. """ rows = [] y = 0.0 columns = int((FILM_W - FILM_PAD * 2 - 34.0) / (12.0 * docs.TEXT_RATIO)) for row in done: lines = wrap(plain(row["item"]), columns) rows.append((row["number"], lines, y)) y += len(lines) * FILM_ROW + 7.0 - line 401
content = y view_h = FILM_H - FILM_TOP - FILM_PAD distance = max(0.0, content - view_h) scroll = distance / FILM_SPEED if distance else 1.0 - line 441
total = scroll + FILM_PAUSE turn = (scroll / total) * 100.0 ring_r = 9.0 circumference = 2 * 3.14159265 * ring_r out = [] add = out.append add('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.0f %.0f" ' 'width="%.0f" height="%.0f" - line 441
style="max-width:100%%;height:auto" ' 'role="img" aria-label="everything finished, scrolling">' % (FILM_W, FILM_H, FILM_W, FILM_H)) add("<!-- %s from ROADMAP.md. Do not edit. -->" % MARKER) add("<style>") add(" .film-list { animation: - line 441
film-scroll %.2fs linear infinite; }" % total) add(" .film-ring { animation: film-count %.2fs linear infinite; }" % total) add(" @keyframes film-scroll {") add(" 0%% { transform: translateY(0); }") add(" %.2f%%, 100%% { transform: - line 441
translateY(-%.1fpx); }" % (turn, distance)) add(" }") add(" @keyframes film-count {") add(" 0%%, %.2f%% { opacity: 0; stroke-dashoffset: %.2f; }" % (turn, circumference)) add(" %.2f%% { opacity: 1; stroke-dashoffset: %.2f; }" % (turn + - line 441
0.01, circumference)) add(" 100%% { opacity: 1; stroke-dashoffset: 0; }") add(" }") # Somebody who asked their system for less movement gets the list at the # top and no countdown, rather than a picture that never settles. add(" @media - line 441
(prefers-reduced-motion: reduce) {") add(" .film-list, .film-ring { animation: none; }") add(" .film-ring { opacity: 0; }") add(" }") add("</style>") add('<defs><clipPath id="film-view"><rect x="0" y="%.1f" width="%.0f" ' - line 441
'height="%.1f"/></clipPath></defs>' % (FILM_TOP, FILM_W, view_h)) add('<rect x="0" y="0" width="%.0f" height="%.0f" rx="10" ' 'fill="var(--bg-inset, %s)"/>' % (FILM_W, FILM_H, colours["bg-inset"])) add('<text x="%.1f" y="26" - line 441
font-family="%s" font-size="13" ' 'fill="var(--fg, %s)">%d items finished</text>' % (FILM_PAD, docs.MONO, colours["fg"], len(done))) add('<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" stroke="var(--border, %s)"/>' - line 481
% (FILM_TOP - 8, FILM_W, FILM_TOP - 8, colours["border"])) add('<g clip-path="url(#film-view)">') add('<g class="film-list">') for number, lines, top in rows: base = FILM_TOP + 12.0 + top add('<rect x="%.1f" y="%.1f" width="20" height="12" - line 481
rx="3" ' 'fill="var(--ok, %s)"/>' % (FILM_PAD, base - 9.5, colours["ok"])) add('<text x="%.1f" y="%.1f" font-family="%s" font-size="9" ' 'fill="var(--bg-inset, %s)" text-anchor="middle">%d</text>' % (FILM_PAD + 10, base, docs.MONO, - line 481
colours["bg-inset"], number)) for index, line in enumerate(lines): add('<text x="%.1f" y="%.1f" font-family="%s" font-size="12" ' 'fill="var(--%s, %s)">%s</text>' % (FILM_PAD + 28, base + index * FILM_ROW, docs.MONO, "fg" if index == 0 - line 481
else "muted", colours["fg"] if index == 0 else colours["muted"], docs.esc(line))) add("</g>") add("</g>") # The countdown, over the top right, outside the clip so it never scrolls. add('<g class="film-ring" opacity="0">') add('<circle - line 481
cx="%.1f" cy="24" r="%.1f" fill="none" ' 'stroke="var(--border, %s)" stroke-width="3"/>' % (FILM_W - 26, ring_r, colours["border"])) add('<circle cx="%.1f" cy="24" r="%.1f" fill="none" ' 'stroke="var(--accent, %s)" stroke-width="3" - line 481
stroke-linecap="round" ' 'stroke-dasharray="%.2f" stroke-dashoffset="%.2f" ' 'transform="rotate(-90 %.1f 24)"/>' % (FILM_W - 26, ring_r, colours["accent"], circumference, circumference, FILM_W - 26)) add("</g>") add("</svg>") return - line 481
"\n".join(out) + "\n" def wrap(text, columns): """Prose as lines no wider than `columns`, broken on words.""" out = [] - line 521
line = "" for word in text.split(): candidate = word if not line else line + " " + word if len(candidate) > columns and line: out.append(line) line = word else: line = candidate if line: out.append(line) return out or [""] # --- the page - line 521
---------------------------------------------------------------- def item_html(title, detail): """An item's name in bold and its explanation after it.""" if not title: return docs.inline_html(detail) if not detail: return - line 521
"<strong>%s</strong>" % docs.inline_html(title) return '<strong>%s</strong> <span class="item-detail">%s</span>' % ( docs.inline_html(title), docs.inline_html(detail)) def item_entry(row): """One list entry, addressable on its own. The - line 521
number is the link. A roadmap item somebody wants to point at is the unit of conversation about this project, and until now the only way to send one was to send the whole page and say which paragraph. """ title, detail = - line 521
split_item(row["item"]) return ('<li id="m%d"><a class="item-link" href="#m%d" ' 'aria-label="link to item %d">%d</a> %s</li>' % (row["number"], row["number"], row["number"], row["number"], item_html(title, detail))) def body(colours, - line 521
groups, counts, generated_at, prose): - line 561
"""The page under the picture: the same items, as readable rows.""" total = sum(counts.values()) done = counts.get("done", 0) out = [] add = out.append # No lede here: `split.page` has already written one from the description, # and two of - line 561
them in a row is the shape of a page nobody proofread. add('<div class="diagram">') add(graphic(colours, groups, counts).rstrip("\n")) add("</div>") add('<p style="color:var(--muted);font-size:13px">%d of %d are ' 'built, tested, - line 561
documented and in <code>main</code>. ' '<strong>Area is not progress.</strong> Every square is the same size ' 'and one item is not one day: the first is the whole signal engine, ' 'and another is a page of questions. The estimates below - line 561
are the ' 'closest thing to an answer, and they are estimates.</p>' % (done, total)) add('<h2 id="film">Everything that is finished, in one go</h2>') add('<p>A little under half a minute. It scrolls what is done, waits four ' 'seconds with - line 561
a countdown in the corner, and starts again.</p>') add('<div class="diagram">') add(film(colours, [row for _, rows in groups for row in rows if row["status"] == "done"]).rstrip("\n")) add("</div>") add('<p - line 561
style="color:var(--muted);font-size:13px">An animation rather than ' 'a video file, and that is a decision rather than a shortcut. This ' 'project ships no codec and does not bundle <code>ffmpeg</code>, and ' 'the rule it already follows - line 561
for video is to render here and always ' 'produce something that needs nothing else installed. An encoded file ' 'would also be a committed binary whose bytes depend on which build of ' 'which encoder made it, so it could not be - line 561
regenerated and compared ' 'the way every other picture here is. If you want a file, ' '<code>ffmpeg -i roadmap-film.svg roadmap.mp4</code> will make you one ' 'from this.</p>') add('<h2 id="what-is-left">What is left, and roughly what it - line 561
takes</h2>') add('<p>Working days, one person, no interruptions, so the calendar will be ' - line 601
'longer than the sum. In the order it is expected to be done.</p>') add('<table><thead><tr><th>#</th><th>Marker</th><th>State</th>' "<th>Estimate</th></tr></thead><tbody>") outstanding = [row for _, rows in groups for row in rows if - line 601
row["status"] in ("next", "planned")] for row in outstanding: title, detail = split_item(row["item"]) add('<tr id="m%d"><td><a class="item-link" href="#m%d" ' 'aria-label="link to item %d">%d</a></td><td>%s</td><td>%s</td>' - line 601
"<td>%s</td></tr>" % (row["number"], row["number"], row["number"], row["number"], item_html(title, detail), docs.esc(row["status"]), docs.esc(row["estimate"] or "not estimated"))) add("</tbody></table>") # Written for the case where there - line 601
are none, because there are none now # and a heading over an empty list reads as a page that failed to load. blocked = [r for _, rows in groups for r in rows if r["status"] == "blocked"] add('<h2 id="blocked">Blocked, and why that is not - line 601
the same as late</h2>') if blocked: add('<p>These are waiting on a decision or on somebody else’s rules ' 'rather than on effort. Working harder does not move them, and ' '<a href="https://github.com/%s/blob/%s/ROADMAP.md">the - line 601
roadmap</a> ' 'says what each one is waiting for.</p>' % (docs.REPO, docs.REF)) add('<ul class="items">') for row in blocked: add(item_entry(row)) add("</ul>") else: add('<p>Nothing is blocked. Five items were, some of them for months, ' - line 601
'each waiting on a decision rather than on effort, and they have ' 'been taken off rather than left to make the list look busy. ' '<a href="https://github.com/%s/blob/%s/ROADMAP.md">The roadmap</a> ' 'says what each one was, what it was - line 601
waiting for, and why the ' 'reasoning is kept even though the item is gone.</p>' % (docs.REPO, docs.REF)) # Grouped by the section it is written under, with the paragraphs that # section opens with. One flat list of a hundred items answers - line 601
"how much is # done" and nothing else; the sections are where the reasoning is, and - line 641
# dropping them was dropping the argument and keeping the score. add('<h2 id="done">Everything that is finished</h2>') add('<p>In the order the roadmap lists it, under the heading it was asked ' 'for. Every item links to itself, so one can - line 641
be sent to somebody on ' 'its own.</p>') for title, rows in groups: finished = [row for row in rows if row["status"] == "done"] if not finished: continue slug = anchor(title) add('<h3 id="%s">%s <a class="item-link" href="#%s" ' - line 641
'aria-label="link to this section">#</a></h3>' % (slug, docs.esc(title), slug)) for paragraph in prose.get(title, []): add("<p>%s</p>" % docs.inline_html(paragraph.replace("\n", " "))) add('<ul class="items">') for row in finished: - line 641
add(item_entry(row)) add("</ul>") add('<p style="color:var(--muted);font-size:13px">Generated from ' '<code>ROADMAP.md</code> at commit time by ' '<code>tools/site/roadmap.py</code>. %s</p>' % docs.esc(generated_at)) return out def - line 641
build(root): colours = docs.palette(root) text = read(os.path.join(root, "ROADMAP.md")) rows = items(text) if not rows: raise SystemExit( "no roadmap items were found in ROADMAP.md.\n" " The tables are parsed by their shape: a number, an - line 641
item, a\n" " bold status. If that shape changed, fix ROW in this file rather\n" " than publishing an empty page.") groups = [] for row in rows: if not groups or groups[-1][0] != row["section"]: groups.append((row["section"], [])) - line 681
groups[-1][1].append(row) counts = {} for row in rows: counts[row["status"]] = counts.get(row["status"], 0) + 1 prose = prose_for(text, [(m.start(), m.group("title")) for m in HEADING.finditer(text)]) note = ("%d items across %d sections." - line 681
% (len(rows), len(groups))) drawing = graphic(colours, groups, counts) index_html = read(os.path.join(root, "website", "index.html")) parts = split.shell(index_html) page = split.page( root, parts, "roadmap", "Roadmap", "What is built, - line 681
what is coming, and roughly when. Generated from ROADMAP.md.", "\n".join(body(colours, groups, counts, note, prose)), # This page is written for itself, so its `#anchor` links point at # its own headings rather than at sections of the - line 681
front page. relink_body=False, ) # `split.page` says the section is also on the front page, which is true of # a section and not of this. Replaced rather than parameterised, because # every other caller of that function wants the sentence - line 681
it writes. page = page.replace( '<p style="color:var(--muted)">This section is also part of ' '<a href="index.html">the front page</a>, where it sits in context ' 'with the rest.</p>', '<p style="color:var(--muted)">The same information as - line 681
' '<a href="https://github.com/%s/blob/%s/ROADMAP.md">ROADMAP.md</a>, ' 'which is where it is written and where the reasoning behind each one ' 'lives.</p>' % (docs.REPO, docs.REF)) page = page.replace("GENERATED by tools/site/split.py - line 681
from website/index.html.", "%s from ROADMAP.md." % MARKER) moving = film(colours, [row for row in rows if row["status"] == "done"]) return { "assets/roadmap.svg": drawing, "website/assets/roadmap.svg": drawing, - line 721
"assets/roadmap-film.svg": moving, "website/assets/roadmap-film.svg": moving, "website/roadmap.html": page, } def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--check", - line 721
action="store_true", help="fail if the page or the picture is out of date") args = parser.parse_args() files = seo.finished(build(ROOT)) if args.check: stale = [] for rel, text in sorted(files.items()): path = os.path.join(ROOT, - line 721
rel.replace("/", os.sep)) if not os.path.exists(path) or read(path) != text: stale.append(rel) if stale: for rel in stale: print(" out of date: %s" % rel) print("\n Run: python tools/site/roadmap.py") return 1 print("the roadmap page and - line 721
its picture match ROADMAP.md") return 0 for rel, text in sorted(files.items()): path = os.path.join(ROOT, rel.replace("/", os.sep)) os.makedirs(os.path.dirname(path), exist_ok=True) with io.open(path, "w", encoding="utf-8", newline="\n") - line 721
as handle: handle.write(text) print(" wrote %s" % rel) return 0 if __name__ == "__main__": sys.exit(main())
tools/site/seo.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Every page says where it lives, and search engines are told they may read it. python tools/site/seo.py # write robots.txt, the sitemap and # the head tags on every page - line 1
python tools/site/seo.py --check # fail if any of it is out of date # Why this is generated rather than written The Open Graph tags were typed into each page by hand, which produced three things worth fixing. `og:image` was - line 1
`assets/banner.png`, a **relative** URL, and the crawlers that read these tags do not resolve relative URLs: every link to this site, in every chat application and every social network, showed no picture at all. There was no `og:url` and - line 1
no canonical link, so a page reached at two addresses is two pages as far as an index is concerned. And two pages, `404.html` and `wiki.html`, had no tags of any kind. So the tags are built from what the page already says. The title comes - line 1
from its `<title>`, the description from its `<meta name="description">`, and the address from where the file is. None of it is a second copy of anything, and a page cannot be added without them. # What is told to a crawler `robots.txt` - line 1
allows everything and names the sitemap. `sitemap.xml` lists every page on the site, generated by walking it, so a page that is added is listed and a page that is deleted stops being listed. That is the whole of what a site can do from its - line 1
own side. It asks to be read; it cannot ask to be ranked. What decides whether a search for "veilvoice" reaches this site is whether anything else links to it, which is not something a file in this repository can arrange. """ import html - line 1
import io import os import re import sys - line 41
HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) SITE = os.path.join(ROOT, "website") def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() def - line 41
repository(): """The owner and name of this project, from the workspace manifest. `Cargo.toml` is where this project says where it lives, so the address of its website is worked out from that rather than typed here. A fork that changes the - line 41
manifest gets its own addresses without editing this file. """ manifest = read(os.path.join(ROOT, "Cargo.toml")) found = re.search(r'^repository\s*=\s*"https://github\.com/([^/"]+)/([^/"]+)"', manifest, re.M) if not found: raise - line 41
SystemExit( "Cargo.toml has no `repository = \"https://github.com/<owner>/<name>\"`, " "so the address of the website cannot be derived from it") return found.group(1), found.group(2) # Where the site is published: GitHub Pages serves a - line 41
project site at # `https://<owner>.github.io/<repo>/`. OWNER, NAME = repository() BASE = "https://%s.github.io/%s/" % (OWNER, NAME) # The picture a link to this site shows. It is the banner the front page draws # in CSS, rendered by - line 41
`assets/generate.py`, at 1280x640: GitHub's social card # size exactly, and the 2:1 that Twitter's large-image card asks for. PREVIEW = "assets/banner.png" PREVIEW_W, PREVIEW_H = 1280, 640 SITE_NAME = "VeilVoice" - line 81
OPEN = "<!-- Written by tools/site/seo.py. Edit that, not this. -->" CLOSE = "<!-- end of the generated addresses -->" BLOCK = re.compile(re.escape(OPEN) + r".*?" + re.escape(CLOSE) + r"\n?", re.S) # The tags this tool owns. Any of these - line 81
left loose in a page is one it wrote # before this existed, and is removed rather than left to contradict the block. OWNED = re.compile( r'^[ \t]*<(?:meta\s+(?:property|name)="(?:og:[^"]*|twitter:[^"]*)"[^>]*' - line 81
r'|link\s+rel="canonical"[^>]*)>[ \t]*\n', re.M, ) TITLE = re.compile(r"<title>(.*?)</title>", re.S) DESCRIPTION = re.compile(r'<meta name="description" content="([^"]*)">') def write(path, text): with io.open(path, "w", encoding="utf-8", - line 81
newline="\n") as handle: handle.write(text) def pages(): """Every page of the site, as a path relative to `website/`, sorted. Walked rather than listed, so a page that is added is covered and a page that is deleted stops being claimed. """ - line 81
out = [] for where, directories, files in os.walk(SITE): directories.sort() for name in sorted(files): if name.endswith(".html"): out.append(os.path.relpath(os.path.join(where, name), SITE).replace(os.sep, "/")) return sorted(out) def - line 81
address(relative): """The canonical URL of a page. - line 121
`index.html` is the directory it is in: a server serves the same bytes for both, and telling an index they are two pages is how a site competes with itself. """ if relative == "index.html": return BASE if relative.endswith("/index.html"): - line 121
return BASE + relative[: -len("index.html")] return BASE + relative def up_to_root(relative): """The `../` prefix that reaches `website/` from this page.""" return "../" * relative.count("/") def escape(text): """Text safe inside a - line 121
`content="..."` attribute. The titles these are taken from are already HTML, so `·` has to be read back to the character it stands for before being written out again. Escaping without that step produced `&middot;`, which is what - line 121
the no-JavaScript page's preview title said before this existed. """ plain = html.unescape(text) return (plain.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def tags(relative, title, description): - line 121
"""The block of addresses and preview tags for one page.""" url = address(relative) image = BASE + PREVIEW kind = "website" if relative in ("index.html", "nojs/index.html") else "article" lines = [ OPEN, '<link rel="canonical" href="%s">' - line 121
% url, '<meta property="og:type" content="%s">' % kind, '<meta property="og:site_name" content="%s">' % SITE_NAME, '<meta property="og:url" content="%s">' % url, - line 161
'<meta property="og:title" content="%s">' % escape(title), '<meta property="og:description" content="%s">' % escape(description), '<meta property="og:image" content="%s">' % image, '<meta property="og:image:width" content="%d">' % - line 161
PREVIEW_W, '<meta property="og:image:height" content="%d">' % PREVIEW_H, '<meta property="og:image:alt" content="VeilVoice: irreversible voice ' 'de-identification">', '<meta name="twitter:card" content="summary_large_image">', '<meta - line 161
name="twitter:title" content="%s">' % escape(title), '<meta name="twitter:description" content="%s">' % escape(description), '<meta name="twitter:image" content="%s">' % image, CLOSE, ] return "\n".join(lines) + "\n" def head_of(relative, - line 161
text): """The page with its address block current, or `None` if it has no head.""" title = TITLE.search(text) description = DESCRIPTION.search(text) if not title or "</head>" not in text: return None block = tags( relative, " - line 161
".join(title.group(1).split()), description.group(1) if description else "", ) text = BLOCK.sub("", text) text = OWNED.sub("", text) # Immediately before `</head>`, which every page has and no generator # writes anything after. return - line 161
text.replace("</head>", block + "</head>", 1) def finish(relative, text): """A page with its address block current. Every generator that writes a page calls this on its own output, so the - line 201
tags are part of what the generator produces and its `--check` compares. A page with no `<head>` comes back untouched. """ updated = head_of(relative, text) return text if updated is None else updated def finished(files): """Every page in - line 201
a generator's `{path: text}` with its address block current. The generators build a dictionary of what they are about to write and then either write it or compare it, so this is one call in each of them rather than one at every place a - line 201
page is produced. """ out = {} for relative, text in files.items(): if relative.startswith("website/") and relative.endswith(".html"): out[relative] = finish(relative[len("website/"):], text) else: out[relative] = text return out def - line 201
robots(): return ( "# VeilVoice. Everything here is meant to be read.\n" "User-agent: *\n" "Allow: /\n" "\n" "Sitemap: %ssitemap.xml\n" % BASE ) def sitemap(relatives): lines = [ '<?xml version="1.0" encoding="UTF-8"?>', '<urlset - line 201
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ] for relative in relatives: lines.append(" <url><loc>%s</loc></url>" % escape(address(relative))) - line 241
lines.append("</urlset>") return "\n".join(lines) + "\n" def build(): """Every file this tool owns, as {path relative to the repository: text}.""" out = {} listed = [] for relative in pages(): path = os.path.join(SITE, relative) updated = - line 241
head_of(relative, read(path)) if updated is None: continue listed.append(relative) out["website/" + relative] = updated out["website/robots.txt"] = robots() out["website/sitemap.xml"] = sitemap(listed) return out def main(): check = - line 241
"--check" in sys.argv files = build() problems = [] for relative, text in sorted(files.items()): path = os.path.join(ROOT, relative) current = read(path) if os.path.exists(path) else None if current == text: continue if check: - line 241
problems.append(relative) else: write(path, text) if problems: print("these are out of date; run tools/site/seo.py:") for relative in problems[:20]: print(" %s" % relative) if len(problems) > 20: - line 281
print(" ... and %d more" % (len(problems) - 20)) return 1 pagecount = len(files) - 2 if check: print(" %d pages, robots.txt and the sitemap all current" % pagecount) else: print(" wrote robots.txt, sitemap.xml and the addresses on %d - line 281
pages" % pagecount) return 0 if __name__ == "__main__": sys.exit(main())
tools/site/serve.py
- line 1
#!/usr/bin/env python3 """Serve the VeilVoice website locally, exactly as GitHub Pages serves it. python3 tools/site/serve.py # http://localhost:8000 python3 tools/site/serve.py --port 9000 python3 tools/site/serve.py --check # start, - line 1
fetch every page, stop # Why this exists The site at https://tilas01.github.io/veilvoice/ is the front door: the downloads, the fingerprint to check a release against, the whole argument for trusting any of this. If the repository ever - line 1
went down, or GitHub Pages did, that front door would be gone -- and it is the one page a user most needs precisely when something has gone wrong. So the site is nothing but static files in `website/`, and this serves them the way Pages - line 1
does: as a document root, with no build step, no framework and no server-side anything. Anybody who cloned the repository can read the whole site offline with one command, and `deploy/nginx.conf` beside this file does the same thing under - line 1
a real web server for anybody who wants to host it properly. # It is also the audit surface During development this is where the site is looked at before it is pushed. Every generator writes into `website/`; this is how you see the result - line 1
the way a visitor will, rather than trusting that the HTML a tool emitted looks right. # What it does not pretend to be It is `http.server` with three narrow additions -- the correct content types, `Cache-Control: no-store` so a stale page - line 1
is never served from a previous run, and a readable directory refusal. It is not hardened for the public internet; `deploy/nginx.conf` is the answer for that, and says so. SPDX-License-Identifier: GPL-3.0-or-later """ from __future__ - line 1
import annotations import argparse - line 41
import contextlib import functools import http.server import socket import sys import threading import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SITE = ROOT / "website" class - line 41
Handler(http.server.SimpleHTTPRequestHandler): """Static files, with the content types GitHub Pages sends. `SimpleHTTPRequestHandler` guesses types from `mimetypes`, whose answers depend on the machine's `/etc/mime.types` and so are not - line 41
the same everywhere. Pinning the few that matter means the page behaves the same on a stripped container as on a full desktop -- which is the whole promise of a static site, kept rather than assumed. """ extensions_map = { - line 41
**http.server.SimpleHTTPRequestHandler.extensions_map, ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", - line 41
".png": "image/png", ".webp": "image/webp", ".ico": "image/x-icon", ".woff2": "font/woff2", ".xml": "application/xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".asc": "text/plain; charset=utf-8", } def end_headers(self) -> None: - line 81
# Never serve a page this process cached from a previous run: the point # of looking at the local site during development is to see the file as # it is now, not as it was before the last generator ran. self.send_header("Cache-Control", - line 81
"no-store") super().end_headers() def log_message(self, fmt: str, *args) -> None: # One quiet line per request, to stderr, so `--check` output stays the # transcript of what was fetched rather than a wall of default logging. - line 81
sys.stderr.write(" %s\n" % (fmt % args)) def make_server(port: int) -> http.server.ThreadingHTTPServer: if not (SITE / "index.html").exists(): raise SystemExit( f"no website found at {SITE}. Run the generators first " - line 81
f"(tools/site/split.py and the rest), or check you are in the repo." ) handler = functools.partial(Handler, directory=str(SITE)) try: return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) except OSError as exc: raise - line 81
SystemExit(f"could not bind port {port}: {exc}") from exc def pages() -> list[str]: """Every HTML page, relative to the site root.""" return sorted( "/" + str(p.relative_to(SITE)) for p in SITE.rglob("*.html") ) def check(port: int) -> - line 81
int: """Start the server, fetch every page, and report any that do not load. This is what CI and `tools/verify.py` can call: it proves the site is servable and every page returns 200, without a person watching a browser. """ server = - line 81
make_server(port) thread = threading.Thread(target=server.serve_forever, daemon=True) - line 121
thread.start() base = f"http://127.0.0.1:{server.server_address[1]}" broken: list[str] = [] try: for page in pages(): try: with urllib.request.urlopen(base + page, timeout=5) as response: if response.status != 200: broken.append(f"{page}: - line 121
HTTP {response.status}") except Exception as exc: # noqa: BLE001 -- report, do not raise broken.append(f"{page}: {exc}") finally: server.shutdown() thread.join(timeout=2) if broken: for line in broken: print(f" {line}") - line 121
print(f"\n{len(broken)} page(s) did not load.") return 1 print(f"served {len(pages())} pages, every one returned 200") return 0 def serve(port: int) -> int: server = make_server(port) host, bound = server.server_address url = - line 121
f"http://localhost:{bound}" print(f"VeilVoice site at {url}") print(f" serving {SITE.relative_to(ROOT)}/ exactly as GitHub Pages does") print(" Ctrl-C to stop") with contextlib.suppress(KeyboardInterrupt): server.serve_forever() - line 121
server.server_close() print("\nstopped") return 0 def free_port() -> int: with socket.socket() as s: - line 161
s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--port", type=int, default=8000, help="default 8000") parser.add_argument( "--check", - line 161
action="store_true", help="start, fetch every page, report failures, exit", ) args = parser.parse_args() if args.check: # A free port, so a --check run never collides with a browser someone # left open on the default one. return - line 161
check(free_port()) return serve(args.port) if __name__ == "__main__": sys.exit(main())
tools/site/split.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Give every section of the front page its own address, without a second copy. python tools/site/split.py # write the section pages python tools/site/split.py --check # - line 1
verify they are current # What this does, and the one thing it refuses to do `website/index.html` has seven sections. Each is worth linking to directly -- "read the verification steps" is a thing people send each other, and a fragment into - line 1
a long page arrives without a title, without a heading of its own, and scrolled to somewhere the reader did not choose. So each section also gets a real page: its own `<title>`, its own `<h1>`, and a URL that means something. **The section - line 1
pages are derived from `index.html`, not written beside it.** Two hand-maintained copies of the same prose is finding F-41 waiting to happen -- website assets drifting from the generator that produced them, silently, with nothing able to - line 1
tell. Here the front page is the source and these are its output, so they cannot disagree: `--check` regenerates into memory and compares, and CI fails if they have parted company. # Every published fragment still works, and that is not - line 1
negotiable `index.html` is **not modified**. Every `#what`, `#download`, `#verify`, `#crypto` link that has ever been published still lands exactly where it did: the sections are all still on the front page, in order, with their ids. That - line 1
matters more than the tidiness of removing them. Thirteen links to `#verify` exist in this repository alone, and a fragment cannot be redirected server-side -- browsers do not send it -- so a fragment whose target has moved lands the - line 1
reader at the top of a page with no explanation. The only safe split is an additive one. Pure standard library. No build step, no dependencies. """ import io - line 41
import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import seo # noqa: E402 the addresses and preview tags every page carries # The sections worth their own page, and what to call each one. # # - line 41
`repo` is deliberately absent: it is a live panel that fetches from GitHub and # means nothing on its own, and `demo` is an illustration of the section above # it rather than a destination. SECTIONS = [ ("what", "What VeilVoice does", "The - line 41
eight things it does, and the honest scope of each."), ("download", "Download VeilVoice", "Builds for ten platforms, signed, with verification instructions."), ("guide", "How to use VeilVoice", "A walkthrough: anonymise a file, scramble a - line 41
microphone, verify a download."), ("verify", "Verify a download", "Check that what you downloaded is what was published, in your browser or with the portable verifier."), ("crypto", "Security and cryptography", "The primitives, the threat - line 41
model, and what the app lock is and is not."), ] SECTION_RE = re.compile( r'<section id="(?P<id>[a-z-]+)"[^>]*>(?P<body>.*?)</section>', re.S) def repo_root(): here = os.path.dirname(os.path.abspath(__file__)) return - line 41
os.path.abspath(os.path.join(here, "..", "..")) def read(path): with io.open(path, encoding="utf-8") as handle: return handle.read() SCRIPT_TAG_RE = re.compile(r'<script src="js/(?P<file>[^"]+)"[^>]*></script>') # `function byId(id) { - line 41
return document.getElementById(id); }` -- one module # wraps the lookup, and an id reached through the wrapper is still an id this # has to see. ALIAS_RE = re.compile( - line 81
r"function (\w+)\(\w+\)\s*\{\s*return document\.getElementById\(\w+\);\s*\}") LOOKUP_RE = re.compile(r'getElementById\("([^"]+)"\)|querySelector(?:All)?\("#([^"]+)"\)') HANDLER = 'document.addEventListener("DOMContentLoaded"' BOUND_RE = - line 81
re.compile(r'var (\w+) = document\.getElementById\("([^"]+)"\)') GUARD_RE = re.compile(r"if \(([^)]*!\w+[^)]*)\)\s*\{\s*return;\s*\}") def ids_used(js): """Every element id a script reaches for.""" found = set() for direct, fragment in - line 81
LOOKUP_RE.findall(js): found.add(direct or fragment) for alias in ALIAS_RE.findall(js): found.update(re.findall(r'\b%s\("([^"]+)"\)' % alias, js)) return found def activation_ids(js): """The ids a script will not start without, read out of - line 81
the script itself. Every module here has the same shape: a `DOMContentLoaded` handler looks its elements up and returns immediately if the ones it cannot work without are absent. Those ids, and not the full set it touches, are what decides - line 81
whether a script has a job on a given page. The distinction is the whole point. `repo.js` touches `#asset-list`, which the download page carries, but it will not run without `#load-repo`, which lives on the front page only -- so the - line 81
download page needs the markup and not the script. `verify.js` will not run without `#drop` and `#file`, and the verify page has both, so that page needs the script. Reading the guard tells those two apart; counting ids does not. Returns - line 81
`(ids, "all")` when a guard was found -- a page needs the script only if it has all of them -- or `(ids, "any")` when there is no guard to read, which is the cautious answer rather than the precise one. """ start = js.find(HANDLER) if - line 81
start < 0: return ids_used(js), "any" body = js[start:] - line 121
for guard in GUARD_RE.finditer(body): bound = {name: element for name, element in BOUND_RE.findall(body[:guard.start()])} names = re.findall(r"!(\w+)", guard.group(1)) # A guard naming something this handler did not look up is a test of # - line 121
something else -- a parsed value, a browser capability -- and says # nothing about which page the script belongs on. if names and all(name in bound for name in names): return {bound[name] for name in names}, "all" return ids_used(js), - line 121
"any" def globals_provided(js): return set(re.findall(r"window\.([A-Z]\w*) = ", js)) def globals_used(js): return set(re.findall(r"window\.([A-Z]\w*)\b", js)) - globals_provided(js) def shell(index_html): """The parts of the front page - line 121
every section page reuses. Taken from `index.html` rather than written again, so a change to the header, the theme picker or the footer reaches these pages without anybody remembering to make it twice. `scripts` is the ones `index.html` - line 121
loads at the *end of its body*. The ones in `<head>` arrive inside the copied head and need no thought. These do not, and until finding F-111 they simply never arrived: `verify.html` carried the whole in-browser verifier -- the drop zone, - line 121
the hash field, the verdict line -- and loaded none of the code behind it. """ head_start = index_html.index("<head>") head_end = index_html.index("</head>") + len("</head>") header_start = index_html.index('<header class="top">') - line 121
header_end = index_html.index("</header>") + len("</header>") footer_start = index_html.index("<footer") footer_end = index_html.index("</footer>") + len("</footer>") scripts = [(m.group("file"), m.group(0)) - line 161
for m in SCRIPT_TAG_RE.finditer(index_html, head_end)] return { "head": index_html[head_start:head_end], "header": index_html[header_start:header_end], "footer": index_html[footer_start:footer_end], "scripts": scripts, } def - line 161
scripts_for(root, page_html, candidates): """Which of the front page's body scripts this page actually needs. Decided by reading each script and this page, so a section that grows a feature gets the code for it without anybody maintaining - line 161
a table here -- the table is what would go stale, and a stale one is the finding this replaces. """ sources = {} for name, _tag in candidates: sources[name] = read(os.path.join(root, "website", "js", name)) present = - line 161
set(re.findall(r'id="([^"]+)"', page_html)) wanted = set() for name, _tag in candidates: ids, how = activation_ids(sources[name]) if not ids: continue if (ids <= present) if how == "all" else bool(ids & present): wanted.add(name) # A - line 161
script that leans on another script's global needs it loaded first. # `repo.js` renders the README through `window.MD`, which is `markdown.js`. for _ in range(len(candidates)): needed = set() for name in wanted: for symbol in - line 161
globals_used(sources[name]): for other, _tag in candidates: if symbol in globals_provided(sources[other]): needed.add(other) if needed <= wanted: - line 201
break wanted |= needed return [tag for name, tag in candidates if name in wanted] def page(root, parts, section_id, title, description, body, relink_body=True): """One section, as a complete document. `relink_body` is for the callers that - line 201
are not sections. A section's body came out of `index.html` and its `#anchor` links point at *other* sections, which exist on the front page and not on this one, so they are rewritten. A page written for itself, like the questions page - line 201
with its own contents list, has `#anchor` links that point at its own headings, and rewriting those sends every one of them to the front page where they land nowhere. Found by `source.test.js`, which checks that a fragment naming another - line 201
page resolves on it. Twenty of them did not. The header and the footer are relinked either way: the navigation is shared from `index.html` and is full of `#what` and `#download`, whoever is calling. """ head = parts["head"] head = - line 201
re.sub(r"<title>.*?</title>", "<title>%s · VeilVoice</title>" % title, head, count=1, flags=re.S) head = re.sub(r'<meta name="description" content="[^"]*">', '<meta name="description" content="%s">' % description, head, count=1) # - line 201
Any `#other-section` link resolves on the front page and nowhere else, so # every one is rewritten to `index.html#other-section`. A split that # quietly breaks the internal links has traded one problem for a worse one, # and `html.test.js` - line 201
catches it: it checks that every fragment on a page # has a target on that page. # # The **header** needs this as much as the body does -- the nav is shared # from index.html and is full of `#what`, `#download`, `#verify`. Missing # that - line 201
was the first thing the suite reported. def relink(text): - line 241
return re.sub(r'href="#([a-z-]+)"', r'href="index.html#\1"', text) if relink_body: body = relink(body) header = relink(parts["header"]) footer = relink(parts["footer"]) document = "\n".join([ "<!doctype html>", "<!-- - line 241
SPDX-License-Identifier: GPL-3.0-or-later -->", "<!-- GENERATED by tools/site/split.py from website/index.html.", " Do not edit: edit the section in index.html and run the tool.", " Verified in CI with `python tools/site/split.py --check`. - line 241
-->", '<html lang="en" data-theme="tokyo-night">', head, "<body>", header, '<main class="wrap" style="padding-top:30px">', "<h1>%s</h1>" % title, '<p class="lede">%s</p>' % description, '<p style="color:var(--muted)">' 'This section is - line 241
also part of <a href="index.html">the front page</a>, ' 'where it sits in context with the rest.</p>', '<section id="%s">' % section_id, body, "</section>", "</main>", footer, "%SCRIPTS%", "</body>", "</html>", "", ]) # Chosen from the - line 241
finished document, so the header and the footer count # too: whatever ends up on the page decides what the page has to load. needed = scripts_for(root, document, parts["scripts"]) return document.replace( "%SCRIPTS%\n", "".join(tag + "\n" - line 241
for tag in needed)) - line 281
def build(root): index_path = os.path.join(root, "website", "index.html") index_html = read(index_path) parts = shell(index_html) found = {m.group("id"): m.group("body") for m in SECTION_RE.finditer(index_html)} missing = [i for i, _, _ in - line 281
SECTIONS if i not in found] if missing: raise SystemExit( "website/index.html no longer has these sections: %s\n" "Either they were renamed -- in which case update SECTIONS here and\n" "keep the old ids as anchors so published links - line 281
survive -- or the\n" "parser needs fixing." % ", ".join(missing)) out = {} for section_id, title, description in SECTIONS: body = found[section_id] # The section's own <h2> is replaced by the page's <h1>, so it would # otherwise appear - line 281
twice. body = re.sub(r"<h2[^>]*>.*?</h2>", "", body, count=1, flags=re.S) out["website/%s.html" % section_id] = page( root, parts, section_id, title, description, body.strip()) return out def main(): root = repo_root() files = - line 281
seo.finished(build(root)) check = "--check" in sys.argv problems = [] for rel, text in sorted(files.items()): path = os.path.join(root, rel.replace("/", os.sep)) if check: try: with io.open(path, encoding="utf-8", newline="") as handle: - line 281
actual = handle.read() except OSError: problems.append("%s: missing" % rel) - line 321
continue if actual.replace("\r\n", "\n") != text: problems.append("%s: differs from index.html" % rel) else: with io.open(path, "w", encoding="utf-8", newline="\n") as handle: handle.write(text) if check: if problems: for line in problems: - line 321
print(" MISMATCH %s" % line) print() print("Run 'python tools/site/split.py' and commit the result.") return 1 print(" section pages match index.html (%d pages)" % len(files)) return 0 print(" wrote %d section pages from - line 321
website/index.html" % len(files)) return 0 if __name__ == "__main__": sys.exit(main())
tools/verify.py
- line 1
#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later """Regenerate everything generated, then run every check, in the right order. python tools/verify.py # regenerate, then check python tools/verify.py --check # check only, - line 1
exactly as CI does # Why this exists Four generators write into this tree, and three of them read files the others write. Run them in the wrong order, or edit a source file after running them, and the committed output is one change behind - line 1
-- which is not a visible fault. It is a green local run and a red CI run ten minutes later. That happened twice in one afternoon, both times the same way: edit a source file, regenerate, edit another source file, commit. The search index - line 1
is built from *every tracked file*, so it goes stale when anything at all changes after it is written. The order below is the dependency order, and it is the whole point of the file: 1. `assets/generate.py` -- artwork, from nothing but - line 1
itself 2. `tools/docs/generate.py` -- reads the Rust doc comments, writes 371 files 3. `tools/search-index/generate.py` -- reads *everything*, so it goes last 4. the checks, which must all see the same tree Anything that regenerates has to - line 1
be staged before the index runs, because the index walks `git ls-files` and a file git has never heard of is not in it. """ import os import subprocess import sys def repo_root(): here = os.path.dirname(os.path.abspath(__file__)) return - line 1
os.path.abspath(os.path.join(here, "..")) - line 41
def run(root, label, command, capture=True): """Run one step. Returns (ok, output). Cargo steps are run with `RUSTFLAGS=-D warnings`, because that is what CI sets and a local check that does not match CI is worse than no local check: it - line 41
passes, and then the push fails ten minutes later for something that was on screen the whole time. Three CI failures in one session came through exactly that gap -- an unused `mut`, a `needless_return`, a dead enum variant -- each a - line 41
warning locally and an error there. """ environment = dict(os.environ) if command and str(command[0]).startswith("cargo"): existing = environment.get("RUSTFLAGS", "") if "-D warnings" not in existing: environment["RUSTFLAGS"] = (existing + - line 41
" -D warnings").strip() try: result = subprocess.run( command, cwd=root, shell=isinstance(command, str), env=environment, stdout=subprocess.PIPE if capture else None, stderr=subprocess.STDOUT if capture else None, ) except OSError as - line 41
error: return False, "%s: could not run (%s)" % (label, error) output = (result.stdout or b"").decode("utf-8", "replace") return result.returncode == 0, output def stage(root): """Stage everything, so the index walk sees newly written - line 41
files. `git ls-files` lists *tracked* files. A generator that has just written a new page leaves it untracked, so the index built immediately afterwards does not contain it -- and CI, regenerating from the committed tree, finds one more - line 41
file and fails with a message about drift that says nothing about why. """ subprocess.run(["git", "add", "-A"], cwd=root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - line 81
GENERATORS = [ # First. It depends on nothing that is generated -- it runs the test suite # and reads Cargo.toml -- and everything after it may quote what it # measures. It was written in last, which put it after the search index: # the - line 81
index walked docs/ before this file had been rewritten and then # disagreed with it, on the very first run. ("measured numbers", [sys.executable, "tools/measured/generate.py"]), ("artwork", [sys.executable, "assets/generate.py"]), # Drawn - line 81
from the command output committed beside them. `--capture`, which # actually runs `veilvoice`, is a separate and manual step: it needs a # build, a machine and a person deciding the output is right. Everything # after that is a pure - line 81
function of those text files, which is why this half # can be regenerated and checked here. # Before the drawings, which mirror the window captures into the website # and record their sizes: a crop after that copy would leave the two out - line 81
of # step, and the size check in `terminal.py --check` is what would notice. ("screenshot borders", [sys.executable, "tools/shots/crop.py"]), # After the border comes off and before the corners go on. A capture is # taken taller than any - line 81
tab needs, so that the longest one is not cut in # half, and this trims each picture back to what it actually contains. # Rounding first would round the corners of a picture that is about to # lose its bottom half. ("screenshot height", - line 81
[sys.executable, "tools/shots/fit.py"]), # After the crop and the fit, never before them: rounding the corners of a # picture that still has a capture border rounds the border. ("screenshot corners", [sys.executable, - line 81
"tools/shots/round.py"]), # After everything that can change a picture's size, because it copies # those sizes into the pages that show them. ("screenshot sizes on the pages", [sys.executable, "tools/shots/attrs.py"]), ("terminal - line 81
drawings", [sys.executable, "tools/shots/terminal.py"]), ("documentation", [sys.executable, "tools/docs/generate.py"]), # After the documentation generator, which owns the wiki directory: # these are per-program views of the user guide and - line 81
land in it too. ("per-program guides", [sys.executable, "tools/docs/guides.py"]), # After the guides, because the wiki's landing page links to them. ("wiki landing and documents", [sys.executable, "tools/docs/wiki.py"]), # The website's - line 81
own source, which `generate.py` does not cover: it reads # Rust doc comments, and these are JavaScript and CSS. Imports the same # module for the palette and the drawing code, so it goes after it. ("website source pages", [sys.executable, - line 81
"tools/docs/sources.py"]), - line 121
# Derived from website/index.html, so it must run after anything that could # edit that file and before the index walks the result. ("section pages", [sys.executable, "tools/site/split.py"]), # After the split, because it borrows that - line 121
tool's header, navigation and # footer from index.html, and before the index, which walks the result. ("roadmap page", [sys.executable, "tools/site/roadmap.py"]), # Before the source pages walk website/js, since this writes one of them. - line 121
("demonstration data", [sys.executable, "tools/site/demo.py"]), ("questions page", [sys.executable, "tools/site/faq.py"]), # After the split, whose header this borrows, and before the index walks it. ("releases page", [sys.executable, - line 121
"tools/site/releases.py"]), # After every generator that writes a page, because it reads each page's # own title and description and writes the addresses from them. Before # the index, which walks the result. ("addresses and the sitemap", - line 121
[sys.executable, "tools/site/seo.py"]), ("search index", [sys.executable, "tools/search-index/generate.py"]), ] CHECKS = [ # First, because a release whose README tells people to download the # previous version is a failure nothing else - line 121
here would notice: the # command works, the verifier passes, and the reader gets the wrong # program. ("every copy of the version agrees with Cargo.toml", [sys.executable, "tools/release/version.py", "--check"]), ("every package installs - line 121
what the workspace builds", [sys.executable, "tools/release/packaging.py"]), ("no state file is written one place and read another", [sys.executable, "tools/audit/state_paths.py"]), # Roadmap item 126. A dependency is a decision, and a - line 121
decision with no sentence # beside it is one nobody can revisit. The day this was added it found # three that no line of code referred to. ("every dependency says what it is for", [sys.executable, "tools/audit/dependencies.py"]), # The - line 121
same question asked of this project's own code rather than of the # code it imports. `dead_code` stops at the crate boundary, so nothing was # watching whether a public item had a caller; five had none, and one of # them was keeping a - line 121
private field and a clone per vault open alive behind # it, which is exactly the shape the compiler cannot report. ("every public item is reached by something", - line 161
[sys.executable, "tools/audit/reachable.py"]), # Three documents list the crates and they listed thirteen, twelve and # thirteen of the twenty-seven. The front page of the website renders the # README, so the shortest of the three was what - line 161
the site said this project # was made of. ("every crate appears in every table that lists the crates", [sys.executable, "tools/audit/crate_tables.py"]), # F-185. A build directory inside the repository is invisible to git, # because - line 161
`.gitignore` matches `target/` at any depth, so it is never # reported and never cleaned. One had been rebuilt by this very tool on # every non-Windows machine and had reached fifteen gigabytes. ("no build output lives outside the one - line 161
directory at the root", [sys.executable, "tools/audit/build_output.py"]), # Roadmap item 151. The mutation campaign itself is weekly, because half an hour # does not fit a per-push job. What fits here is the half that does not need # a - line 161
campaign: every argued-for survivor still points at a real file and a # real line. Those move whenever code above them moves, and without this the # list would quietly point at the wrong lines until the next weekly run. ("every argued-for - line 161
surviving mutant still points at real code", [sys.executable, "tools/mutants/check.py", "--lint"]), # Beside it, and answering the other question. `dependencies.py` asks # whether somebody said why each dependency is here; this asks - line 161
whether # anything is watching it. A manifest outside every Dependabot entry is # unmonitored quietly, which is worse than a dependency with a known # problem, because nobody is looking at it. ("every manifest is covered by a Dependabot - line 161
entry", [sys.executable, "tools/audit/dependabot.py"]), # And the third question about dependencies, which is whether the code # still compiles without the optional ones. Nine of the twelve release # jobs turn `veilvoice-audio`'s `live` - line 161
feature off because `cpal` has no # backend for them, and nothing built that configuration until a module # lost its `#[cfg]` and failed all nine at once. Listed rather than built # here: `--build` is a compile, which belongs in CI beside - line 161
the release # build rather than in a pass somebody runs before every commit. ("every feature selection a release builds is still declared", [sys.executable, "tools/audit/features.py"]), # The check that lets an accepted advisory stay - line 161
accepted: RUSTSEC-2023-0071 # is about RSA private-key operations, and this crate only ever verifies a # signature against a public key. It ran only in CI until now, which meant # the one guard behind a security argument was the one nobody - line 161
could run - line 201
# before pushing. ("no crate reaching pgp performs a private-key operation", [sys.executable, "tools/audit/rsa_usage.py"]), # A de-identifier whose randomness is predictable does not work, and a weak # draw produces output that looks - line 201
exactly like a strong one. Rust puts the # three names that do not promise a cryptographic draw, `thread_rng`, # `SmallRng` and `StdRng`, in the most obvious crate. ("every random draw comes from the OS CSPRNG", [sys.executable, - line 201
"tools/audit/randomness.py"]), # A tag is a label, not a version: whoever owns the action can move it, and # two of the ones here were branches rather than tags. An unpinned action # runs whatever it points at that morning, on a runner - line 201
holding a checkout # and a token. ("every action a workflow runs is pinned to a commit", [sys.executable, "tools/audit/actions.py"]), # F-170. The tag a release publishes must name the commit that was built, # or the reproducibility every - line 201
other check exists to support is a claim # about a different tree than the one somebody would check out. ("the release step tags the commit it built", [sys.executable, "tools/audit/publishing.py"]), ("the app-manifest tooling works", - line 201
[sys.executable, "tools/sign/selftest.py"]), ("artwork matches its generator", [sys.executable, "assets/generate.py", "--check"]), ("no screenshot has a capture border", [sys.executable, "tools/shots/crop.py", "--check"]), ("no screenshot - line 201
has empty space below its content", [sys.executable, "tools/shots/fit.py", "--check"]), ("screenshots have rounded corners", [sys.executable, "tools/shots/round.py", "--check"]), ("every screenshot tag matches its file", [sys.executable, - line 201
"tools/shots/attrs.py", "--check"]), ("terminal drawings match their output", [sys.executable, "tools/shots/terminal.py", "--check"]), # The recorded sessions, re-run and compared. This is the check that would # have caught the - line 201
demonstration inventing the verifier's output, and it is # the only one here that runs the programs rather than reading about them. ("recorded sessions match the programs", [sys.executable, "tools/shots/sessions.py", "--check"]), - line 201
("documentation matches the source", [sys.executable, "tools/docs/generate.py", "--check"]), ("per-program guides match the user guide", - line 241
[sys.executable, "tools/docs/guides.py", "--check"]), # Two questions in one: whether the wiki's prose pages still match the # documents they are converted from, and whether every `[[link]]` in the # whole wiki names a page that exists. - line 241
The second matters because a wiki # link to a missing page does not fail loudly: GitHub renders it as an # invitation to create that page, so a typo looks like a feature. ("the wiki matches the documents, and every link in it resolves", - line 241
[sys.executable, "tools/docs/wiki.py", "--check"]), ("website source pages match their files", [sys.executable, "tools/docs/sources.py", "--check"]), ("section pages match index.html", [sys.executable, "tools/site/split.py", "--check"]), - line 241
("the roadmap page matches ROADMAP.md", [sys.executable, "tools/site/roadmap.py", "--check"]), ("the demonstration matches the source", [sys.executable, "tools/site/demo.py", "--check"]), ("the questions page matches docs/FAQ.md", - line 241
[sys.executable, "tools/site/faq.py", "--check"]), ("the releases page matches CHANGELOG.md", [sys.executable, "tools/site/releases.py", "--check"]), ("every page says where it lives, and the sitemap lists it", [sys.executable, - line 241
"tools/site/seo.py", "--check"]), ("search index matches the tree", [sys.executable, "tools/search-index/generate.py", "--check"]), ("measured numbers match the tree", [sys.executable, "tools/measured/generate.py", "--check"]), ("the line - line 241
counter is right about what a line is", [sys.executable, "tools/loc/count.py", "--self-test"]), ("website suites", ["node", "tools/site-tests/run.js"]), ("the local site serves every page", [sys.executable, "tools/site/serve.py", - line 241
"--check"]), ] CARGO = [ ("formatting", ["cargo", "fmt", "--all", "--check"]), ("clippy", ["cargo", "clippy", "--workspace", "--all-targets"]), ("tests", ["cargo", "test", "--workspace"]), ] def main(): root = repo_root() check_only = - line 241
"--check" in sys.argv - line 281
# `--quick` runs everything except the three cargo steps. Those are most of # the wall time and almost none of the failures: what actually reaches CI # red, twice in one afternoon, is a generated file left behind by a commit # that added a - line 281
source file. The search index walks every text file in the # tree, so adding one and not regenerating is enough. This is the check to # run before a push that touches no Rust; `tools/verify.py` whole is still # the one to run before a - line 281
release. quick = "--quick" in sys.argv failed = [] if not check_only: print("regenerating, in dependency order") for label, command in GENERATORS: stage(root) # so the walk sees anything written by the step before ok, output = run(root, - line 281
label, command) print(" %-16s %s" % (label, "ok" if ok else "FAILED")) if not ok: print(output) failed.append(label) stage(root) print() print("checking" + (", without the cargo steps" if quick else "")) for label, command in CHECKS + ([] - line 281
if quick else CARGO): ok, output = run(root, label, command) print(" %-34s %s" % (label, "ok" if ok else "FAILED")) if not ok: failed.append(label) for line in output.strip().splitlines()[-25:]: print(" " + line) print() if failed: - line 281
print("%d check(s) failed: %s" % (len(failed), ", ".join(failed))) return 1 print("everything regenerated and every check passed") return 0 if __name__ == "__main__": - line 321
sys.exit(main())
Build and CI
.cargo/audit.toml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # cargo-audit policy. # # Two categories, kept apart on purpose because they are not the same kind of # decision: # # 1. *Unmaintained* advisories -- a maintenance signal, not a known exploit. - line 1
# 2. A *vulnerability* judged unreachable in this tree. There is exactly one, # it is argued in full below, and it is guarded mechanically in CI so that # the argument cannot quietly stop being true. # # The second category is a weakening - line 1
of the rule this file used to state -- # "vulnerabilities always fail the build" -- and it is written out rather than # folded into the list above it, because an exception that looks like the # entries around it is an exception nobody - line 1
re-reads. Every entry is reviewed at # each release. [advisories] ignore = [ # paste 1.0.15, a proc-macro helper, pulled in transitively by the audio and # GUI stacks. Compile-time only: it emits no code into the binary and has # no - line 1
runtime attack surface. No known vulnerability, author simply stepped # away. Drops out when upstream crates migrate. "RUSTSEC-2024-0436", # --- category 2: a vulnerability, judged unreachable ------------------- # # rsa 0.9.10 -- - line 1
RUSTSEC-2023-0071, the Marvin attack: key recovery through # a timing side channel. Medium severity, and no fixed version exists. # # It arrives through exactly one path: # # rsa <- pgp <- veilvoice-verify # # The advisory concerns - line 1
operations performed with an RSA **private** key -- # that is what "key recovery" means, and a timing oracle needs a secret to # be leaking. `veilvoice-verify` performs signature *verification* and # nothing else: it holds a public key - line 1
compiled into the binary, there is no # private key anywhere in this repository or in any released artefact, and - line 41
# no code path in the workspace decrypts or signs with RSA. VeilVoice's own # cryptography is X25519 + ML-KEM-768, XChaCha20-Poly1305 and Argon2id; it # has never used RSA for anything but checking release signatures, and the # release - line 41
signing key is RSA-4096 so that check cannot be avoided. # # There is therefore no secret for the side channel to leak. That is an # argument about how the crate is *used*, so CI guards the usage: the "no # RSA private-key operations" job - line 41
fails the build if any secret-key or # decryption API appears in the verifier. If that argument ever stops being # true, the build stops with it rather than this comment quietly ageing. "RUSTSEC-2023-0071", ] # `ttf-parser` was the third - line 41
entry here, accepted on the ground that no # attacker-supplied font ever reaches it. It left the dependency graph with the # move to egui 0.36, which rasterises text without it, so the argument is not # needed rather than being weaker: the - line 41
crate is gone. This note is the record # that the exception existed and why it stopped being one, since an entry that # simply disappears reads as one nobody re-read. # Unsound and yanked crates should still stop a release. - line 41
informational_warnings = ["unsound", "notice"]
.cargo/config.toml
- line 1
# Portable cargo configuration. # # IMPORTANT: nothing machine-specific or user-identifying is committed here. # Reproducibility depends on path remapping performed by the build ENVIRONMENT # (CI sets --remap-path-prefix from - line 1
$GITHUB_WORKSPACE and $CARGO_HOME, plus # SOURCE_DATE_EPOCH), never on any one contributor's absolute paths. See # docs/REPRODUCIBLE_BUILDS.md. # # Local developers who build inside a cloud-synced folder (OneDrive/Dropbox) # should - line 1
redirect the target directory to avoid sync churn and file locks: # $env:CARGO_TARGET_DIR = "$env:LOCALAPPDATA\veilvoice\target" (PowerShell) # export CARGO_TARGET_DIR="$HOME/.cache/veilvoice/target" (sh) [net] git-fetch-with-cli = true - line 1
[build] # No target-cpu=native: keeping the default baseline is required for # reproducible, portable binaries that run on any x86-64 machine. rustflags = []
.github/dependabot.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # VeilVoice dependency monitoring. # # # What this repository actually depends on # # Two ecosystems, and only two. Every third-party line of code that ships in a # VeilVoice binary comes in - line 1
through Cargo; everything that runs in CI comes in # through an action. There is no npm, bundler or pip ecosystem here: the # website's JavaScript and the site tests use Node's own built-in modules # (`fs`, `path`, `vm`, `child_process`) - line 1
and nothing else, and every script under # `tools/` and `assets/` is pure standard-library Python. A `package.json` or a # `Gemfile` added here would declare no dependencies, and a manifest that says # nothing is one more file that has to - line 1
be kept true. # # `Cargo.toml` is this project's package.json. There are 15 of them: the # workspace root, 13 crates, and `fuzz/`. # # # This file is checked rather than remembered # # `tools/audit/dependabot.py` reads this file and the - line 1
tree beside it, and fails # the build if a manifest directory exists that no entry below covers. A crate # added outside the workspace, or a second workflow directory, would otherwise # stop being monitored silently, which is the kind of - line 1
gap that is noticed a # year later. It runs in CI beside `tools/audit/dependencies.py`. # # Why an upgrade here cannot break the project abruptly # # Four things hold at once, and the point is that no single one of them is # trusted on its - line 1
own: # # 1. **Nothing merges itself.** There is no auto-merge. Every pull request # below waits for a person, and CI has to be green before that person can # sensibly say yes. # 2. **CI is the gate, and it is not a smoke test.** Thirteen - line 1
jobs: the # workspace built and tested on Linux, macOS and Windows, two 32-bit # targets, the offline proof, the parser campaigns, the advisory scan and # every generated file checked against its source. # 3. **A major is never grouped.** - line 1
Minor and patch arrive batched, because # twenty of those a week is noise. A major arrives alone, because it can - line 41
# change behaviour, drop a platform or raise the minimum Rust version, and # this project builds on the BSDs and on 32-bit targets where that matters # more than it usually does. `cpal` 0.15 to 0.18 was thirty-seven compile # errors across - line 41
four files in the realtime path: exactly the kind of thing # that must arrive as its own decision. # 4. **A decision already made does not reopen every week.** The `ignore` # list below is majors that have been considered and refused, each - line 41
with # the reason written where the dependency is declared. Minors and patches # still arrive for every one of them, which is the half that matters for # security: an advisory is almost always fixed in a patch. # # The fourth point is the - line 41
one with a sharp edge, so it is worth being exact: # an `ignore` entry suppresses the security update for that dependency at that # update type too. That is why every entry below is scoped to # `version-update:semver-major` and never to a - line 41
whole dependency. A bare # `dependency-name` with no `update-types` would silence the advisory as well, # which is the opposite of what this file is for. version: 2 updates: # ---- Cargo, the workspace ---- # # One entry covers the root - line 41
manifest and every member crate: Cargo is a # workspace-aware ecosystem, so Dependabot resolves `Cargo.lock` from the # root and sees all 27 members through it. A separate entry per crate would # open 27 pull requests for one bumped - line 41
version. - package-ecosystem: cargo directory: "/" schedule: interval: weekly day: monday time: "07:00" timezone: Etc/UTC # Ten is high enough that a quiet week clears the queue and low enough that # a noisy one does not bury the - line 41
repository. Every one of these is code that # ships in a signed binary and is read before it is merged. open-pull-requests-limit: 10 commit-message: # Matches this repository's own subject style: a lowercase area, a colon, - line 81
# then what changed. `deps` rather than `chore`, because a dependency # bump is a change to what this program ships, not housekeeping. prefix: "deps" prefix-development: "deps(dev)" include: scope groups: # Patch and minor bumps arrive as - line 81
one pull request per week rather than # as twenty. A group that fails CI is bisected by hand, which is the # trade: fewer reviews, and a slower answer on the rare week one breaks. minor-and-patch: update-types: - minor - patch # A major - line 81
bump is its own pull request, ungrouped, because it is a decision # rather than an update: it can change behaviour, drop a platform, or raise # the minimum Rust version, and this project builds on the BSDs and on # 32-bit targets where - line 81
that matters more than it usually does. # # Ten of those decisions are already made, and made together. The # cryptographic line is held on the generation `pgp` 0.20 uses; the reason # is written in full beside `argon2` in - line 81
`crates/veilvoice-crypto/Cargo.toml`. # Their majors are ignored so the same pull requests do not reopen every # week; minors and patches still arrive. Remove these the day `pgp` moves # or the signature check stops needing it (roadmap - line 81
item 149). ignore: - dependency-name: "sha2" update-types: ["version-update:semver-major"] - dependency-name: "hkdf" update-types: ["version-update:semver-major"] - dependency-name: "chacha20poly1305" update-types: - line 81
["version-update:semver-major"] - dependency-name: "argon2" update-types: ["version-update:semver-major"] - dependency-name: "x25519-dalek" update-types: ["version-update:semver-major"] - dependency-name: "ml-kem" update-types: - line 81
["version-update:semver-major"] - dependency-name: "rand_core" update-types: ["version-update:semver-major"] - dependency-name: "rand" - line 121
update-types: ["version-update:semver-major"] - dependency-name: "rand_chacha" update-types: ["version-update:semver-major"] - dependency-name: "getrandom" update-types: ["version-update:semver-major"] # Not part of the cryptographic - line 121
generation above, and refused on its own # grounds: `region` 4 made `region::unlock` an `unsafe` function, and this # crate unlocks explicitly rather than through the RAII guard, for the # reason `veilvoice-crypto`'s `amnesia` module gives - line 121
at length. Taking it # would put an `unsafe` block into a crate whose `#![forbid(unsafe_code)]` # is a promise made on the website, to buy `no_std` this project does not # use. The full argument is beside the dependency in # - line 121
`crates/veilvoice-crypto/Cargo.toml`. Patches and minors still arrive. - dependency-name: "region" update-types: ["version-update:semver-major"] labels: - dependencies - rust # ---- Cargo, the fuzzing package ---- # # `fuzz/` is - line 121
deliberately excluded from the workspace (it needs nightly and # its own profile), so the entry above does not reach it. Without this entry # the fuzz targets would silently stop being monitored, which is the kind of # gap that is only - line 121
noticed when something in it has been unmaintained for a # year. `tools/audit/dependencies.py` includes it for the same reason. - package-ecosystem: cargo directory: "/fuzz" schedule: interval: monthly open-pull-requests-limit: 3 - line 121
commit-message: prefix: "deps(fuzz)" include: scope labels: - dependencies - rust - fuzzing # ---- GitHub Actions ---- - line 161
# # The actions in `.github/workflows/` run with a token that can write to this # repository and, on the release workflow, publish signed artefacts. An # action pinned to a tag that has been moved is a supply-chain problem with # this - line 161
project's signing in its blast radius, so these are monitored on the # same terms as the code. - package-ecosystem: github-actions directory: "/" schedule: interval: weekly day: monday time: "07:00" timezone: Etc/UTC - line 161
open-pull-requests-limit: 5 commit-message: prefix: "ci" include: scope groups: # Same rule as the crates: batched when it is routine, alone when it is a # decision. A major version of an action can change its inputs or its # outputs, and - line 161
these actions check out the code, build it and publish # signed artefacts, so one arriving inside a group of eight is one nobody # reads. Majors are left ungrouped deliberately. actions: update-types: - minor - patch labels: - dependencies - line 161
- ci
.github/workflows/ci.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Build, lint and test on every supported platform. Windows first, because it # is the primary target, then macOS and Linux. name: ci on: push: branches: [main] pull_request: workflow_dispatch: - line 1
# Least privilege for every job below. A workflow with no `permissions` block # inherits the repository default, which can be write for every scope, and this # one checks out code, builds it and runs tests: none of that needs to be able # - line 1
to push a commit, move a tag or open an issue. The one job that needs more # asks for it on its own, so the grant is visible next to the thing using it. permissions: contents: read env: CARGO_TERM_COLOR: always # A fresh clone must build - line 1
with no secrets. Nothing here reads one. RUSTFLAGS: -D warnings jobs: test: name: test / ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] steps: - uses: - line 1
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # cpal needs ALSA headers, and eframe needs the X11/Wayland stack. - name: Install Linux audio and windowing headers - line 41
if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ libasound2-dev libudev-dev pkg-config \ libgtk-3-dev libxkbcommon-dev libwayland-dev \ libxkbcommon-x11-0 - name: Install the pinned - line 41
toolchain run: rustup show # rust-toolchain.toml selects and installs it - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Formatting run: cargo fmt --all -- --check - name: Clippy run: cargo clippy - line 41
--workspace --all-targets --locked # Platform-gated code is only compiled on the platform it is gated to, so # a helper left ungated -- or gated to the wrong thing -- is invisible # everywhere else and an error here. Three separate CI - line 41
failures in one # day came through that gap: an unused `mut`, a dead enum variant, and a # `#[cfg(windows)]` that attached to the item above the one it was meant # for. Each was clean on the author's Windows machine. # # This is the same - line 41
clippy run; it is called out as its own step so that # when it fails, the reason is named rather than looking like a generic # lint failure on a machine nobody has. - name: Clippy (platform-gated code compiles on this platform too) run: - line 41
cargo clippy --workspace --all-targets --locked --tests - name: Tests run: cargo test --workspace --locked - name: Release build run: cargo build --workspace --release --locked # Every `cargo` line above takes the default features, and - line 41
nine of the # twelve jobs in the release workflow do not: `cpal` has no backend for - line 81
# the BSDs and cannot be linked into a musl or cross build, so those # targets turn `veilvoice-audio`'s `live` feature off. That configuration # was built by nothing here, which meant code reachable only with the # feature on passed every - line 81
check on all three platforms and then failed # nine release jobs at once. # # That is not hypothetical. `pub mod record;` lost its # `#[cfg(feature = "live")]` when a `playback` module was inserted above # it and took the attribute line - line 81
that had belonged to `record`. The crate # then built everywhere CI looked and nowhere it did not, and it was found # by dispatching a release two days later. The tool reads the selections # out of `release.yml` rather than repeating them, - line 81
so a target added to # that matrix is built here without anybody remembering to. # # Linux only: the selections are feature sets rather than architectures, # and the one that was broken is broken identically on every host. - name: Every - line 81
feature selection a release builds must compile if: runner.os == 'Linux' run: python tools/audit/features.py --build # Two of this project's shipped defects were reachable only where a pointer # is 32 bits wide: F-4, an arithmetic - line 81
overflow, and F-11, an erase loop that # never terminated and left the file it was destroying intact. Both were # found by reading. Neither was reachable by any campaign in this matrix, # because every entry in it is 64-bit, and - line 81
`docs/AUDIT.md` has named this as # the single highest-value change available to CI since the fifth round. # # Two targets, and only one of them is emulated: # # * **i686** binaries run on the runner's own kernel. Nothing is emulated; # - line 81
`gcc-multilib` is there for the linker and the 32-bit C runtime. # * **armv7** is compiled natively with the cross linker and only the test # binaries are run under `qemu-arm-static`, which needs the cross # sysroot as its loader prefix. - line 81
Measured locally: the same 682 tests # take 18 seconds of execution on i686 and 88 on armv7, so the emulation # is not what this job costs. Compilation is. # # **Not the whole workspace, and the reason is packaging rather than # - line 81
correctness.** `veilvoice-audio`, `veilvoice-cli`, `veilvoice-gui` and # `veilvoice-video` link ALSA, GTK and X11, and building them for a second - line 121
# architecture means a multiarch sysroot for all of it. The arithmetic, the # parsers and the erase loop are in the crates listed below, which are the # ones a narrow pointer can break. Saying "the workspace passes on 32-bit" # would be - line 121
the overstatement this project's second rule exists to prevent. # # Debug rather than release, for the same reason the fuzz job gives: the # release profile turns overflow checks off, and an overflow is the thing # being looked for. - line 121
narrow: name: 32-bit / ${{ matrix.target }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - target: i686-unknown-linux-gnu # `libasound2-dev:i386` is what lets `veilvoice-audio` and # `veilvoice-video` join the list - line 121
below. They were excluded for # years as "a multiarch sysroot exercise"; for ALSA it is one # package and one `--add-architecture`. GTK is the one that is # genuinely hard, so `veilvoice-cli` and `veilvoice-gui` are still # out. packages: - line 121
gcc-multilib libc6-dev-i386 libasound2-dev:i386 multiarch: i386 extra: -p veilvoice-audio -p veilvoice-video pkgconfig: /usr/lib/i386-linux-gnu/pkgconfig - target: armv7-unknown-linux-gnueabihf packages: gcc-arm-linux-gnueabihf - line 121
libc6-dev-armhf-cross qemu-user-static multiarch: "" extra: "" pkgconfig: "" env: # Read by cargo for the armv7 entry and simply unused by the i686 one, # which needs no cross linker and no runner at all. - line 121
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_RUNNER: qemu-arm-static QEMU_LD_PREFIX: /usr/arm-linux-gnueabihf steps: - uses: - line 121
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - line 161
- name: Install the cross toolchain for ${{ matrix.target }} run: | if [ -n "${{ matrix.multiarch }}" ]; then sudo dpkg --add-architecture ${{ matrix.multiarch }} fi sudo apt-get update sudo apt-get install -y --no-install-recommends ${{ - line 161
matrix.packages }} - name: Install the pinned toolchain run: rustup show - name: Add the target run: rustup target add ${{ matrix.target }} - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ - line 161
matrix.target }} # The list is spelled out rather than `--workspace --exclude`, so that a # crate added to the workspace is absent here until somebody decides it # belongs, rather than joining silently and failing for a linker reason # - line 161
that has nothing to do with 32-bit arithmetic. - name: Tests on a 32-bit target env: # Only set for the entry that has a 32-bit sysroot; empty elsewhere, # where pkg-config must keep finding the host's own files. PKG_CONFIG_PATH: ${{ - line 161
matrix.pkgconfig }} PKG_CONFIG_ALLOW_CROSS: 1 run: | cargo test --locked --target ${{ matrix.target }} ${{ matrix.extra }} \ -p veilvoice-core -p veilvoice-crypto -p veilvoice-meta \ -p veilvoice-conversation -p veilvoice-video -p - line 161
veilvoice-policy \ -p veilvoice-setup -p veilvoice-verify \ -p veilvoice-guard -p veilvoice-watch # The generated artwork is committed, so it must match what the generator # produces. Otherwise the two drift and the "verifiable from - line 161
source" claim # quietly stops being true. # # **Two interpreters, and the reason is F-97.** A drawing generated here - line 201
# matched on 3.11 and differed on 3.12, with no change to the source: 3.12 # gave `sum` compensated summation over floats, one box centred a tenth of a # pixel further along, and a file committed from one machine stopped matching # the - line 201
generator run on another. A generated file compared byte for byte must # not depend on which Python is installed, and the only way to know that is to # run it on more than one. assets: name: generated assets are current (python ${{ - line 201
matrix.python }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: ['3.11', '3.13'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: - line 201
actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python }} # Compares decoded pixels, not compressed bytes: zlib's output differs # between Python builds, so a byte-for-byte diff would - line 201
fail spuriously # while telling us nothing about whether the artwork actually changed. - name: Committed artwork must match the generator run: python assets/generate.py --check # The search index is committed for the same reason and - line 201
carries the same # risk in a sharper form: a stale index does not look broken, it answers # questions confidently about code that no longer looks like that. The # generator is deterministic, so this is a byte comparison. # # `fetch-depth` - line 201
matters here -- the generator walks `git ls-files`, so a # checkout missing files would produce a smaller index and "fail" for a # reason that has nothing to do with the index. actions/checkout@v7 gives # a complete working tree, which is - line 201
what this needs. - name: Committed search index must match the repository run: python tools/search-index/generate.py --check # The per-crate and per-file documentation is generated from the doc # comments in the source, so a comment edited - line 201
without regenerating leaves # 287 files describing code that has moved on. That is finding F-41 -- # generated output drifting from its generator, silently -- and it is the - line 241
# reason this documentation is generated rather than written. The check # also sweeps for files the generator no longer produces, so a deleted # module cannot leave its page behind describing something that is gone. - name: Committed - line 241
documentation must match the source run: python tools/docs/generate.py --check # The per-section pages are derived from website/index.html, so editing # a section without regenerating leaves five pages describing a front # page that has - line 241
moved on -- the F-41 shape again, in a fifth place. - name: Committed section pages must match the front page run: python tools/site/split.py --check # The roadmap page and its picture are generated from ROADMAP.md, so a # roadmap item - line 241
whose status changes without regenerating leaves the published # roadmap disagreeing with the roadmap -- the F-41 shape a sixth time, and # the one place it is a claim about what is finished. `tools/verify.py` # has run this check since - line 241
the page existed; CI did not, so a stale # roadmap was the one generated output that could pass here. - name: Committed roadmap must match ROADMAP.md run: python tools/site/roadmap.py --check # The functional line count appears in - line 241
README.md, in docs/MEASURED.md and # in 28 crate documents, all of them written by one counter. A counter is # a small thing that is easy to get subtly wrong -- `//` inside a string # literal is not a comment, and Rust block comments nest - line 241
-- so it is # checked against the cases it has to get right before any of those # numbers are believed. The site suite compares the numbers themselves # against `docs/MEASURED.md`; this checks the tool that measured them. - name: The line - line 241
counter must be right about what a line is run: python tools/loc/count.py --self-test # Ten more guards that existed and did not fail a build. # # Every one of these has a `--check`, every one was run by hand before a # release, and none - line 241
of them was here. That is the F-41 shape again, one # level up: not a generated file drifting, but the *check for* a drifting # file being advisory. Three of them caught real staleness in the round # that added this block, which is how the - line 241
gap was noticed at all: # # * `demo.py` found two new tabs with no screenshot and no caption, - line 281
# * `sources.py` found generated source pages behind their source, # * `terminal.py` found drawings disagreeing with the text they came # from. # # A run that catches drift only when somebody remembers to look is a run # that catches drift - line 281
eventually. None of these needs a built binary, so # they belong in this job rather than one that waits on a compile. - name: Generated source pages must match the source run: python tools/docs/sources.py --check - name: The releases page - line 281
must match CHANGELOG.md run: python tools/site/releases.py --check - name: The walkthrough must match the tabs and commands that exist run: python tools/site/demo.py --check - name: Screenshots must be trimmed, fitted and rounded run: | - line 281
python tools/shots/crop.py --check python tools/shots/fit.py --check python tools/shots/round.py --check python tools/shots/attrs.py --check - name: Terminal drawings must match their captured text run: python tools/shots/terminal.py - line 281
--check - name: Every copy of the version must agree with the workspace run: python tools/release/version.py --check - name: Packaging definitions must match what a release publishes run: python tools/release/packaging.py --check # Roadmap - line 281
item 126. A dependency is code this project ships and does not # review, build time on every machine that compiles this, and one more # thing that has to work on the BSDs and the 32-bit targets. One sentence # where it is declared, or it - line 281
does not go in. The day this was written it # found three that no line of code referred to. - name: Every dependency must say what it is for run: python tools/audit/dependencies.py - line 321
# The same question asked of this project's own code. `dead_code` stops # at the crate boundary, because a public item might have a consumer the # compiler cannot see, so nothing here was checking that any of them did. # Five had no caller - line 321
and no test. One was an accessor keeping a private # field alive, and a clone of an audit result on every vault open with it, # which is the shape the compiler is structurally unable to report. - name: Every public item must be reached by - line 321
something run: python tools/audit/reachable.py # Three documents list the crates, and a crate is added somewhere else. # They had drifted to thirteen, twelve and thirteen of twenty-seven, and # the website renders the README, so the - line 321
shortest of them was the public # description of what this project contains. - name: Every crate must appear in every table that lists the crates run: python tools/audit/crate_tables.py # The canonical address, the preview picture and the - line 321
sitemap entry each # page carries. They used to be typed into each page, which is how the # preview picture came to be a relative URL that no crawler resolves. - name: Every page must say where it lives run: python tools/site/seo.py - line 321
--check # F-185. `.gitignore` matches `target/` at any depth, which is right and # also means a build directory inside the repository never appears in # `git status`, never appears in a diff and is never cleaned. One was # being rebuilt by - line 321
the measured-numbers tool on every machine that is not # Windows, and reached fifteen gigabytes before anything noticed. - name: No build output may live outside the one directory at the root run: python tools/audit/build_output.py # - line 321
Roadmap item 151. The campaign that produces this list runs weekly in # mutants.yml, because half an hour over eight files does not fit here. # This is the part that does: the line numbers in the committed list of # survivors move whenever - line 321
the code above them moves, and a list pointing # at the wrong lines is worse than no list. - name: Every argued-for surviving mutant must still point at real code run: python tools/mutants/check.py --lint # And the other half of the - line 321
question. Saying what a dependency is for # does not make anything watch it: a crate outside the workspace, or a - line 361
# second workflow directory, falls outside every Dependabot entry and # stops being monitored without anything saying so. - name: Every manifest must be covered by a Dependabot entry run: python tools/audit/dependabot.py # And the - line 361
supply-chain half. `uses: owner/action@v2` names a label, not a # version, and the owner can move it whenever they like: two of the ones # this repository used were branches. Whatever it points at on the day is # executed on a runner - line 361
holding a checkout of this repository and a token. - name: Every action a workflow runs must be pinned to a commit run: python tools/audit/actions.py # The seed that does the veiling is what stands between a recording and # the voice it - line 361
came from, and a nonce reused is an AEAD broken. Neither # degrades visibly: weak randomness looks exactly like strong randomness # from the outside, so nothing downstream ever notices. - name: Every random draw must come from the OS - line 361
CSPRNG run: python tools/audit/randomness.py # F-170. A release is a claim that these binaries came from this source, # and the tag is the load-bearing part of it. Creating a release for a # tag that does not exist yet makes GitHub create - line 361
the tag, and with no # `target_commitish` it creates it at the default branch rather than at # the commit the run built. v0.1.20 and v0.1.21 were both tagged that # way. This reads the publishing step and fails if it stops naming the # - line 361
commit, or if it stops refusing to publish notes it could not read. - name: The release step must tag the commit it built run: python tools/audit/publishing.py # Four more of `tools/verify.py`'s checks that ran only when somebody ran # - line 361
them. Each needs nothing but the tree and an interpreter, so there was # no reason for them to be here and every reason: the first is the guard # for F-141 and F-142, a file written one place and read from another, # which is the defect - line 361
this project has shipped twice. - name: No state file is written one place and read another run: python tools/audit/state_paths.py - name: The per-program guides must match the user guide run: python tools/docs/guides.py --check - line 401
# The wiki is the same documents with their links rewritten for a flat # namespace, so it can go stale the same way. This also walks every # `[[link]]` in all 202 pages: a wiki link to a page that does not # exist renders as an invitation - line 401
to create it rather than as an error, # so nothing else would ever notice. - name: The wiki must match the documents, and every link must resolve run: python tools/docs/wiki.py --check - name: The questions page must match docs/FAQ.md run: - line 401
python tools/site/faq.py --check - name: The app-manifest generator must still verify what it writes run: python tools/sign/selftest.py # The site's Markdown renderer decides what `js/repo.js` is allowed to put # into the page via - line 401
`innerHTML`, so it is a security boundary and is tested # like one. No framework and no package.json: a test suite that pulls a # hundred packages off a registry is a supply chain nobody has read, which is # the same argument the site - line 401
itself makes for not loading a CDN. site: name: website tests runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - line 401
with: node-version: '22' - name: Characters, structure, rendering, hostile input, scroll reveal run: node tools/site-tests/run.js # The local host script, which is the site's fallback if GitHub Pages is # not there. It starts the server, - line 401
fetches every page and stops. It was # in `tools/verify.py` and not here, so the fallback was checked only by # the person who ran it. - name: The local site must serve every page run: python tools/site/serve.py --check # A longer - line 401
randomised campaign than the per-commit default. Still not a # coverage-guided fuzzer, and `docs/AUDIT.md` does not pretend otherwise. - name: Extended hostile-markdown campaign run: MD_FUZZ_ROUNDS=200000 node - line 401
tools/site-tests/markdown.hostile.test.js - line 441
# The container header, the app-lock file and the RIFF chunk walker all parse # input an attacker chooses. The per-commit `cargo test` runs a short campaign # against each; this runs a long one, because the bugs these found, an # Argon2 - line 441
parallelism overflow and an unbounded memory cost, were both # reachable from a file somebody sends you. fuzz: name: parser campaigns runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - line 441
run: rustup show - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Debug, not release: the release profile disables overflow checks, and # an arithmetic overflow on hostile input is exactly what is being looked # - line 441
for here. - name: Container, app-lock and WAV parsers env: VEILVOICE_FUZZ_ROUNDS: 1000000 run: | cargo test -p veilvoice-crypto --test parser_fuzz --locked cargo test -p veilvoice-meta --test wav_fuzz --locked cargo test -p veilvoice-core - line 441
--test hostile_audio --locked audit: name: dependency advisories runs-on: ubuntu-latest # Over the top-level `contents: read`, because `audit-check` reports what it # finds as a check run and opens an issue for a new advisory. Without - line 441
these # it still scans and still fails the job; it just cannot say where. permissions: contents: read checks: write issues: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: - line 441
rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2 with: token: ${{ secrets.GITHUB_TOKEN }} # The three questions cargo-audit does not ask: whether every licence in # the graph can ship under GPL-3.0-or-later, whether every - line 441
crate comes - line 481
# from crates.io, and whether one crate is compiled at two versions. # `deny.toml` is the policy; its advisory exceptions are the same three # as `.cargo/audit.toml`'s, and `tools/audit/dependencies.py` fails if # the two files stop - line 481
agreeing. - name: Licences, sources and duplicate versions uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 with: command: check # A privacy tool that phones home is a broken privacy tool. This is a - line 481
coarse # check, not a proof, but it catches an accidental HTTP client landing in the # dependency graph. offline: name: no network dependencies runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - line 481
# v7 - run: rustup show - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Fail if a networking crate appears in the tree run: | cargo tree --workspace --edges normal --prefix none --format '{p}' \ | sort -u - line 481
> /tmp/deps.txt # 1. HTTP and WebSocket clients. No exceptions, ever: this is the # claim the front page makes and the one a reader checks first. if grep -Eiq '^(reqwest|hyper|curl|ureq|tungstenite|isahc|surf|attohttpc) ' /tmp/deps.txt; - line 481
then echo "::error::an HTTP or WebSocket client entered the dependency graph" grep -Ei '^(reqwest|hyper|curl|ureq|tungstenite|isahc|surf|attohttpc) ' /tmp/deps.txt exit 1 fi # 2. Crates that can open a socket at all. # # This step used to - line 481
check only the list above and then print "no # networking crates found", which is a broader sentence than the # check behind it. Five socket-capable crates are in the graph and # always have been, all of them reached through the *window*: - line 481
# # tokio, mio, socket2 the file dialog (`rfd`) reaches the XDG - line 521
# desktop portal through `ashpd` and # `zbus`, which speak D-Bus over a Unix # socket; `tokio` brings its socket # primitives whether or not a TCP one is # ever opened # polling `calloop`'s epoll/kqueue wrapper, under # `winit`'s Wayland - line 521
event loop # calloop-wayland-source the Wayland display connection itself, # also a Unix socket # # None of them is reachable from the command line, which links no # window. VeilVoice's own code names no network API anywhere -- # - line 521
`std::net`, `TcpStream`, `UdpSocket` and `SocketAddr` appear # nowhere in `crates/`. But a reader checking the claim the way # this project invites them to would find these five and read # nothing about them, which is the defect this - line 521
fixes. # # The set is pinned rather than allowlisted. A *new* socket-capable # crate, or one of these disappearing, fails this job and a person # looks at why. A list that grows silently is not a check. expected=$(printf '%s\n' - line 521
calloop-wayland-source mio polling socket2 tokio) found=$(grep -Eio '^(mio|socket2|tokio|async-std|smol|polling|calloop-wayland-source) ' /tmp/deps.txt \ | tr -d ' ' | sort -u) if [ "$found" != "$expected" ]; then echo "::error::the set of - line 521
socket-capable crates changed" echo "expected:"; printf '%s\n' "$expected" echo "found:"; printf '%s\n' "$found" echo "If this is deliberate, update this list and say in docs/AUDIT.md what" echo "the new crate is for and what it talks to." - line 521
exit 1 fi echo "no HTTP or WebSocket client; the five socket-capable crates are the expected ones" # 3. The source names no network API. # # The comment above has claimed since it was written that # "`std::net`, `TcpStream`, `UdpSocket` - line 521
and `SocketAddr` appear # nowhere in `crates/`". That was true and it was a *comment*: a # sentence a reader is asked to believe, sitting inside a job whose - line 561
# whole purpose is to replace belief with a check. This is the check. # # The two matches that are allowed are tests which forbid these very # words, in the crash reporter and in the dependency report. A test # that names the thing it - line 561
prohibits is not the thing. - name: Fail if VeilVoice's own source names a network API run: | set -euo pipefail PATTERN='std::net|TcpStream|TcpListener|UdpSocket|SocketAddr|to_socket_addrs|getaddrinfo' hits=$(grep -rInE "$PATTERN" - line 561
--include='*.rs' crates/ \ | grep -v 'crates/veilvoice-gui/src/crashreport.rs' \ | grep -v 'crates/veilvoice-verify/src/deps.rs' || true) if [ -n "$hits" ]; then echo "::error::VeilVoice's own code names a network API" echo "$hits" exit 1 - line 561
fi echo "no network API named anywhere in crates/" # Everything above reasons about the *source* and the dependency graph. This # job asks the built program itself, three ways, because a reader who does # not trust the author should not - line 561
have to trust the author's reading either. # # 1. What the binary imports. A program that cannot call `socket` or # `connect` cannot open one, whatever its source says. # 2. What it does with no network at all. `unshare -n` gives the - line 561
process an # empty network namespace: no interfaces, not even loopback. A program # that needs the network fails there. # 3. What syscalls it actually makes, under `strace`. # # The command line and the window are checked separately and - line 561
claimed # separately, because they are not the same program and the honest statements # about them differ. The command line links no window and opens nothing. The # window talks to the display server and the desktop portal, which are local - line 561
# sockets, and the claim there is the precise one: every socket it opens is # `AF_UNIX`, and it never asks for an internet socket. offline-runtime: name: the built program opens no network socket runs-on: ubuntu-latest steps: - line 601
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - run: rustup show - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install the windowing headers and the tracers run: | sudo apt-get - line 601
update sudo apt-get install -y --no-install-recommends \ libasound2-dev libgtk-3-dev libxkbcommon-dev libwayland-dev \ libxkbcommon-x11-0 strace xvfb - name: Build both binaries run: cargo build --release --locked -p veilvoice-cli -p - line 601
veilvoice-gui # 1. Imports. # # `socketpair` is the one that is allowed, and it is worth saying why # rather than listing it: it creates a connected pair of `AF_UNIX` # descriptors inside this process's own address space. It takes no # - line 601
address, reaches no host, and is what the standard library uses to # talk between a parent and a child. It cannot carry a byte off the # machine. - name: The command line imports no network function run: | set -euo pipefail found=$(nm -D - line 601
--undefined-only target/release/veilvoice \ | grep -oiE '\b(socket|connect|getaddrinfo|gethostbyname|sendto|recvfrom|bind|listen|accept)\b' \ | sort -u || true) if [ -n "$found" ]; then echo "::error::the command line binary imports a - line 601
network function" echo "$found" exit 1 fi echo "veilvoice imports no network function (socketpair, which is AF_UNIX and local, is not one)" # 2. No network namespace at all. # # `unshare -rn` on its own is not enough here. It asks for a - line 601
*user* # namespace so that an ordinary account can make a network one, and # Ubuntu 24.04 refuses unprivileged user namespaces by default # (`kernel.apparmor_restrict_unprivileged_userns`). The step failed - line 641
# with "write failed /proc/self/uid_map: Operation not permitted" on # every run since it was added, and because it failed early the two # steps after it were skipped: the job proved nothing at all while # looking like it was proving three - line 641
things. # # So the namespace is obtained whichever way this kernel allows, and # the program is dropped back to the ordinary account inside it, so # what is measured is still VeilVoice as somebody actually runs it. # If no way works, this - line 641
fails: a guard that quietly skips is worse # than no guard, because it is green. - name: The command line does its work with no network namespace run: | set -euo pipefail # Best effort, and not depended on: where the restriction can be # - line 641
lifted, the unprivileged path below is the one that runs. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ >/dev/null 2>&1 || true me="$(id -u):$(id -g)" if unshare -rn true 2>/dev/null; then empty_netns() { unshare -rn - line 641
"$@"; } echo "empty network namespace: unprivileged, through a user namespace" elif sudo unshare -n true 2>/dev/null; then empty_netns() { sudo unshare -n -- setpriv --reuid="${me%%:*}" --regid="${me##*:}" \ --clear-groups "$@" } echo - line 641
"empty network namespace: made as root, program dropped back to $me" else echo "::error::this runner will not make an empty network namespace, so nothing here is proved" exit 1 fi python3 - <<'WAV' import struct, math, io rate, seconds = - line 641
48000, 2 d = io.BytesIO() for i in range(rate * seconds): d.write(struct.pack('<h', int(9000 * math.sin(i / 40.0)))) - line 681
raw = d.getvalue() open('/tmp/offline.wav', 'wb').write( b'RIFF' + struct.pack('<I', 36 + len(raw)) + b'WAVEfmt ' + struct.pack('<IHHIIHH', 16, 1, 1, rate, rate * 2, 2, 16) + b'data' + struct.pack('<I', len(raw)) + raw) WAV empty_netns - line 681
target/release/veilvoice anonymise /tmp/offline.wav \ -o /tmp/offline.veiled.wav --encrypt false --yes test -s /tmp/offline.veiled.wav echo "de-identified a recording inside an empty network namespace" # 3. Syscalls. - name: The command - line 681
line makes no network syscall run: | set -euo pipefail for args in \ "anonymise /tmp/offline.wav -o /tmp/traced.wav --encrypt false --yes" \ "verify --help" \ "--version"; do strace -f -e trace=%network -o /tmp/trace.txt \ - line 681
target/release/veilvoice $args >/dev/null 2>&1 || true # strace writes one "+++ exited +++" line per process even when it # traced nothing, so the test is for syscalls rather than for lines. calls=$(grep -cE '^[0-9]+ - line 681
+(socket|connect|bind|listen|accept|sendto|recvfrom|sendmsg|recvmsg)' /tmp/trace.txt || true) if [ "$calls" != "0" ]; then echo "::error::veilvoice $args made $calls network syscall(s)" cat /tmp/trace.txt exit 1 fi done echo "no network - line 681
syscall from any command line invocation" # 4. The window, and the precise claim about it. - name: The window opens only local sockets, never an internet one run: | set -euo pipefail Xvfb :95 -screen 0 800x600x24 >/dev/null 2>&1 & sleep 3 - line 681
DISPLAY=:95 LIBGL_ALWAYS_SOFTWARE=1 timeout 30 \ strace -f -e trace=%network -o /tmp/gui-trace.txt \ - line 721
target/release/veilvoice-gui --tab about --size 800x600 >/dev/null 2>&1 || true if [ ! -s /tmp/gui-trace.txt ]; then echo "::error::the window produced no trace, so this proved nothing" exit 1 fi # Any internet socket at all, asked for or - line 721
connected to, fails. if grep -q 'AF_INET' /tmp/gui-trace.txt; then echo "::error::the window asked for an internet socket" grep 'AF_INET' /tmp/gui-trace.txt | head -20 exit 1 fi families=$(grep -oE 'socket\(AF_[A-Z0-9_]+' - line 721
/tmp/gui-trace.txt \ | grep -oE 'AF_[A-Z0-9_]+' | sort -u | tr '\n' ' ') echo "socket families the window asked for: ${families:-none}" echo "no AF_INET or AF_INET6 anywhere: the window's sockets are local" # `rsa` is in the dependency - line 721
graph through `pgp`, and it carries # RUSTSEC-2023-0071 -- the Marvin attack, key recovery through a timing side # channel, with no fixed version available. `.cargo/audit.toml` accepts it on # one specific ground: the advisory is about RSA - line 721
*private key* operations, and # VeilVoice only ever verifies a signature against a public key compiled into # it. There is no secret for a timing oracle to leak. # # That is an argument about how the crate is used, not about the crate, so - line 721
it # has to be enforced rather than believed. If a secret-key or decryption API # ever appears, this fails and the acceptance has to be re-argued. # # Both crates that reach `pgp` are checked, and the list is derived from the # manifests - line 721
rather than written here: the signature arithmetic moved from # `veilvoice-verify` into its `check` module so the desktop application could # share it, and for a while afterwards this job still grepped only the crate # it had started in. A - line 721
guard whose scope is narrower than the claim it # enforces is worse than no guard, because it is green. rsa-usage: name: no RSA private-key operations runs-on: ubuntu-latest steps: - line 761
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Fail if any crate reaching pgp gains a private-key code path run: python tools/audit/rsa_usage.py
.github/workflows/fuzz.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # The seven coverage-guided fuzz targets, run from the committed corpus. # # `ci.yml`'s fuzz job runs the deterministic campaigns inside `cargo test`, # which need only the stable toolchain and - line 1
finish in minutes. These are the # libFuzzer targets in `fuzz/fuzz_targets/`, which need nightly and a long run # to be worth anything. Until this workflow existed they ran when somebody # remembered to run them, which is the shape of - line 1
F-160: a check that exists and # is not in CI catches things eventually. # # Weekly rather than per push, because the useful campaign length is what # makes them useful, and dispatchable, for the run before a release. # # Two minutes per - line 1
target here, against the ten to twenty minutes of a campaign # somebody sits through before a release. That is enough to fail on a # regression the corpus already reaches and is not a substitute for the long # run, which `docs/AUDIT.md` - line 1
records with its duration each time it is done. name: fuzz on: schedule: - cron: '17 4 * * 1' workflow_dispatch: permissions: contents: read jobs: campaigns: name: coverage-guided campaigns runs-on: ubuntu-latest steps: - uses: - line 1
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install nightly and cargo-fuzz run: | rustup toolchain install nightly --profile minimal cargo +nightly install cargo-fuzz --locked - line 41
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: fuzz # The memory limit matches the campaigns recorded in docs/AUDIT.md. The # container and lock targets reach Argon2 with parameters the file # - line 41
declares, and the attended ceiling is four gibibytes by design; a # lower limit reports that ceiling as an out-of-memory, which it is not. - name: Every target, two minutes each, from the committed corpus working-directory: fuzz run: | set - line 41
-euo pipefail for target in $(ls fuzz_targets | sed 's/\.rs$//'); do echo "== $target" cargo +nightly fuzz run "$target" -- \ -max_total_time=120 -max_len=65536 -rss_limit_mb=6144 -timeout=25 done - name: Keep anything that crashed if: - line 41
failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: fuzz-artifacts path: fuzz/artifacts if-no-files-found: ignore
.github/workflows/mutants.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Mutation testing over the cryptography, weekly. # # Every other check in this repository asks whether an input was explored. # `cargo fuzz` generates inputs by coverage feedback, the - line 1
deterministic # campaigns generate them by construction, and clippy and Miri read the code. # None of them asks the other question: **is there an assertion for what came # back**. Changing the code a line at a time and seeing whether any - line 1
test # objects is the only thing here that does. # # The first run of it found fifteen changes the whole suite accepted, in four # files that had already been fuzzed, already audited twice, and already had # long arguments written about - line 1
them. One replaced the randomness adapter with # something that returns a constant, and everything passed. That is F-180, and # it is why this is a workflow rather than an afternoon somebody spends. # # Weekly rather than per push, because - line 1
a campaign takes half an hour over four # files and a per-push job cannot afford that. Dispatchable, for the run before # a release. # # **The committed baseline is the point of the design.** A campaign that prints # a number tells you the - line 1
number changed; a campaign compared against a # committed list of known survivors tells you *which* mutant is new, as a diff # somebody reads in a pull request. `tools/mutants/survivors.txt` is that list, # and every line in it carries the - line 1
argument for why that mutant cannot be # killed. A new survivor fails this job. name: mutants on: schedule: # Tuesdays, so it does not land on the same morning as the fuzz workflow. - cron: '41 4 * * 2' workflow_dispatch: permissions: - line 1
contents: read jobs: - line 41
campaign: name: mutation testing, the cryptography runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install the pinned toolchain run: rustup show - uses: - line 41
Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Install cargo-mutants run: cargo install cargo-mutants --locked # `--timeout 300` because the crypto tests run Argon2id, which is # expensive by design: the default - line 41
timeout reports a mutant as timing out # when the suite simply took as long as it always takes, and a timeout # reads as neither caught nor missed. - name: Change the cryptography a line at a time run: | set -euo pipefail cargo mutants -p - line 41
veilvoice-crypto \ --file crates/veilvoice-crypto/src/aead.rs \ --file crates/veilvoice-crypto/src/kdf.rs \ --file crates/veilvoice-crypto/src/container.rs \ --file crates/veilvoice-crypto/src/hybrid.rs \ --file - line 41
crates/veilvoice-crypto/src/lock.rs \ --file crates/veilvoice-crypto/src/vault.rs \ --file crates/veilvoice-crypto/src/weave.rs \ --file crates/veilvoice-crypto/src/tape.rs \ --timeout 300 \ || true mv mutants.out/missed.txt - line 41
missed-crypto.txt # The two readers that parse a file somebody else produced. They are a # different crate, so a separate invocation, and its output is added to # the same comparison below. Both were campaigned to zero survivors when # - line 41
this was written: the walker's guard against reading past an odd-sized # last chunk, and the alignment of the bland metadata written in place of # what was stripped, which is what makes a cleaned file look like any - line 81
# other file rather than like one this tool has been over. - name: Change the metadata readers a line at a time run: | set -euo pipefail cargo mutants -p veilvoice-meta \ --file crates/veilvoice-meta/src/wav.rs \ --file - line 81
crates/veilvoice-meta/src/image.rs \ --timeout 300 \ || true cat missed-crypto.txt mutants.out/missed.txt > mutants.out/missed.txt.all mv mutants.out/missed.txt.all mutants.out/missed.txt # Compared rather than counted. `|| true` above is - line 81
deliberate: the # campaign exits non-zero when anything survives, and something is # expected to survive, so the verdict belongs to this step and to the # committed list rather than to the exit code. - name: No mutant may survive that is - line 81
not already argued for run: python tools/mutants/check.py mutants.out/missed.txt - name: Keep the full result if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mutants-out path: - line 81
mutants.out if-no-files-found: ignore
.github/workflows/pages.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Publish website/ to GitHub Pages. # # Deploying through Actions rather than a branch folder is not a preference: # "deploy from a branch" only ever offers the repository root or /docs, and # - line 1
/docs already holds the whitepaper and the reproducible-build guide. Actions # can publish any directory, so the site lives in website/ where it belongs. name: pages on: push: branches: [main] paths: - 'website/**' - 'assets/**' - - line 1
'README.md' - '.github/workflows/pages.yml' # After a release, always. # # The site is not only documentation: the download page names the current # version, the releases page is generated from CHANGELOG.md, and the verify # page describes - line 1
files that a release publishes. Cutting a release changed # all of that and deployed none of it, because a tag push touches no path in # the list above. The site stayed on the previous release until somebody # happened to edit a file under - line 1
`website/`, which could be days. # # So the release workflow finishing is a reason to publish, on the same # footing as somebody editing a page. Success only: a release that failed # halfway is not something to redeploy the site for. - line 1
workflow_run: workflows: [release] types: [completed] workflow_dispatch: permissions: contents: read pages: write - line 41
id-token: write # Never cancel a running deploy: a half-published site is worse than an old one. concurrency: group: pages cancel-in-progress: false jobs: build: runs-on: ubuntu-latest # A failed release is not a reason to publish. Every - line 41
other trigger is: a # push to main, or somebody pressing the button. if: >- github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' steps: # The default branch explicitly. On a `workflow_run` the checkout - line 41
would # otherwise be the commit that triggered the release, which is a tag; the # site is published from `main`, and for a release those are the same # commit until they are not. - uses: - line 41
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: main # The banner and icons are generated, and the site is the most visible # place a stale copy would show. Regenerating and diffing here keeps the # published - line 41
artwork honest to the script that claims to produce it. - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.12' - name: Artwork must match the generator run: python assets/generate.py --check - line 41
- name: Sync generated artwork into the site run: | cp assets/banner.png assets/icon.png assets/icon-32.png website/assets/ # A privacy tool's website loading a CDN would undercut the whole claim, # so the absence of third-party requests - line 41
is enforced rather than asserted. - name: No third-party requests - line 81
run: | if grep -rniE '(src|href)="https?://(?!(github\.com|www\.audacityteam\.org|vb-audio\.com|creativecommons\.org|www\.gnu\.org))' \ --include='*.html' website/ -P; then echo "::error::an external asset reference entered the site" exit - line 81
1 fi if grep -rniE '<script[^>]+src="https?://' --include='*.html' website/; then echo "::error::a third-party script entered the site" exit 1 fi echo "no third-party assets found" - name: Every page must reference the current signing key - line 81
run: | want="$(tr -d ' \n' < website/assets/fingerprint.txt | tr 'a-f' 'A-F')" missing=0 for page in website/index.html website/wiki.html website/nojs/index.html; do got="$(tr -d ' \n' < "$page" | tr 'a-f' 'A-F')" case "$got" in *"$want"*) - line 81
echo " ok: $page" ;; *) echo "::error::$page does not carry the signing-key fingerprint"; missing=1 ;; esac done exit $missing - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - uses: - line 81
actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: website deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - id: deploy uses: - line 81
actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
.github/workflows/release.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Build, verify and publish release artefacts for a v* tag. # # Two properties this workflow exists to establish: # 1. Every binary is built twice, in different directories, and compared byte # - line 1
for byte. The per-platform verdict is published in the release notes # rather than hidden: a release that is not reproducible on some target # still ships, but says so, because quietly claiming reproducibility would # be worse than - line 1
admitting a gap. # 2. Signing is optional. The private key lives in a repository secret that is # absent on forks, so a fork can still produce a complete (unsigned) # release rather than a broken one. name: release on: push: tags: ['v*'] - line 1
workflow_dispatch: inputs: tag: description: Version to publish, e.g. v0.1.15 (leave empty for a dry run) required: false permissions: contents: write env: CARGO_TERM_COLOR: always # The version being published. # # This input existed and - line 1
was **ignored**: every job named its artefacts after # `github.ref_name`, which on a dispatch is the branch, so a run from `main` # produced files called `veilvoice-main-...` and would have tried to publish a # release named `main`. A - line 1
declared input that changes nothing is worse than no # input, because it reads like a supported way to do the thing. # # It is read now, and it is how a release is cut when a tag cannot be pushed # from where the work was done. Given - line 1
`v0.1.15`, this builds the branch it was - line 41
# dispatched from, names everything `v0.1.15`, and publishes a release, which # creates the tag at that commit. Given nothing, it is a dry run: everything # is built, compared, hashed, signed and reported, and nothing is published. # # - line 41
Empty on a tag push, so this is the version either way. VERSION_TAG: ${{ github.event.inputs.tag || github.ref_name }} jobs: build: name: ${{ matrix.label }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - os: - line 41
windows-latest target: x86_64-pc-windows-msvc label: windows-x86_64 archive: zip - os: macos-latest target: aarch64-apple-darwin label: macos-arm64 archive: tar.gz # Intel macOS is cross-compiled from the Apple Silicon runner rather # than - line 41
built on a macos-13 one: those runners are being retired, and # a release should not sit in a queue waiting for deprecated # hardware. The Apple SDK ships both slices, so rustc cross-links # this target natively. - os: macos-latest target: - line 41
x86_64-apple-darwin label: macos-x86_64 archive: tar.gz - os: ubuntu-latest target: x86_64-unknown-linux-gnu label: linux-x86_64 archive: tar.gz # Native ARM64 Linux. GitHub provides arm64 runners for public # repositories, so this is a - line 41
real build rather than a cross one and # the GUI comes with it. - os: ubuntu-24.04-arm - line 81
target: aarch64-unknown-linux-gnu label: linux-arm64 archive: tar.gz # Raspberry Pi OS 32-bit. Cross-compiled, and CLI only: the GUI would # need the whole GTK/OpenGL stack for armhf on the build host, which # is a great deal of machinery - line 81
for a target where a headless box is # the normal case anyway. - os: ubuntu-24.04-arm target: armv7-unknown-linux-gnueabihf label: linux-armv7-pi archive: tar.gz cli_only: true # The libc package is named explicitly: apt would normally # - line 81
pull it in as a recommendation, and --no-install-recommends # leaves the linker without Scrt1.o and crti.o. cross_gcc: gcc-arm-linux-gnueabihf libc6-dev-armhf-cross # Statically linked musl builds. One binary, no libc version to # match, - line 81
runs on any Linux including Alpine and old distributions. # CLI only, for the same reason as above. - os: ubuntu-latest target: x86_64-unknown-linux-musl label: linux-x86_64-musl-static archive: tar.gz cli_only: true cross_gcc: musl-tools - line 81
- os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl label: linux-arm64-musl-static archive: tar.gz cli_only: true cross_gcc: musl-tools steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Only the GUI - line 81
needs the windowing stack. A CLI-only target does not, and # on a cross target these packages would be for the wrong architecture. - name: Install Linux audio and windowing headers if: runner.os == 'Linux' && !matrix.cli_only run: | - line 121
sudo apt-get update sudo apt-get install -y --no-install-recommends \ libasound2-dev libudev-dev pkg-config \ libgtk-3-dev libxkbcommon-dev libwayland-dev \ libxkbcommon-x11-0 - name: Install the cross toolchain if: matrix.cross_gcc run: | - line 121
sudo apt-get update sudo apt-get install -y --no-install-recommends ${{ matrix.cross_gcc }} - name: Install the pinned toolchain run: | rustup show rustup target add ${{ matrix.target }} # Everything that would otherwise vary between two - line 121
builds of the same # source. See docs/REPRODUCIBLE_BUILDS.md. - name: Pin the build environment shell: bash run: | echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> "$GITHUB_ENV" # `pwd -P` resolves symlinks. macOS reports the - line 121
workspace under /tmp # but canonicalises it to /private/tmp, and a remap that does not # match the canonical path silently does nothing. echo "SRC_A=$(pwd -P)" >> "$GITHUB_ENV" # Per-linker determinism flags. case "${{ runner.os }}" in - line 121
Windows) # MSVC stamps a timestamp and a PDB signature into the PE header; # /Brepro replaces both with a hash of the input. echo "REPRO_LINK=-C link-arg=/Brepro" >> "$GITHUB_ENV" ;; macOS) # ld64 writes an LC_UUID load command that is not - line 121
a pure function # of the input, so two identical builds differ by 16 bytes. # -no_uuid omits it. The cost is that crash reports cannot be # matched to a dSYM, which these stripped release binaries have # no dSYM for anyway. - line 161
echo "REPRO_LINK=-C link-arg=-Wl,-no_uuid" >> "$GITHUB_ENV" # Stops `ar` writing timestamps into any static archive. echo "ZERO_AR_DATE=1" >> "$GITHUB_ENV" ;; *) echo "REPRO_LINK=" >> "$GITHUB_ENV" ;; esac - name: Select what to build - line 161
shell: bash run: | if [ "${{ matrix.cli_only }}" = "true" ]; then # No live audio on these. cpal needs ALSA headers built for the # target architecture, which a cross or musl build does not have, # and a statically linked binary cannot - line 161
dlopen the system ALSA # anyway. File processing, encryption and metadata cleaning are # pure Rust and work fine, so the feature is switched off rather # than the build failing. # The verifier ships everywhere, including here, and since - line 161
0.1.18 # it does so as part of `veilvoice` rather than as a binary of its # own. It is most useful precisely on the stripped-down targets, # where GnuPG is least likely to be present, which is why the # command line is built even where the - line 161
window is not. echo "CARGO_ARGS=-p veilvoice-cli --no-default-features" >> "$GITHUB_ENV" echo "BINARIES=veilvoice" >> "$GITHUB_ENV" else echo "CARGO_ARGS=--workspace" >> "$GITHUB_ENV" echo "BINARIES=veilvoice veilvoice-gui" >> - line 161
"$GITHUB_ENV" fi case "${{ matrix.target }}" in armv7-unknown-linux-gnueabihf) echo "CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc" >> "$GITHUB_ENV" ;; esac - name: Build shell: bash run: | export - line 161
RUSTFLAGS="--remap-path-prefix=$SRC_A=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo $REPRO_LINK" - line 201
cargo build --release --locked --target ${{ matrix.target }} $CARGO_ARGS # Reproducibility is the whole basis of "verify the binary matches the # source", so it is checked here rather than asserted in the docs. - name: Build again, - line 201
elsewhere, and compare id: repro shell: bash run: | # Deliberately not under /tmp: macOS would canonicalise it and defeat # the remap, making this check pass for the wrong reason. rebuild="$HOME/vv-rebuild" rm -rf "$rebuild" cp -r "$SRC_A" - line 201
"$rebuild" cd "$rebuild" src_b="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_b=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo $REPRO_LINK" cargo build --release --locked --target ${{ matrix.target }} $CARGO_ARGS - line 201
status=reproducible for name in $BINARIES; do for ext in "" ".exe"; do a="$SRC_A/target/${{ matrix.target }}/release/$name$ext" b="$src_b/target/${{ matrix.target }}/release/$name$ext" [ -f "$a" ] || continue if cmp -s "$a" "$b"; then echo - line 201
" identical: $name$ext" else echo "::warning::$name$ext differs between two builds of the same source" # Report enough to diagnose it next time rather than guess. echo " size A: $(wc -c < "$a") size B: $(wc -c < "$b")" echo " first - line 201
differing byte: $(cmp "$a" "$b" 2>&1 | head -1)" echo " differing byte count: $(cmp -l "$a" "$b" 2>/dev/null | wc -l)" status=not-reproducible fi done done echo "status=$status" >> "$GITHUB_OUTPUT" echo "${{ matrix.label }}: $status" > - line 201
"repro-${{ matrix.label }}.txt" cp "repro-${{ matrix.label }}.txt" "$SRC_A/" - line 241
# The icon lives in the executable's resource section, and a build that # failed to put it there succeeds silently -- the only symptom is # Explorer drawing a generic glyph. Checked on the artefacts that are # about to be signed. - name: - line 241
Windows binaries must carry their icon if: runner.os == 'Windows' shell: bash # `target/<triple>/release`, not `target/release`: every build here is # an explicit `--target`, so cargo puts the output under the triple. # Pointing at the - line 241
wrong path made the check report "no Windows binaries # found" and fail the whole release -- which is the right behaviour for # a check that must never pass vacuously, and the wrong path to give it. run: python - line 241
tools/release/check-windows-icons.py "target/${{ matrix.target }}/release" - name: Stage the archive shell: bash run: | out="veilvoice-${{ env.VERSION_TAG }}-${{ matrix.label }}" mkdir -p "dist/$out" bin="target/${{ matrix.target - line 241
}}/release" for name in $BINARIES; do for ext in "" ".exe"; do [ -f "$bin/$name$ext" ] && cp "$bin/$name$ext" "dist/$out/" done done cp README.md LICENSE "dist/$out/" cp "repro-${{ matrix.label }}.txt" dist/ 2>/dev/null || true if [ "${{ - line 241
matrix.cli_only }}" = "true" ]; then { echo "This build contains the command-line tool only, without live" echo "microphone scrambling." echo echo "The desktop app needs the GTK and OpenGL stack for this" echo "architecture, and live - line 241
capture needs ALSA headers for it. Neither is" echo "available to a cross or statically linked build. Everything else" echo "works: anonymise, clean, encrypt, decrypt, keygen, shred." echo echo "Run 'veilvoice info' to confirm what this - line 241
binary supports." echo "To get live scrambling, build natively on the target machine." } > "dist/$out/NOTES.txt" - line 281
fi cp -r docs "dist/$out/docs" # The per-file documentation references a generated SVG banner for # every crate and every source file. Shipping the pages without them # gives an offline reader 63 documents of broken images, so the # - line 281
banners travel with the pages they belong to. About 100 KB of text. mkdir -p "dist/$out/assets" cp -r assets/banners "dist/$out/assets/banners" # Platform icons, in the form each system actually reads. # # Windows needs nothing here: - line 281
`build.rs` embeds `assets/icon.ico` in # each executable's resource section, which is where Explorer, the # taskbar and a pinned shortcut look. Shipping a loose `.ico` beside # the binary -- which is what used to happen -- puts it - line 281
somewhere # Windows never reads and the user never opens. case "${{ matrix.label }}" in macos-*) # The Finder reads `.icns`, and only from inside a bundle. The # file ships so a user (or a future `.app` recipe) has it; the # bundle itself - line 281
is not built here and is not claimed to be. cp assets/icon.icns "dist/$out/" ;; linux-*) # freedesktop: a launcher entry plus the hicolor theme sizes. # Without these every Linux and BSD desktop draws a placeholder. cp -r assets/linux - line 281
"dist/$out/desktop" ;; esac echo "STAGE=$out" >> "$GITHUB_ENV" - name: Archive (zip) if: matrix.archive == 'zip' shell: pwsh run: Compress-Archive -Path "dist/$env:STAGE" -DestinationPath "dist/$env:STAGE.zip" - name: Archive (tar.gz) if: - line 281
matrix.archive == 'tar.gz' shell: bash run: tar -C dist -czf "dist/$STAGE.tar.gz" "$STAGE" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - line 321
name: ${{ matrix.label }} path: | dist/*.${{ matrix.archive }} dist/repro-*.txt if-no-files-found: error # FreeBSD has no GitHub runner, so this builds inside a VM on a Linux one. # # It is CLI-only and deliberately so: `cpal` has no - line 321
backend for the BSDs, so # live capture cannot work there. Everything else, meaning file # de-identification, encryption and metadata cleaning, is pure Rust and runs # fine, which is why the `live` feature exists to be turned off rather - line 321
than # the whole build failing. # # Marked continue-on-error: a third-party VM action failing should not hold up # a release for the four first-class platforms. When it fails, the release # simply ships without a FreeBSD archive and says - line 321
so. freebsd: name: freebsd-x86_64 runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The VM has no git, and a build whose timestamp comes from the clock # is a - line 321
build that never reproduces. Written out here, where git # exists, and read inside the VM. - name: Pin the build timestamp for the VM run: git log -1 --pretty=%ct > .source-date-epoch - name: Build the CLI in a FreeBSD VM uses: - line 321
vmactions/freebsd-vm@4469451fe39bee80be4066836c5170362e9349f3 # v1 with: usesh: true prepare: pkg install -y rust run: | set -e # The same three things the other ten platforms do to make a build # reproducible: pin the timestamp, remap the - line 321
source path, and remap # the cargo home. Without all three the second build below differs # for reasons that have nothing to do with the source. SOURCE_DATE_EPOCH="$(cat .source-date-epoch)" - line 361
export SOURCE_DATE_EPOCH src_a="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_a=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo" cargo build --release --locked -p veilvoice-cli --no-default-features ./target/release/veilvoice - line 361
info cp target/release/veilvoice "$HOME/vv-first" # Build again from a copy at a different path. This is the whole # check: if the two differ, something in the build depends on where # it happened or when, and "verify the binary matches - line 361
the source" # would be a claim nobody could act on. rebuild="$HOME/vv-rebuild" rm -rf "$rebuild" cp -r "$src_a" "$rebuild" cd "$rebuild" src_b="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_b=/veilvoice - line 361
--remap-path-prefix=$HOME/.cargo=/cargo" cargo build --release --locked -p veilvoice-cli --no-default-features if cmp -s "$HOME/vv-first" target/release/veilvoice; then echo reproducible > "$src_a/.repro-verdict" else # Said in as many - line 361
words rather than dropped: a platform that stops # reproducing must be visible in the release, not silently absent. echo "not-reproducible (two builds of the same source differ)" \ > "$src_a/.repro-verdict" echo "::warning::the second - line 361
build differs from the first" fi cd "$src_a" - name: Stage the archive run: | out="veilvoice-${{ env.VERSION_TAG }}-freebsd-x86_64" mkdir -p "dist/$out" cp target/release/veilvoice "dist/$out/" cp README.md LICENSE "dist/$out/" cp -r docs - line 361
"dist/$out/docs" # The per-file documentation references a generated SVG banner for # every crate and every source file. Shipping the pages without them # gives an offline reader 63 documents of broken images, so the # banners travel with - line 361
the pages they belong to. About 100 KB of text. - line 401
mkdir -p "dist/$out/assets" cp -r assets/banners "dist/$out/assets/banners" # FreeBSD, OpenBSD and NetBSD use the same freedesktop launcher # conventions as Linux, so they get the same icon set. cp -r assets/linux "dist/$out/desktop" { - line 401
echo "This FreeBSD build has no live microphone scrambling: cpal, the" echo "audio device library, has no BSD backend. Everything else works:" echo "anonymise, clean, encrypt, decrypt, keygen." echo echo "Run 'veilvoice info' to confirm - line 401
what this binary supports." } > "dist/$out/FREEBSD-NOTES.txt" tar -C dist -czf "dist/$out.tar.gz" "$out" # The verdict the VM actually reached, not an assumption about it. echo "freebsd-x86_64: $(cat .repro-verdict 2>/dev/null || echo - line 401
'not-verified (the second build did not run)')" \ > dist/repro-freebsd-x86_64.txt - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: freebsd-x86_64 path: | dist/*.tar.gz dist/repro-*.txt - line 401
if-no-files-found: error openbsd: name: openbsd-x86_64 runs-on: ubuntu-latest # Allowed to fail without stopping a release, exactly like FreeBSD. These # run in an emulated VM on a Linux runner and are the most fragile jobs # here; a - line 401
release should not be blocked because somebody else's VM image # moved. When one fails the release simply ships without that archive. continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # - line 401
The VM has no git, and a build whose timestamp comes from the clock # is a build that never reproduces. Written out here, where git # exists, and read inside the VM. - name: Pin the build timestamp for the VM run: git log -1 --pretty=%ct > - line 401
.source-date-epoch - name: Build the CLI in a Openbsd VM - line 441
uses: vmactions/openbsd-vm@86cdc08415d9d0865267e686561e276c52d62530 # v1 with: usesh: true prepare: pkg_add rust run: | set -e # The same three things the other ten platforms do to make a build # reproducible: pin the timestamp, remap the - line 441
source path, and remap # the cargo home. Without all three the second build below differs # for reasons that have nothing to do with the source. SOURCE_DATE_EPOCH="$(cat .source-date-epoch)" export SOURCE_DATE_EPOCH src_a="$(pwd -P)" - line 441
export RUSTFLAGS="--remap-path-prefix=$src_a=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo" cargo build --release --locked -p veilvoice-cli --no-default-features ./target/release/veilvoice info cp target/release/veilvoice - line 441
"$HOME/vv-first" # Build again from a copy at a different path. This is the whole # check: if the two differ, something in the build depends on where # it happened or when, and "verify the binary matches the source" # would be a claim - line 441
nobody could act on. rebuild="$HOME/vv-rebuild" rm -rf "$rebuild" cp -r "$src_a" "$rebuild" cd "$rebuild" src_b="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_b=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo" cargo build - line 441
--release --locked -p veilvoice-cli --no-default-features if cmp -s "$HOME/vv-first" target/release/veilvoice; then echo reproducible > "$src_a/.repro-verdict" else # Said in as many words rather than dropped: a platform that stops # - line 441
reproducing must be visible in the release, not silently absent. echo "not-reproducible (two builds of the same source differ)" \ > "$src_a/.repro-verdict" echo "::warning::the second build differs from the first" fi cd "$src_a" - line 481
- name: Stage the archive run: | out="veilvoice-${{ env.VERSION_TAG }}-openbsd-x86_64" mkdir -p "dist/$out" cp target/release/veilvoice "dist/$out/" cp README.md LICENSE "dist/$out/" cp -r docs "dist/$out/docs" # The per-file documentation - line 481
references a generated SVG banner for # every crate and every source file. Shipping the pages without them # gives an offline reader 63 documents of broken images, so the # banners travel with the pages they belong to. About 100 KB of - line 481
text. mkdir -p "dist/$out/assets" cp -r assets/banners "dist/$out/assets/banners" # FreeBSD, OpenBSD and NetBSD use the same freedesktop launcher # conventions as Linux, so they get the same icon set. cp -r assets/linux "dist/$out/desktop" - line 481
{ echo "This build has no live microphone scrambling: cpal, the audio" echo "device library, has no BSD backend. Everything else works:" echo "anonymise, clean, encrypt, decrypt, keygen, shred." echo echo "OpenBSD's pledge/unveil are not - line 481
used: VeilVoice opens files the user names, and a sandbox it configures for itself would be a claim this project has not tested." echo echo "Run 'veilvoice info' to confirm what this binary supports." } > "dist/$out/NOTES.txt" tar -C dist - line 481
-czf "dist/$out.tar.gz" "$out" # Built once, in a VM, rather than twice in separate directories. # Reported as what it is rather than claimed. # The verdict the VM actually reached, not an assumption about it. echo "openbsd-x86_64: $(cat - line 481
.repro-verdict 2>/dev/null || echo 'not-verified (the second build did not run)')" \ > dist/repro-openbsd-x86_64.txt - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: openbsd-x86_64 path: | - line 481
dist/*.tar.gz dist/repro-*.txt if-no-files-found: error netbsd: - line 521
name: netbsd-x86_64 runs-on: ubuntu-latest # Allowed to fail without stopping a release, exactly like FreeBSD. These # run in an emulated VM on a Linux runner and are the most fragile jobs # here; a release should not be blocked because - line 521
somebody else's VM image # moved. When one fails the release simply ships without that archive. continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The VM has no git, and a build whose - line 521
timestamp comes from the clock # is a build that never reproduces. Written out here, where git # exists, and read inside the VM. - name: Pin the build timestamp for the VM run: git log -1 --pretty=%ct > .source-date-epoch - name: Build the - line 521
CLI in a Netbsd VM uses: vmactions/netbsd-vm@20ee93a005c9fc78743e6e05269f4cb023b87413 # v1 with: usesh: true prepare: /usr/sbin/pkg_add rust run: | set -e # The same three things the other ten platforms do to make a build # reproducible: - line 521
pin the timestamp, remap the source path, and remap # the cargo home. Without all three the second build below differs # for reasons that have nothing to do with the source. SOURCE_DATE_EPOCH="$(cat .source-date-epoch)" export - line 521
SOURCE_DATE_EPOCH src_a="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_a=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo" cargo build --release --locked -p veilvoice-cli --no-default-features ./target/release/veilvoice info cp - line 521
target/release/veilvoice "$HOME/vv-first" # Build again from a copy at a different path. This is the whole # check: if the two differ, something in the build depends on where # it happened or when, and "verify the binary matches the - line 521
source" # would be a claim nobody could act on. rebuild="$HOME/vv-rebuild" rm -rf "$rebuild" cp -r "$src_a" "$rebuild" - line 561
cd "$rebuild" src_b="$(pwd -P)" export RUSTFLAGS="--remap-path-prefix=$src_b=/veilvoice --remap-path-prefix=$HOME/.cargo=/cargo" cargo build --release --locked -p veilvoice-cli --no-default-features if cmp -s "$HOME/vv-first" - line 561
target/release/veilvoice; then echo reproducible > "$src_a/.repro-verdict" else # Said in as many words rather than dropped: a platform that stops # reproducing must be visible in the release, not silently absent. echo "not-reproducible - line 561
(two builds of the same source differ)" \ > "$src_a/.repro-verdict" echo "::warning::the second build differs from the first" fi cd "$src_a" - name: Stage the archive run: | out="veilvoice-${{ env.VERSION_TAG }}-netbsd-x86_64" mkdir -p - line 561
"dist/$out" cp target/release/veilvoice "dist/$out/" cp README.md LICENSE "dist/$out/" cp -r docs "dist/$out/docs" # The per-file documentation references a generated SVG banner for # every crate and every source file. Shipping the pages - line 561
without them # gives an offline reader 63 documents of broken images, so the # banners travel with the pages they belong to. About 100 KB of text. mkdir -p "dist/$out/assets" cp -r assets/banners "dist/$out/assets/banners" # FreeBSD, - line 561
OpenBSD and NetBSD use the same freedesktop launcher # conventions as Linux, so they get the same icon set. cp -r assets/linux "dist/$out/desktop" { echo "This build has no live microphone scrambling: cpal, the audio" echo "device library, - line 561
has no BSD backend. Everything else works:" echo "anonymise, clean, encrypt, decrypt, keygen, shred." echo echo "Built against pkgsrc's Rust, which may lag the pinned toolchain; that is why this archive is marked not-verified for - line 561
reproducibility." echo echo "Run 'veilvoice info' to confirm what this binary supports." } > "dist/$out/NOTES.txt" - line 601
tar -C dist -czf "dist/$out.tar.gz" "$out" # Built once, in a VM, rather than twice in separate directories. # Reported as what it is rather than claimed. # The verdict the VM actually reached, not an assumption about it. echo - line 601
"netbsd-x86_64: $(cat .repro-verdict 2>/dev/null || echo 'not-verified (the second build did not run)')" \ > dist/repro-netbsd-x86_64.txt - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: - line 601
netbsd-x86_64 path: | dist/*.tar.gz dist/repro-*.txt if-no-files-found: error publish: name: publish needs: [build, freebsd, openbsd, netbsd] # `build` must succeed; `freebsd` is allowed to fail without stopping a # release, so this runs - line 601
as long as the matrix itself did. if: always() && needs.build.result == 'success' runs-on: ubuntu-latest # Evaluated at job level on purpose. A step's own `env:` block is not in # scope for that step's `if:`, so gating on it there silently - line 601
disables the # step forever, which is exactly what v0.1.0 and v0.1.1 did. env: HAS_SIGNING_KEY: ${{ secrets.GPG_PRIVATE_KEY != '' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: - line 601
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: staging merge-multiple: true # **Roadmap item 97.** A signed list of what is inside every archive. # # `SHA256SUMS` covers the archives. That proves a - line 601
download is the one that # was published and says nothing whatever about the folder somebody # unzipped it into, which is the copy they actually run. Nothing on disk # records which archive a directory came from, so a verifier could only - line 641
# report the two separately and tell people to extract it again. # # This closes that gap with arithmetic rather than advice. Staged # **before** `SHA256SUMS` is computed, so the hash list covers this file # too and the signature therefore - line 641
covers it as well. The chain a verifier # can then follow is complete and every link in it is checkable: # # SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each file on disk # # A script rather than six lines of shell here, because six - line 641
lines of shell # cannot be run on a laptop, cannot be tested, and would be exercised for # the first time on the day a release goes out. This is the one link in # that chain nothing else checks, and # - line 641
`crates/veilvoice-verify/tests/release_manifest.rs` builds a synthetic # release, runs this script, and reads the result back with the parser # that will read it for real. - uses: - line 641
actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.12' - name: List what is inside each archive run: python tools/release/contents.py staging -o staging/CONTENTS.sha256 - name: Checksums run: | cd - line 641
staging # The reproducibility reports are notes about the build, not artefacts # of it, so they are summarised in the notes rather than shipped. mkdir -p ../repro && mv repro-*.txt ../repro/ 2>/dev/null || true sha256sum * > SHA256SUMS cat - line 641
SHA256SUMS # Optional: only runs when the maintainer has added the secret. Forks and # first-time contributors still get a complete, unsigned release. - name: Sign the checksums id: sign if: env.HAS_SIGNING_KEY == 'true' env: - line 641
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | - line 681
# printf '%s', not echo: echo appends a newline, and a passphrase # with a trailing newline is simply the wrong passphrase. printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import # --passphrase-file, never --passphrase: an argument is - line 681
visible to # every other process through the process list. pp="$(mktemp)" printf '%s' "$GPG_PASSPHRASE" > "$pp" # Detached, and over the hash file only. The binaries are never # touched, so signing cannot disturb their reproducibility. gpg - line 681
--batch --yes --pinentry-mode loopback --passphrase-file "$pp" \ --detach-sign --armor staging/SHA256SUMS shred -u "$pp" 2>/dev/null || rm -f "$pp" # Publish the public half so a verifier need not trust a keyserver. gpg --armor --export > - line 681
staging/veilvoice-signing-key.asc # Verify here rather than let whoever downloads it find out first. gpg --verify staging/SHA256SUMS.asc staging/SHA256SUMS echo "signed=true" >> "$GITHUB_OUTPUT" - name: Release notes run: | # The changelog - line 681
lives in the repository, so what a release claims to # contain is reviewable in the same commit that contains it, rather # than being written from memory at tag time. tag="${{ env.VERSION_TAG }}" changes="" if [ -f CHANGELOG.md ]; then - line 681
changes="$(awk -v want="## $tag" ' $0 == want { taking = 1; next } taking && /^## / { exit } taking { print } ' CHANGELOG.md)" fi # **A release that could not read its own changelog does not publish.** # # This used to print "No changelog - line 681
section found" into the notes and - line 721
# carry on, and v0.1.21 was published that way: the heading had been # written `## 0.1.21 - 2026-09-10` while this reads `## v0.1.21` # exactly, so the whole entry, seven hundred lines of it, was silently # dropped and the release went out - line 721
describing nothing. # # A note saying the notes are missing is not a smaller version of the # notes. It is a published release nobody can tell the contents of, # and this project's own rule is that a release's notes live in # CHANGELOG.md - line 721
and everything else derives from it. Deriving nothing # is a failure, so it fails here rather than shipping. # # A dry run is exempt: it publishes nothing, so it is allowed to # report the gap instead of stopping. That is the same - line 721
condition the # gate below uses, written the same way: a tag push always publishes, # and a dispatch publishes when it was given a version. publishing="" if [ "${{ github.event_name }}" != "workflow_dispatch" ] \ || [ -n "${{ - line 721
github.event.inputs.tag }}" ]; then publishing=yes fi if [ -z "$changes" ] && [ -n "$publishing" ]; then echo "::error::CHANGELOG.md has no '## $tag' section, so this release would publish with no notes. The heading must be exactly '## - line 721
$tag', matching every other entry in the file." exit 1 fi { echo "## VeilVoice ${{ env.VERSION_TAG }}" echo echo "Irreversible voice de-identification, fully offline." echo if [ -n "$changes" ]; then echo "$changes" echo echo "---" echo - line 721
else echo "> No changelog section found for \`$tag\` in \`CHANGELOG.md\`." echo fi echo "### Verify what you downloaded" - line 761
echo echo '```bash' echo "sha256sum -c SHA256SUMS --ignore-missing" echo '```' echo if [ -f staging/SHA256SUMS.asc ]; then fpr="$(gpg --show-keys --with-colons staging/veilvoice-signing-key.asc | awk -F: '/^fpr:/ {print $10; exit}')" echo - line 761
"### Verify the signature" echo echo "\`SHA256SUMS.asc\` is a detached OpenPGP signature over the hash list." echo "No binary is signed in place, so signing cannot disturb the bit-for-bit" echo "reproducibility above." echo echo "**1. - line 761
Import the key** (also on the [website](https://tilas01.github.io/veilvoice/#verify)):" echo echo '```bash' echo "gpg --import veilvoice-signing-key.asc" echo '```' echo echo "**2. Check the fingerprint is exactly this.** A \"Good - line 761
signature\" from" echo "some other key proves nothing. The fingerprint is the part that matters:" echo echo '```' echo "$fpr" echo '```' echo echo "**3. Verify the hash list, then the files against it:**" echo echo '```bash' echo "gpg - line 761
--verify SHA256SUMS.asc SHA256SUMS" echo "sha256sum -c SHA256SUMS --ignore-missing" echo '```' echo echo "Expect \`Good signature from \"tilas01\"\`. The key carries no e-mail" echo "address by design. GnuPG will also warn that the key is - line 761
not certified" echo "with a trusted signature. That is normal and simply means you have not" echo "personally signed it; the fingerprint check above is what establishes" echo "that it is the right key." echo echo "On macOS use \`shasum -a - line 761
256 -c SHA256SUMS --ignore-missing\`." - line 801
else echo "> This build is **unsigned**: no signing key is configured for this repository." fi echo echo "### Reproducibility" echo echo "Every binary was built twice, in different directories, and compared:" echo for f in - line 801
repro/repro-*.txt; do [ -f "$f" ] && echo "- \`$(cat "$f")\`" done echo echo "See \`docs/REPRODUCIBLE_BUILDS.md\` to reproduce a build yourself." echo echo "Built on: \`${{ runner.os }}\` runners, toolchain pinned by - line 801
\`rust-toolchain.toml\`." } > NOTES.md # **What decides whether this publishes, and the two things checked # before it does.** # # A tag push publishes, as it always has. A manual run publishes only # when it was given a version, and then - line 801
only if that version survives # both checks below; a manual run with no version is a dry run and # reaches the summary at the end instead. # # The checks matter because a release cut from a branch has none of the # protection a tag gives. - line 801
A tag is a commit somebody chose; a branch moves. # So the shape of the version is checked, because `softprops` would # cheerfully create a tag called `main`, and the version is checked # against the workspace, because publishing `v0.1.16` - line 801
from a tree that # says `0.1.15` produces a release whose own binaries disagree with its # name. - name: Is this a release, and is it a coherent one id: gate run: | tag="${{ env.VERSION_TAG }}" if [ "${{ github.event_name }}" = - line 801
"workflow_dispatch" ] \ && [ -z "${{ github.event.inputs.tag }}" ]; then echo "no version given: this is a dry run and nothing will be published" echo "publish=false" >> "$GITHUB_OUTPUT" - line 841
exit 0 fi case "$tag" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "::error::'$tag' is not a version like v0.1.15"; exit 1 ;; esac want="${tag#v}" have="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)" if [ "$want" != "$have" ]; then - line 841
echo "::error::asked to publish $tag from a tree whose workspace version is $have" exit 1 fi echo "publishing $tag from a workspace at $have" echo "publish=true" >> "$GITHUB_OUTPUT" # **`target_commitish` is what makes the tag name the - line 841
commit that was # built**, and it was missing. Creating a release for a tag that does not # exist yet makes GitHub create the tag, and with no target it creates it # at the **default branch**, not at the commit this run compiled. So a # - line 841
release dispatched from a branch, or from `main` after `main` had moved # on, published binaries from one commit under a tag pointing at another. # # Both v0.1.20 and v0.1.21 were tagged that way before this was found: # v0.1.21's tag - line 841
landed on a tree whose `Cargo.toml` still said 0.1.20, and # v0.1.20's landed thirteen minutes ahead of what it published, on the # commit that happens to be F-169. # # This is the one thing in this workflow that cannot be a small mistake. - line 841
# Everything above exists to let somebody rebuild these binaries from # this source and compare; a tag pointing at different source means they # get a different answer and correctly conclude that the release does not # reproduce. The claim - line 841
is the product here, so this line is the product. - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 if: steps.gate.outputs.publish == 'true' with: tag_name: ${{ env.VERSION_TAG }} target_commitish: ${{ - line 841
github.sha }} files: staging/* body_path: NOTES.md draft: false - line 881
prerelease: ${{ contains(env.VERSION_TAG, '-') }} - name: What a dry run produced if: steps.gate.outputs.publish != 'true' run: | echo "Dry run for ${{ env.VERSION_TAG }}. Nothing was published." echo cat staging/SHA256SUMS echo echo "--- - line 881
release notes ---" cat NOTES.md
.github/workflows/wiki.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Publish `wiki/` to the repository's GitHub wiki. # # A GitHub wiki is a git repository of its own, at `<repo>.wiki.git`, holding # flat Markdown pages and nothing else. The pages in `wiki/` - line 1
are generated into # the main repository by `tools/docs/generate.py`, `tools/docs/guides.py` and # `tools/docs/wiki.py`, and CI already fails if they have drifted from the # source. This is the step that puts them where a reader looks. # # - line 1
Why a copy rather than a symlink or a submodule: the wiki repository cannot # contain either. It is flat Markdown, so the pages are written into it. # # The wiki repository does not exist until the first page is created through # the web - line 1
interface. Enabling the wiki in Settings is not enough on its own, so # the clone below says exactly that when it fails, rather than leaving somebody # reading a git error about a repository that was never initialised. name: wiki on: push: - line 1
branches: [main] paths: - 'wiki/**' - '.github/workflows/wiki.yml' workflow_dispatch: # Pushing to the wiki is a write to this repository's content. permissions: contents: write # One at a time. Two runs racing to push to the same wiki - line 1
repository is a # rejected push and a red workflow for no reason anybody can act on. concurrency: group: wiki cancel-in-progress: false jobs: publish: - line 41
name: publish the wiki runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Clone the wiki repository run: | set -euo pipefail if ! git clone \ "https://x-access-token:${{ - line 41
secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" \ wiki-repo 2>/tmp/clone.log; then cat /tmp/clone.log echo "::error::Could not clone ${{ github.repository }}.wiki.git." echo "A GitHub wiki repository is not created - line 41
when the wiki is" echo "enabled in Settings. It is created when the first page is" echo "saved through the web interface. Open the repository's Wiki" echo "tab, create a page with any content, save it, and re-run" echo "this workflow: - line 41
everything it holds is overwritten from" echo "wiki/ on the first successful run, so what that first page" echo "says does not matter." exit 1 fi - name: Copy the generated pages in run: | set -euo pipefail # Every page in the wiki is - line 41
generated, so the wiki is replaced rather # than merged into: a page deleted here must not survive there. The # `.git` directory is what makes this a delete-and-copy rather than a # fresh `git init`, because the history is worth keeping. - line 41
find wiki-repo -maxdepth 1 -name '*.md' -delete cp wiki/*.md wiki-repo/ echo "pages now in the wiki: $(ls wiki-repo/*.md | wc -l)" - name: Commit and push, if anything changed working-directory: wiki-repo run: | set -euo pipefail git - line 41
config user.name "tilas01" git config user.email "tilas01@users.noreply.github.com" - line 81
git add -A if git diff --cached --quiet; then echo "the wiki already matches the repository, nothing to push" exit 0 fi git commit -m "Publish the wiki from ${{ github.sha }} Generated in the main repository and copied here. Every page has - line 81
a source file there, and CI fails if the two disagree, so editing a page here would be overwritten by the next run." git push echo "pushed"
Cargo.lock
- line 1
# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "accesskit" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1
"d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ "uuid", ] [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1
"320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1
"d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "bytes", "crypto-common", "generic-array", ] [[package]] name = "aes" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] - line 41
[[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", "aes", "cipher", "ctr", - line 41
"ghash", "subtle", ] [[package]] name = "aes-kw" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" dependencies = [ "aes", ] - line 41
[[package]] name = "ahash" version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "getrandom 0.3.4", - line 41
"once_cell", "version_check", "zerocopy", ] [[package]] name = "alsa" version = "0.11.0" - line 81
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.13.2", "cfg-if", "libc", ] [[package]] name = - line 81
"alsa-sys" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", ] [[package]] name = - line 81
"android-activity" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", "bitflags 2.13.2", - line 81
"cc", "jni", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum", "thiserror 2.0.20", ] [[package]] name = "android-properties" - line 121
version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "anstream" version = "1.0.0" source = - line 121
"registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - line 121
"is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - line 121
[[package]] name = "anstyle-parse" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] - line 121
name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - line 161
"windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - line 161
"anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "arboard" version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 161
"0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", "log", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", - line 161
"parking_lot", "percent-encoding", "windows-sys 0.60.2", "x11rb", ] [[package]] name = "argon2" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 161
"3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ - line 201
"base64ct", "blake2", "cpufeatures", "password-hash", "zeroize", ] [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 201
"d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 201
"175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "ashpd" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 201
"d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ "enumflags2", "futures-channel", "futures-util", "rand 0.9.5", "raw-window-handle", "serde", "serde_repr", "tokio", "url", "zbus", ] [[package]] name = - line 201
"async-broadcast" version = "0.7.2" - line 241
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ "event-listener", "event-listener-strategy", "futures-core", - line 241
"pin-project-lite", ] [[package]] name = "async-recursion" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - line 241
"proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "async-trait" version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" - line 241
dependencies = [ "proc-macro2", "quote", "syn 3.0.5", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 241
"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.5.1" - line 281
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base16ct" version = "0.2.0" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" version = "0.22.1" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" version = "1.8.3" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" version = "0.10.0" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.9.1" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitfields" version = "1.0.3" source = - line 281
"registry+https://github.com/rust-lang/crates.io-index" - line 321
checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" dependencies = [ "bitfields-impl", ] [[package]] name = "bitfields-impl" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" - line 321
checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", "thiserror 2.0.20", ] [[package]] name = "bitflags" version = "1.3.2" source = - line 321
"registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.13.2" source = - line 321
"registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" version = "1.1.1" source = - line 321
"registry+https://github.com/rust-lang/crates.io-index" checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", "tap", "wyz", ] - line 361
[[package]] name = "blake2" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ "digest", ] [[package]] name = - line 361
"block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = - line 361
"block-padding" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] [[package]] name = - line 361
"block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ "objc2 0.5.2", ] [[package]] name = "block2" version - line 361
= "0.6.2" - line 401
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ "objc2 0.6.4", ] [[package]] name = "blowfish" version = "0.9.1" source = - line 401
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", "cipher", ] [[package]] name = "buffer-redux" version = "1.1.0" source = - line 401
"registry+https://github.com/rust-lang/crates.io-index" checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" dependencies = [ "memchr", ] [[package]] name = "bumpalo" version = "3.20.3" source = - line 401
"registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" version = "1.25.2" source = - line 401
"registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] - line 441
[[package]] name = "bytemuck_derive" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - line 441
"syn 3.0.5", ] [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = - line 441
"byteorder-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" version = "1.12.1" source = - line 441
"registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" version = "0.13.0" source = - line 441
"registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", "slab", - line 481
"thiserror 1.0.69", ] [[package]] name = "calloop" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags - line 481
2.13.2", "polling", "rustix 1.1.4", "slab", "tracing", ] [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 481
"95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] [[package]] name = "calloop-wayland-source" version = "0.4.1" source = - line 481
"registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", "rustix 1.1.4", "wayland-backend", "wayland-client", ] - line 521
[[package]] name = "camellia" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" dependencies = [ "byteorder", "cipher", ] - line 521
[[package]] name = "cast5" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" dependencies = [ "cipher", ] [[package]] name = - line 521
"cc" version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] - line 521
[[package]] name = "cfb-mode" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" dependencies = [ "cipher", ] - line 561
[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" version = "0.2.2" - line 561
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "cgl" version = "0.3.2" source = - line 561
"registry+https://github.com/rust-lang/crates.io-index" checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" dependencies = [ "libc", ] [[package]] name = "chacha20" version = "0.9.1" source = - line 561
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "chacha20poly1305" version = - line 561
"0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - line 601
"cipher", "poly1305", "zeroize", ] [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = - line 601
[ "crypto-common", "inout", "zeroize", ] [[package]] name = "clap" version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" - line 601
dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 601
"7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" - line 641
version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", "syn 3.0.5", ] [[package]] - line 641
name = "clap_lex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" version = "5.4.1" source - line 641
= "registry+https://github.com/rust-lang/crates.io-index" checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", ] [[package]] name = "cmac" version = "0.7.2" source = - line 641
"registry+https://github.com/rust-lang/crates.io-index" checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", "digest", ] [[package]] name = "codespan-reporting" version = "0.13.1" - line 641
source = "registry+https://github.com/rust-lang/crates.io-index" - line 681
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "unicode-width", ] [[package]] name = "color" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 681
"2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" dependencies = [ "bytemuck", ] [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 681
"1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 681
"cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", ] [[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 681
"4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] [[package]] - line 721
name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" version = "0.10.0" source - line 721
= "registry+https://github.com/rust-lang/crates.io-index" checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] [[package]] name = "core-foundation" version = "0.9.4" - line 721
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" - line 721
version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" version = "0.23.2" source = - line 721
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation", "core-graphics-types", "foreign-types", - line 761
"libc", ] [[package]] name = "core-graphics-types" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags - line 761
1.3.2", "core-foundation", "libc", ] [[package]] name = "coreaudio-rs" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" - line 761
dependencies = [ "bitflags 2.13.2", "libc", "objc2-audio-toolbox", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", ] [[package]] name = "cpal" version = "0.18.2" source = - line 761
"registry+https://github.com/rust-lang/crates.io-index" checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" dependencies = [ "alsa", "block2 0.6.2", "coreaudio-rs", "dasp_sample", "jni", "js-sys", - line 801
"libc", "mach2 0.6.0", "ndk", "ndk-context", "num-derive", "num-traits", "objc2 0.6.4", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", "web-sys", - line 801
"windows", "windows-core", ] [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = - line 801
[ "libc", ] [[package]] name = "crc24" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" [[package]] name = "crc32fast" version - line 801
= "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", - line 841
] [[package]] name = "crossbeam-utils" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" version = - line 841
"0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" version = "0.5.5" source = - line 841
"registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", "rand_core 0.6.4", "subtle", "zeroize", ] [[package]] name = - line 841
"crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "rand_core 0.6.4", - line 841
"typenum", ] [[package]] name = "ctr" version = "0.9.2" - line 881
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ "cipher", ] [[package]] name = "cursor-icon" version = "1.2.0" source = - line 881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" version = "4.1.3" source = - line 881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", "fiat-crypto", - line 881
"rustc_version", "subtle", "zeroize", ] [[package]] name = "curve25519-dalek-derive" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 881
"f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] - line 921
name = "cx448" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" dependencies = [ "crypto-bigint", "elliptic-curve", "pkcs8", - line 921
"rand_core 0.6.4", "serdect 0.3.0", "sha3", "signature", "subtle", "zeroize", ] [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 921
"fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" - line 921
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn 2.0.119", ] - line 961
[[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - line 961
"syn 2.0.119", ] [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = - line 961
"data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbl" version = "0.3.2" source = - line 961
"registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" dependencies = [ "generic-array", ] [[package]] name = "der" version = "0.7.10" source = - line 961
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", - line 1001
"zeroize", ] [[package]] name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - line 1001
"derive_builder_macro", ] [[package]] name = "derive_builder_core" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" - line 1001
dependencies = [ "darling", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "derive_builder_macro" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1001
"ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", "syn 2.0.119", ] [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1001
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - line 1041
"derive_more-impl", ] [[package]] name = "derive_more-impl" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - line 1041
"convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", "unicode-xid", ] [[package]] name = "des" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1041
"ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" dependencies = [ "cipher", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1041
"9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "const-oid", "crypto-common", "subtle", ] [[package]] name = "dispatch" - line 1081
version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "dispatch2" version = "0.3.1" source = - line 1081
"registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", ] [[package]] name = - line 1081
"displaydoc" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", "syn 3.0.5", ] - line 1081
[[package]] name = "dlib" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] [[package]] name = - line 1081
"document-features" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1121
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1121
"75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1121
"d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dsa" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1121
"48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" dependencies = [ "digest", "num-bigint-dig", "num-traits", "pkcs8", "rfc6979", "sha2", "signature", "zeroize", ] [[package]] name = "eax" version = "0.5.0" source = - line 1121
"registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ "aead", - line 1161
"cipher", "cmac", "ctr", "subtle", ] [[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies - line 1161
= [ "der", "digest", "elliptic-curve", "rfc6979", "signature", "spki", ] [[package]] name = "ecolor" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1161
"0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", ] [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1161
"115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", "signature", ] - line 1201
[[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", - line 1201
"ed25519", "rand_core 0.6.4", "serde", "sha2", "subtle", "zeroize", ] [[package]] name = "eframe" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1201
"55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" dependencies = [ "ahash", "bytemuck", "document-features", "egui", "egui-wgpu", "egui-winit", "egui_glow", "glow", "glutin", "glutin-winit", "image", "js-sys", "log", - line 1201
"objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "profiling", - line 1241
"raw-window-handle", "static_assertions", "wasm-bindgen", "web-sys", "web-time", "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1241
"dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ "accesskit", "ahash", "bitflags 2.13.2", "emath", "epaint", "itertools", "log", "nohash-hasher", "profiling", "smallvec", "unicode-segmentation", - line 1241
"web-sys", ] [[package]] name = "egui-wgpu" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" dependencies = [ "ahash", - line 1241
"bytemuck", "document-features", "egui", "epaint", - line 1281
"log", "profiling", "thiserror 2.0.20", "type-map", "web-time", "wgpu", "winit", ] [[package]] name = "egui-winit" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1281
"98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" dependencies = [ "arboard", "bytemuck", "egui", "log", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", "smithay-clipboard", - line 1281
"web-time", "winit", ] [[package]] name = "egui_glow" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" dependencies = [ - line 1281
"bytemuck", "egui", "glow", "log", "profiling", - line 1321
"winit", ] [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" - line 1321
version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "base64ct", "crypto-bigint", "digest", "ff", - line 1321
"generic-array", "group", "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", "serde_json", "serdect 0.2.0", "subtle", "tap", "zeroize", ] [[package]] name = "emath" version = "0.36.2" source = - line 1321
"registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ - line 1361
"bytemuck", ] [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" - line 1361
version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", ] [[package]] name = - line 1361
"enumflags2_derive" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] - line 1361
[[package]] name = "epaint" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" dependencies = [ "ahash", "bytemuck", "ecolor", - line 1361
"emath", - line 1401
"epaint_default_fonts", "font-types", "harfrust", "log", "nohash-hasher", "parking_lot", "profiling", "self_cell", "skrifa", "smallvec", "unicode-general-category", "unicode-segmentation", "vello_cpu", ] [[package]] name = - line 1401
"epaint_default_fonts" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" version = "1.0.2" - line 1401
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = - line 1401
"registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "error-code" version = "3.4.0" - line 1441
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "euclid" version = "0.22.14" source = - line 1441
"registry+https://github.com/rust-lang/crates.io-index" checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] [[package]] name = "event-listener" version = "5.4.2" source = - line 1441
"registry+https://github.com/rust-lang/crates.io-index" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ "parking", "pin-project-lite", ] [[package]] name = "event-listener-strategy" version = - line 1441
"0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ "event-listener", "pin-project-lite", ] [[package]] name = "extended" - line 1441
version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] name = "fastrand" - line 1481
version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" version = "0.2.7" source = - line 1481
"registry+https://github.com/rust-lang/crates.io-index" checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" version = "0.3.7" source = - line 1481
"registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] [[package]] name = "fearless_simd" version = "0.4.1" source = - line 1481
"registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "ff" version = "0.13.1" source = - line 1481
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ "bitvec", "rand_core 0.6.4", "subtle", ] [[package]] name = "fiat-crypto" version = - line 1481
"0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1521
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1521
"3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1521
"6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide 0.9.1", "zlib-rs", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1521
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1521
"77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1521
"b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" dependencies = [ "bytemuck", ] - line 1561
[[package]] name = "foreign-types" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - line 1561
"foreign-types-shared", ] [[package]] name = "foreign-types-macros" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" - line 1561
dependencies = [ "proc-macro2", "quote", "syn 3.0.5", ] [[package]] name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1561
"aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1561
"cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" - line 1601
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-channel" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1601
"b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1601
"92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1601
"53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1601
"f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", "futures-io", "parking", "pin-project-lite", ] [[package]] name = "futures-macro" version = "0.3.34" source = - line 1601
"registry+https://github.com/rust-lang/crates.io-index" - line 1641
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", "syn 3.0.5", ] [[package]] name = "futures-task" version = "0.3.34" source = - line 1641
"registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" version = "0.3.34" source = - line 1641
"registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", "futures-task", "pin-project-lite", "slab", ] - line 1641
[[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", - line 1641
"version_check", "zeroize", ] [[package]] name = "gethostname" version = "1.1.0" - line 1681
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", "windows-link", ] [[package]] name = "getrandom" version = - line 1681
"0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "getrandom" version = - line 1681
"0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi 5.3.0", "wasip2", ] [[package]] name = - line 1681
"getrandom" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", ] - line 1721
[[package]] name = "ghash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", "polyval", ] - line 1721
[[package]] name = "gl_generator" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" dependencies = [ "khronos_api", "log", - line 1721
"xml-rs", ] [[package]] name = "glifo" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - line 1721
"foldhash", "hashbrown", "log", "peniko", "skrifa", "smallvec", "vello_common", ] [[package]] name = "glow" - line 1761
version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", "wasm-bindgen", "web-sys", ] - line 1761
[[package]] name = "glutin" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ "bitflags 2.13.2", - line 1761
"cfg_aliases", "cgl", "dispatch2", "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", "libloading", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", "raw-window-handle", - line 1761
"wayland-sys", "windows-sys 0.52.0", "x11-dl", ] [[package]] name = "glutin-winit" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1761
"85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" - line 1801
dependencies = [ "cfg_aliases", "glutin", "raw-window-handle", "winit", ] [[package]] name = "glutin_egl_sys" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1801
"4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ "gl_generator", "windows-sys 0.52.0", ] [[package]] name = "glutin_glx_sys" version = "0.6.1" source = - line 1801
"registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" dependencies = [ "gl_generator", "x11-dl", ] [[package]] name = "glutin_wgl_sys" version = "0.6.1" source - line 1801
= "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" dependencies = [ "gl_generator", ] [[package]] name = "group" version = "0.13.0" source = - line 1801
"registry+https://github.com/rust-lang/crates.io-index" - line 1841
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "rand_core 0.6.4", "subtle", ] [[package]] name = "guillotiere" version = "0.7.0" source = - line 1841
"registry+https://github.com/rust-lang/crates.io-index" checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ "euclid", ] [[package]] name = "half" version = "2.7.1" source = - line 1841
"registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", "num-traits", "zerocopy", ] [[package]] name = "harfrust" version = - line 1841
"0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", "read-fonts", "smallvec", ] - line 1881
[[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash", ] [[package]] - line 1881
name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.5.3" source = - line 1881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" version = "0.4.3" source = - line 1881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" version = "0.12.4" source = - line 1881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ "hmac", ] [[package]] name = "hmac" version = "0.12.1" source = - line 1881
"registry+https://github.com/rust-lang/crates.io-index" - line 1921
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest", ] [[package]] name = "hound" version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1921
"62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" [[package]] name = "hybrid-array" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1921
"f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" dependencies = [ "typenum", ] [[package]] name = "icu_collections" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1921
"fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.3.0" source = - line 1921
"registry+https://github.com/rust-lang/crates.io-index" checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - line 1961
"displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 1961
"12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = - line 1961
"2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" version = "2.3.0" source = - line 1961
"registry+https://github.com/rust-lang/crates.io-index" checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", - line 1961
"icu_provider", "zerotrie", "zerovec", - line 2001
] [[package]] name = "icu_properties_data" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" - line 2001
version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", - line 2001
"zerofrom", "zerotrie", "zerovec", ] [[package]] name = "idea" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" dependencies = - line 2001
[ "cipher", ] [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" - line 2041
version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = - line 2041
"idna_adapter" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] - line 2041
[[package]] name = "image" version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - line 2041
"moxcms", "num-traits", "png", "tiff", ] [[package]] name = "img-parts" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" - line 2041
dependencies = [ "bytes", - line 2081
"crc32fast", "miniz_oxide 0.8.9", ] [[package]] name = "indexmap" version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" - line 2081
dependencies = [ "equivalent", "hashbrown", ] [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" - line 2081
dependencies = [ "generic-array", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - line 2081
[[package]] name = "itertools" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] [[package]] name - line 2081
= "itoa" - line 2121
version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" version = "0.22.4" source = - line 2121
"registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ "cfg-if", "combine", "jni-macros", "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror - line 2121
2.0.20", "walkdir", "windows-link", ] [[package]] name = "jni-macros" version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" - line 2121
dependencies = [ "proc-macro2", "quote", "rustc_version", "simd_cesu8", "syn 2.0.119", ] [[package]] name = "jni-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2121
"41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - line 2161
"jni-sys 0.4.1", ] [[package]] name = "jni-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - line 2161
"jni-sys-macros", ] [[package]] name = "jni-sys-macros" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - line 2161
"quote", "syn 2.0.119", ] [[package]] name = "jobserver" version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - line 2161
"getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - line 2161
"cfg-if", "futures-util", - line 2201
"wasm-bindgen", ] [[package]] name = "k256" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - line 2201
"ecdsa", "elliptic-curve", "once_cell", "sha2", "signature", ] [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2201
"cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] [[package]] name = "kem" version = "0.3.0-pre.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2201
"2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" dependencies = [ "rand_core 0.6.4", "zeroize", ] [[package]] name = "khronos_api" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" - line 2241
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2241
"4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", "polycool", "smallvec", ] [[package]] name = "lazy_static" version = "1.5.0" source = - line 2241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ "spin", ] [[package]] name = "libc" version = "0.2.189" source = - line 2241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" version = "0.8.9" source = - line 2241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", "windows-link", ] [[package]] - line 2281
name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" version = "0.1.24" source = - line 2281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "bitflags 2.13.2", "libc", "plain", "redox_syscall 0.9.4", ] [[package]] name = - line 2281
"linebender_resource_handle" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linux-raw-sys" version = - line 2281
"0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" version = "0.12.1" source = - line 2281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" version = "0.8.3" source = - line 2281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - line 2321
[[package]] name = "litrs" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" version = "0.4.14" - line 2321
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "lofty" version = "0.25.2" source = - line 2321
"registry+https://github.com/rust-lang/crates.io-index" checksum = "615d6334c1196e2f23f2708455bf8d71c374818cad7c34adaa0f8f885abf5d7c" dependencies = [ "byteorder", "data-encoding", "flate2", "lofty_attr", "log", "ogg_pager", "paste", ] - line 2321
[[package]] name = "lofty_attr" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd" dependencies = [ "proc-macro2", "quote", "syn - line 2321
3.0.5", - line 2361
] [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" version = "0.4.3" - line 2361
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] name = "mach2" version = "0.6.0" source = - line 2361
"registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "md-5" version = "0.10.6" source = - line 2361
"registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", "digest", ] [[package]] name = "memchr" version = "2.8.3" source = - line 2361
"registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] - line 2401
name = "memmap2" version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] [[package]] name = "memoffset" - line 2401
version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "miniz_oxide" version = - line 2401
"0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "miniz_oxide" version = - line 2401
"0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "mio" version = "1.2.3" - line 2441
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "ml-kem" version = - line 2441
"0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" dependencies = [ "hybrid-array", "kem", "rand_core 0.6.4", "sha3", ] [[package]] name = - line 2441
"moxcms" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", ] [[package]] name = "naga" - line 2441
version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.2", "cfg-if", - line 2481
"cfg_aliases", "codespan-reporting", "half", "hashbrown", "indexmap", "libm", "log", "naga-types", "num-traits", "once_cell", "rustc-hash 1.1.0", "thiserror 2.0.20", "unicode-ident", ] [[package]] name = "naga-types" version = "30.0.1" - line 2481
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" dependencies = [ "hashbrown", "indexmap", "rustc-hash 1.1.0", "thiserror 2.0.20", ] [[package]] - line 2481
name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.13.2", "jni-sys 0.3.1", "log", - line 2481
"ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", - line 2521
] [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "ndk-sys" version = - line 2521
"0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys 0.3.1", ] [[package]] name = "nohash-hasher" version - line 2521
= "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "nom" version = "8.0.0" source = - line 2521
"registry+https://github.com/rust-lang/crates.io-index" checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", ] [[package]] name = "num-bigint-dig" version = "0.8.6" source = - line 2521
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ "lazy_static", "libm", - line 2561
"num-integer", "num-iter", "num-traits", "rand 0.8.8", "serde", "smallvec", "zeroize", ] [[package]] name = "num-complex" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2561
"73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", ] [[package]] name = "num-derive" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2561
"ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "num-integer" version = "0.1.47" source = - line 2561
"registry+https://github.com/rust-lang/crates.io-index" checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" - line 2601
version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ "num-integer", "num-traits", ] [[package]] name = - line 2601
"num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", ] [[package]] name = - line 2601
"num_enum" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", ] [[package]] - line 2601
name = "num_enum_derive" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", - line 2601
"quote", "syn 2.0.119", ] - line 2641
[[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" version = "0.5.2" - line 2641
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ "objc-sys", "objc2-encode", ] [[package]] name = "objc2" version = "0.6.4" - line 2641
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] [[package]] name = "objc2-app-kit" version = "0.2.2" source = - line 2641
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", - line 2641
"objc2-core-image", "objc2-foundation 0.2.2", "objc2-quartz-core", ] - line 2681
[[package]] name = "objc2-app-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 - line 2681
0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] [[package]] name = "objc2-audio-toolbox" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2681
"6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", "objc2 0.6.4", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", ] [[package]] - line 2681
name = "objc2-avf-audio" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ "bitflags 2.13.2", "objc2 0.6.4", - line 2681
"objc2-foundation 0.3.2", ] - line 2721
[[package]] name = "objc2-cloud-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", - line 2721
"block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-contacts" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2721
"a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-core-audio" version = "0.3.2" source = - line 2721
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", "objc2 0.6.4", "objc2-core-audio-types", "objc2-core-foundation", - line 2721
"objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-audio-types" - line 2761
version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", "objc2 0.6.4", ] [[package]] name = - line 2761
"objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 - line 2761
0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" - line 2761
dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", "libc", "objc2 0.6.4", ] [[package]] name = "objc2-core-graphics" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2761
"e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", - line 2801
"dispatch2", "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] [[package]] name = "objc2-core-image" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2801
"55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", ] [[package]] name = "objc2-core-location" version = "0.2.2" source = - line 2801
"registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", ] [[package]] - line 2801
name = "objc2-encode" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" version = "0.2.2" - line 2801
source = "registry+https://github.com/rust-lang/crates.io-index" - line 2841
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", ] [[package]] name = "objc2-foundation" version = "0.3.2" source = - line 2841
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", "objc2-core-foundation", ] - line 2841
[[package]] name = "objc2-io-surface" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - line 2841
"objc2 0.6.4", "objc2-core-foundation", ] [[package]] name = "objc2-link-presentation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2841
"a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - line 2881
"objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-metal" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2881
"dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-quartz-core" version = "0.2.2" source = - line 2881
"registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - line 2881
"objc2-metal", ] [[package]] name = "objc2-symbols" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ "objc2 - line 2881
0.5.2", "objc2-foundation 0.2.2", ] - line 2921
[[package]] name = "objc2-ui-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 - line 2921
0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core", "objc2-symbols", "objc2-uniform-type-identifiers", - line 2921
"objc2-user-notifications", ] [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = - line 2921
[ "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", ] [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2921
"44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - line 2961
"objc2 0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-user-notifications" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 2961
"76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] [[package]] name = "ocb3" version = "0.1.0" source = - line 2961
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ "aead", "cipher", "ctr", "subtle", ] [[package]] name = "ogg_pager" version = "0.7.2" - line 2961
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" dependencies = [ "byteorder", ] [[package]] name = "once_cell" - line 3001
version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = - line 3001
"registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opaque-debug" version = "0.3.1" source = - line 3001
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "orbclient" version = "0.3.55" source = - line 3001
"registry+https://github.com/rust-lang/crates.io-index" checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", ] [[package]] name = "ordered-stream" version = "0.2.0" source = - line 3001
"registry+https://github.com/rust-lang/crates.io-index" checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ "futures-core", "pin-project-lite", ] [[package]] name = "p256" version = "0.13.2" - line 3001
source = "registry+https://github.com/rust-lang/crates.io-index" - line 3041
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", "elliptic-curve", "primeorder", "sha2", ] [[package]] name = "p384" version = "0.13.1" source = - line 3041
"registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ "ecdsa", "elliptic-curve", "primeorder", "sha2", ] [[package]] name = "p521" version = - line 3041
"0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ "base16ct", "ecdsa", "elliptic-curve", "primeorder", "rand_core - line 3041
0.6.4", "sha2", ] [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - line 3081
[[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - line 3081
"parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - line 3081
"cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link", ] [[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 3081
"346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core 0.6.4", "subtle", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" - line 3081
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - line 3121
[[package]] name = "pem-rfc7468" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ "base64ct", ] [[package]] - line 3121
name = "peniko" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "bytemuck", "color", "kurbo", - line 3121
"linebender_resource_handle", "smallvec", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 3121
"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pgp" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 3121
"1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ "aead", "aes", "aes-gcm", "aes-kw", "argon2", "base64", - line 3161
"bitfields", "block-padding", "blowfish", "buffer-redux", "byteorder", "bytes", "camellia", "cast5", "cfb-mode", "cipher", "const-oid", "crc24", "curve25519-dalek", "cx448", "derive_builder", "derive_more", "des", "digest", "dsa", "eax", - line 3161
"ecdsa", "ed25519-dalek", "elliptic-curve", "flate2", "generic-array", "hex", "hkdf", "idea", "k256", "log", "md-5", "memchr", "nom", "num-bigint-dig", "num-traits", "num_enum", "ocb3", "p256", "p384", "p521", - line 3201
"rand 0.8.8", "replace_with", "ripemd", "rsa", "sha1", "sha1-checked", "sha2", "sha3", "signature", "smallvec", "snafu", "subtle", "twofish", "x25519-dalek", "zeroize", ] [[package]] name = "pin-project" version = "1.1.13" source = - line 3201
"registry+https://github.com/rust-lang/crates.io-index" checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" version = "1.1.13" - line 3201
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] [[package]] name = "pin-project-lite" - line 3201
version = "0.2.17" - line 3241
source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" version = "0.7.5" source = - line 3241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ "der", "pkcs8", "spki", ] [[package]] name = "pkcs8" version = "0.10.2" source = - line 3241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "spki", ] [[package]] name = "pkg-config" version = "0.3.34" source = - line 3241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" version = "0.2.3" source = - line 3241
"registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "png" version = "0.18.1" source = - line 3241
"registry+https://github.com/rust-lang/crates.io-index" - line 3281
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] [[package]] name = "polling" version = "3.11.0" source = - line 3281
"registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", "rustix 1.1.4", - line 3281
"windows-sys 0.61.2", ] [[package]] name = "pollster" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = - line 3281
"poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", "universal-hash", ] - line 3321
[[package]] name = "polycool" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" dependencies = [ "arrayvec", ] [[package]] name - line 3321
= "polyval" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", "cpufeatures", "opaque-debug", - line 3321
"universal-hash", ] [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = - line 3321
"portable-atomic-util" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name - line 3321
= "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" - line 3361
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = - line 3361
"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "primal-check" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum =
Cargo.toml
- line 1
# VeilVoice: cryptographically-modulated, irreversible voice de-identification. # # Workspace root. Every member crate is buildable by anyone with the pinned # stable toolchain (see rust-toolchain.toml) and NO secrets: `cargo build` # - line 1
works on a fresh clone. Release signing / packaging secrets are optional and # only consumed by CI when present. # # SPDX-License-Identifier: GPL-3.0-or-later [workspace] resolver = "2" members = [ "crates/veilvoice-conversation", - line 1
"crates/veilvoice-core", "crates/veilvoice-crypto", "crates/veilvoice-audio", "crates/veilvoice-meta", "crates/veilvoice-gui", "crates/veilvoice-cli", "crates/veilvoice-video", "crates/veilvoice-watch", "crates/veilvoice-guard", - line 1
"crates/veilvoice-policy", "crates/veilvoice-setup", "crates/veilvoice-verify", ] # `fuzz/` is a cargo-fuzz package and needs nightly plus libFuzzer. Excluding it # keeps the pinned stable toolchain sufficient for everything else, so a - line 1
fresh # clone still builds with `cargo build` and nothing demands nightly. See # fuzz/README.md. exclude = ["fuzz"] [workspace.package] version = "0.1.22" edition = "2021" # The oldest Rust this actually compiles on, measured rather than - line 1
assumed. # # This said 1.96 -- the version that happened to be current when it was # written -- and that one line was the *only* thing stopping the OpenBSD build: # its packaged Rust is 1.94.1, cargo refused before compiling anything, and - line 1
the - line 41
# release shipped without an OpenBSD archive for two versions while the # documentation explained the platform was behind. It was not. We were. # # Verified by installing 1.94.0 and checking every crate, including the GUI. # - line 41
`rust-toolchain.toml` still pins the newer toolchain for development and CI; # this is the floor, and lowering it costs nothing until something in the tree # needs a newer feature -- at which point cargo says so, by name. rust-version = - line 41
"1.94" license = "GPL-3.0-or-later" authors = ["tilas01"] repository = "https://github.com/tilas01/veilvoice" homepage = "https://github.com/tilas01/veilvoice" # Deliberately no e-mail / personal identifiers anywhere in this tree. - line 41
[workspace.dependencies] # --- DSP / math --- realfft = "3" # real-input FFT built on rustfft; re-exports num-complex # The complex FFT `realfft` is built on. Named here so both are pinned # together: a version skew between them is a type - line 41
mismatch in the engine's hot # path. rustfft = "6" # --- cryptographic randomness --- # # Held with the crypto generation in `crates/veilvoice-crypto/Cargo.toml`, and # for the same reason: `pgp` 0.20 pins `rand` 0.8, and a second `rand` - line 41
in the # graph is a second copy of the same code in both binaries. `rand` 0.9 also # changed how `gen_range` draws, so the same seed would produce a different # modulation stream across the bump; that is not a promise this project makes, # - line 41
but it is worth moving on purpose rather than by accident. getrandom = "0.2" # OS CSPRNG seed # The `Rng` traits the modulation stream is driven through. The randomness # itself is `rand_chacha`, seeded from `getrandom`; this is the - line 41
interface, not # the source. rand = "0.8" rand_chacha = "0.3" # ChaCha20 CSPRNG for parameter modulation # --- secret hygiene --- zeroize = { version = "1", features = ["derive"] } # Reproducible-build friendly, maximally optimised release - line 41
profile. # codegen-units=1 + fat LTO also removes cross-CU nondeterminism. - line 81
[profile.release] opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort" strip = true overflow-checks = false debug = false incremental = false # Faster iteration while developing the DSP without losing all optimisation. [profile.dev] - line 81
opt-level = 1
crates/veilvoice-audio/Cargo.toml
- line 1
[package] name = "veilvoice-audio" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Real-time capture and playback - line 1
(cpal), lock-free ring buffers, virtual-cable routing and file import for VeilVoice." [features] default = ["live"] # Live capture and playback. Optional because `cpal` has no backend for the # BSDs, and everything else this crate does, - line 1
meaning decoding, encoding and # running the engine over a buffer, is pure Rust and builds anywhere. Turning this off # keeps file processing available on platforms that cannot do live audio. live = ["dep:cpal", "dep:ringbuf"] - line 1
[dependencies] # The engine itself: `Deidentifier::process` is what the live path runs per # block, and `DeidConfig` is what it is configured with. veilvoice-core = { path = "../veilvoice-core" } # Locked, zeroizing memory for a recording - line 1
that must never reach the page # file or a plaintext temporary. See `record`. veilvoice-crypto = { path = "../veilvoice-crypto" } # Cross-platform audio I/O: WASAPI, CoreAudio, ALSA/JACK. cpal = { version = "0.18", optional = true } # - line 1
Lock-free SPSC ring buffer between the audio callbacks and the DSP thread. ringbuf = { version = "0.5", optional = true } # WAV read/write. hound = "3" # Decoding for everything else on import. symphonia = { version = "0.6", features = - line 1
["mp3", "isomp4", "aac", "flac", "vorbis", "ogg", "wav", "pcm"] } [dev-dependencies] # Tests that write a file and read it back, in a directory that goes away with # them. tempfile = "3"
crates/veilvoice-cli/Cargo.toml
- line 1
[package] name = "veilvoice-cli" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Command-line interface for - line 1
VeilVoice: anonymise files, scramble a microphone live, strip metadata, encrypt recordings." [[bin]] name = "veilvoice" path = "src/main.rs" [features] default = ["live"] # Live microphone scrambling. Off on platforms with no `cpal` - line 1
backend (the # BSDs); file processing, encryption and metadata cleaning still work there. live = ["veilvoice-audio/live"] [dependencies] # The verifier, folded in so a release is checked by the same program it ships # beside rather than by - line 1
a second binary somebody has to find. It was a binary # of its own until 0.1.18; `src/lib.rs` there says what that cost. veilvoice-verify = { path = "../veilvoice-verify" } # The engine's version, its configuration, and the seed-roll range - line 1
parser the # flags share with the window. veilvoice-core = { path = "../veilvoice-core" } # Everything sealed: the container, the app lock, owner-only writes and # `shred`. veilvoice-crypto = { path = "../veilvoice-crypto" } # `veilvoice - line 1
guard`: the manifest of this installation, and what it can and # cannot blame. veilvoice-guard = { path = "../veilvoice-guard" } # Capture, playback, decoding and the live session `veilvoice live` and # `record` run. veilvoice-audio = { - line 1
path = "../veilvoice-audio", default-features = false } # Group mode: the plan, the render and the subtitles. veilvoice-conversation = { path = "../veilvoice-conversation" } # Stripping tags, EXIF and GPS out of what is written. - line 41
veilvoice-meta = { path = "../veilvoice-meta" } # Settings somebody else decided, and the mandate that seals them. veilvoice-policy = { path = "../veilvoice-policy" } # Installing this copy, and finding the encrypted volumes it can write - line 41
into. veilvoice-setup = { path = "../veilvoice-setup" } # The preview page, the palette every speaker is coloured from, and the render # size. veilvoice-video = { path = "../veilvoice-video" } # Who is holding the microphone and the - line 41
camera, which the safety catch reads. veilvoice-watch = { path = "../veilvoice-watch" } # The argument parser and the derived subcommands. The help it prints is the # reference the documentation is generated from. clap = { version = "4", - line 41
features = ["derive"] } # Reading a passphrase without echoing it. A terminal that shows the password # while it is typed is the one thing this must not do. rpassword = "7" # Wiping the passphrase buffer once the key is derived from it. - line 41
zeroize.workspace = true [dev-dependencies] # Tests that write a vault, a lock or a plan and read it back, in a directory # that goes away with them. tempfile = "3" # Embeds the application icon and version block into the Windows - line 41
executable. # A *build* dependency only: it is not in the shipped dependency graph, and the # `offline` CI job that forbids network crates is unaffected. [target.'cfg(windows)'.build-dependencies] # The Windows resource block: the icon, - line 41
the version and the manifest, compiled # into the executable by `build.rs`. winresource = "0.1"
crates/veilvoice-conversation/Cargo.toml
- line 1
[package] name = "veilvoice-conversation" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Several speakers in one - line 1
recording: who spoke when, a distinct voice for each, names, and subtitles." [dependencies] # The engine each speaker's voice is a configuration of, and the voice table # slots are assigned from. veilvoice-core = { path = - line 1
"../veilvoice-core" } # For `privatefile`. A plan holds every speaker's name and every word # somebody typed, which is the same content as the subtitles it produces. veilvoice-crypto = { path = "../veilvoice-crypto" } [dev-dependencies] # - line 1
Tests that write a plan and read it back, in a directory that goes away with # them. tempfile = "3"
crates/veilvoice-core/Cargo.toml
- line 1
[package] name = "veilvoice-core" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Irreversible voice - line 1
de-identification DSP engine: cryptographically-modulated pitch/formant scrambling with preserved intelligibility." [dependencies] # The real-input FFT the spectral stage and the accent stage both run on, and # the `num_complex` types they - line 1
share. realfft.workspace = true # The operating system's own randomness, which is what the modulation stream # is seeded from. getrandom.workspace = true # The `Rng` traits the modulation stream is drawn through. rand.workspace = true # - line 1
ChaCha20 as the modulation stream itself: a CSPRNG, so the parameter path # cannot be predicted from any part of it that was observed. rand_chacha.workspace = true # Wiping the modulation seed when the stream that holds it is dropped. - line 1
zeroize.workspace = true [dev-dependencies] # Std only for tests; no extra deps needed.
crates/veilvoice-crypto/Cargo.toml
- line 1
[package] name = "veilvoice-crypto" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Argon2id KDF, - line 1
X25519+ML-KEM-768 hybrid KEM, XChaCha20-Poly1305 at-rest encryption and page-locked amnesic secrets for VeilVoice." [dependencies] # --- secret hygiene --- zeroize.workspace = true # Constant-time comparison. An `==` on a verifier or a tag - line 1
is a timing oracle, # and this is the type that refuses to be one. subtle = "2" # Page-locking wrapper. The one place this crate steps outside `forbid(unsafe)`; # see the `amnesia` module for why and how it is contained. # # Held at 3 - line 1
deliberately. Version 4 made `region::unlock` an `unsafe` function, # and this crate unlocks explicitly rather than through the RAII guard, for the # reason the `amnesia` module gives at length: the guard's destructor fails # loudly when - line 1
Windows drops pages from a working set on its own, and a type # whose whole job is holding key material must not abort while being dropped. # Taking 4 would therefore put an `unsafe` block into a crate whose # `#![forbid(unsafe_code)]` is - line 1
a promise made on the website and in the # security notes, to buy no-std support this project does not use. `cargo # audit` reports nothing against 3, so there is no security reason to move. region = "3" # --- key derivation --- # # The - line 1
RustCrypto crates below are held on the generation `pgp` 0.20 uses # (`sha2` 0.10, `hkdf` 0.12, `argon2` 0.5, `x25519-dalek` 2, `rand_core` 0.6) # rather than moved to the current one. `pgp` is the release-signature # verifier and pins - line 1
that generation; taking the newer one here would compile # two copies of every primitive into both binaries, which is the one thing # `deny.toml` exists to notice. None of the newer versions fixes a # vulnerability: they are API - line 1
generations, and `cargo audit` is clean on this # one. The whole line moves together the day `pgp` moves, or the day the # signature check stops needing it (roadmap item 149). - line 41
argon2 = "0.5" # Deriving the separate keys each container, lock and vault uses from one # secret, with a distinct info string for each. hkdf = "0.12" # The hash HKDF runs over. sha2 = "0.10" # --- authenticated encryption --- - line 41
chacha20poly1305 = "0.10" # --- hybrid key agreement --- x25519-dalek = { version = "2", features = ["static_secrets"] } # ML-KEM-768, the post-quantum half of the hybrid key exchange. X25519 is the # other half and neither alone is - line 41
trusted. ml-kem = "0.2" # --- randomness --- getrandom.workspace = true # The RNG traits the key exchange takes its randomness through. rand_core = "0.6" [dev-dependencies] # Tests that seal a file and open it again, in a directory that - line 41
goes away with # them. tempfile = "3"
crates/veilvoice-guard/Cargo.toml
- line 1
[package] name = "veilvoice-guard" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Integrity manifest and tamper - line 1
detection for VeilVoice's own files, with best-effort attribution of what changed them." [dependencies] # Sealing the manifest with a passphrase, so a list of what this installation # is cannot be quietly rewritten. veilvoice-crypto = { - line 1
path = "../veilvoice-crypto" } # The SHA-256 of every file the manifest lists, written in the same form # `sha256sum` prints so it can be checked without this program. sha2 = "0.10" [dev-dependencies] # Tests that write a manifest and - line 1
check it, in a directory that goes away with # them. tempfile = "3"
crates/veilvoice-gui/Cargo.toml
- line 1
[package] name = "veilvoice-gui" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "egui/eframe front-end for - line 1
VeilVoice: Tokyo Night, monospace, three modes." [[bin]] name = "veilvoice-gui" path = "src/main.rs" [dependencies] # The engine's version and the configuration every tab that runs it is set # from. veilvoice-core = { path = - line 1
"../veilvoice-core" } # Device enumeration, decoding, playback, and the live session the Studio # starts. veilvoice-audio = { path = "../veilvoice-audio" } # The app lock, the containers, the recording vault and the page-locked memory # a - line 1
decrypted take lives in. veilvoice-crypto = { path = "../veilvoice-crypto" } # Stripping tags out of a result before it is written. veilvoice-meta = { path = "../veilvoice-meta" } # Settings somebody else decided, and which controls that - line 1
leaves the person. veilvoice-policy = { path = "../veilvoice-policy" } # The Verify tab: the same signed hash lists, the same contents manifest # and the same offer to run the reader's own GnuPG as `veilvoice verify`, # from the same code - line 1
rather than a second copy. veilvoice-verify = { path = "../veilvoice-verify" } # Group mode and the Studio's plan, the render, and the subtitles. veilvoice-conversation = { path = "../veilvoice-conversation" } # The Install tab, the - line 1
encrypted volumes it can write into, and the free space # the first-run card measures. veilvoice-setup = { path = "../veilvoice-setup" } # The palettes speakers are coloured from, the preview page and the render # size. veilvoice-video = { - line 1
path = "../veilvoice-video" } - line 41
# The Monitor tab's feed of what is holding the microphone and the camera. veilvoice-watch = { path = "../veilvoice-watch" } # The integrity panel: the manifest of this installation and what changed in # it. veilvoice-guard = { path = - line 41
"../veilvoice-guard" } # The window itself: the event loop, the graphics context and the `App` this # crate implements. eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"] } # The - line 41
widgets. Every panel in this crate is drawn with it. egui = "0.36" # The platform's own file and folder panel, so choosing a file looks like # choosing a file on that machine rather than in this program. rfd = { version = "0.16", - line 41
default-features = false, features = ["xdg-portal", "tokio"] } # Wipes typed passphrases out of the text fields that necessarily hold them in # the clear; see the `security` module for what that does and does not buy. zeroize.workspace = - line 41
true [dev-dependencies] # Tests that write settings, a vault or a plan and read it back, in a # directory that goes away with them. tempfile = "3" # Embeds the application icon and version block into the Windows executable. # A *build* - line 41
dependency only: it is not in the shipped dependency graph, and the # `offline` CI job that forbids network crates is unaffected. [target.'cfg(windows)'.build-dependencies] # The Windows resource block: the icon, the version and the - line 41
manifest, compiled # into the executable by `build.rs`. winresource = "0.1"
crates/veilvoice-meta/Cargo.toml
- line 1
[package] name = "veilvoice-meta" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Strip or spoof identifying - line 1
metadata: audio tags, and image EXIF/GPS." [dependencies] # Audio tag containers (ID3v1/v2, Vorbis comments, MP4 atoms, APE, ...). lofty = "0.25" # Container-level image surgery: drops EXIF/XMP without re-encoding pixels. img-parts = "0.4" - line 1
[dev-dependencies] # Tests that write a real WAV, so the cleaner is run over a file rather than # over bytes this crate made up. hound = "3" # Tests that write a file and clean it, in a directory that goes away with # them. tempfile = "3"
crates/veilvoice-policy/Cargo.toml
- line 1
[package] name = "veilvoice-policy" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Settings that can only be - line 1
tightened, sealed with the project's own post-quantum cryptography." [dependencies] # The engine configuration a saved profile is (`workspace`). veilvoice-core = { path = "../veilvoice-core" } # Which voice mode a saved group recording was - line 1
made with (`workspace`). veilvoice-conversation = { path = "../veilvoice-conversation" } # Sealing a policy so it cannot be quietly rewritten, and the owner-only write # that puts it on disk. veilvoice-crypto = { path = - line 1
"../veilvoice-crypto" } [dev-dependencies] # Tests that write a policy and read it back, in a directory that goes away # with them. tempfile = "3"
crates/veilvoice-setup/Cargo.toml
- line 1
[package] name = "veilvoice-setup" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Per-user installation and - line 1
companion-software detection, shared by the command line and the desktop app." [dependencies]
crates/veilvoice-verify/Cargo.toml
- line 1
# The portable verifier: one small binary that checks a VeilVoice release # without GnuPG installed. See src/lib.rs for what it does and does not prove. # # SPDX-License-Identifier: GPL-3.0-or-later [package] name = "veilvoice-verify" - line 1
version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true description = "Verify a VeilVoice release without GnuPG - line 1
installed" [dependencies] # A full OpenPGP implementation, in pure Rust, so this binary needs no GnuPG # and no C toolchain. # # `default-features = false` drops bzip2. A detached signature over a hash list # is never compressed, so the - line 1
only thing the default feature would buy is a # decompressor this tool can never reach -- and an attack surface reachable # only through input it refuses anyway. # # This is by far the largest dependency in the project, and that is a real - line 1
cost # honestly weighed: the alternative was hand-writing OpenPGP packet parsing and # RSA PKCS#1 v1.5 verification, and a subtle mistake there would be a silent # accept in the one tool whose entire job is not to silently accept. A widely - line 1
# used implementation that many people read is the better risk. # The checking itself lives in this crate's `check` module, so the desktop # application's verify tab and this binary share one implementation rather than # holding two. This - line 1
crate keeps the command line, the reporting, and the # download shim; it no longer owns the arithmetic. # Where an installation goes on each platform, so the `install` command puts # files exactly where the application itself would. No - line 1
dependencies of its own. veilvoice-setup = { path = "../veilvoice-setup" } # Roadmap item 97. Runs the GnuPG already on the machine, so the signature is # checked by two independent implementations rather than only this one. No # - line 1
dependencies of its own, which matters in the one binary here whose size is - line 41
# a feature. # Reading the signed public key and checking a detached signature with no # GnuPG installed, which is the whole point of this verifier. pgp = { version = "0.20", default-features = false } # The SHA-256 in - line 41
`check::sha256_file`, which is the hash every published # list is written in. sha2 = "0.10" [dev-dependencies] # Tests that build a release layout and verify it, in a directory that goes # away with them. tempfile = "3" # Embeds the - line 41
application icon and version block into the Windows executable. # A *build* dependency only: it is not in the shipped dependency graph, and the # `offline` CI job that forbids network crates is unaffected. - line 41
[target.'cfg(windows)'.build-dependencies] # The Windows resource block: the icon, the version and the manifest, compiled # into the executable by `build.rs`. winresource = "0.1"
crates/veilvoice-video/Cargo.toml
- line 1
[package] name = "veilvoice-video" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "A watchable version of a veiled - line 1
conversation: a waveform, a circle per speaker, subtitles, and an honest account of what needs ffmpeg." [dependencies] # Deflate, for the PNG frames the video is drawn as. PNG is a deflate stream, # and a PNG written with stored blocks is - line 1
about six megabytes a frame, so a # render would write gigabytes of intermediate files. Pure Rust, no C, and # already in this tree underneath `flate2` (which `lofty` and `pgp` both pull # in), so naming it here adds nothing to the graph - line 1
that was not compiled # already. miniz_oxide = "0.9" # The plan the preview page is drawn from: who speaks, when, and in which # colour. veilvoice-conversation = { path = "../veilvoice-conversation" } [dev-dependencies] # Tests that write - line 1
a page and read it back, in a directory that goes away with # them. tempfile = "3"
crates/veilvoice-watch/Cargo.toml
- line 1
[package] name = "veilvoice-watch" version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Detect which applications are - line 1
currently using the microphone and camera, with alerts on change." # Deliberately dependency-free. Windows is read through reg.exe, which ships # with the OS, and Linux through /proc, which is just files. Neither justifies # pulling in - line 1
platform bindings, and a privacy monitor with no dependencies is # one less supply chain to trust. [dependencies] [dev-dependencies] # Tests that write a screen-recorder allowlist or a driver listing and read it # back, in a directory that - line 1
goes away with them. tempfile = "3"
deny.toml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # cargo-deny policy: licences, sources and duplicate versions. # # `cargo audit` answers one question, whether anything in the graph has an # advisory against it, and `.cargo/audit.toml` argues - line 1
each exception. This file # answers the three questions that tool does not ask: whether every licence in # the graph can be shipped under GPL-3.0-or-later, whether every crate comes # from crates.io, and whether the same crate is compiled - line 1
twice at two versions. # # The advisory list is repeated here because cargo-deny reads its own file and # not cargo-audit's. The arguments live in `.cargo/audit.toml`; this is the # same three identifiers and nothing else, and - line 1
`tools/audit/dependencies.py` # fails the build if the two lists drift apart. [graph] all-features = false no-default-features = false [advisories] ignore = [ "RUSTSEC-2024-0436", # paste: unmaintained, compile-time only - line 1
"RUSTSEC-2023-0071", # rsa: Marvin needs a private key; there is none here ] [licenses] # Every licence that appears in the graph today, each compatible with # GPL-3.0-or-later. A licence not on this list stops the build until somebody # - line 1
has read it, which is the point: the graph is meant to be readable, and a # licence nobody has read is not. allow = [ "GPL-3.0-or-later", "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-1-Clause", "BSD-2-Clause", - line 1
"BSD-3-Clause", "ISC", "Zlib", - line 41
"0BSD", "Unlicense", "BSL-1.0", "MPL-2.0", "LGPL-2.1-or-later", "Unicode-3.0", "OFL-1.1", "Ubuntu-font-1.0", "CC0-1.0", ] confidence-threshold = 0.8 [licenses.private] ignore = false [bans] # Two versions of one crate is two copies of its - line 41
code in every binary and, for # a cryptographic crate, two implementations of one primitive. A warning # rather than a failure: the graph carries thirty-odd today, nearly all of them # `windows-sys`, `objc2` and `rustix` generations that - line 41
the windowing and audio # stacks pull at different ages, and only their upstream releases can collapse # those. The ones this project can move itself (`rand`, `rand_core`, # `rand_chacha`, `getrandom`) are moved by taking the dependency - line 41
updates that # align them, not by hiding the warning. multiple-versions = "warn" wildcards = "allow" highlight = "all" workspace-default-features = "allow" external-default-features = "allow" [sources] unknown-registry = "deny" unknown-git - line 41
= "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"]
fuzz/Cargo.toml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Coverage-guided fuzzing for every parser in VeilVoice that reads bytes # somebody else produced. # # Deliberately OUTSIDE the workspace (see `exclude` in the root Cargo.toml). # `cargo fuzz` - line 1
needs a nightly toolchain and libFuzzer, and the workspace pins # stable so that everything else can be run by anyone who cloned the repository. # Keeping this separate means the pinned toolchain is not disturbed by a tool # most people - line 1
will never run, and `cargo build` on a fresh clone does not # suddenly demand nightly. # # See fuzz/README.md for how to run it and what it has found. [package] name = "veilvoice-fuzz" version = "0.0.0" publish = false edition = "2021" - line 1
license = "GPL-3.0-or-later" [package.metadata] cargo-fuzz = true [dependencies] # The harness `cargo fuzz` builds each target against. libfuzzer-sys = "0.4" # The container header and the lock file, both of which parse bytes somebody # - line 1
else produced. veilvoice-crypto = { path = "../crates/veilvoice-crypto" } # The WAV chunk walker, which reads a file this program did not write. veilvoice-meta = { path = "../crates/veilvoice-meta" } # The manifest parser, which reads a - line 1
list a machine other than this one may # have written. veilvoice-guard = { path = "../crates/veilvoice-guard" } # The contents manifest parser, which reads a published list. veilvoice-verify = { path = "../crates/veilvoice-verify" } # - line 1
`default-features = false` drops cpal: the fuzzers touch decoding and header # checking only, and there is no reason to link an audio backend into them. veilvoice-audio = { path = "../crates/veilvoice-audio", default-features = false } - line 41
[[bin]] name = "container_header" path = "fuzz_targets/container_header.rs" test = false doc = false [[bin]] name = "lock_file" path = "fuzz_targets/lock_file.rs" test = false doc = false [[bin]] name = "wav_chunks" path = - line 41
"fuzz_targets/wav_chunks.rs" test = false doc = false [[bin]] name = "wav_preflight" path = "fuzz_targets/wav_preflight.rs" test = false doc = false [[bin]] name = "guard_manifest" path = "fuzz_targets/guard_manifest.rs" test = false doc = - line 41
false [[bin]] name = "hybrid_keys" path = "fuzz_targets/hybrid_keys.rs" test = false doc = false [[bin]] name = "release_contents" path = "fuzz_targets/release_contents.rs" - line 81
test = false doc = false # Overflow checks ON, deliberately, in a profile that is otherwise optimised. # # This is the whole reason the release profile's setting is not simply # inherited: `overflow-checks = false` is what hid F-2 and F-4 - line 81
from every # release-mode run. A fuzzer that cannot see an arithmetic overflow cannot find # the two bugs of that shape this project has already shipped. [profile.release] debug = 1 overflow-checks = true debug-assertions = true
packaging/flatpak/io.github.tilas01.VeilVoice.yml
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Flatpak manifest for the VeilVoice desktop application. # # flatpak-builder --user --install build packaging/flatpak/io.github.tilas01.VeilVoice.yml # # # The sandbox is the point, so it is - line 1
kept tight # # A Flatpak's permissions are a public claim about what the application can # reach, and a privacy tool asking for more than it needs is making a poor one. # What is granted, and why each one: # # --socket=wayland, - line 1
--socket=fallback-x11, --share=ipc, --device=dri # It is a desktop application and has to draw a window. # --socket=pulseaudio # Live mode records from a microphone and plays back through a virtual # cable. Without this the file-processing - line 1
half still works. # --filesystem=xdg-music, --filesystem=xdg-documents, --filesystem=xdg-download # Recordings live in one of those three often enough that the portal # alone is awkward. Not `home`, and emphatically not `host`. # # What is - line 1
NOT granted, deliberately: # # --share=network # There is none. VeilVoice has no networking code, CI fails the build if # an HTTP client enters the dependency graph, and this line's absence is # the claim a user can check without reading - line 1
any of that. # --talk-name=org.freedesktop.secrets # Passphrases are held in page-locked memory for the session and never # stored. Reaching a keyring would mean writing a secret somewhere. # --filesystem=host, --device=all # Never. - line 1
app-id: io.github.tilas01.VeilVoice runtime: org.freedesktop.Platform runtime-version: '24.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: veilvoice-gui - line 41
finish-args: - --socket=wayland - --socket=fallback-x11 - --share=ipc - --device=dri - --socket=pulseaudio - --filesystem=xdg-music - --filesystem=xdg-documents - --filesystem=xdg-download # No --share=network. See the note above: this is - line 41
the checkable form of the # "fully offline" claim. build-options: append-path: /usr/lib/sdk/rust-stable/bin env: CARGO_HOME: /run/build/veilvoice/cargo # Same remaps as the project's own release build, so a Flatpak build is # comparable - line 41
with the published binaries rather than differing by paths. RUSTFLAGS: --remap-path-prefix=/run/build/veilvoice=/veilvoice modules: - name: veilvoice buildsystem: simple build-commands: # --locked: exactly the dependency versions the - line 41
project tested. - cargo build --release --locked --workspace --offline - install -Dm755 target/release/veilvoice-gui /app/bin/veilvoice-gui - install -Dm755 target/release/veilvoice /app/bin/veilvoice - install -Dm644 assets/icon.png - line 41
/app/share/icons/hicolor/256x256/apps/io.github.tilas01.VeilVoice.png - install -Dm644 packaging/flatpak/io.github.tilas01.VeilVoice.desktop /app/share/applications/io.github.tilas01.VeilVoice.desktop - install -Dm644 - line 41
packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml /app/share/metainfo/io.github.tilas01.VeilVoice.metainfo.xml sources: - type: git url: https://github.com/tilas01/veilvoice.git tag: v0.1.22 # `flatpak-cargo-generator.py` produces - line 41
this from Cargo.lock so the build # needs no network. Regenerate it whenever Cargo.lock changes. - cargo-sources.json
rust-toolchain.toml
- line 1
# Pinned toolchain: a fixed compiler is a prerequisite for bit-for-bit # reproducible builds. CI uses this exact version; contributors get it # automatically via rustup. [toolchain] channel = "1.96.0" components = ["rustfmt", "clippy"] - line 1
profile = "minimal"
Licence and legal
LICENSE
- line 1
GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - line 1
Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By - line 1
contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General - line 1
Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public - line 1
Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces - line 1
of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if - line 1
you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the - line 1
same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: - line 41
(1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this - line 41
free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny - line 41
users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern - line 41
of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise - line 41
substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States - line 41
should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To - line 41
prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to - line 41
version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this - line 81
License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than - line 81
the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a - line 81
work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes - line 81
copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. - line 81
Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible - line 81
feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to - line 81
view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work - line 81
for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces - line 81
specified for a particular programming language, one that - line 121
is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is - line 121
not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major - line 121
Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code - line 121
interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to - line 121
control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. - line 121
For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, - line 121
such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding - line 121
Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated - line 121
conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its - line 161
content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as - line 161
your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply - line 161
with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that - line 161
prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 - line 161
makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO - line 161
copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the - line 161
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's - line 161
users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you - line 161
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any - line 201
non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for - line 201
each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of - line 201
source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices - line 201
stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this - line 201
License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License - line 201
gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, - line 201
if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature - line 201
extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not - line 201
used to limit the access or legal rights of the compilation's users - line 241
beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form - line 241
under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including - line 241
a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical - line 241
distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy - line 241
of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing - line 241
this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This - line 241
alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a - line 241
charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy - line 241
the object code is a network server, the Corresponding Source - line 281
may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what - line 281
server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers - line 281
where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a - line 281
System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or - line 281
(2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, - line 281
"normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product - line 281
is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product - line 281
means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must - line 281
suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or - line 281
specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the - line 321
User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. - line 321
But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation - line 321
Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. - line 321
Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and - line 321
Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for - line 321
unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the - line 321
entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those - line 321
permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from - line 321
any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can - line 321
give appropriate copyright permission. - line 361
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or - line 361
limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed - line 361
by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use - line 361
for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and - line 361
authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors - line 361
and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this - line 361
License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material - line 361
governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source - line 361
files, a statement of the additional terms that apply to those files, or a notice indicating - line 401
where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not - line 401
propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses - line 401
granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly - line 401
and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder - line 401
is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure - line 401
the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have - line 401
been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to - line 401
receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, - line 441
nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your - line 441
acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, - line 441
subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or - line 441
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the - line 441
party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable - line 441
efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this - line 441
License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - line 441
11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's - line 441
"essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its - line 441
contributor version, - line 481
but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent - line 481
with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, - line 481
modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to - line 481
practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly - line 481
relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, - line 481
then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of - line 481
this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered - line 481
work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring - line 481
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is - line 481
automatically extended to all recipients of the covered work and works based on it. - line 521
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. - line 521
You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying - line 521
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from - line 521
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in - line 521
this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are - line 521
imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously - line 521
your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom - line 521
you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of - line 521
this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License - line 521
will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the - line 561
combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present - line 561
version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" - line 561
applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General - line 561
Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement - line 561
of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or - line 561
copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT - line 561
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE - line 561
RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. - line 601
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, - line 601
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM - line 601
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above - line 601
cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of - line 601
liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the - line 601
best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most - line 601
effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) - line 601
<year> <name of author> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your - line 601
option) any later version. - line 641
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You - line 641
should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. If the program does terminal - line 641
interaction, make it output a short notice like this when it starts in an interactive mode: <program> Copyright (C) <year> <name of author> This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, - line 641
and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands - line 641
might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information - line 641
on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may - line 641
consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read - line 641
<https://www.gnu.org/licenses/why-not-lgpl.html>.
website/user-agreements/LEGAL-WAIVER.txt
- line 1
VEILVOICE -- LEGAL DISCLAIMER AND LIABILITY WAIVER ================================================= Project: VeilVoice -- irreversible voice de-identification Author: tilas01 Repo: https://github.com/tilas01/veilvoice Site: - line 1
https://tilas01.github.io/veilvoice/ Licence: GPL-3.0-or-later Version: 1.0 Updated: 2026-08-16 This is the authoritative text of the notice shown in the welcome dialog on the website. It is kept here as plain text so that it can be read, - line 1
quoted, diffed and archived without running any JavaScript, and so that the website has a stable thing to link to. If the dialog and this file ever disagree, this file is the one that is meant. VeilVoice was developed with AI assistance - line 1
(Claude, by Anthropic) and has been reviewed and audited by tilas01. That review is a maintainer audit: it catches what the author can see. No external firm and no independent researcher has reviewed this code. If you are relying on it for - line 1
something where being identified would cause you real harm, read the source before you trust it -- it is written to be read, and every claim below is checkable against it. 1. SCOPE -------- This waiver covers all content, tools, binaries, - line 1
documentation and source code provided on the website and within the GitHub repository tilas01/veilvoice. That includes, but is not limited to: - the de-identification engine (veilvoice-core) - the cryptography (veilvoice-crypto) - the - line 1
audio path (veilvoice-audio) and metadata cleaner (veilvoice-meta) - the command-line tool (veilvoice) and desktop application (veilvoice-gui) - the website, the wiki, the in-browser hash verifier - every release binary and every generated - line 1
artefact - line 41
2. NO WARRANTY -------------- Everything listed in section 1 is provided strictly "AS IS", without warranty of any kind, express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular - line 41
purpose and non-infringement. This restates, in ordinary words, sections 15 and 16 of the GNU General Public License version 3, which is the licence this software is distributed under. 3. LIMITATION OF LIABILITY -------------------------- - line 41
The author (tilas01) expressly disclaims all liability for any direct, indirect, incidental, consequential or punitive damages of any nature, including but not limited to: a. data loss or corruption; b. loss of a recording, a key, or a - line 41
passphrase, including through the self-destruct and secure-erase features, which are irreversible by design; c. failure of de-identification to prevent a particular person from being recognised in a particular recording; d. legal, - line 41
professional, personal or physical consequences arising from being identified despite using this software; e. failure of encryption to protect a file against a particular adversary; f. conflicts arising from third-party audio drivers, - line 41
virtual cables or proprietary software. 4. WHAT THIS SOFTWARE DOES NOT DO --------------------------------- This section exists because misunderstanding it is the way someone gets hurt. - It does NOT hide what you said. Intelligibility is - line 41
preserved deliberately. The words are in the output and can be read, transcribed and searched. If the message itself is sensitive, encrypt it, or do not send it. - It does NOT remove a regional accent completely. The melody and colour of - line 81
an accent are destroyed. Which phonemes you actually produced cannot be changed by any signal-level transform, so a strong accent may remain audible. - It does NOT sanitise the background. Room acoustics, other voices, a doorbell, a - line 81
passing siren -- all of that passes through. Check what else is in your recording. - It does NOT protect you from an attacker already running code on your machine, or one who can tap the microphone before VeilVoice receives it. - It does - line 81
NOT clean anything outside the file: filenames, filesystem timestamps, and the channel you send it over are your responsibility. - Page-locking keeps keys out of the swap file. It does not survive hibernation, which writes memory to disk - line 81
wholesale. 5. INTENDED USE AND YOUR RESPONSIBILITY --------------------------------------- VeilVoice is built for people who have a legitimate reason to not be identified by their voice: journalists and their sources, whistleblowers, - line 81
survivors of abuse and stalking, activists, researchers publishing recorded interviews, and anyone who would simply rather a company not hold a biometric identifier of them. You assume full and sole responsibility for every use you make of - line 81
it, and for determining whether that use is lawful where you are. Disguising your voice is legal in most places; using a disguised voice to commit fraud, to harass, to impersonate, or to evade a lawful obligation is not, and nothing in - line 81
this project is intended to assist with any of that. Some features are destructive by design. Where a feature erases data or destroys a key, it is irreversible, it is opt-in, and it is gated behind an explicit confirmation. That gate is a - line 81
safety net, not a substitute for understanding what the option does. 6. AI ASSISTANCE AND SOURCE REVIEW - line 121
---------------------------------- As stated above, this project was developed with AI assistance (Claude, by Anthropic) and reviewed and audited by tilas01. This is disclosed because you are entitled to know how the software you rely on - line 121
was produced, and because it should inform how much you verify before trusting it. AI assistance does not remove the author's responsibility for the result, and it is not offered as an excuse for any defect. The practical consequence for - line 121
you: read the source. The whole project is published under the GPL precisely so that you can. The cryptography uses standard, well-reviewed primitives from established libraries rather than anything invented for this project, and the - line 121
de-identification argument can be checked by reading two source files. 7. PRIVACY ---------- The website is a static site on GitHub Pages. It sets no cookies, performs no session tracking, uses no analytics, loads no web fonts and pulls in - line 121
no third-party scripts of any kind. Your colour-scheme choice and your acceptance of these terms are stored in your own browser, in localStorage and sessionStorage respectively. Neither leaves your device, and there is no server for them - line 121
to be sent to. Clear them from your browser's own settings at any time. The in-browser hash verifier hashes files locally using the browser's built-in WebCrypto. The file you select never leaves your machine; there is no upload and no - line 121
endpoint that could receive it. One optional feature makes a third-party request: the live repository panel fetches public data from api.github.com. It is behind a button, it is announced on the page, and nothing else depends on it. The - line 121
application itself makes no network connections at all. This is enforced in continuous integration, which fails the build if an HTTP client enters the dependency graph. - line 161
8. THIRD-PARTY SOFTWARE ----------------------- VeilVoice links to and recommends software it does not own. Each is covered by its own licence and its own warranty position, and nothing here alters them. - Virtual audio routing on Windows - line 161
is commonly provided by VB-CABLE, which is proprietary donationware from VB-Audio. It is NOT bundled, and installing it is a separate decision you make. - Audacity is recommended for recording and editing. It is free software under the - line 161
GPL, maintained independently of this project. - All Rust dependencies are permissive-licensed (MIT, Apache-2.0, BSD, ISC) and are listed in Cargo.lock. 9. LICENCE ---------- VeilVoice is free software under the GNU General Public License, - line 161
version 3 or (at your option) any later version. You may run it for any purpose, study how it works, change it, and redistribute it, including commercially. If you distribute a modified version you must release your changes under the same - line 161
licence and make the corresponding source available. There is no NonCommercial restriction and no field-of-use restriction: a privacy tool that people are not free to inspect, modify and pass on is not one they can fully trust. Full text: - line 161
LICENSE.txt in this folder. Plain-English summary: LICENCE-PLAIN-ENGLISH.txt in this folder. 10. ACCEPTANCE -------------- Using this website, the repository, the released binaries or any output they produce constitutes your binding - line 161
agreement to these terms in full. - line 201
The welcome dialog asks you to confirm two things before continuing: (1) that you have read and understood this waiver, including section 4, which sets out what this software does not do; and (2) that you have read the licence and will - line 201
comply with it. Your acceptance is recorded in your browser's sessionStorage and is discarded when you close the tab. It is not sent anywhere, because there is nowhere for it to be sent.
website/user-agreements/LICENCE-PLAIN-ENGLISH.txt
- line 1
THE LICENCE, IN PLAIN ENGLISH ============================= Project: VeilVoice Licence: GNU General Public License, version 3 or later (GPL-3.0-or-later) Full text: LICENSE.txt in this folder, and https://www.gnu.org/licenses/gpl-3.0.txt - line 1
This file is a summary written for people, not a legal document. Where this summary and LICENSE.txt disagree, LICENSE.txt is the licence. WHAT YOU CAN DO, FREELY ----------------------- - Run it, for any purpose whatsoever. There is no - line 1
restriction on who you are or what you use it for. - Read every line of it. Study how it works. - Copy it, mirror it, keep an offline copy, put it on a USB stick. - Change it. Fork it. Build something else out of it. - Redistribute it, - line 1
including selling it or using it commercially. - Use it at work, in a newsroom, in a clinic, in a class, in a shelter. You do not need to ask permission for any of that. That is the point of publishing it. THE CONDITIONS -------------- - line 1
Share the source If you give someone the program, you must also make the corresponding source code available to them -- including any changes you made. Same licence Derivative works are released under the GPL too, so the next person gets - line 1
the same freedoms you got. Keep the notices Leave the copyright and licence notices in place. State changes If you modified it, say so, so nobody blames the original - line 41
for your edits. WHY THIS LICENCE, AND NOT A NON-COMMERCIAL ONE ---------------------------------------------- A privacy tool is only as trustworthy as it is inspectable. If you cannot read the code, you are taking on faith that a program - line 41
which processes your voice does what it claims -- and "trust me" is exactly the thing this project exists to avoid needing. So the licence is deliberately a free-software one, with no NonCommercial clause and no field-of-use restriction. A - line 41
"you may not use this commercially" rule would sound protective, but it would mean a newsroom could not use it in paid work, a security consultancy could not deploy it for a client, and no Linux distribution could package it. That is a lot - line 41
of people locked out in exchange for very little. Copyleft does the protective work instead. Anyone may build a business on VeilVoice; nobody may take it, close the source, and hand users a version they can no longer inspect. The freedom - line 41
travels with the code. WHAT THE LICENCE DOES NOT COVER ------------------------------- The licence says what you may do with the work. It says nothing about whether the work is fit for your purpose, and it disclaims warranty entirely -- - line 41
see sections 15 and 16 of LICENSE.txt, restated in ordinary words in LEGAL-WAIVER.txt. In particular, read section 4 of the waiver, which sets out plainly what this software does *not* do. The most important line: it does not hide what you - line 41
said, only who said it. That is a deliberate design choice, not a shortcoming, and misunderstanding it is the one mistake that could actually hurt someone. IF YOU WANT TO DO SOMETHING THE LICENCE DOES NOT ALLOW - line 41
------------------------------------------------------ - line 81
Ask. Open an issue on the repository and explain what you have in mind. In practice the GPL already permits almost everything anyone reasonably wants; the usual reason to ask is wanting to distribute a modified version without publishing - line 81
the modifications, and that is the one thing the licence is there to prevent.
website/user-agreements/LICENSE.txt
- line 1
GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - line 1
Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By - line 1
contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General - line 1
Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public - line 1
Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces - line 1
of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if - line 1
you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the - line 1
same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: - line 41
(1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this - line 41
free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny - line 41
users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern - line 41
of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise - line 41
substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States - line 41
should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To - line 41
prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to - line 41
version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this - line 81
License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than - line 81
the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a - line 81
work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes - line 81
copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. - line 81
Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible - line 81
feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to - line 81
view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work - line 81
for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces - line 81
specified for a particular programming language, one that - line 121
is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is - line 121
not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major - line 121
Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code - line 121
interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to - line 121
control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. - line 121
For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, - line 121
such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding - line 121
Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated - line 121
conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its - line 161
content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as - line 161
your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply - line 161
with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that - line 161
prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 - line 161
makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO - line 161
copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the - line 161
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's - line 161
users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you - line 161
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any - line 201
non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for - line 201
each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of - line 201
source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices - line 201
stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this - line 201
License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License - line 201
gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, - line 201
if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature - line 201
extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not - line 201
used to limit the access or legal rights of the compilation's users - line 241
beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form - line 241
under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including - line 241
a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical - line 241
distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy - line 241
of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing - line 241
this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This - line 241
alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a - line 241
charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy - line 241
the object code is a network server, the Corresponding Source - line 281
may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what - line 281
server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers - line 281
where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a - line 281
System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or - line 281
(2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, - line 281
"normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product - line 281
is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product - line 281
means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must - line 281
suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or - line 281
specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the - line 321
User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. - line 321
But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation - line 321
Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. - line 321
Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and - line 321
Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for - line 321
unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the - line 321
entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those - line 321
permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from - line 321
any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can - line 321
give appropriate copyright permission. - line 361
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or - line 361
limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed - line 361
by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use - line 361
for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and - line 361
authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors - line 361
and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this - line 361
License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material - line 361
governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source - line 361
files, a statement of the additional terms that apply to those files, or a notice indicating - line 401
where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not - line 401
propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses - line 401
granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly - line 401
and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder - line 401
is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure - line 401
the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have - line 401
been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to - line 401
receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, - line 441
nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your - line 441
acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, - line 441
subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or - line 441
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the - line 441
party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable - line 441
efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this - line 441
License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - line 441
11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's - line 441
"essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its - line 441
contributor version, - line 481
but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent - line 481
with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, - line 481
modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to - line 481
practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly - line 481
relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, - line 481
then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of - line 481
this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered - line 481
work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring - line 481
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is - line 481
automatically extended to all recipients of the covered work and works based on it. - line 521
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. - line 521
You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying - line 521
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from - line 521
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in - line 521
this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are - line 521
imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously - line 521
your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom - line 521
you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of - line 521
this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License - line 521
will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the - line 561
combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present - line 561
version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" - line 561
applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General - line 561
Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement - line 561
of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or - line 561
copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT - line 561
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE - line 561
RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. - line 601
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, - line 601
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM - line 601
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above - line 601
cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of - line 601
liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the - line 601
best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most - line 601
effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) - line 601
<year> <name of author> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your - line 601
option) any later version. - line 641
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You - line 641
should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. If the program does terminal - line 641
interaction, make it output a short notice like this when it starts in an interactive mode: <program> Copyright (C) <year> <name of author> This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, - line 641
and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands - line 641
might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information - line 641
on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may - line 641
consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read - line 641
<https://www.gnu.org/licenses/why-not-lgpl.html>.
Other
.gitattributes
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Line endings are pinned, and this is not a style preference. # # F-72. Three tests read this project's own source with `include_str!` and # searched it for "\n}\n". They passed on every - line 1
machine whose checkout uses LF # and failed on GitHub's Windows runners, which default to `core.autocrlf=true` # and therefore hand the compiler a file full of CRLF. The pattern matched # nothing, and the failure appeared only in CI -- on - line 1
the one platform where the # developer had just run the same tests and watched them pass. # # The larger reason is the generators. Every artefact in this repository is # regenerated and then compared **byte for byte** by `tools/verify.py`. - line 1
The # generators write LF unconditionally, so a contributor whose git converts text # on checkout would find every `--check` failing on files they had not touched, # with a diff nothing can show them. # # So: text is text, and text is LF, - line 1
everywhere, for everyone. * text=auto eol=lf # Except where bytes are bytes. `text=auto` guesses well and a wrong guess here # corrupts a file silently, so the formats this project actually ships are # named rather than left to detection. - line 1
*.png binary *.gif binary *.ico binary *.jpg binary *.jpeg binary *.wav binary *.flac binary *.ogg binary *.mp4 binary *.pdf binary *.woff binary *.woff2 binary *.ttf binary *.otf binary *.zip binary *.gz binary *.xz binary - line 41
# Detached signatures and key material are ASCII-armoured, and a byte of # difference makes them fail to verify. Left as text, LF, like everything else, # but named here so nobody is tempted to mark them binary and break the diff. *.asc - line 41
text eol=lf
.gitignore
- line 1
# Build output (also commonly redirected out of the source tree via # CARGO_TARGET_DIR; see .cargo/config.toml). /target target/ **/*.rs.bk # Never commit private key material or captured audio. *.key *.pgp *.gpg !*.pub !*public*.asc - line 1
/secrets/ *.wav *.mp3 *.flac *.ogg !assets/** # curated demo/test assets are opted back in explicitly # OS / editor noise .DS_Store Thumbs.db desktop.ini .idea/ .vscode/ *.swp # Bundled third-party installers are fetched at packaging time, - line 1
not committed. /packaging/vendor/ # Locally generated release-signing material. NEVER commit any of this. # `scripts/generate-signing-key.py` writes here; the private key and the # passphrase that protects it live only on the maintainer's - line 1
machine and in # GitHub's encrypted secret store. Only the public key and fingerprint are # ever meant to leave this folder. /gpg_secrets/ gpg_secrets/ # Armoured OpenPGP files are ignored by default so a private key can never be # - line 1
committed by accident. The *public* key is meant to be published, so the two # places it legitimately lives are allowed back in explicitly. - line 41
*.asc !website/assets/veilvoice-signing-key.asc !**/public-key.asc # Python bytecode. Importing `assets/generate.py` from a check script -- which # is a reasonable thing to do -- drops a .pyc next to it. A compiled blob is # precisely what - line 41
this repository must not contain: the whole claim about the # artwork is that it comes from a script you can read. __pycache__/ *.pyc # Local development files. These are working notes and editor configuration, # not part of what anybody - line 41
downloads, and a public repository is not the place # for either. # # NOTE, because untracking is not deleting: `git rm --cached` stops a file # being published from now on, but every version already committed stays in the # repository's - line 41
history and can still be fetched by anyone who clones it. # Removing it from history means rewriting published history and force-pushing, # which is a separate and deliberate act. The public-facing replacement for the # handover notes is - line 41
ROADMAP.md, which is written to be read by anybody. HANDOFF.md HANDOVER.md .claude/ .claude*/ CLAUDE.md **/CLAUDE.md .mcp.json .cursor/ .aider* .vscode/ .idea/ # Agent skills. Installed locally for Rust and security review practice; they # - line 41
are tooling for whoever is working on this, not part of what anybody # downloads, and several carry their own upstream licences. .claude/skills/ skills/ # The next-session prompt, beside HANDOFF.md. Working notes, not a deliverable. - line 81
NEXT-SESSION.md # Self-signed code-signing material. The private key must never be committed; # only the public certificate and its fingerprint are published, on the website. veilvoice-code-key.pem *.p7s APPMANIFEST.json.sig # The - line 81
dependency watch: the recorded graph, the journal of every version change # with its date and changelog, the backups and the diffs. Deliberately not # committed: this directory is a local working record of what the dependencies # are - line 81
doing, not something the repository publishes. # # Dependabot itself is configured and committed, at `.github/dependabot.yml`. # This directory is the local half: the same manifests read on demand, with a # record kept beside them. Nothing - line 81
in here is read by GitHub. .dependabot/
crates/veilvoice-cli/build.rs
- (module)
Embed the Windows application icon and version information. Without this the executable has no icon: Explorer draws the generic glyph, the taskbar shows it, and a pinned shortcut looks like nothing in particular. The icon used to be - (module)
shipped *beside* the binary as a loose `.ico` -- a file Windows never reads. `assets/icon.ico` already carries all six sizes Windows asks for, generated by `assets/generate.py` from the same pixels as everything else. # Two different - (module)
`cfg`s, and confusing them broke the build `winresource` is declared under `[target.'cfg(windows)'.build-dependencies]`, and for a **build** dependency that `cfg` describes the **host** doing the compiling -- not the target being compiled - (module)
for. So on a Linux runner the crate is simply absent, and a build script that named it unconditionally failed to compile before it could check anything. That is what the first version of this file did, and CI caught it. Hence both gates: * - (module)
`#[cfg(windows)]` on the code, so it exists only where the dependency does -- a question about the host; * `CARGO_CFG_TARGET_OS` at run time, so a Windows host cross-compiling for Linux does not staple a Windows resource onto an ELF binary - (module)
-- a question about the target. The consequence worth stating plainly: **cross-compiling to Windows from a non-Windows host produces a binary with no icon.** The release workflow builds Windows on a Windows runner, so shipped binaries have - (module)
one, and the release fails if they do not -- `tools/release/check-windows-icons.py` reads the built PE rather than trusting this file. # In plain words Runs while VeilVoice is being compiled, not while it is running. On Windows it puts the - (module)
icon and the version information into the finished program, so the file looks right in Explorer and its properties say what it is. It is a build-time step only and nothing it uses ends up inside the shipped program. - fn main
- fn embed
crates/veilvoice-gui/build.rs
- (module)
Embed the Windows application icon and version information. Without this the executable has no icon: Explorer draws the generic glyph, the taskbar shows it, and a pinned shortcut looks like nothing in particular. The icon used to be - (module)
shipped *beside* the binary as a loose `.ico` -- a file Windows never reads. `assets/icon.ico` already carries all six sizes Windows asks for, generated by `assets/generate.py` from the same pixels as everything else. # Two different - (module)
`cfg`s, and confusing them broke the build `winresource` is declared under `[target.'cfg(windows)'.build-dependencies]`, and for a **build** dependency that `cfg` describes the **host** doing the compiling -- not the target being compiled - (module)
for. So on a Linux runner the crate is simply absent, and a build script that named it unconditionally failed to compile before it could check anything. That is what the first version of this file did, and CI caught it. Hence both gates: * - (module)
`#[cfg(windows)]` on the code, so it exists only where the dependency does -- a question about the host; * `CARGO_CFG_TARGET_OS` at run time, so a Windows host cross-compiling for Linux does not staple a Windows resource onto an ELF binary - (module)
-- a question about the target. The consequence worth stating plainly: **cross-compiling to Windows from a non-Windows host produces a binary with no icon.** The release workflow builds Windows on a Windows runner, so shipped binaries have - (module)
one, and the release fails if they do not -- `tools/release/check-windows-icons.py` reads the built PE rather than trusting this file. # In plain words Runs while the desktop application is being compiled, not while it is running. On - (module)
Windows it embeds the icon and the version information into the executable, which is what gives the window its title-bar icon and makes the file's properties say what it is. - fn main
- fn embed
crates/veilvoice-verify/build.rs
- (module)
Embed the Windows application icon and version information. Without this the executable has no icon: Explorer draws the generic glyph, the taskbar shows it, and a pinned shortcut looks like nothing in particular. The icon used to be - (module)
shipped *beside* the binary as a loose `.ico` -- a file Windows never reads. `assets/icon.ico` already carries all six sizes Windows asks for, generated by `assets/generate.py` from the same pixels as everything else. # Two different - (module)
`cfg`s, and confusing them broke the build `winresource` is declared under `[target.'cfg(windows)'.build-dependencies]`, and for a **build** dependency that `cfg` describes the **host** doing the compiling -- not the target being compiled - (module)
for. So on a Linux runner the crate is simply absent, and a build script that named it unconditionally failed to compile before it could check anything. That is what the first version of this file did, and CI caught it. Hence both gates: * - (module)
`#[cfg(windows)]` on the code, so it exists only where the dependency does -- a question about the host; * `CARGO_CFG_TARGET_OS` at run time, so a Windows host cross-compiling for Linux does not staple a Windows resource onto an ELF binary - (module)
-- a question about the target. The consequence worth stating plainly: **cross-compiling to Windows from a non-Windows host produces a binary with no icon.** The release workflow builds Windows on a Windows runner, so shipped binaries have - (module)
one, and the release fails if they do not -- `tools/release/check-windows-icons.py` reads the built PE rather than trusting this file. # In plain words Runs while the verifier is being compiled, not while it is running. On Windows it - (module)
embeds the icon and version information. This matters more here than elsewhere: the verifier is the program people download to check a download, so it is the one most worth looking like what it claims to be. - fn main
- fn embed
deploy/nginx.conf
- line 1
# Serve the VeilVoice website under nginx, the same files GitHub Pages serves. # # This is the "host it properly" answer that tools/site/serve.py points at. The # development script is http.server with a few fixes; this is for somebody who - line 1
# wants the site on a real server -- a mirror, an internal copy, a fallback for # if the repository ever goes down. # # 1. Point `root` at the website/ directory of a checkout. # 2. Include this in your nginx config, or drop it in - line 1
sites-available and # link it, then `nginx -t` and reload. # # There is no build step and nothing server-side, because the site has none: # it is static files, and that is the whole reason it can be trusted and # mirrored. Any web server - line 1
that serves a directory does the same job; Caddy, # Apache and `python3 -m http.server` are all fine. This file just writes the # few things down for the one server people most often reach for. # # SPDX-License-Identifier: GPL-3.0-or-later - line 1
server { listen 8080; listen [::]:8080; server_name localhost; # The document root: the website/ directory of a VeilVoice checkout. # Change this to wherever you cloned the repository. root /path/to/veilvoice/website; index index.html; # - line 1
Every link on the site carries its extension, so no clean-URL rewriting # is needed -- and adding any would be a way for the local copy to behave # differently from the published one, which is the one thing a mirror must # not do. A - line 1
missing file is a 404, served as the site's own page. error_page 404 /404.html; location = /404.html { internal; } location / { try_files $uri $uri/ =404; } - line 41
# The signing key and the hash lists are text, and a browser that offered # to "open" them in a helper application rather than showing them would be # unhelpful at exactly the wrong moment. Served as plain text, inline. location ~* - line 41
\.(asc|txt)$ { default_type text/plain; add_header Content-Disposition inline; } # Content types nginx does not always know, pinned so the page behaves the # same here as on GitHub Pages. types { text/html html; text/css css; - line 41
text/javascript js; application/json json; image/svg+xml svg; image/png png; image/webp webp; image/x-icon ico; font/woff2 woff2; application/xml xml; text/plain txt asc; } # The site is static and self-contained: it makes no third-party - line 41
requests, # loads no analytics, and needs no scripts from anywhere but itself. This # says so to the browser, so a mirror cannot quietly become a place that # watches its visitors. add_header Content-Security-Policy "default-src 'self'; - line 41
img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'" always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy no-referrer always; # Short cache. A - line 41
mirror that pinned a long max-age would keep showing an old # fingerprint after the real one rotated, which is the one number on the # site that must never be stale. location ~* \.(css|js|svg|png|webp|woff2)$ { add_header Cache-Control - line 41
"public, max-age=300"; } location ~* \.html$ { - line 81
add_header Cache-Control "no-cache"; } }
docs/example.palette
- line 1
# An example VeilVoice palette. # # Copy this to the `palettes` directory beside your preferences file, rename it # to whatever you like, and it appears in the theme picker next time the app # starts. The settings tab shows you the exact - line 1
path on your machine. # # Windows %APPDATA%\veilvoice\palettes\ # Linux ~/.config/veilvoice/palettes/ # macOS ~/Library/Application Support/veilvoice/palettes/ # # The file must be named `something.palette`. Anything else in the directory - line 1
is # ignored rather than complained about. # # --------------------------------------------------------------------------- # Two rules, and the second one is the one people hit # - line 1
--------------------------------------------------------------------------- # # 1. All twelve colour tokens must be present. A missing one is an error naming # the token, not a colour quietly borrowed from the default theme -- a # palette - line 1
that is *mostly* yours with a few colours from somewhere else, and # no indication which, is worse than one that refuses to load. # # 2. The colours must be readable against each other. VeilVoice computes the # WCAG contrast ratio and - line 1
refuses a palette that fails, telling you the # measured ratio so you know how far off it is: # # fg on bg at least 4.5:1 body text # fg on bg-soft at least 4.5:1 the same text on a raised panel # muted on bg at least 3.0:1 secondary text - line 1
# accent on bg at least 3.0:1 links and controls # err on bg at least 3.0:1 a warning nobody can read is # worse than no warning # # This is arithmetic rather than taste. If a palette is rejected, lighten # (on a dark scheme) or darken (on - line 1
a light one) the colour it names until it # passes -- usually a small move is enough. # # Every value is a six-digit hex colour with a leading `#`. Short forms like # `#fff`, named colours like `red`, and `0x112233` are all refused rather - line 1
than # guessed at. - line 41
# The id is written into your preferences file, so it must never change once # you have selected the palette. Lower-case letters, digits and hyphens only, # and it may not be the id of a built-in theme. id = example # What the picker - line 41
shows. Anything you like. name = Example # `true` for a light scheme, which changes egui's base widget styling. light = false # --- backgrounds ------------------------------------------------------------ bg = #101014 bg-soft = #1a1a22 - line 41
bg-inset = #0b0b0e border = #2f2f3a # --- text ------------------------------------------------------------------- fg = #e6e6f0 muted = #9a9ab0 # --- the project's own two colours ------------------------------------------ # `accent` is - line 41
the primary; `accent-2` is the "veiled" half of the mark. accent = #7aa2f7 accent-2 = #bb9af7 # --- meaning ---------------------------------------------------------------- cyan = #7dcfff ok = #9ece6a warn = #e0af68 err = #f7768e
fuzz/.gitignore
- line 1
# cargo-fuzz working directories. The *working* corpus and the crash artefacts # are local findings, not source: a crash worth keeping becomes a test in the # crate it belongs to, which is where it will actually keep being run. # # - line 1
`seeds/` is deliberately not here, and the difference is worth stating. # A crash artefact is an output. A seed is an *input*: it is what stops a cold # run spending its budget rediscovering that a lock file begins with # `VEILLOK1`. See - line 1
README.md for why only two targets have one. target/ corpus/ artifacts/ coverage/ # The one entry here that is not a working directory, so it gets its own # sentence rather than sitting at the end of that list unexplained. # # The - line 1
workspace commits its lock file, because a released binary has to be # rebuildable from the same dependency versions years later and that is the # whole basis of the reproducibility claim. Nothing here is released. These # targets exist to - line 1
find defects, and a campaign run against last year's pinned # dependencies is a campaign that cannot find a defect introduced since. So # `fuzz/` resolves fresh, the weekly workflow does not pass `--locked`, and # Dependabot watches - line 1
`fuzz/Cargo.toml` so a version range that stops making # sense is still raised. Cargo.lock
install/install.bat
- line 1
@echo off REM SPDX-License-Identifier: GPL-3.0-or-later REM REM VeilVoice installer for Windows -- a wrapper, so that double-clicking works. REM REM All of the work, and every one of the checks, is in install.ps1 next to this REM file. - line 1
This exists only because .bat is what Windows runs on a double-click REM and because `powershell -ExecutionPolicy Bypass -File ...` is a mouthful to REM type. Keeping the logic in one place matters here more than usual: two REM - line 1
implementations of a verification routine means one of them is the stale one, REM and the stale one is the one that will be running when it matters. REM REM install.bat interactive REM install.bat -Yes no prompts, no optional components - line 1
REM install.bat -Version v0.1.9 REM REM Arguments are passed through to install.ps1 unchanged. setlocal set "SCRIPT=%~dp0install.ps1" if not exist "%SCRIPT%" ( echo. echo REFUSED: install.ps1 was not found next to this file. echo. echo - line 1
Expected: %SCRIPT% echo. echo This wrapper does nothing on its own -- every check lives in that echo script. Download the whole install directory, not just this file. echo. exit /b 1 ) REM -NoProfile: a profile script can redefine - line 1
anything, including the cmdlets REM used to verify the download. -ExecutionPolicy Bypass applies to this process REM only and changes no machine setting. powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" %* set - line 1
"RC=%ERRORLEVEL%" - line 41
REM A double-clicked window closes the moment the script ends, taking any REM refusal message with it. Pause only when there is nobody to read the exit REM code -- i.e. when this was not run from an existing console. echo. if not - line 41
"%RC%"=="0" ( echo Installation did not complete. The reason is above. ) if "%CMDCMDLINE:~0,7%"=="cmd /c " pause exit /b %RC%
install/install.ps1
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # VeilVoice installer for Windows. # # powershell -ExecutionPolicy Bypass -File install.ps1 # .\install.ps1 -Yes # no prompts, no optional components # .\install.ps1 -Version v0.1.9 # - line 1
.\install.ps1 -Prefix "D:\Tools\VeilVoice" # # --------------------------------------------------------------------------- # What this script will and will not do # - line 1
--------------------------------------------------------------------------- # # It downloads a release, proves it is the one this project published, and puts # it on your PATH. It refuses -- naming the check that failed -- rather than # - line 1
continuing past anything it could not verify. There is no switch to skip # verification, because an installer with one is an installer whose # verification is decorative. # # It installs nothing else unless you say so. VB-CABLE, Audacity - line 1
and GnuPG are # offered once, as explicit questions that default to **no**. With -Yes they # are not installed at all: -Yes means "do not ask me", and answering an # unasked question by installing software on somebody's machine is exactly - line 1
the # behaviour that makes install scripts untrustworthy. # # --------------------------------------------------------------------------- # The order of the checks, which is the whole point # - line 1
--------------------------------------------------------------------------- # # 1. Fetch the public key and check its fingerprint against the constant # below, which is hardcoded in this file. This is the only anchor in the # process; - line 1
everything after it is only as good as this comparison. # 2. Verify the detached signature over SHA256SUMS with that key. # 3. Verify the archive's SHA-256 against the now-trusted SHA256SUMS. # 4. Only then unpack and install. # # The - line 1
order matters. Checking the hash first proves only that the download # matches a list that might itself have been replaced; the signature is what # makes the list worth checking against. Without GnuPG, steps 1 and 2 cannot be # done at - line 1
all, and this script stops rather than pretending that a hash checked - line 41
# against an unverified list is a security check. [CmdletBinding()] param( [switch] $Yes, [string] $Version = "", [string] $Prefix = "", [switch] $WithVBCable, [switch] $WithAudacity, [switch] $WithGpg ) $ErrorActionPreference = "Stop" # - line 41
--------------------------------------------------------------------------- # The trust anchor. Hardcoded on purpose: if it were fetched, it would not be # an anchor. Compare it against the fingerprint published in README.md, on # - line 41
https://tilas01.github.io/veilvoice/ and in every release's notes. # --------------------------------------------------------------------------- $FINGERPRINT = "8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A" $REPO = "tilas01/veilvoice" $KEY_URL - line 41
= "https://tilas01.github.io/veilvoice/assets/veilvoice-signing-key.asc" $KEY_URL_FALLBACK = "https://raw.githubusercontent.com/$REPO/main/website/assets/veilvoice-signing-key.asc" $LABEL = "windows-x86_64" function Write-Step { param($m) - line 41
Write-Host "==> $m" -ForegroundColor DarkGray } function Write-Good { param($m) Write-Host " ok $m" -ForegroundColor Green } function Write-Say { param($m) Write-Host $m } # Every refusal goes through here, so every refusal names the - line 41
check. function Deny { param([string] $Reason, [string[]] $Detail = @()) Write-Host "" Write-Host "REFUSED: $Reason" -ForegroundColor Red foreach ($line in $Detail) { Write-Host " $line" } Write-Host "" Write-Host "Nothing has been - line 41
installed." exit 1 } - line 81
function Test-Have { param($name) return [bool](Get-Command $name -ErrorAction SilentlyContinue) } # Where to find GnuPG. # # On PATH if it is there. Otherwise a fixed list of absolute, well-known # install locations -- Gpg4win's, and the - line 81
copy Git for Windows bundles, which a # great many people already have without knowing it and without it being on # their PATH. That turns "install Gpg4win first" into "verified" for a large # share of Windows users. # # The locations are - line 81
absolute and enumerated on purpose. `gpg` as a bare name # would be resolved through Windows' search order, which includes the current # working directory ahead of most of PATH -- so running this installer from a # folder containing a file - line 81
called gpg.exe would run that instead. That is # finding F-13 in `docs/AUDIT.md`, in the two modules where it mattered most, # and the same rule applies with more force here: this is the program that # decides whether the download is - line 81
genuine. function Find-Gpg { $onPath = Get-Command "gpg" -ErrorAction SilentlyContinue if ($onPath) { return $onPath.Source } $known = @( (Join-Path $env:ProgramFiles "GnuPG\bin\gpg.exe"), (Join-Path ${env:ProgramFiles(x86)} - line 81
"GnuPG\bin\gpg.exe"), (Join-Path $env:ProgramFiles "Git\usr\bin\gpg.exe"), (Join-Path ${env:ProgramFiles(x86)} "Git\usr\bin\gpg.exe"), (Join-Path $env:LOCALAPPDATA "Programs\Git\usr\bin\gpg.exe") ) foreach ($candidate in $known) { if - line 81
($candidate -and (Test-Path $candidate)) { return $candidate } } return $null } # Git for Windows bundles an MSYS build of GnuPG, and it does not understand # Windows paths. Given `C:\Users\...` it treats the whole thing as a *relative* # - line 81
POSIX path and resolves it against the current directory, producing # `/c/current/dir/C:\Users\...` and failing with "directory does not exist". # Measured, not guessed -- it is what the first run of this script did. - line 121
# # So paths handed to an MSYS gpg are translated to `/c/Users/...` form. A # native Gpg4win build takes Windows paths as they are and must not be # translated, hence the flag rather than doing it unconditionally. function Test-MsysGpg { - line 121
param([string] $Path) return ($Path -match '\\Git\\usr\\bin\\gpg\.exe$' -or $Path -match '\\usr\\bin\\gpg\.exe$') } function ConvertTo-GpgPath { param([string] $Path, [bool] $Msys) if (-not $Msys) { return $Path } $p = $Path -replace '\\', - line 121
'/' if ($p -match '^([A-Za-z]):/(.*)$') { return "/" + $Matches[1].ToLower() + "/" + $Matches[2] } return $p } # Native commands and `$ErrorActionPreference = "Stop"` do not mix on Windows # PowerShell 5.1: anything the program writes to - line 121
stderr is turned into an # ErrorRecord, which then terminates the script even when the program exited # successfully. gpg writes progress to stderr as a matter of course. So native # calls run with the preference relaxed and are judged on - line 121
their exit code, # which is the thing that actually says whether they worked. function Invoke-Gpg { param([string] $Exe, [string[]] $GpgArgs) $previous = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & $Exe - line 121
@GpgArgs 2>&1 return [pscustomobject]@{ Code = $LASTEXITCODE; Output = $output } } finally { $ErrorActionPreference = $previous } } function Ask { param([string] $Question) # Defaults to no, every time. A prompt whose default is "yes" is - line 121
not a # question, it is an announcement. if ($Yes) { return $false } - line 161
if ([Console]::IsInputRedirected) { return $false } $answer = Read-Host " $Question [y/N]" return ($answer -match '^[yY]') } # TLS 1.2 on Windows PowerShell 5.1, whose default is still SSL3/TLS1.0 and # which therefore cannot reach - line 161
github.com at all without this line. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 function Get-File { param([string] $Url, [string] $Path) try { # -UseBasicParsing: on 5.1 the default path needs Internet - line 161
Explorer's # engine to be initialised, which fails on a server or a fresh account. Invoke-WebRequest -Uri $Url -OutFile $Path -UseBasicParsing return $true } catch { return $false } } Write-Say "" Write-Say " VeilVoice installer" # - line 161
--------------------------------------------------------------------------- # Which version # --------------------------------------------------------------------------- if (-not $Version) { Write-Step "Asking GitHub for the latest - line 161
release" try { $latest = Invoke-RestMethod -Uri "https://api.github.com/repos/$REPO/releases/latest" -UseBasicParsing $Version = $latest.tag_name } catch { Deny "could not reach the GitHub API to find the latest release" @( "Pass one - line 161
explicitly instead: .\install.ps1 -Version v0.1.9" ) } } if (-not $Version) { Deny "could not read a tag name from the GitHub API reply" @( - line 201
"Pass one explicitly instead: .\install.ps1 -Version v0.1.9" ) } $ARCHIVE = "veilvoice-$Version-$LABEL.zip" $BASE = "https://github.com/$REPO/releases/download/$Version" Write-Say " version $Version" Write-Say " build $LABEL" Write-Say " - line 201
key $FINGERPRINT" Write-Say "" # Short on purpose. GnuPG's agent puts its socket inside the home directory it # is given, and a Unix-domain socket path cannot exceed about 108 bytes -- a # limit the MSYS build of GnuPG that Git for Windows - line 201
bundles is subject to as # well. A full GUID here produced a 90-character home directory, the agent # failed to start with "exit status 2", and the import failed for a reason that # had nothing to do with the key. Measured: 37 characters - line 201
worked, 90 did not. $WORK = Join-Path ([IO.Path]::GetTempPath()) ("vv" + [Guid]::NewGuid().ToString("N").Substring(0, 8)) New-Item -ItemType Directory -Path $WORK -Force | Out-Null try { # - line 201
----------------------------------------------------------------------- # 1. Download # ----------------------------------------------------------------------- Write-Step "Downloading" if (-not (Get-File "$BASE/$ARCHIVE" "$WORK\$ARCHIVE")) - line 201
{ Deny "could not download $ARCHIVE" @( "Checked: $BASE/$ARCHIVE", "If that version is not published, the releases page lists what is:", " https://github.com/$REPO/releases" ) } Write-Good $ARCHIVE if (-not (Get-File "$BASE/SHA256SUMS" - line 201
"$WORK\SHA256SUMS")) { Deny "could not download SHA256SUMS" @( "Without the hash list the download cannot be checked, so it will", "not be installed." ) - line 241
} Write-Good "SHA256SUMS" # The per-file manifest, so the installed program can check every file and # not just the archive. Best effort: older releases do not publish it, and # it is covered by SHA256SUMS so it cannot be swapped - line 241
undetected. $HaveContents = Get-File "$BASE/CONTENTS.sha256" "$WORK\CONTENTS.sha256" if (-not (Get-File "$BASE/SHA256SUMS.asc" "$WORK\SHA256SUMS.asc")) { Deny "this release has no signature (SHA256SUMS.asc)" @( "Every signed release - line 241
publishes one. Its absence means either that", "this release was built without the signing key, or that you are not", "looking at the release you think you are.", "", "This script will not install an unsigned build." ) } Write-Good - line 241
"SHA256SUMS.asc" # ----------------------------------------------------------------------- # 2. The key, and its fingerprint # ----------------------------------------------------------------------- $gpg = Find-Gpg if (-not $gpg) { - line 241
Write-Say "" Write-Say " GnuPG is not installed, and it is what verifies the signature." Write-Say " Without it this script can check that the download matches a" Write-Say " hash list, but not that the hash list is the one VeilVoice" - line 241
Write-Say " published -- which is not a security check at all." Write-Say "" if ($WithGpg -or (Ask "Install GnuPG (Gpg4win) now?")) { if (Test-Have "winget") { winget install --id GnuPG.Gpg4win -e --accept-package-agreements - line 241
--accept-source-agreements $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") } else { Deny "winget is not available to install GnuPG" @( "Install Gpg4win - line 241
yourself and run this again:", " https://gpg4win.org/" ) - line 281
} } $gpg = Find-Gpg if (-not $gpg) { Deny "GnuPG is still not available" @( "The signature cannot be verified without it, and this script does", "not install software it could not verify.", "", "Install Gpg4win and run this again: - line 281
https://gpg4win.org/" ) } } Write-Step "Checking the signing key's fingerprint" # F-79, the other half. `Get-File` swallows the exception, so this script # never printed somebody else's error here the way `install.sh` did. What # it also - line 281
never did was say that the first address failed and the second # one answered, which is worth a line: on a network that refuses the # website and allows the repository, the reader should know which copy of # the key was checked. if (-not - line 281
(Get-File $KEY_URL "$WORK\key.asc")) { Write-Say " the website copy could not be fetched; trying the repository copy" if (-not (Get-File $KEY_URL_FALLBACK "$WORK\key.asc")) { Deny "could not download the public key" @( "Tried: $KEY_URL", " - line 281
and: $KEY_URL_FALLBACK" ) } } # A throwaway keyring. Importing into the user's own is a side effect # nobody asked for, and it would also let a previously-imported key satisfy # this check instead of the one just downloaded. $msys = - line 281
Test-MsysGpg $gpg $home_ = Join-Path $WORK "g" New-Item -ItemType Directory -Path $home_ -Force | Out-Null $homeArg = ConvertTo-GpgPath $home_ $msys # See the note on $WORK. If TEMP itself is long enough that even a short - line 321
# name overflows the agent's socket path, say so plainly -- the failure # would otherwise surface as "the key could not be imported", which is # true and completely misleading. if ($msys -and $homeArg.Length -gt 80) { Deny "the temporary - line 321
directory path is too long for this build of GnuPG" @( "Using: $gpg", "Home: $homeArg ($($homeArg.Length) characters)", "", "This is the GnuPG bundled with Git for Windows. Its agent puts a", "Unix-domain socket inside that directory, and - line 321
such a path cannot", "exceed about 108 bytes, so the agent will not start.", "", "Install Gpg4win, which is a native Windows build and has no such", "limit, then run this again: https://gpg4win.org/", "Or set TEMP to a shorter path first." - line 321
) } $keyArg = ConvertTo-GpgPath "$WORK\key.asc" $msys $imported = Invoke-Gpg $gpg @("--homedir", $homeArg, "--batch", "--quiet", "--import", $keyArg) if ($imported.Code -ne 0) { Deny "the downloaded public key could not be imported" @( - line 321
"The file at $KEY_URL is not a valid OpenPGP public key,", "or GnuPG could not write its temporary keyring.", "", ($imported.Output | Out-String).Trim() ) } $fprLines = (Invoke-Gpg $gpg @("--homedir", $homeArg, "--batch", "--with-colons", - line 321
"--fingerprint")).Output $got = "" foreach ($line in $fprLines) { $t = [string]$line if ($t -like "fpr:*") { $got = ($t -split ":")[9] break } } if ($got -ne $FINGERPRINT) { - line 361
$shown = $got if (-not $shown) { $shown = "(none)" } Deny "the signing key's fingerprint does not match" @( "expected $FINGERPRINT", "found $shown", "", "This is the check that anchors every other one, so nothing further", "was attempted. - line 361
Either the key you fetched is not VeilVoice's, or the", "fingerprint in this script has been altered. Compare it against the", "one published in README.md and on the website before going further." ) } Write-Good "fingerprint matches - line 361
$FINGERPRINT" # ----------------------------------------------------------------------- # 3. The signature over the hash list # ----------------------------------------------------------------------- Write-Step "Verifying the signature - line 361
over SHA256SUMS" $verified = Invoke-Gpg $gpg @( "--homedir", $homeArg, "--batch", "--verify", (ConvertTo-GpgPath "$WORK\SHA256SUMS.asc" $msys), (ConvertTo-GpgPath "$WORK\SHA256SUMS" $msys) ) if ($verified.Code -ne 0) { Deny "the signature - line 361
over SHA256SUMS is not valid" @( "The hash list is not the one signed by $FINGERPRINT.", "Do not use this download.", "", ($verified.Output | Out-String).Trim() ) } Write-Good "signature is good" # - line 361
----------------------------------------------------------------------- # 4. The archive's hash, against the now-trusted list # ----------------------------------------------------------------------- Write-Step "Verifying the archive - line 361
against SHA256SUMS" $want = "" foreach ($line in (Get-Content "$WORK\SHA256SUMS")) { # `hash name`, and sha256sum writes a `*` before the name in binary mode. - line 401
$parts = $line -split '\s+', 2 if ($parts.Count -eq 2) { $name = $parts[1].Trim().TrimStart('*') if ($name -eq $ARCHIVE) { $want = $parts[0].Trim(); break } } } if (-not $want) { Deny "$ARCHIVE is not listed in SHA256SUMS" @( "The - line 401
signature was good, so the list is genuine -- it simply does not", "mention this file. That means this archive was not part of this release." ) } $got = (Get-FileHash -Algorithm SHA256 -Path "$WORK\$ARCHIVE").Hash.ToLower() if ($got -ne - line 401
$want.ToLower()) { Deny "the archive's SHA-256 does not match the signed hash list" @( "expected $want", "found $got", "", "The download does not match what was signed. It is corrupt,", "truncated, or not the file that was published." ) } - line 401
Write-Good "sha256 matches ($got)" # ----------------------------------------------------------------------- # 5. Unpack and install # ----------------------------------------------------------------------- Write-Step "Unpacking" - line 401
Expand-Archive -Path "$WORK\$ARCHIVE" -DestinationPath "$WORK\unpacked" -Force $src = Get-ChildItem "$WORK\unpacked" -Directory | Select-Object -First 1 if (-not $src) { Deny "the archive did not contain the directory expected" } if (-not - line 401
$Prefix) { $Prefix = Join-Path $env:LOCALAPPDATA "Programs\VeilVoice" } New-Item -ItemType Directory -Path $Prefix -Force | Out-Null Copy-Item -Path (Join-Path $src.FullName "*") -Destination $Prefix -Recurse -Force Write-Good "installed - line 401
to $Prefix" # 6. Have the installed program check every file, one more way. The archive - line 441
# verified against the signed list, so the binary is trustworthy; now use it # to run the full per-file check through its own independent code path. If # it disagrees, remove what was installed and stop. $vv = Join-Path $Prefix - line 441
"veilvoice.exe" if ($HaveContents -and (Test-Path $vv)) { Write-Step "Verifying every file with the installed program" & $vv verify auto "$WORK" *> $null # Exit 2 means a check ran and failed; 3 means it could not complete, # which is not - line 441
a disagreement. Only 2 undoes the install. if ($LASTEXITCODE -eq 0) { Write-Good "every file checks out" } elseif ($LASTEXITCODE -eq 2) { Remove-Item -Path (Join-Path $Prefix "veilvoice.exe") -ErrorAction SilentlyContinue Remove-Item -Path - line 441
(Join-Path $Prefix "veilvoice-gui.exe") -ErrorAction SilentlyContinue Deny "the installed program's own check disagreed with the download" @( "The archive matched the signed list, but the per-file check", "failed. The installed programs - line 441
have been removed. Do not use this copy." ) } else { Write-Say " the extra per-file check could not complete; the archive" Write-Say " itself already verified against the signed list, so this is fine." } } # On the *user's* PATH, not the - line 441
machine's: this needs no administrator and # affects nobody else's account. $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($null -eq $userPath) { $userPath = "" } if (($userPath -split ';') -notcontains $Prefix) { - line 441
[Environment]::SetEnvironmentVariable("Path", ($userPath.TrimEnd(';') + ";" + $Prefix), "User") Write-Good "added to your PATH (open a new terminal to pick it up)" } # ----------------------------------------------------------------------- - line 441
# 6. Optional extras -- asked once, defaulting to no # ----------------------------------------------------------------------- if (-not $Yes) { Write-Say "" Write-Say " Optional, and nothing depends on them:" Write-Say "" - line 481
Write-Say " VB-CABLE -- a virtual audio cable, which is what lets the live" Write-Say " mode feed a veiled microphone into a call. It is" Write-Say " PROPRIETARY donationware by VB-Audio, not free" Write-Say " software, and is not bundled - line 481
with VeilVoice." Write-Say " Installing it means accepting their licence." Write-Say "" Write-Say " Audacity -- a free audio editor, useful for recording and for" Write-Say " trimming a file before veiling it. Not bundled: it" Write-Say " - line 481
is GPL-2.0-or-later, which cannot be combined with" Write-Say " this project's GPL-3.0-or-later." Write-Say "" if (Ask "Open the VB-CABLE download page in your browser?") { $WithVBCable = $true } if (Ask "Install Audacity?") { - line 481
$WithAudacity = $true } } if ($WithVBCable) { # Deliberately not a silent download-and-run. VB-CABLE is proprietary # software with its own licence and its own installer, and this script # has no business accepting somebody else's terms on - line 481
your behalf -- # still less running an unverified third-party installer as part of a # script whose entire subject is verifying what you run. Write-Step "Opening the VB-CABLE page" Start-Process "https://vb-audio.com/Cable/" Write-Say " - line 481
Follow their instructions, then reboot before using live mode." } if ($WithAudacity) { Write-Step "Installing Audacity" if (Test-Have "winget") { winget install --id Audacity.Audacity -e --accept-package-agreements - line 481
--accept-source-agreements } else { Write-Say " winget is not available. Get it from https://www.audacityteam.org/" } } # ----------------------------------------------------------------------- Write-Say "" Write-Host "Installed." - line 481
-ForegroundColor Green Write-Say "" Write-Say " Every check passed: the key's fingerprint, the signature over the" - line 521
Write-Say " hash list, and the archive's hash against that list." Write-Say "" Write-Say " Open a new terminal, then:" Write-Say " veilvoice --help" Write-Say " veilvoice info # what this build supports" Write-Say "" Write-Say " The - line 521
desktop app is veilvoice-gui.exe in:" Write-Say " $Prefix" Write-Say "" Write-Say " What it does and does not do:" Write-Say " https://github.com/$REPO/blob/main/docs/WHITEPAPER.md" Write-Say "" } finally { if (Test-Path $WORK) { - line 521
Remove-Item -Recurse -Force $WORK -ErrorAction SilentlyContinue } }
install/install.sh
- line 1
#!/bin/sh # SPDX-License-Identifier: GPL-3.0-or-later # # VeilVoice installer for Linux and macOS. # # sh install.sh # interactive # sh install.sh --yes # no prompts, no optional components # sh install.sh --version v0.1.9 # sh install.sh - line 1
--prefix ~/.local # sh install.sh --help # # --------------------------------------------------------------------------- # What this script will and will not do # --------------------------------------------------------------------------- - line 1
# # It downloads a release, proves it is the one this project published, and puts # it on your PATH. It refuses -- loudly, naming the check that failed -- rather # than continuing past anything it could not verify. There is no "--force" - line 1
and # no "skip verification" switch, because an installer with one is an installer # whose verification is decorative. # # It installs *nothing else* unless you say so. Audacity and GPG are offered, # once, as an explicit question that - line 1
defaults to **no**. With `--yes` they are # not installed at all: `--yes` means "do not ask me", and answering an # unasked question by installing software on somebody's machine is precisely # the behaviour that makes install scripts - line 1
untrustworthy. # # --------------------------------------------------------------------------- # The order of the checks, which is the whole point # --------------------------------------------------------------------------- # # 1. Fetch - line 1
the public key and check its **fingerprint** against the constant # below, which is hardcoded in this file. A key that does not match is # refused. This is the only anchor in the whole process: everything after # it is only as trustworthy - line 1
as this comparison. # 2. Verify the detached signature over SHA256SUMS with that key. # 3. Verify the archive's SHA-256 against the now-trusted SHA256SUMS. # 4. Only then unpack, and only then install. # # Doing it in this order matters. - line 1
Checking the hash first and the signature - line 41
# afterwards proves only that the file matches a list that might itself have # been replaced. The signature is what makes the list worth checking against. # # If GPG is not installed, steps 1 and 2 cannot be done, and the script says so # - line 41
and stops rather than quietly falling back to "the hash matched". A hash # checked against an unverified list is not a security check. set -eu # --------------------------------------------------------------------------- # The trust - line 41
anchor. Hardcoded on purpose: if this is fetched, it is not an # anchor. Compare it against the fingerprint published in README.md, on # https://tilas01.github.io/veilvoice/ and in every release's notes. # - line 41
--------------------------------------------------------------------------- FINGERPRINT="8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A" REPO="tilas01/veilvoice" KEY_URL="https://tilas01.github.io/veilvoice/assets/veilvoice-signing-key.asc" - line 41
KEY_URL_FALLBACK="https://raw.githubusercontent.com/$REPO/main/website/assets/veilvoice-signing-key.asc" VERSION="" PREFIX="" ASSUME_YES=0 WANT_AUDACITY=0 WANT_GPG=0 # - line 41
--------------------------------------------------------------------------- # Output # --------------------------------------------------------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_OK=$(printf '\033[32m'); - line 41
C_BAD=$(printf '\033[31m') C_DIM=$(printf '\033[2m'); C_OFF=$(printf '\033[0m') else C_OK=''; C_BAD=''; C_DIM=''; C_OFF='' fi say() { printf '%s\n' "$*"; } step() { printf '%s\n' "${C_DIM}==>${C_OFF} $*"; } good() { printf '%s\n' " - line 41
${C_OK}ok${C_OFF} $*"; } - line 81
# Every refusal goes through here, so every refusal names the check. refuse() { printf '\n%s\n' "${C_BAD}REFUSED${C_OFF}: $1" >&2 shift for line in "$@"; do printf '%s\n' " $line" >&2; done printf '\n%s\n' "Nothing has been installed." >&2 - line 81
exit 1 } usage() { sed -n '3,11p' "$0" | sed 's/^# \{0,1\}//' exit 0 } # --------------------------------------------------------------------------- # Arguments # --------------------------------------------------------------------------- - line 81
while [ $# -gt 0 ]; do case "$1" in --yes|-y) ASSUME_YES=1 ;; --version) shift; VERSION="${1:-}" ;; --version=*) VERSION="${1#*=}" ;; --prefix) shift; PREFIX="${1:-}" ;; --prefix=*) PREFIX="${1#*=}" ;; --with-audacity) WANT_AUDACITY=1 ;; - line 81
--with-gpg) WANT_GPG=1 ;; --help|-h) usage ;; *) refuse "unknown option: $1" "Run '$0 --help' for the options." ;; esac shift done # --------------------------------------------------------------------------- # Tools this script needs # - line 81
--------------------------------------------------------------------------- have() { command -v "$1" >/dev/null 2>&1; } # Defined here rather than beside the optional-extras section further down, # because the GnuPG prompt above may call - line 81
it. A function used before its # definition is simply not defined yet -- the shell reads top to bottom. - line 121
install_optional() { package="$1" if have apt-get; then sudo apt-get install -y "$package" elif have dnf; then sudo dnf install -y "$package" elif have pacman; then sudo pacman -S --noconfirm "$package" elif have zypper; then sudo zypper - line 121
install -y "$package" elif have apk; then sudo apk add "$package" elif have brew; then brew install "$package" elif have pkg; then sudo pkg install -y "$package" else say " Could not find a package manager to install '$package'." say " - line 121
Install it yourself if you want it." return 1 fi } if have curl; then fetch() { curl -fsSL --proto '=https' --tlsv1.2 -o "$2" "$1"; } elif have wget; then fetch() { wget -q -O "$2" "$1"; } else refuse "neither curl nor wget is installed" \ - line 121
"One of them is needed to download anything at all." \ "Debian/Ubuntu: sudo apt install curl" \ "Fedora: sudo dnf install curl" \ "macOS: curl is already present; check your PATH" fi # SHA-256, from whichever tool this system has. if have - line 121
sha256sum; then sha256_of() { sha256sum "$1" | cut -d' ' -f1; } elif have shasum; then sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; } else refuse "no SHA-256 tool found" \ "Looked for 'sha256sum' and 'shasum'." \ "Without one, the - line 121
download cannot be checked, so it will not be installed." fi # --------------------------------------------------------------------------- - line 161
# Which build # --------------------------------------------------------------------------- detect_label() { os=$(uname -s) arch=$(uname -m) case "$os" in Linux) case "$arch" in x86_64|amd64) echo "linux-x86_64" ;; aarch64|arm64) echo - line 161
"linux-arm64" ;; armv7l|armv7) echo "linux-armv7-pi" ;; *) echo "" ;; esac ;; Darwin) case "$arch" in arm64) echo "macos-arm64" ;; x86_64) echo "macos-x86_64" ;; *) echo "" ;; esac ;; FreeBSD) case "$arch" in amd64|x86_64) echo - line 161
"freebsd-x86_64" ;; *) echo "" ;; esac ;; *) echo "" ;; esac } LABEL=$(detect_label) [ -n "$LABEL" ] || refuse "no published build for $(uname -s) $(uname -m)" \ "The releases page lists what is published:" \ " - line 161
https://github.com/$REPO/releases" \ "Building from source works on any platform Rust supports:" \ " cargo install --path crates/veilvoice-cli" # The archive suffix. Everything except Windows is a tarball. EXT="tar.gz" - line 201
# --------------------------------------------------------------------------- # Which version # --------------------------------------------------------------------------- if [ -z "$VERSION" ]; then step "Asking GitHub for the latest - line 201
release" TMP_TAG=$(mktemp) if ! fetch "https://api.github.com/repos/$REPO/releases/latest" "$TMP_TAG"; then rm -f "$TMP_TAG" refuse "could not reach the GitHub API to find the latest release" \ "Pass one explicitly instead: $0 --version - line 201
v0.1.9" fi VERSION=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP_TAG" | head -1) rm -f "$TMP_TAG" [ -n "$VERSION" ] || refuse "could not read a tag name from the GitHub API reply" \ "Pass one explicitly - line 201
instead: $0 --version v0.1.9" fi ARCHIVE="veilvoice-$VERSION-$LABEL.$EXT" BASE="https://github.com/$REPO/releases/download/$VERSION" say "" say " VeilVoice installer" say " version $VERSION" say " build $LABEL" say " key $FINGERPRINT" say - line 201
"" WORK=$(mktemp -d) cleanup() { rm -rf "$WORK"; } trap cleanup EXIT INT TERM # --------------------------------------------------------------------------- # 1. Download # - line 201
--------------------------------------------------------------------------- step "Downloading" fetch "$BASE/$ARCHIVE" "$WORK/$ARCHIVE" \ || refuse "could not download $ARCHIVE" \ "Checked: $BASE/$ARCHIVE" \ "If that version or platform is - line 201
not published, the releases page lists what is:" \ - line 241
" https://github.com/$REPO/releases" good "$ARCHIVE" fetch "$BASE/SHA256SUMS" "$WORK/SHA256SUMS" \ || refuse "could not download SHA256SUMS" \ "Without the hash list the download cannot be checked, so it will not be installed." good - line 241
"SHA256SUMS" # The per-file manifest, so the freshly installed binary can check every file # in the archive and not just the archive as a whole. Best effort: releases # before v0.1.15 do not publish it, and its absence is not fatal -- the - line 241
archive # hash still pins the contents. It is covered by SHA256SUMS, so it cannot be # swapped without the signature check catching it. HAVE_CONTENTS=1 fetch "$BASE/CONTENTS.sha256" "$WORK/CONTENTS.sha256" 2>/dev/null || HAVE_CONTENTS=0 - line 241
SIGNED=1 fetch "$BASE/SHA256SUMS.asc" "$WORK/SHA256SUMS.asc" 2>/dev/null || SIGNED=0 if [ "$SIGNED" = "1" ]; then good "SHA256SUMS.asc" else refuse "this release has no signature (SHA256SUMS.asc)" \ "Every signed release publishes one. Its - line 241
absence means either that this" \ "release was built without the signing key, or that you are not looking" \ "at the release you think you are." \ "" \ "This script will not install an unsigned build." fi # - line 241
--------------------------------------------------------------------------- # 2. The key, and its fingerprint # --------------------------------------------------------------------------- if ! have gpg && ! have gpg2; then say "" say " - line 241
GnuPG is not installed, and it is what verifies the signature." say " Without it this script can check that the download matches a hash" say " list, but not that the hash list is the one VeilVoice published --" say " which is not a - line 241
security check at all." say "" if [ "$ASSUME_YES" = "1" ] || [ "$WANT_GPG" = "1" ]; then - line 281
[ "$WANT_GPG" = "1" ] || refuse "GnuPG is not installed" \ "Install it and run this again, or pass --with-gpg to have this" \ "script install it for you:" \ " Debian/Ubuntu: sudo apt install gnupg" \ " Fedora: sudo dnf install gnupg2" \ " - line 281
macOS: brew install gnupg" else printf ' Install GnuPG now? [y/N] ' read -r answer </dev/tty || answer="" case "$answer" in [yY]*) WANT_GPG=1 ;; esac fi if [ "$WANT_GPG" = "1" ]; then install_optional gnupg || true fi have gpg || have gpg2 - line 281
|| refuse "GnuPG is still not available" \ "The signature cannot be verified without it, and this script does not" \ "install software it could not verify." fi GPG=$(command -v gpg 2>/dev/null || command -v gpg2) step "Checking the signing - line 281
key's fingerprint" # F-79. The first attempt's own error output is suppressed and replaced with a # line of this script's. # # There are two places to get the key and the second one is a fallback, so a # failure here is not a failure at - line 281
all. But `curl -fsS` prints its own message # on stderr, so what a reader saw was # # ==> Checking the signing key's fingerprint # curl: (22) The requested URL returned error: 403 # ok fingerprint matches 8101FB... # # an error from - line 281
another program inside a security step, immediately above the # word "ok", with nothing to say the two lines are about different things. # Measured on a machine whose network refuses the website but allows the # repository, which is an - line 281
ordinary situation: a proxy, a filtered network, or # GitHub Pages simply being down. - line 321
# # The final failure stays loud and names both addresses. What is silenced is an # attempt that did not matter. if ! fetch "$KEY_URL" "$WORK/key.asc" 2>/dev/null; then say " the website copy could not be fetched; trying the repository - line 321
copy" fetch "$KEY_URL_FALLBACK" "$WORK/key.asc" \ || refuse "could not download the public key" \ "Tried: $KEY_URL" \ " and: $KEY_URL_FALLBACK" fi # A throwaway keyring: importing into the user's own is a side effect nobody # asked for, - line 321
and it would also mean a previously-imported key could satisfy # this check instead of the one just downloaded. export GNUPGHOME="$WORK/gnupg" mkdir -p "$GNUPGHOME" chmod 700 "$GNUPGHOME" "$GPG" --batch --quiet --import "$WORK/key.asc" - line 321
2>/dev/null \ || refuse "the downloaded public key could not be imported" \ "The file at $KEY_URL is not a valid OpenPGP public key." GOT=$("$GPG" --batch --with-colons --fingerprint 2>/dev/null \ | awk -F: '$1 == "fpr" { print $10; exit - line 321
}') if [ "$GOT" != "$FINGERPRINT" ]; then refuse "the signing key's fingerprint does not match" \ "expected $FINGERPRINT" \ "found ${GOT:-(none)}" \ "" \ "This is the check that anchors every other one, so nothing further" \ "was - line 321
attempted. Either the key you fetched is not VeilVoice's, or the" \ "fingerprint in this script has been altered. Compare it against the" \ "one published in README.md and on the website before going further." fi good "fingerprint matches - line 321
$FINGERPRINT" # --------------------------------------------------------------------------- # 3. The signature over the hash list # --------------------------------------------------------------------------- - line 361
step "Verifying the signature over SHA256SUMS" "$GPG" --batch --quiet --verify "$WORK/SHA256SUMS.asc" "$WORK/SHA256SUMS" 2>/dev/null \ || refuse "the signature over SHA256SUMS is not valid" \ "The hash list is not the one signed by - line 361
$FINGERPRINT." \ "Do not use this download." good "signature is good" # --------------------------------------------------------------------------- # 4. The archive's hash, against the now-trusted list # - line 361
--------------------------------------------------------------------------- step "Verifying the archive against SHA256SUMS" WANT=$(awk -v want="$ARCHIVE" '$2 == want || $2 == "*" want { print $1; exit }' "$WORK/SHA256SUMS") [ -n "$WANT" ] - line 361
|| refuse "$ARCHIVE is not listed in SHA256SUMS" \ "The signature was good, so the list is genuine -- it simply does not" \ "mention this file. That means this archive was not part of this release." GOT=$(sha256_of "$WORK/$ARCHIVE") if [ - line 361
"$WANT" != "$GOT" ]; then refuse "the archive's SHA-256 does not match the signed hash list" \ "expected $WANT" \ "found $GOT" \ "" \ "The download does not match what was signed. It is corrupt, truncated," \ "or not the file that was - line 361
published." fi good "sha256 matches ($GOT)" # --------------------------------------------------------------------------- # 5. Unpack and install # --------------------------------------------------------------------------- step - line 361
"Unpacking" tar -xzf "$WORK/$ARCHIVE" -C "$WORK" \ || refuse "the archive could not be unpacked" "It verified, but tar could not read it." SRC="$WORK/veilvoice-$VERSION-$LABEL" [ -d "$SRC" ] || SRC=$(find "$WORK" -maxdepth 1 -type d -name - line 361
'veilvoice-*' | head -1) [ -d "$SRC" ] || refuse "the archive did not contain the directory expected" if [ -z "$PREFIX" ]; then # ~/.local/bin needs no root and is on the default PATH of every current - line 401
# distribution. Installing to /usr/local by default would mean asking for # a password, which an installer should do only when it must. PREFIX="$HOME/.local" fi BIN="$PREFIX/bin" mkdir -p "$BIN" INSTALLED="" for name in veilvoice - line 401
veilvoice-gui; do if [ -f "$SRC/$name" ]; then cp "$SRC/$name" "$BIN/$name" chmod 755 "$BIN/$name" INSTALLED="$INSTALLED $name" fi done [ -n "$INSTALLED" ] || refuse "no VeilVoice binary was found inside the archive" good - line 401
"installed$INSTALLED to $BIN" # --------------------------------------------------------------------------- # 6. Have the installed program check itself, every file, one more way # - line 401
--------------------------------------------------------------------------- # The archive verified against the signed list, so the binary just installed is # trustworthy. Now use it to run the full per-file check -- the same # `veilvoice - line 401
verify` a user would run by hand, through an independent code path # (a pure-Rust OpenPGP implementation) and the signed per-file manifest. If it # disagrees with what got installed, that is a reason to stop and say so. if [ -x - line 401
"$BIN/veilvoice" ] && [ "$HAVE_CONTENTS" = "1" ] && [ "$SIGNED" = "1" ]; then step "Verifying every file with the installed program" if "$BIN/veilvoice" verify auto "$WORK" >/dev/null 2>&1; then good "every file checks out" else # Undo the - line 401
install rather than leave a half-trusted copy in place. for name in $INSTALLED; do rm -f "$BIN/$name"; done refuse "the installed program's own check disagreed with the download" "The archive matched the signed list, but the per-file - line 401
verification" "did not. The installed files have been removed. Run:" " veilvoice verify auto <the folder you downloaded to>" "on a copy you trust, and do not use this one." fi fi # - line 401
--------------------------------------------------------------------------- # 6. Optional extras -- asked once, defaulting to no - line 441
# --------------------------------------------------------------------------- # # Deliberately after the install, and deliberately not part of it. These are # other people's software; VeilVoice recommends them and does not bundle them. # - line 441
Audacity in particular is GPL-2.0-or-later, which is incompatible with this # project's GPL-3.0-or-later for combining code -- recommending it is fine, # shipping it inside anything is not. if [ "$ASSUME_YES" = "0" ] && [ -t 0 ]; then say - line 441
"" say " Optional, and nothing depends on them:" say "" say " Audacity -- a free audio editor. Useful for recording and for" say " trimming a file before veiling it. Not bundled: it is" say " GPL-2.0-or-later, which cannot be combined with - line 441
this" say " project's GPL-3.0-or-later." say "" printf ' Install Audacity? [y/N] ' read -r answer </dev/tty || answer="" case "$answer" in [yY]*) WANT_AUDACITY=1 ;; esac fi if [ "$WANT_AUDACITY" = "1" ]; then step "Installing Audacity" - line 441
install_optional audacity && good "Audacity installed" || \ say " Audacity was not installed. VeilVoice does not need it." fi # --------------------------------------------------------------------------- # Done # - line 441
--------------------------------------------------------------------------- say "" say "${C_OK}Installed.${C_OFF}" say "" say " Every check passed: the key's fingerprint, the signature over the hash" say " list, and the archive's hash - line 441
against that list." say "" case ":$PATH:" in *":$BIN:"*) ;; - line 481
*) say " $BIN is not on your PATH. Add it:" say "" say " echo 'export PATH=\"\$PATH:$BIN\"' >> ~/.profile" say "" ;; esac say " Start with: veilvoice --help" say " veilvoice info # what this build supports" say "" say " What it does and - line 481
does not do:" say " https://github.com/$REPO/blob/main/docs/WHITEPAPER.md" say ""
packaging/aur/.SRCINFO
- line 1
pkgbase = veilvoice pkgdesc = Irreversible voice de-identification, fully offline pkgver = 0.1.22 pkgrel = 1 url = https://tilas01.github.io/veilvoice/ arch = x86_64 arch = aarch64 license = GPL-3.0-or-later makedepends = cargo makedepends - line 1
= git depends = alsa-lib depends = gtk3 depends = libxkbcommon depends = libglvnd optdepends = gnupg: check a release with your own GnuPG rather than the key built in optdepends = ffmpeg: import audio from a video container, and write - line 1
video out optdepends = pipewire: a loopback device for live mode source = veilvoice-0.1.18.tar.gz::https://github.com/tilas01/veilvoice/archive/refs/tags/v0.1.18.tar.gz sha256sums = - line 1
0000000000000000000000000000000000000000000000000000000000000000 pkgname = veilvoice
packaging/aur/PKGBUILD
- line 1
# Maintainer: tilas01 <https://github.com/tilas01> # SPDX-License-Identifier: GPL-3.0-or-later # # Arch package built from a tagged release tarball. Every byte is compiled on # the machine installing it: no binary is downloaded and nothing - line 1
is trusted # that makepkg did not build. For the live branch, see PKGBUILD-git beside # this file. # # Verify what you install: `veilvoice verify key` prints the signing key and # its fingerprint, to compare against README.md and the - line 1
website. The stronger # check is that this package builds from source, so what lands in /usr/bin is # what your own machine compiled. pkgname=veilvoice pkgver=0.1.22 pkgrel=1 pkgdesc="Irreversible voice de-identification, fully offline" - line 1
arch=('x86_64' 'aarch64') url="https://tilas01.github.io/veilvoice/" license=('GPL-3.0-or-later') # Runtime: ALSA for live mode, and the usual desktop stack for the window. depends=('alsa-lib' 'gtk3' 'libxkbcommon' 'libglvnd') - line 1
makedepends=('cargo' 'git') optdepends=( 'gnupg: check a release with your own GnuPG rather than the key built in' 'ffmpeg: import audio from a video container, and write video out' 'pipewire: a loopback device for live mode' ) - line 1
source=("$pkgname-$pkgver.tar.gz::https://github.com/tilas01/veilvoice/archive/refs/tags/v$pkgver.tar.gz") # Set by `updpkgsums` when the tag is cut, and checked into the AUR repository # with the release. It is deliberately not SKIP: an - line 1
unchecked source is the one # thing this package exists to argue against, and a package that skipped the # check while shipping a verifier would be saying two different things. - line 1
sha256sums=('0000000000000000000000000000000000000000000000000000000000000000') prepare() { cd "$pkgname-$pkgver" export RUSTUP_TOOLCHAIN=stable - line 41
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" } build() { cd "$pkgname-$pkgver" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target # Both front ends. The verifier is part of `veilvoice` since 0.1.18 and is - line 41
# no longer a binary of its own. cargo build --frozen --release -p veilvoice-cli -p veilvoice-gui } check() { cd "$pkgname-$pkgver" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target # The workspace suite. It is entirely offline - line 41
and needs no audio device. cargo test --frozen --release --workspace } package() { cd "$pkgname-$pkgver" install -Dm0755 target/release/veilvoice "$pkgdir/usr/bin/veilvoice" install -Dm0755 target/release/veilvoice-gui - line 41
"$pkgdir/usr/bin/veilvoice-gui" install -Dm0644 assets/icon.png \ "$pkgdir/usr/share/icons/hicolor/256x256/apps/veilvoice.png" install -Dm0644 packaging/veilvoice.desktop \ "$pkgdir/usr/share/applications/veilvoice.desktop" install -Dm0644 - line 41
LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm0644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" for doc in docs/*.md; do install -Dm0644 "$doc" "$pkgdir/usr/share/doc/$pkgname/$(basename "$doc")" done # Manual - line 41
pages are generated from the programs' own help, so they cannot # describe a flag that is not there. python3 tools/release/manpage.py target/release/veilvoice \ - line 81
"$pkgdir/usr/share/man/man1/veilvoice.1" python3 tools/release/manpage.py target/release/veilvoice-gui \ "$pkgdir/usr/share/man/man1/veilvoice-gui.1" \ --summary "the VeilVoice desktop application" }
packaging/aur/PKGBUILD-git
- line 1
# Maintainer: tilas01 <https://github.com/tilas01> # SPDX-License-Identifier: GPL-3.0-or-later # # The live branch, built from the repository as it stands. This is the package # for somebody who wants what is in main rather than what was - line 1
last tagged; for # a release, use the PKGBUILD beside this file. # # `pkgver()` derives a version from the tags, so `makepkg` on two different # days produces two comparably ordered packages rather than two called the # same thing. - line 1
pkgname=veilvoice-git _pkgname=veilvoice pkgver=0.1.17.r0.g0000000 pkgrel=1 pkgdesc="Irreversible voice de-identification, fully offline (git)" arch=('x86_64' 'aarch64') url="https://tilas01.github.io/veilvoice/" - line 1
license=('GPL-3.0-or-later') depends=('alsa-lib' 'gtk3' 'libxkbcommon' 'libglvnd') makedepends=('cargo' 'git') optdepends=( 'gnupg: check a release with your own GnuPG rather than the key built in' 'ffmpeg: import audio from a video - line 1
container, and write video out' 'pipewire: a loopback device for live mode' ) provides=("$_pkgname=$pkgver") conflicts=("$_pkgname") source=("$_pkgname::git+https://github.com/tilas01/veilvoice.git") # A git source is pinned by the commit - line 1
makepkg checked out, which is recorded # in the built package. There is no tarball to hash. sha256sums=('SKIP') pkgver() { cd "$_pkgname" git describe --long --tags --abbrev=7 2>/dev/null \ | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' \ || - line 1
printf "0.r%s.g%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)" - line 41
} prepare() { cd "$_pkgname" export RUSTUP_TOOLCHAIN=stable cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" } build() { cd "$_pkgname" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target cargo build --frozen - line 41
--release -p veilvoice-cli -p veilvoice-gui } check() { cd "$_pkgname" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target cargo test --frozen --release --workspace } package() { cd "$_pkgname" install -Dm0755 - line 41
target/release/veilvoice "$pkgdir/usr/bin/veilvoice" install -Dm0755 target/release/veilvoice-gui "$pkgdir/usr/bin/veilvoice-gui" install -Dm0644 assets/icon.png \ "$pkgdir/usr/share/icons/hicolor/256x256/apps/veilvoice.png" install - line 41
-Dm0644 packaging/veilvoice.desktop \ "$pkgdir/usr/share/applications/veilvoice.desktop" install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm0644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" for doc in - line 41
docs/*.md; do install -Dm0644 "$doc" "$pkgdir/usr/share/doc/$pkgname/$(basename "$doc")" done python3 tools/release/manpage.py target/release/veilvoice \ - line 81
"$pkgdir/usr/share/man/man1/veilvoice.1" python3 tools/release/manpage.py target/release/veilvoice-gui \ "$pkgdir/usr/share/man/man1/veilvoice-gui.1" \ --summary "the VeilVoice desktop application" }
packaging/debian/changelog
- line 1
veilvoice (0.1.22-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. -- tilas01 <tilas01@users.noreply.github.com> Wed, 16 Sep 2026 00:00:00 +0000 veilvoice (0.1.21-1) unstable; urgency=medium * - line 1
See CHANGELOG.md in the source for what changed in this release. -- tilas01 <tilas01@users.noreply.github.com> Thu, 10 Sep 2026 00:00:00 +0000 veilvoice (0.1.20-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed - line 1
in this release. -- tilas01 <tilas01@users.noreply.github.com> Tue, 08 Sep 2026 00:00:00 +0000 veilvoice (0.1.19-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. -- tilas01 - line 1
<tilas01@users.noreply.github.com> Mon, 07 Sep 2026 00:00:00 +0000 veilvoice (0.1.18-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. -- tilas01 <tilas01@users.noreply.github.com> Thu, 03 Sep - line 1
2026 00:00:00 +0000 veilvoice (0.1.17-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. -- tilas01 <tilas01@users.noreply.github.com> Wed, 02 Sep 2026 00:00:00 +0000 veilvoice (0.1.16-1) - line 1
unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. This file exists because dpkg-buildpackage requires it and takes the - line 41
package's version from it, so it is the one place in packaging/ that decides what version is built. tools/site-tests/packaging.test.js compares it against the workspace version and fails the build if the two disagree. -- tilas01 - line 41
<tilas01@users.noreply.github.com> Tue, 01 Sep 2026 00:00:00 +0000 veilvoice (0.1.15-1) unstable; urgency=medium * See CHANGELOG.md in the source for what changed in this release. This file exists because dpkg-buildpackage requires it and - line 41
takes the package's version from it, so it is the one place in packaging/ that decides what version is built. tools/site-tests/packaging.test.js compares it against the workspace version and fails the build if the two disagree. -- tilas01 - line 41
<tilas01@users.noreply.github.com> Mon, 31 Aug 2026 00:00:00 +0000
packaging/debian/control
- line 1
Source: veilvoice Section: sound Priority: optional Maintainer: tilas01 <tilas01@users.noreply.github.com> Build-Depends: debhelper-compat (= 13), cargo, rustc (>= 1.96), pkg-config, libasound2-dev, libgtk-3-dev, libxkbcommon-dev, python3 - line 1
Standards-Version: 4.7.0 Homepage: https://github.com/tilas01/veilvoice Rules-Requires-Root: no Package: veilvoice Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends} Description: irreversible voice de-identification, fully - line 1
offline VeilVoice destroys the biometric voiceprint of a speaker - pitch, formants, timbre, micro-timing and the melody of an accent - so that neither software nor a human listener can re-identify the speaker or reconstruct the original - line 1
voice, while the words themselves stay clean and transcribable. . It does not hide what was said. Intelligibility is preserved on purpose, and the words remain in the output and can be transcribed. If the message itself is sensitive, - line 1
encrypt it; that is a separate problem with a separate answer. . Fully offline by construction: there is no networking code in the project and the build fails if an HTTP client enters the dependency graph. . This package contains the - line 1
command-line tool and the portable release verifier. Package: veilvoice-gui Architecture: any # libxkbcommon-x11-0 is named explicitly and the rest is left to shlibs. # The window toolkit opens that one by name at startup rather than - line 1
linking # against it, so dpkg-shlibdeps cannot see it: the package installed # cleanly and the application aborted before drawing anything. - line 41
Depends: ${shlibs:Depends}, ${misc:Depends}, veilvoice (= ${binary:Version}), libxkbcommon-x11-0 Description: irreversible voice de-identification - desktop application The VeilVoice desktop application. . See the veilvoice package for - line 41
what the tool does and, just as importantly, what it does not do.
packaging/debian/copyright
- line 1
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: veilvoice Upstream-Contact: tilas01 <tilas01@users.noreply.github.com> Source: https://github.com/tilas01/veilvoice Files: * Copyright: 2026 tilas01 - line 1
License: GPL-3.0-or-later License: GPL-3.0-or-later This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of - line 1
the License, or (at your option) any later version. . This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See - line 1
the GNU General Public License for more details. . On Debian systems, the complete text of the GNU General Public License version 3 can be found in "/usr/share/common-licenses/GPL-3".
packaging/debian/rules
- line 1
#!/usr/bin/make -f # SPDX-License-Identifier: GPL-3.0-or-later export DH_VERBOSE = 1 # Reproducible builds: Debian sets SOURCE_DATE_EPOCH, and these remaps stop the # build directory and the cargo registry path being baked into the binary. - line 1
The # same flags the project's own release workflow uses. export RUSTFLAGS = --remap-path-prefix=$(CURDIR)=/veilvoice --remap-path-prefix=$(HOME)/.cargo=/cargo %: dh $@ override_dh_auto_build: # --locked: build exactly the dependency - line 1
versions the project tested. cargo build --release --locked --workspace override_dh_auto_test: cargo test --release --locked --workspace override_dh_auto_install: # Manual pages, generated from each binary's own `--help` rather than # - line 1
written out and kept in step by hand. `lintian` reported # `no-manual-page` for all three; a page written separately would be a # second description of the interface, and this project has already paid # for that arrangement once (F-71). - line 1
python3 tools/release/manpage.py target/release/veilvoice \ debian/veilvoice/usr/share/man/man1/veilvoice.1 python3 tools/release/manpage.py target/release/veilvoice-gui \ debian/veilvoice-gui/usr/share/man/man1/veilvoice-gui.1 \ --summary - line 1
"the VeilVoice desktop application" gzip -9n debian/veilvoice/usr/share/man/man1/*.1 gzip -9n debian/veilvoice-gui/usr/share/man/man1/*.1 install -Dpm 0755 target/release/veilvoice \ debian/veilvoice/usr/bin/veilvoice install -Dpm 0755 - line 1
target/release/veilvoice-gui \ debian/veilvoice-gui/usr/bin/veilvoice-gui install -Dpm 0644 assets/icon.png \ debian/veilvoice-gui/usr/share/icons/hicolor/256x256/apps/veilvoice.png install -Dpm 0644 packaging/veilvoice.desktop \ - line 1
debian/veilvoice-gui/usr/share/applications/veilvoice.desktop
packaging/debian/source-format
- line 1
3.0 (quilt)
packaging/flatpak/io.github.tilas01.VeilVoice.desktop
- line 1
[Desktop Entry] Type=Application Name=VeilVoice GenericName=Voice de-identification Comment=Destroy the voiceprint in a recording, keep the words Exec=veilvoice-gui Icon=io.github.tilas01.VeilVoice Terminal=false - line 1
Categories=AudioVideo;Audio;Security; Keywords=voice;anonymise;anonymize;privacy;audio;de-identification; StartupNotify=true
packaging/flatpak/io.github.tilas01.VeilVoice.metainfo.xml
- line 1
<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-License-Identifier: GPL-3.0-or-later --> <component type="desktop-application"> <id>io.github.tilas01.VeilVoice</id> <name>VeilVoice</name> <summary>Destroy the voiceprint in a recording, - line 1
keep the words</summary> <metadata_license>CC0-1.0</metadata_license> <project_license>GPL-3.0-or-later</project_license> <description> <p> VeilVoice destroys the biometric voiceprint of a speaker: pitch, formants, timbre, micro-timing and - line 1
the melody of an accent. Neither software nor a human listener can re-identify the speaker or reconstruct the original voice, while the words themselves stay clean and transcribable. </p> <p> It does not hide what was said. Intelligibility - line 1
is preserved on purpose, and the words remain in the output and can be transcribed. If the message itself is sensitive, encrypt it: that is a separate problem with a separate answer. </p> <p>What it does:</p> <ul> <li>Anonymise a - line 1
recording, with metadata stripped, faster than real time</li> <li>Scramble a microphone live, into a virtual audio cable</li> <li>Encrypt recordings at rest by default, X25519 with ML-KEM-768</li> <li>Strip audio tags, image EXIF and - line 1
GPS</li> <li>Report which applications are holding the microphone or camera</li> </ul> <p>Limits, stated rather than hidden:</p> <ul> <li>A strong regional accent may still be audible: what phonemes you produced cannot be changed at the - line 1
signal level</li> <li>The application lock is a verifier, not encryption, and is not tamper-proof</li> <li>Secure erase is unreliable on flash storage</li> </ul> <p> - line 41
Fully offline by construction: there is no networking code, and this Flatpak requests no network permission. </p> </description> <launchable type="desktop-id">io.github.tilas01.VeilVoice.desktop</launchable> <url - line 41
type="homepage">https://tilas01.github.io/veilvoice/</url> <url type="bugtracker">https://github.com/tilas01/veilvoice/issues</url> <url type="vcs-browser">https://github.com/tilas01/veilvoice</url> <developer id="io.github.tilas01"> - line 41
<name>tilas01</name> </developer> <content_rating type="oars-1.1" /> <!-- No network permission, and that is the whole point rather than an oversight. `flathub::manifest` linting flags applications that request more than they use; this one - line 41
requests less than most, and the absence of network access is the checkable form of the project's central claim. --> <!-- The newest release only, and deliberately. Listing every release would mean writing a date beside each one, and the - line 41
dates of the releases between this and 0.1.9 are not recorded anywhere this file could be generated from. An invented date in a metadata file is the kind of unchecked claim this project refuses everywhere else, so the full history stays in - line 41
CHANGELOG.md where it is written rather than being half-copied here. The version is compared against the workspace version by the site suite. --> <releases> <release version="0.1.22" date="2026-09-16"> <description> <p>See the release - line 41
notes for what changed.</p> </description> </release> <release version="0.1.21" date="2026-09-10"> <description> - line 81
<p>See the release notes for what changed.</p> </description> </release> <release version="0.1.20" date="2026-09-08"> <description> <p>See the release notes for what changed.</p> </description> </release> <release version="0.1.19" - line 81
date="2026-09-07"> <description> <p>See the release notes for what changed.</p> </description> </release> <release version="0.1.18" date="2026-09-03"> <description> <p>See the release notes for what changed.</p> </description> </release> - line 81
<release version="0.1.17" date="2026-09-02"> <description> <p>See the release notes for what changed.</p> </description> </release> <release version="0.1.16" date="2026-09-01"> <description> <p>A verifier that no longer calls a genuine - line 81
release unverified, and an interface with no dashes in it.</p> </description> </release> <release version="0.1.15" date="2026-08-31"> <description> <p> Veiled recordings can be written straight into a Cryptomator vault or a VeraCrypt - line 81
volume, the app lock can seal every recording it writes, the window can lock itself when it has not been used, and an interview can be taken from an OBS recording through to a video with a voice for every person in it. </p> </description> - line 81
</release> - line 121
<release version="0.1.14" date="2026-08-28"> <description> <p> A safety catch that notices when another program picks up a real microphone while you are being veiled, a baseline of what normally runs on the machine, a second passphrase - line 121
that opens the application with nothing in it, and an interface that reads like English. </p> </description> </release> </releases> </component>
packaging/gentoo/media-sound/veilvoice/metadata.xml
- line 1
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE pkgmetadata SYSTEM "https://www.gentoo.org/dtd/metadata.dtd"> <pkgmetadata> <maintainer type="person"> <email>tilas01@users.noreply.github.com</email> <name>tilas01</name> </maintainer> - line 1
<longdescription lang="en"> VeilVoice destroys the biometric voiceprint of a speaker - pitch, formants, timbre, micro-timing and the melody of an accent - so that neither software nor a human listener can re-identify the speaker or - line 1
reconstruct the original voice, while the words themselves stay clean and transcribable. It does not hide what was said. </longdescription> <use> <flag name="cli">Build the command-line tool</flag> <flag name="gui">Build the desktop - line 1
application</flag> <flag name="live">Enable live microphone scrambling (needs ALSA)</flag> </use> <upstream> <bugs-to>https://github.com/tilas01/veilvoice/issues</bugs-to> <remote-id type="github">tilas01/veilvoice</remote-id> </upstream> - line 1
</pkgmetadata>
packaging/gentoo/media-sound/veilvoice/veilvoice-9999.ebuild
- line 1
# Copyright 2026 tilas01 # Distributed under the terms of the GNU General Public License v3 or later # SPDX-License-Identifier: GPL-3.0-or-later EAPI=8 # A live ebuild that builds from this repository the way every other Gentoo # package - line 1
does: fetch the source, compile it on the user's machine, install it # through the package manager. No binary is downloaded and nothing is trusted # that the machine did not compile itself. # # For a fixed release, copy this to - line 1
veilvoice-0.1.9.ebuild, drop the git-r3 # inherit and the EGIT_ lines, and set SRC_URI to the release tarball. CRATES="" inherit cargo git-r3 DESCRIPTION="Irreversible voice de-identification, fully offline" - line 1
HOMEPAGE="https://tilas01.github.io/veilvoice/" EGIT_REPO_URI="https://github.com/tilas01/veilvoice.git" EGIT_BRANCH="main" # The crate itself is GPL-3+. Its dependencies are all permissive, which is # compatible in that direction; the - line 1
second list is what Gentoo expects for the # vendored crates. LICENSE="GPL-3+" LICENSE+=" Apache-2.0 BSD BSD-2 ISC MIT Unicode-3.0 ZLIB" SLOT="0" KEYWORDS="~amd64 ~arm64" IUSE="+cli gui live" REQUIRED_USE="|| ( cli gui )" DEPEND=" live? ( - line 1
media-libs/alsa-lib ) gui? ( x11-libs/gtk+:3 x11-libs/libxkbcommon media-libs/libglvnd - line 41
) " RDEPEND="${DEPEND}" BDEPEND=">=virtual/rust-1.96" QA_FLAGS_IGNORED="usr/bin/veilvoice usr/bin/veilvoice-gui" src_configure() { local myfeatures=() # `live` is a default-on cargo feature, so it has to be switched off # explicitly rather - line 41
than merely not requested. if ! use live; then myfeatures+=( --no-default-features ) fi cargo_src_configure "${myfeatures[@]}" } src_compile() { local packages=() use cli && packages+=( -p veilvoice-cli ) use gui && packages+=( -p - line 41
veilvoice-gui ) cargo_src_compile "${packages[@]}" } src_test() { cargo_src_test --workspace } src_install() { if use cli; then dobin "$(cargo_target_dir)/veilvoice" fi if use gui; then dobin "$(cargo_target_dir)/veilvoice-gui" newicon -s - line 41
256 assets/icon.png veilvoice.png domenu packaging/veilvoice.desktop fi - line 81
dodoc README.md dodoc -r docs/. einstalldocs } pkg_postinst() { elog "VeilVoice destroys the voiceprint, not the words." elog "Intelligibility is preserved on purpose: the words remain in the" elog "output and can be transcribed. If the - line 81
message itself is sensitive," elog "encrypt it -- that is a separate problem with a separate answer." elog "" elog "Limits, stated rather than hidden:" elog " - a strong regional accent may still be audible" elog " - the application lock - line 81
names and encrypts VeilVoice's own files," elog " but cannot hide that VeilVoice is installed, and anybody who" elog " can read the folder can still delete it" elog " - secure erase is unreliable on flash storage" elog "" if use live; then - line 81
elog "Live mode needs a virtual audio device: media-sound/pulseaudio" elog "or a JACK/PipeWire loopback." else elog "Built without USE=live: file processing only, no microphone." fi elog "" # The verifier used to be its own binary and was - line 81
installed unconditionally. # It is part of `veilvoice` now, so this line is only true where the command # line was actually built; USE=-cli gets pointed at the window instead. if use cli; then elog "Verify a release you did not build: - line 81
veilvoice verify --help" else elog "Verify a release you did not build: open VeilVoice and use the" elog "Verify tab. Same check, same code underneath." fi }
packaging/homebrew/veilvoice.rb
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # Homebrew formula for VeilVoice. # # brew install --build-from-source packaging/homebrew/veilvoice.rb # # A formula rather than a cask, deliberately. A cask installs a prebuilt binary; # a - line 1
formula builds from source on the machine that will run it. For a tool whose # whole argument is "you can check this yourself", compiling from the tagged # source is the more honest default -- and it sidesteps macOS notarisation # - line 1
entirely, which this project cannot do under a pseudonym. class Veilvoice < Formula desc "Irreversible voice de-identification, fully offline" homepage "https://tilas01.github.io/veilvoice/" url - line 1
"https://github.com/tilas01/veilvoice/archive/refs/tags/v0.1.22.tar.gz" # Replace on each release with the sha256 from the published SHA256SUMS, # which is signed. `brew fetch --force veilvoice` then prints what it saw. sha256 - line 1
"REPLACE_WITH_THE_SIGNED_SHA256_OF_THE_SOURCE_TARBALL" license "GPL-3.0-or-later" head "https://github.com/tilas01/veilvoice.git", branch: "main" depends_on "rust" => :build def install # --locked: exactly the dependency versions the - line 1
project tested. A formula # that resolves fresh versions at install time is not installing the # software that was audited. # # The CLI and the verifier only. The desktop app needs a windowing stack # that Homebrew is a poor fit for; macOS - line 1
users who want the GUI should take # the signed release archive, which is a real app bundle. system "cargo", "build", "--release", "--locked", "-p", "veilvoice-cli" bin.install "target/release/veilvoice" doc.install "README.md" doc.install - line 1
Dir["docs/*"] end def caveats <<~EOS - line 41
VeilVoice destroys the voiceprint, not the words. Intelligibility is preserved on purpose: the words remain in the output and can be transcribed. If the message itself is sensitive, encrypt it. This formula installs the command-line tool - line 41
and the portable verifier. The desktop application ships in the signed release archives. Live microphone scrambling needs a virtual audio device (BlackHole or Loopback on macOS). Neither is bundled. EOS end test do # `info` reports what - line 41
the build supports, so this asserts the binary runs # and that the offline claim survived packaging. assert_match "VeilVoice", shell_output("#{bin}/veilvoice info") assert_match "none, by construction", shell_output("#{bin}/veilvoice - line 41
info") # The verifier must carry the right key. If a packaging step ever mangled # the embedded key, this is where it shows up. assert_match "8101FB3BB28D02FB239E0CDF9CC1C7E7A9B5833A", shell_output("#{bin}/veilvoice verify key") end end
packaging/rpm/veilvoice.spec
- line 1
# SPDX-License-Identifier: GPL-3.0-or-later # # RPM spec for VeilVoice (Fedora, RHEL, openSUSE). # # rpmbuild -ba packaging/rpm/veilvoice.spec \ # --define "_sourcedir $PWD/dist" --define "vv_version 0.1.22" # # Builds from the published - line 1
source tarball rather than repackaging a binary, # which is what a distribution package is supposed to do: the person installing # it gets something their own machine compiled from source they can read. %global vv_version - line 1
%{?vv_version}%{!?vv_version:0.1.22} Name: veilvoice Version: %{vv_version} Release: 1%{?dist} Summary: Irreversible voice de-identification, fully offline # The whole work is GPL-3.0-or-later. Dependencies are permissive and are # - line 1
statically linked by cargo, which is compatible in that direction. License: GPL-3.0-or-later URL: https://github.com/tilas01/veilvoice Source0: https://github.com/tilas01/veilvoice/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.ta - line 1
r.gz BuildRequires: cargo BuildRequires: rust >= 1.96 BuildRequires: pkgconfig(alsa) BuildRequires: gcc BuildRequires: python3 # The desktop app needs the windowing stack; the CLI does not. BuildRequires: pkgconfig(gtk+-3.0) BuildRequires: - line 1
pkgconfig(xkbcommon) %description VeilVoice destroys the biometric voiceprint of a speaker: pitch, formants, timbre, micro-timing and the melody of an accent. Neither software nor a human listener can re-identify the speaker or reconstruct - line 1
the original voice, while the words themselves stay clean and transcribable. It does not hide what was said. Intelligibility is preserved on purpose, - line 41
and the words remain in the output and can be transcribed. If the message itself is sensitive, encrypt it; that is a separate problem with a separate answer. Fully offline by construction: there is no networking code in the project and the - line 41
build fails if an HTTP client enters the dependency graph. %package gui Summary: Desktop application for VeilVoice Requires: %{name} = %{version}-%{release} # Named rather than left to the automatic dependency generator. The window # - line 41
toolkit opens this one by name at startup instead of linking against it, so # nothing that reads a binary's linkage knows it is required: the package # installed cleanly and the application aborted before drawing anything. Requires: - line 41
libxkbcommon-x11 %description gui The VeilVoice desktop application. %prep %autosetup -n %{name}-%{version} %build # --locked: build exactly the dependency versions the project tested, rather # than whatever resolves today. A package that - line 41
silently drifts from the tested # graph is not the software that was audited. cargo build --release --locked --workspace %install install -Dpm 0755 target/release/veilvoice %{buildroot}%{_bindir}/veilvoice install -Dpm 0755 - line 41
target/release/veilvoice-gui %{buildroot}%{_bindir}/veilvoice-gui install -Dpm 0644 assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/veilvoice.png install -Dpm 0644 packaging/veilvoice.desktop - line 41
%{buildroot}%{_datadir}/applications/veilvoice.desktop # Manual pages, generated from each binary's own `--help`. Written out by hand # they would be a second description of the interface, kept in step with the # first by nothing but - line 41
attention. python3 tools/release/manpage.py target/release/veilvoice \ %{buildroot}%{_mandir}/man1/veilvoice.1 python3 tools/release/manpage.py target/release/veilvoice-gui \ - line 81
%{buildroot}%{_mandir}/man1/veilvoice-gui.1 \ --summary "the VeilVoice desktop application" %check cargo test --release --locked --workspace %files %license LICENSE %doc README.md docs/ %{_bindir}/veilvoice %{_mandir}/man1/veilvoice.1* - line 81
%files gui %{_bindir}/veilvoice-gui %{_mandir}/man1/veilvoice-gui.1* %{_datadir}/icons/hicolor/256x256/apps/veilvoice.png %{_datadir}/applications/veilvoice.desktop %changelog * Wed Sep 16 2026 tilas01 <tilas01@users.noreply.github.com> - - line 81
0.1.22-1 - See CHANGELOG.md in the source for what changed in this release. * Thu Sep 10 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.21-1 - See CHANGELOG.md in the source for what changed in this release. * Tue Sep 08 2026 - line 81
tilas01 <tilas01@users.noreply.github.com> - 0.1.20-1 - See CHANGELOG.md in the source for what changed in this release. * Mon Sep 07 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.19-1 - See CHANGELOG.md in the source for what - line 81
changed in this release. * Thu Sep 03 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.18-1 - See CHANGELOG.md in the source for what changed in this release. * Wed Sep 02 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.17-1 - - line 81
See CHANGELOG.md in the source for what changed in this release. * Tue Sep 01 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.16-1 - See CHANGELOG.md in the source for what changed in this release. - line 121
* Mon Aug 31 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.15-1 - See CHANGELOG.md in the source for what changed. The newest entry here is - compared against the workspace version by the site suite. * Fri Aug 28 2026 tilas01 - line 121
<tilas01@users.noreply.github.com> - 0.1.14-1 - See CHANGELOG.md in the source for what changed. The newest entry here is - compared against the workspace version by the site suite, so it cannot go - five releases stale again without the - line 121
build failing. * Tue Aug 18 2026 tilas01 <tilas01@users.noreply.github.com> - 0.1.9-1 - Search across the repository and website, a portable verifier, install scripts.
packaging/veilvoice.desktop
- line 1
[Desktop Entry] Type=Application Name=VeilVoice GenericName=Voice de-identification Comment=Destroy the voiceprint in a recording, keep the words Exec=veilvoice-gui Icon=veilvoice Terminal=false Categories=AudioVideo;Audio;Security; - line 1
Keywords=voice;anonymise;anonymize;privacy;audio;de-identification; StartupNotify=true
packaging/wix/veilvoice.wxs
- line 1
<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-License-Identifier: GPL-3.0-or-later WiX v4 source for the Windows installer. Build (needs the WiX .NET tool: `dotnet tool install -g wix`): wix build packaging/wix/veilvoice.wxs -arch x64 ^ - line 1
-d Version=0.1.22 -d BinDir=dist\veilvoice-v0.1.22-windows-x86_64 ^ -o dist/VeilVoice-0.1.22-x64.msi # What this installs, and what it deliberately does not Two executables, the licence, the documentation, a Start Menu shortcut, and - line 1
nothing else. No service, no scheduled task, no driver, nothing that runs at startup, no registry key outside the installer's own uninstall entry. A privacy tool that installs a background service is a privacy tool with a background - line 1
service, and this one has no need of it. It installs **per-machine** into Program Files, so it needs elevation. That is the one thing an MSI gives that the script does not, and it is why this exists alongside `install/install.ps1` rather - line 1
than instead of it. # The optional components VB-CABLE and Audacity appear in the feature tree as features that are **off by default** (`Level="1000"`, above the install level, so they are not selected unless the user goes into the - line 1
custom-setup page and turns them on). They do not install those programs. They cannot: VB-CABLE is proprietary donationware with its own licence and its own installer, and Audacity is a separate project. What they install is a shortcut to - line 1
the download page, so that choosing them is an informed act rather than a silent one. This is the same rule the install scripts follow, for the same reason: never silently, never by default. An installer that quietly bundles somebody - line 1
else's proprietary driver is exactly the kind of thing this project exists to be the opposite of. # What is NOT verified here - line 41
An MSI can be signed with an Authenticode certificate. This one is not, and cannot be: Authenticode requires a certificate issued to a verified legal identity, and this project is published under a pseudonym. Windows will show an "unknown - line 41
publisher" warning. That is stated in `docs/INSTALL.md` rather than worked around, and it is the reason the OpenPGP signature over SHA256SUMS remains the real check: verify the archive first, then install. --> <Wix - line 41
xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> <Package Name="VeilVoice" Manufacturer="tilas01" Version="$(var.Version)" UpgradeCode="6F3B1C2A-9D4E-4A18-B7C5-2E9A1F0D8B34" - line 41
Scope="perMachine" Compressed="yes"> <!-- Upgrades replace an older install rather than sitting beside it. --> <MajorUpgrade DowngradeErrorMessage="A newer version of VeilVoice is already installed." /> <MediaTemplate EmbedCab="yes" /> - line 41
<!-- The licence has to be accepted, and it is the real GPL text. --> <WixVariable Id="WixUILicenseRtf" Value="packaging/wix/LICENSE.rtf" /> <ui:WixUI Id="WixUI_FeatureTree" /> <Icon Id="VeilVoiceIcon" SourceFile="assets/icon.ico" /> - line 41
<Property Id="ARPPRODUCTICON" Value="VeilVoiceIcon" /> <Property Id="ARPURLINFOABOUT" Value="https://tilas01.github.io/veilvoice/" /> <Property Id="ARPHELPLINK" Value="https://github.com/tilas01/veilvoice" /> <!-- No "run at startup" - line 41
checkbox, deliberately: there is nothing to run. --> <StandardDirectory Id="ProgramFiles64Folder"> <Directory Id="INSTALLFOLDER" Name="VeilVoice"> <Directory Id="DocsFolder" Name="docs" /> </Directory> </StandardDirectory> - line 41
<StandardDirectory Id="ProgramMenuFolder"> <Directory Id="AppShortcutFolder" Name="VeilVoice" /> - line 81
</StandardDirectory> <!-- ================= the program itself ================= --> <Feature Id="Main" Title="VeilVoice" Level="1" AllowAbsent="no" Description="The command-line tool, the desktop app and the portable verifier."> - line 81
<ComponentGroupRef Id="ProgramFiles" /> <ComponentGroupRef Id="Shortcuts" /> </Feature> <!-- ================= optional, off by default ================= --> <!-- Level="1000" is above the default install level, so neither of these is - line 81
selected unless the user turns it on in the feature tree. Never silently, never by default. --> <Feature Id="VBCableLink" Level="1000" Title="VB-CABLE (a link, not the software)" Description="Adds a shortcut to VB-Audio's download page. - line 81
VB-CABLE is a virtual audio cable, needed only for live mode. It is PROPRIETARY donationware by VB-Audio, is not free software, and is NOT installed by this installer -- you would be accepting their licence, on their site."> - line 81
<ComponentGroupRef Id="VBCableShortcut" /> </Feature> <Feature Id="AudacityLink" Level="1000" Title="Audacity (a link, not the software)" Description="Adds a shortcut to Audacity's download page. Audacity is a free audio editor, useful for - line 81
recording before veiling. It is not bundled: it is GPL-2.0-or-later, which cannot be combined with this project's GPL-3.0-or-later."> <ComponentGroupRef Id="AudacityShortcut" /> </Feature> <ComponentGroup Id="ProgramFiles" - line 81
Directory="INSTALLFOLDER"> <Component Id="VeilVoiceCli" Guid="*"> <File Id="veilvoice.exe" Source="$(var.BinDir)\veilvoice.exe" KeyPath="yes" /> </Component> <Component Id="VeilVoiceGui" Guid="*"> <File Id="veilvoice_gui.exe" - line 81
Source="$(var.BinDir)\veilvoice-gui.exe" KeyPath="yes" /> </Component> <Component Id="LicenceText" Guid="*"> <File Id="LICENSE" Source="$(var.BinDir)\LICENSE" KeyPath="yes" /> </Component> <Component Id="Readme" Guid="*"> <File - line 81
Id="README.md" Source="$(var.BinDir)\README.md" KeyPath="yes" /> </Component> </ComponentGroup> - line 121
<ComponentGroup Id="Shortcuts" Directory="AppShortcutFolder"> <Component Id="AppShortcut" Guid="*"> <Shortcut Id="GuiShortcut" Name="VeilVoice" Description="Irreversible voice de-identification" Target="[INSTALLFOLDER]veilvoice-gui.exe" - line 121
WorkingDirectory="INSTALLFOLDER" Icon="VeilVoiceIcon" /> <Shortcut Id="SiteShortcut" Name="VeilVoice documentation" Target="https://tilas01.github.io/veilvoice/" /> <RemoveFolder Id="RemoveAppShortcutFolder" Directory="AppShortcutFolder" - line 121
On="uninstall" /> <RegistryValue Root="HKCU" Key="Software\tilas01\VeilVoice" Name="installed" Type="integer" Value="1" KeyPath="yes" /> </Component> </ComponentGroup> <ComponentGroup Id="VBCableShortcut" Directory="AppShortcutFolder"> - line 121
<Component Id="VBCableUrl" Guid="*"> <Shortcut Id="VBCableLinkShortcut" Name="Get VB-CABLE (proprietary, third party)" Target="https://vb-audio.com/Cable/" /> <RegistryValue Root="HKCU" Key="Software\tilas01\VeilVoice" Name="vbcable_link" - line 121
Type="integer" Value="1" KeyPath="yes" /> </Component> </ComponentGroup> <ComponentGroup Id="AudacityShortcut" Directory="AppShortcutFolder"> <Component Id="AudacityUrl" Guid="*"> <Shortcut Id="AudacityLinkShortcut" Name="Get Audacity - line 121
(free software, third party)" Target="https://www.audacityteam.org/download/" /> <RegistryValue Root="HKCU" Key="Software\tilas01\VeilVoice" Name="audacity_link" Type="integer" Value="1" KeyPath="yes" /> </Component> </ComponentGroup> - line 121
</Package> </Wix>