crates/veilvoice-verify/src/tests.rs

what this file is for · veilvoice-verify · 1373 lines · the same file on GitHub

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 marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! 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 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 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 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 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.

use super::*;


#[test]
fn the_embedded_key_parses_and_is_the_expected_one() {
    let key = embedded_key().expect("the compiled-in key must parse");
    assert_eq!(fingerprint_of(&key), FINGERPRINT);
}



#[test]
fn the_embedded_key_carries_no_email_address() {
    // The pseudonym rule, enforced where it would actually ship: this key is
    // compiled into a binary handed to strangers. Its user ID is `tilas01`
    // and nothing else.
    let key = embedded_key().unwrap();
    for uid in key.details.users.iter() {
        let id = String::from_utf8_lossy(uid.id.id()).into_owned();
        assert!(
            !id.contains('@'),
            "the signing key's user id contains an address: {id}"
        );
    }
}



#[test]
fn the_fingerprint_constant_is_written_out_not_computed() {
    // A weak but real check that the constant is a literal: 40 uppercase hex
    // characters. If somebody replaces it with something derived from the key
    // itself, the comparison in `embedded_key` stops being a check at all.
    assert_eq!(FINGERPRINT.len(), 40);
    assert!(FINGERPRINT
        .chars()
        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase()));
}


// --- the hash-list parser --------------------------------------------------


#[test]
fn a_hash_is_found_by_its_file_name() {
    let sums = "\
aaaa  first.tar.gz
bbbb  second.tar.gz
";
    assert_eq!(
        digest_from_sums(sums, "second.tar.gz").as_deref(),
        Some("bbbb")
    );
}



#[test]
fn a_binary_mode_star_is_not_part_of_the_name() {
    // `sha256sum` writes `hash *name` in binary mode, and that asterisk is not
    // part of the file name. Treating it as one would make every hash list
    // produced on Windows look like it mentioned no files at all.
    let sums = "cccc *archive.zip\n";
    assert_eq!(
        digest_from_sums(sums, "archive.zip").as_deref(),
        Some("cccc")
    );
}



#[test]
fn a_file_that_is_not_listed_is_not_found() {
    let sums = "aaaa  first.tar.gz\n";
    assert!(digest_from_sums(sums, "other.tar.gz").is_none());
}



#[test]
fn a_name_that_merely_contains_the_wanted_one_does_not_match() {
    // `evil-archive.zip` must not satisfy a request for `archive.zip`.
    let sums = "aaaa  evil-archive.zip\n";
    assert!(digest_from_sums(sums, "archive.zip").is_none());
}



#[test]
fn blank_and_comment_lines_are_skipped() {
    let sums = "\n# a comment\n\naaaa  first.tar.gz\n";
    assert_eq!(
        digest_from_sums(sums, "first.tar.gz").as_deref(),
        Some("aaaa")
    );
}



#[test]
fn a_malformed_line_is_skipped_rather_than_panicking() {
    let sums = "nowhitespaceatall\naaaa  first.tar.gz\n";
    assert_eq!(
        digest_from_sums(sums, "first.tar.gz").as_deref(),
        Some("aaaa")
    );
}


// --- digest comparison -----------------------------------------------------


#[test]
fn digests_compare_case_insensitively_and_ignore_surrounding_space() {
    assert!(digests_match("ABCDEF", "abcdef"));
    assert!(digests_match("  abcdef\n", "abcdef"));
    assert!(!digests_match("abcdef", "abcde0"));
}


// --- signature verification ------------------------------------------------


#[test]
fn a_signature_that_is_not_openpgp_is_refused() {
    let key = embedded_key().unwrap();
    let result = verify_detached(&key, "this is not a signature", b"data");
    assert!(result.is_err(), "arbitrary text must not verify");
}



#[test]
fn an_empty_signature_is_refused() {
    let key = embedded_key().unwrap();
    assert!(verify_detached(&key, "", b"data").is_err());
}



#[test]
fn an_armoured_block_that_is_not_a_signature_is_refused() {
    // The public key is a valid OpenPGP armoured block, and is not a
    // signature. Feeding it in must fail to parse rather than be accepted by
    // something that only checked "does this look armoured".
    let key = embedded_key().unwrap();
    assert!(verify_detached(&key, crate::check::PUBLIC_KEY, b"data").is_err());
}


// ---------------------------------------------------------------------------
// The quiet level is only as good as the last line nobody gated
// ---------------------------------------------------------------------------


/// **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 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 `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 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 is a rule with two definitions.
#[test]
fn every_line_printed_by_a_check_goes_through_the_level() {
    // A source-reading test, so the line endings have to be settled first.
    // F-72: these searched for "\n}\n" and passed on every machine
    // whose checkout uses LF. GitHub's Windows runners default to
    // core.autocrlf=true, so the file arrives with CRLF, the pattern
    // matches nothing, and three tests failed there and nowhere else --
    // including on the developer machine that had just run them.
    // Normalised here as well as pinned in .gitattributes: a test that
    // depends on a git setting is a test somebody will trip over.
    let source = include_str!("lib.rs").replace("\r\n", "\n");

    // What is allowed to print unconditionally, and why.
    //
    // The single exception, and it lives in one function so that this list
    // has one entry rather than one per command that prints its own help.
    let asked_for_directly = ["print!(\"{text}\");"];

    // The macro bodies themselves, which are where the gating lives.
    let inside_a_macro = [
        r#"            println!($($arg)*);"#,
        r#"            print!($($arg)*);"#,
        r#"            println!("        {}", format!($($arg)*));"#,
    ];

    let mut ungated = Vec::new();
    let mut gate: Option<usize> = None;

    for (number, line) in source.lines().enumerate() {
        let trimmed = line.trim();
        let indent = line.len() - line.trim_start().len();

        // An explicit gate opens here and closes at the brace that matches its
        // indentation. Tracked rather than matched by regex because the block
        // is several lines long and every line inside it is protected.
        if trimmed.starts_with("if report::level() >= Loudness::")
            || trimmed.starts_with("if crate::report::level() >= ")
        {
            gate = Some(indent);
            continue;
        }
        if let Some(opened) = gate {
            if trimmed == "}" && indent == opened {
                gate = None;
                continue;
            }
        }

        let prints = trimmed.contains("println!(")
            || trimmed.contains("print!(")
            || trimmed.contains("eprintln!(");
        if !prints || trimmed.starts_with("//") {
            continue;
        }
        if gate.is_some() || inside_a_macro.contains(&line) {
            continue;
        }
        if asked_for_directly.contains(&trimmed) {
            continue;
        }
        ungated.push(format!("lib.rs:{}: {trimmed}", number + 1));
    }

    assert!(
        ungated.is_empty(),
        "these lines print without asking the level, so `--quiet` is not quiet:\n{}",
        ungated.join("\n")
    );
}



/// 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` would report a bad signature as a typing mistake.
#[test]
fn nothing_exits_with_an_undocumented_status() {
    let source = include_str!("lib.rs").replace("\r\n", "\n");
    let mut stray = Vec::new();
    for (number, line) in source.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.starts_with("//") || trimmed.starts_with("///") {
            continue;
        }
        if trimmed.contains("ExitCode::FAILURE") {
            stray.push(format!("lib.rs:{}: {trimmed}", number + 1));
        }
    }
    assert!(
        stray.is_empty(),
        "ExitCode::FAILURE is 1, which now means a usage error. Use a Status:\n{}",
        stray.join("\n")
    );
}


// ---------------------------------------------------------------------------
// A test that reads source has to say which line endings it expects
// ---------------------------------------------------------------------------


/// **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 `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 `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 will trip over on a machine nobody here owns.
#[test]
fn every_test_that_reads_source_normalises_its_line_endings() {
    let source = include_str!("tests.rs").replace("\r\n", "\n");
    // Assembled at run time so this line does not contain the thing it looks
    // for. Written out in full, the guard matched itself and reported its own
    // detection as the defect -- an honest failure, and a useless one.
    let invocation = concat!("include_str", "!(");
    let normalised = concat!(".repl", "ace(");

    let mut bare = Vec::new();
    for (number, line) in source.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.starts_with("//") || !trimmed.contains(invocation) {
            continue;
        }
        if !trimmed.contains(normalised) {
            bare.push(format!("tests.rs:{}: {trimmed}", number + 1));
        }
    }

    assert!(
        bare.is_empty(),
        "these read source without settling the line endings first:\n{}",
        bare.join("\n")
    );
}



/// 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.
#[test]
fn searching_for_a_brace_on_its_own_line_fails_against_crlf() {
    let lf = "fn thing() {\n    ()\n}\n\nfn next() {}\n";
    let crlf = lf.replace('\n', "\r\n");

    assert!(lf.find("\n}\n").is_some(), "LF is what the tests assumed");
    assert!(
        crlf.find("\n}\n").is_none(),
        "if this ever matches, the defect was something else"
    );
    // And the fix, applied to the awkward form.
    assert!(crlf.replace("\r\n", "\n").find("\n}\n").is_some());
}



/// `.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 being deleted.
#[test]
fn the_repository_pins_its_line_endings() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap();
    let attributes = std::fs::read_to_string(root.join(".gitattributes"))
        .expect(".gitattributes is what keeps the generators' byte checks honest");
    assert!(
        attributes.contains("* text=auto eol=lf"),
        "text has to be pinned to LF for every checkout:\n{attributes}"
    );
    // And the formats where a wrong guess corrupts a file silently.
    for binary in ["*.png", "*.wav", "*.gif"] {
        assert!(
            attributes.contains(&format!("{binary}   binary"))
                || attributes.contains(&format!("{binary}  binary"))
                || attributes.contains(&format!("{binary} binary")),
            "{binary} must be marked binary:\n{attributes}"
        );
    }
}



/// **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 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.
#[test]
fn the_contents_list_is_verified_before_it_is_parsed() {
    let source = include_str!("lib.rs").replace("\r\n", "\n");
    let body = source
        .split("fn manifest(")
        .nth(1)
        .expect("the manifest reader has to be findable");
    let body = body.split("\nfn ").next().unwrap();
    let checked = body
        .find("check_file")
        .expect("the contents list must be checked against the signed hash list");
    let parsed = body.find("contents::parse").expect("and then parsed");
    assert!(
        checked < parsed,
        "the contents list is parsed before it is verified"
    );
}



/// 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.
#[test]
fn a_release_without_a_contents_list_is_not_a_failure() {
    let found = discover::Found {
        directory: std::path::PathBuf::from("."),
        archives: Vec::new(),
        sums: None,
        signature: None,
        contents: None,
    };
    assert!(matches!(manifest(&found), Manifest::None));
}



/// A contents list with no signed hash list beside it cannot be used, and
/// "cannot be used" is reported rather than quietly skipped.
#[test]
fn a_contents_list_with_nothing_to_check_it_against_is_unusable() {
    let found = discover::Found {
        directory: std::path::PathBuf::from("."),
        archives: Vec::new(),
        sums: None,
        signature: None,
        contents: Some(std::path::PathBuf::from("CONTENTS.sha256")),
    };
    match manifest(&found) {
        Manifest::Unusable(why) => assert!(why.contains("signed hash list"), "{why}"),
        other => panic!("expected Unusable, got {}", matches_name(&other)),
    }
}



/// A name for a [`Manifest`], for a failing assertion to print.
fn matches_name(manifest: &Manifest) -> &'static str {
    match manifest {
        Manifest::None => "None",
        Manifest::Unusable(_) => "Unusable",
        Manifest::Ready(_) => "Ready",
    }
}



/// **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 not to run a release that is entirely sound. Only an answer
/// from GnuPG counts, and only a bad answer counts against.
#[test]
fn a_gnupg_that_cannot_run_is_never_counted_against_the_release() {
    let source = include_str!("lib.rs").replace("\r\n", "\n");
    let body = source
        .split("fn report_gnupg(")
        .nth(1)
        .expect("the GnuPG report has to be findable");
    let body = body.split("\nfn ").next().unwrap();
    for arm in [
        "the signing key could not be added to your keyring",
        "GnuPG could not check the signature",
    ] {
        let at = body.find(arm).unwrap_or_else(|| panic!("{arm} is gone"));
        // The next `problems += 1` must belong to a later arm, not this one.
        let rest = &body[at..];
        let next_arm = rest.find("Nothing about the download changed");
        let next_count = rest.find("problems += 1");
        assert!(
            next_arm.is_some() && next_arm < next_count,
            "{arm} counts against the release"
        );
    }
}



/// **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 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 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 it searches.
#[test]
fn a_named_directory_that_is_not_there_is_refused_before_anything_is_searched() {
    let source = include_str!("lib.rs").replace("\r\n", "\n");
    let body = source
        .split("fn command_auto(")
        .nth(1)
        .expect("command_auto has to be findable");
    let body = body.split("\nfn ").next().unwrap();

    let guard = body
        .find("is_dir()")
        .expect("command_auto no longer checks that a named directory exists");
    let search = body
        .find("discover::search(")
        .expect("command_auto no longer searches");
    assert!(
        guard < search,
        "the existence check has to come before the search; otherwise a \
         mistyped path is answered with a result about somewhere else"
    );

    assert!(
        body.contains("named.display()"),
        "the refusal has to name the directory it was given, or the reader \
         cannot tell which path was wrong"
    );
}



/// 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
/// 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 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 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 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 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.
#[test]
fn no_interface_string_has_a_gap_where_a_line_continuation_belongs() {
    let crates = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
    let mut sources = Vec::new();
    let mut pending = vec![crates.to_path_buf()];
    while let Some(directory) = pending.pop() {
        for entry in std::fs::read_dir(&directory).expect("the crates live here") {
            let entry = entry.expect("a readable directory entry");
            let path = entry.path();
            if path.is_dir() {
                // `target` is build output, and can be enormous.
                if path.file_name().is_some_and(|n| n == "target") {
                    continue;
                }
                pending.push(path);
            } else if path.extension().is_some_and(|e| e == "rs") {
                sources.push(path);
            }
        }
    }
    assert!(
        sources.len() > 50,
        "the walk found {} files, so it is not looking at the workspace",
        sources.len()
    );

    let mut found = Vec::new();
    for path in &sources {
        let text = std::fs::read_to_string(path).expect("a readable source file");
        let text = text.replace("\r\n", "\n");
        // Everything up to the test module: what a person is shown.
        let interface = text.split("\n#[cfg(test)]").next().unwrap();
        for (number, line) in interface.lines().enumerate() {
            if line.chars().count() <= 100 {
                continue;
            }
            // An escape is not a word. Without this the `n` of a `\n` reads as
            // the letter before a gap, and a needle that searches this
            // repository's own indented source looks like broken prose.
            //
            // **Replaced with `~`, not with `.`.** A full stop is in the set
            // that opens a gap below, so a `\n` followed by the indentation of
            // an embedded script read as a sentence with a hole in it. Every
            // line of the player's inline JavaScript has that shape, and the
            // only reason this did not fire years ago is that those lines were
            // under the length threshold. `~` is in no set here and cannot
            // start or end a gap.
            let line = line.replace("\\n", "~~").replace("\\t", "~~");
            // The gap has to sit between two words to be prose rather than a
            // column: three spaces after a letter or a comma, and a letter
            // after them.
            let bytes: Vec<char> = line.chars().collect();
            let gap = bytes.windows(5).any(|w| {
                (w[0].is_ascii_alphabetic() || w[0] == ',' || w[0] == '.')
                    && w[1] == ' '
                    && w[2] == ' '
                    && w[3] == ' '
                    && (w[4] == ' ' || w[4].is_ascii_alphabetic())
            });
            if gap {
                found.push(format!(
                    "{}:{}",
                    path.file_name().unwrap().to_string_lossy(),
                    number + 1
                ));
            }
        }
    }
    assert!(
        found.is_empty(),
        "these lines join a string literal without the backslash that would \
         close the gap, so the text renders with the source indentation in the \
         middle of it: {found:?}"
    );
}



/// 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 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 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 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
/// `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 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 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 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 of it drift the moment the set grows.
#[test]
fn every_command_line_drawing_is_shown_in_the_readme() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("the workspace root");

    let tool = std::fs::read_to_string(root.join("tools/shots/terminal.py"))
        .expect("the tool that draws them");
    let names: Vec<String> = tool
        .split("COMMANDS = [")
        .nth(1)
        .and_then(|rest| rest.split("\n]").next())
        .expect("`COMMANDS` has to be findable")
        .lines()
        .filter_map(|line| {
            let (_, rest) = line.trim().split_once("(\"")?;
            let (name, _) = rest.split_once('"')?;
            Some(name.to_string())
        })
        .collect();
    assert!(!names.is_empty(), "no drawing names were found to check");

    let readme = std::fs::read_to_string(root.join("README.md")).expect("the README");
    let missing: Vec<&str> = names
        .iter()
        .map(String::as_str)
        .filter(|name| !readme.contains(&format!("cli-{name}.svg")))
        .collect();
    assert!(
        missing.is_empty(),
        "README.md shows no drawing of these command line screens: {}. Every \
         one that `tools/shots/terminal.py` draws is committed, so one nothing \
         references is a picture nobody will ever see.",
        missing.join(", ")
    );
}



/// 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 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
/// tomorrow fails this rather than being noticed by a reader.
#[test]
fn every_tab_has_a_picture_in_the_readme_and_on_the_website() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("the workspace root");

    let app = std::fs::read_to_string(root.join("crates/veilvoice-gui/src/app.rs"))
        .expect("the window's own source");
    let keys: Vec<String> = app
        .split("pub fn key(self) -> &'static str {")
        .nth(1)
        .and_then(|rest| rest.split("\n    }").next())
        .expect("`Tab::key` has to be findable")
        .lines()
        .filter_map(|line| {
            let (_, rest) = line.split_once("=> \"")?;
            let (key, _) = rest.split_once('"')?;
            Some(key.to_string())
        })
        .collect();
    assert!(!keys.is_empty(), "no tab keys were found to check");

    for (what, path) in [
        ("README.md", root.join("README.md")),
        ("website/index.html", root.join("website/index.html")),
    ] {
        let text = std::fs::read_to_string(&path).expect("a readable page");
        let missing: Vec<&str> = keys
            .iter()
            .map(String::as_str)
            .filter(|key| !text.contains(&format!("gui-{key}.png")))
            .collect();
        assert!(
            missing.is_empty(),
            "{what} shows no picture of these tabs: {}. Every tab the window \
             has needs one, or the section describes a different application \
             from the one that ships.",
            missing.join(", ")
        );
    }
}



/// 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 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 there are.
#[test]
fn the_readme_counts_the_window_tabs_the_window_actually_has() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("the workspace root");

    let app = std::fs::read_to_string(root.join("crates/veilvoice-gui/src/app.rs"))
        .expect("the window's own source");
    let body = app
        .split("enum Tab {")
        .nth(1)
        .and_then(|rest| rest.split("\n}").next())
        .expect("`enum Tab` has to be findable");
    // A variant is a bare capitalised name on its own line, ending in a comma.
    // Doc comments and attributes are not variants.
    let tabs = body
        .lines()
        .map(str::trim)
        .filter(|line| {
            line.ends_with(',')
                && !line.starts_with("//")
                && !line.starts_with('#')
                && line[..line.len() - 1]
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric())
                && line.starts_with(|c: char| c.is_ascii_uppercase())
        })
        .count();
    assert!(tabs >= 2, "found {tabs} tabs, which cannot be right");

    let words = [
        "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
        "eleven", "twelve",
    ];
    let expected = words
        .get(tabs)
        .unwrap_or_else(|| panic!("no word for {tabs} tabs; add one"));

    let readme = std::fs::read_to_string(root.join("README.md")).expect("README.md");
    let claim = readme
        .split(" tabs:")
        .next()
        .and_then(|before| before.rsplit('\n').next())
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .expect("README.md must say how many tabs the window has, as `N tabs:`");
    assert!(
        claim.to_ascii_lowercase().ends_with(*expected),
        "README.md says `{claim} tabs`, the window has {tabs} ({expected})"
    );
}



#[test]
fn no_page_tells_a_reader_to_run_a_program_that_no_longer_exists() {
    use std::path::{Path, PathBuf};

    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../");

    // Records of the past, which are allowed to describe it.
    let history = [
        "AUDIT.md",
        "CHANGELOG.md",
        "releases.html",
        "search.html",
        "search-index.json",
    ];

    // What follows the name when it is being run rather than named.
    let invoked = [
        " --",
        " -q",
        " auto",
        " file",
        " deps",
        " gnupg",
        " hash",
        " reproduce",
        " help",
    ];

    fn gather(dir: &Path, into: &mut Vec<PathBuf>) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                gather(&path, into);
            } else if path.extension().is_some_and(|e| {
                ["md", "html", "txt", "py", "sh", "ps1", "bat", "rs"]
                    .contains(&e.to_string_lossy().as_ref())
            }) {
                into.push(path);
            }
        }
    }

    let mut files = Vec::new();
    for place in [
        "README.md",
        "docs",
        "website",
        "packaging",
        "tools",
        "assets/screenshots",
    ] {
        let path = root.join(place);
        if path.is_dir() {
            gather(&path, &mut files);
        } else if path.is_file() {
            files.push(path);
        }
    }
    assert!(files.len() > 50, "only {} files were read", files.len());

    let mut wrong = Vec::new();
    for path in &files {
        let name = path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .into_owned();
        if history.contains(&name.as_str()) {
            continue;
        }
        // This test's own explanation names the thing it forbids.
        if path.ends_with("tests.rs") {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(path) else {
            continue;
        };
        for (number, line) in text.replace("\r\n", "\n").lines().enumerate() {
            let Some(at) = line.find("veilvoice-verify") else {
                continue;
            };
            let rest = &line[at + "veilvoice-verify".len()..];
            // A path in front of it only counts when the name ends there.
            // With another segment after it the line is naming a source file
            // inside the crate; with nothing after it, a leading `./` or an
            // absolute path makes it a command somebody is told to run.
            let ends_here =
                rest.is_empty() || rest.starts_with([' ', '`', '"', '\'', ',', '.', ')']);
            let run = invoked.iter().any(|form| rest.starts_with(form))
                || (line[..at].ends_with('/') && ends_here && !rest.starts_with('.'));
            if run {
                wrong.push(format!("{}:{}: {}", name, number + 1, line.trim()));
            }
        }
    }

    assert!(
        wrong.is_empty(),
        "these tell a reader to run `veilvoice-verify`, which has not been a \
         program since 0.1.18. It is `veilvoice verify`, and the desktop \
         application's Verify tab:\n{}",
        wrong.join("\n")
    );
}



/// **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 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 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 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 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 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 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.
#[test]
fn no_desktop_test_opens_a_device_a_dialog_or_a_window() {
    let gui = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the crates directory")
        .join("veilvoice-gui")
        .join("src");

    let mut sources = Vec::new();
    let mut pending = vec![gui.clone()];
    while let Some(directory) = pending.pop() {
        for entry in std::fs::read_dir(&directory).expect("the desktop crate's source") {
            let entry = entry.expect("a readable directory entry");
            let path = entry.path();
            if path.is_dir() {
                pending.push(path);
            } else if path.extension().is_some_and(|e| e == "rs") {
                sources.push(path);
            }
        }
    }
    assert!(
        sources.len() > 20,
        "the walk found {} files, so it is not looking at the desktop crate",
        sources.len()
    );

    /// What reaches the platform. Each of these opens something the machine
    /// owns: a sound card, a file panel, or a stream on either.
    const REACHES_THE_PLATFORM: &[&str] = &[
        "devices::list(",
        "devices::open(",
        "playback::start(",
        "LiveSession::start(",
        "LiveSession::start_recording(",
        "rfd::FileDialog",
    ];

    /// 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 the test's name rather than the
    /// call: another test may not borrow it.
    const ALLOWED: &str = "building_the_app_with_real_device_enumeration_does_not_panic";

    let mut offenders = Vec::new();
    for path in &sources {
        let name = path
            .file_name()
            .expect("a file")
            .to_string_lossy()
            .into_owned();
        let text = std::fs::read_to_string(path)
            .expect("a readable source file")
            .replace("\r\n", "\n");

        // A file under `src/<module>/tests.rs` is test code whole; anything
        // else is test code only after its `#[cfg(test)]`.
        let is_test_file = name == "tests.rs";
        let body = if is_test_file {
            text.as_str()
        } else {
            match text.split_once("\n#[cfg(test)]") {
                Some((_, tests)) => tests,
                None => continue,
            }
        };

        // Which test each line belongs to, so the exception can be by name.
        let mut current = String::new();
        for (number, line) in body.lines().enumerate() {
            let trimmed = line.trim_start();
            if let Some(rest) = trimmed.strip_prefix("fn ") {
                current = rest.split('(').next().unwrap_or("").to_string();
            }
            if trimmed.starts_with("//") {
                continue;
            }
            if current == ALLOWED {
                continue;
            }
            for reaching in REACHES_THE_PLATFORM {
                let Some(at) = line.find(reaching) else {
                    continue;
                };
                // A needle is not a call. `dialog.rs`'s own guard searches the
                // source for `rfd::FileDialog`, and the string it searches for
                // is not an opened dialog. An odd number of quotes before the
                // match means it is inside one.
                if line[..at].matches('"').count() % 2 == 1 {
                    continue;
                }
                offenders.push(format!(
                    "{name}:{}: {} (in {current})",
                    number + 1,
                    line.trim()
                ));
            }
        }
    }

    assert!(
        offenders.is_empty(),
        "these tests open something the machine owns, which is how F-163 and \
         F-165 killed the desktop test binary on Windows after every test had \
         passed. Assert the thing the test names instead, by reading the source \
         of the function it is about:\n{}",
        offenders.join("\n")
    );
}



/// **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 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 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.
#[test]
fn only_the_session_builds_a_recorder() {
    let crates = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the crates directory")
        .to_path_buf();

    /// Where the rate is known, and therefore the one place this may appear.
    const HOME: &str = "veilvoice-audio";

    let mut sources = Vec::new();
    let mut pending = vec![crates.clone()];
    while let Some(directory) = pending.pop() {
        for entry in std::fs::read_dir(&directory).expect("a crate directory") {
            let entry = entry.expect("a readable directory entry");
            let path = entry.path();
            if path.is_dir() {
                pending.push(path);
            } else if path.extension().is_some_and(|e| e == "rs") {
                sources.push(path);
            }
        }
    }
    assert!(
        sources.len() > 100,
        "the walk found {} files, so it is not looking at the workspace",
        sources.len()
    );

    let mut offenders = Vec::new();
    for path in &sources {
        if path.components().any(|c| c.as_os_str() == HOME) {
            continue;
        }
        let text = std::fs::read_to_string(path)
            .expect("a readable source file")
            .replace("\r\n", "\n");
        for (number, line) in text.lines().enumerate() {
            let trimmed = line.trim_start();
            if trimmed.starts_with("//") || trimmed.starts_with("///") {
                continue;
            }
            let Some(at) = line.find("record::start(") else {
                continue;
            };
            // A needle is not a call: this guard names the string it looks for.
            if line[..at].matches('"').count() % 2 == 1 {
                continue;
            }
            offenders.push(format!(
                "{}:{}: {}",
                path.display(),
                number + 1,
                line.trim()
            ));
        }
    }

    assert!(
        offenders.is_empty(),
        "these build a recorder outside `{HOME}`, so they choose the rate its \
         WAV header is written with, and the only correct answer is the one the \
         device gave `LiveSession::start_recording`. Ask that function for the \
         recorders instead, with a `Keeping`:\n{}",
        offenders.join("\n")
    );
}



/// **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 is the Studio now, and `start_session` is the only
/// starter, which is most of what moving it was worth.
#[test]
fn the_desktop_starts_a_live_session_in_exactly_one_place() {
    let gui = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the crates directory")
        .join("veilvoice-gui")
        .join("src");

    let mut sources = Vec::new();
    let mut pending = vec![gui.clone()];
    while let Some(directory) = pending.pop() {
        for entry in std::fs::read_dir(&directory).expect("the desktop crate's source") {
            let entry = entry.expect("a readable directory entry");
            let path = entry.path();
            if path.is_dir() {
                pending.push(path);
            } else if path.extension().is_some_and(|e| e == "rs") {
                sources.push(path);
            }
        }
    }

    let mut starters = Vec::new();
    for path in &sources {
        let name = path
            .file_name()
            .expect("a file")
            .to_string_lossy()
            .into_owned();
        let text = std::fs::read_to_string(path)
            .expect("a readable source file")
            .replace("\r\n", "\n");
        // Which function each line is in, so the message names it.
        let mut current = String::new();
        for (number, line) in text.lines().enumerate() {
            let trimmed = line.trim_start();
            if let Some(rest) = trimmed.strip_prefix("fn ") {
                current = rest.split('(').next().unwrap_or("").to_string();
            }
            if trimmed.starts_with("//") {
                continue;
            }
            // **Roadmap item 147.** A room is a session too, and is under the same
            // rule: the microphone it opens is opened once or not at all.
            for needle in [
                "LiveSession::start(",
                "LiveSession::start_recording(",
                "RoomSession::start(",
            ] {
                let Some(at) = line.find(needle) else {
                    continue;
                };
                if line[..at].matches('"').count() % 2 == 1 {
                    continue;
                }
                starters.push(format!("{name}:{}: in {current}", number + 1));
            }
        }
    }

    // Two: one microphone and a room, both in `Studio::start_session`, which
    // is the function that chooses between them. They are counted rather than
    // capped at one because a room is a different call with different
    // arguments; what the guard is about is that neither is reachable from a
    // second place, and both being in one function is how "never both" holds.
    assert_eq!(
        starters.len(),
        2,
        "a session is started in {} places in the desktop crate. Two starters \
         for the same kind is two opens of the same microphone, which is what \
         having a live tab beside the Studio was:\n{}",
        starters.len(),
        starters.join("\n")
    );
    for starter in &starters {
        assert!(
            starter.starts_with("studio.rs:"),
            "the starters should all be the Studio's, and one is {starter}"
        );
        assert!(
            starter.ends_with("in start_session"),
            "a session is started outside `Studio::start_session`, so the \
             Studio can hold a room and a single session at once: {starter}"
        );
    }
}



/// **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, 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 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 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 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.
#[test]
fn no_audio_callback_allocates_or_blocks() {
    let crates = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the crates directory")
        .to_path_buf();

    /// 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 OPENS_A_STREAM: &[&str] = &["build_input_stream(", "build_output_stream("];

    /// 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 `reaches` below.
    const FORBIDDEN: &[(&str, &str)] = &[
        ("vec![", "allocates"),
        ("Vec::", "allocates"),
        (".to_vec()", "allocates"),
        (".to_owned()", "allocates"),
        (".to_string()", "allocates"),
        ("String::", "allocates"),
        ("format!", "allocates"),
        (".collect()", "allocates"),
        ("Box::new", "allocates"),
        (".clone()", "may allocate"),
        (".push(", "may reallocate; the ring's `try_push` does not"),
        (".insert(", "may reallocate"),
        (".extend(", "may reallocate"),
        (".resize(", "may reallocate"),
        (".reserve(", "allocates"),
        (".lock()", "blocks; `try_lock` is what this code uses"),
        ("println!", "takes the lock on standard output"),
        ("eprintln!", "takes the lock on standard error"),
        ("print!", "takes the lock on standard output"),
    ];

    let mut sources = Vec::new();
    let mut pending = vec![crates.clone()];
    while let Some(directory) = pending.pop() {
        for entry in std::fs::read_dir(&directory).expect("a crate directory") {
            let entry = entry.expect("a readable directory entry");
            let path = entry.path();
            if path.is_dir() {
                pending.push(path);
            } else if path.extension().is_some_and(|e| e == "rs") {
                sources.push(path);
            }
        }
    }

    let mut callbacks = 0;
    let mut offenders = Vec::new();
    for path in &sources {
        let name = path
            .file_name()
            .expect("a file")
            .to_string_lossy()
            .into_owned();
        let text = std::fs::read_to_string(path)
            .expect("a readable source file")
            .replace("\r\n", "\n");

        for opener in OPENS_A_STREAM {
            let mut from = 0;
            while let Some(at) = text[from..].find(opener) {
                let at = from + at;
                from = at + opener.len();
                // A needle is not a call: this guard names what it looks for.
                let line_start = text[..at].rfind('\n').map_or(0, |n| n + 1);
                if text[line_start..at].matches('"').count() % 2 == 1 {
                    continue;
                }

                // The data callback is the first closure after the opener, and
                // its body is from its `{` to the matching `}`.
                let Some(brace) = text[at..].find("| {").map(|n| at + n + 2) else {
                    continue;
                };
                let mut depth = 0usize;
                let mut end = brace;
                for (offset, byte) in text[brace..].char_indices() {
                    match byte {
                        '{' => depth += 1,
                        '}' => {
                            depth -= 1;
                            if depth == 0 {
                                end = brace + offset;
                                break;
                            }
                        }
                        _ => {}
                    }
                }
                callbacks += 1;

                let before = text[..brace].matches('\n').count();
                for (number, line) in text[brace..end].lines().enumerate() {
                    let trimmed = line.trim_start();
                    if trimmed.starts_with("//") {
                        continue;
                    }
                    for (needle, cost) in FORBIDDEN {
                        let Some(hit) = line.find(needle) else {
                            continue;
                        };
                        // `try_lock()` and `try_push(` end in the needle and are
                        // the non-blocking forms. Only the bare call counts.
                        if line[..hit].ends_with("try_") {
                            continue;
                        }
                        if line[..hit].matches('"').count() % 2 == 1 {
                            continue;
                        }
                        offenders.push(format!(
                            "{name}:{}: {} ({needle} {cost})",
                            before + number + 1,
                            line.trim()
                        ));
                    }
                }
            }
        }
    }

    assert!(
        callbacks >= 3,
        "the walk found {callbacks} audio callbacks, and there are at least \
         three: the live path's input and output, and playback's. A guard that \
         finds none passes for the wrong reason"
    );
    assert!(
        offenders.is_empty(),
        "these lines run on an audio thread with a deadline in milliseconds, \
         and each of them can miss it. Do the work before the stream starts, \
         into a buffer sized once:\n{}",
        offenders.join("\n")
    );
}