Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-ssh/tests/command_parsing.rs

//! The exec parser, table-driven (the ACL model).
//!
//! This is the most security-critical code in the crate, so the table is the
//! test rather than a handful of hand-written cases, and it has an
//! **acceptance** half as well as a rejection half. A parser that refuses
//! everything passes every rejection test ever written and serves nothing; that
//! is the identity-value trap, and `legal_forms_are_accepted` is what catches
//! it.
//!
//! `the_rejection_table_can_go_red` is the red half: it points the same
//! rejection table at a deliberately naive parser and asserts the table
//! *fails* against it. Without that, a table of inputs that a correct parser
//! rejects and a table of inputs that any parser rejects look identical.
//!
//! # What changed when the sandbox merged into `gunnar-core`
//!
//! The sandbox under this parser is now `gunnar_core::resolve_repo_path`, the
//! only one. Two rows of the table below had to change with it, and both are
//! marked in place:
//!
//! * `a/b/c.git` is **no longer refused**: nested namespaces are legal, and the
//!   deleted `gunnar_ssh::path` was the copy that disagreed. It is replaced by
//!   `a/b/c/d/e.git`, which is over `MAX_COMPONENTS`.
//! * `ns/repo.lock` is **newly refused**, merged in from the deleted copy.
//!
//! Everything about *quoting* stayed here, because git sends the quotes and the
//! sandbox must not know SSH exists (the one-sandbox rule).

use gunnar_core::PathReject;
use gunnar_ssh::command::{parse, CommandError, GitService, Operation, MAX_EXEC_LEN, SERVICES};
use gunnar_ssh::Access;

/// Every form a legitimate client sends. All of these MUST parse.
const LEGAL: &[(&str, GitService, &str, &str)] = &[
    // scp-style: `git clone git@host:nordisk/osm-katana.git`
    (
        "git-upload-pack 'nordisk/osm-katana.git'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // ssh:// URL: the leading slash is git's, not the client's idea of absolute
    (
        "git-upload-pack '/nordisk/osm-katana.git'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // no `.git` suffix, GitHub-style, and what `git clone host:ns/repo` sends
    (
        "git-upload-pack 'nordisk/osm-katana'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // unquoted, which some clients and every hand-typed `ssh host <cmd>` send
    (
        "git-upload-pack nordisk/osm-katana.git",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    (
        "git-receive-pack 'nordisk/osm-katana.git'",
        GitService::ReceivePack,
        "nordisk",
        "osm-katana",
    ),
    (
        "git-receive-pack '/nordisk/osm-katana.git'",
        GitService::ReceivePack,
        "nordisk",
        "osm-katana",
    ),
    // names with the punctuation real repositories use
    (
        "git-upload-pack 'team-1/my_repo.v2.git'",
        GitService::UploadPack,
        "team-1",
        "my_repo.v2",
    ),
    // **A nested namespace.** The sandbox this crate used to carry
    // refused this outright; `gunnar-core`'s (the one that survived) resolves
    // it, and it is the more permissive of the two. It is here in the
    // *acceptance* half so that reinstating the stricter rule cannot pass
    // unnoticed.
    (
        "git-upload-pack 'team/sub/repo.git'",
        GitService::UploadPack,
        "team/sub",
        "repo",
    ),
    (
        "git-receive-pack '/team/sub/deeper/repo.git'",
        GitService::ReceivePack,
        "team/sub/deeper",
        "repo",
    ),
    // ── git-lfs-transfer (LFS over SSH): the THREE-token, UNQUOTED shape ───
    //
    // MEASURED 2026-08-03 against git-lfs 3.7.1 on the reference desktop, by putting a script in
    // `GIT_SSH_COMMAND` and logging its argv. The exec line that arrives is
    // exactly:
    //
    //     git-lfs-transfer /repo.git upload
    //
    // No quotes anywhere, a leading slash from the `ssh://` URL, and a third
    // token. Every other verb in this table arrives as `git-upload-pack
    // '/ns/repo.git'`, i.e. two tokens with literal quote characters *inside*
    // the argv element. So this verb is the one whose parse is not the shape
    // everything else was written against, and until now nothing here exercised
    // it at all.
    (
        "git-lfs-transfer /nordisk/osm-katana.git download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
    (
        "git-lfs-transfer /nordisk/osm-katana.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "nordisk",
        "osm-katana",
    ),
    // Without the leading slash, which is what a `host:ns/repo.git` remote yields.
    (
        "git-lfs-transfer nordisk/osm-katana.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "nordisk",
        "osm-katana",
    ),
    // Quoted anyway. git-lfs does not send this, but `unquote` runs on the LFS
    // path by the same route as on a git path, and a client or a proxy that
    // quoted it must not be refused for it.
    (
        "git-lfs-transfer '/nordisk/osm-katana.git' download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
    // A nested namespace, i.e. a path that itself contains the separator the
    // operation is split on. `rsplit_once(' ')` is what makes this work and a
    // `split_once` would put `sub/repo.git download` in the path.
    (
        "git-lfs-transfer /team/sub/repo.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "team/sub",
        "repo",
    ),
    // No `.git` suffix, which git-lfs sends whenever the remote URL has none.
    (
        "git-lfs-transfer /nordisk/osm-katana download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
];

#[test]
fn legal_forms_are_accepted() {
    for (raw, service, namespace, repo) in LEGAL {
        let parsed = parse(raw.as_bytes())
            .unwrap_or_else(|err| panic!("should have accepted {raw:?}, got {err}"));
        assert_eq!(parsed.service, *service, "{raw:?}");
        assert_eq!(parsed.repo.namespace(), *namespace, "{raw:?}");
        assert_eq!(parsed.repo.name(), *repo, "{raw:?}");
    }
}

#[test]
fn the_required_access_travels_with_the_command() {
    // Asserted on the parse output, not on the table, because the dispatch
    // reads it from the parse output. A table row that never reaches a
    // `GitCommand` would prove nothing.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .required,
        Access::Read
    );
    assert_eq!(
        parse(b"git-receive-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .required,
        Access::Write
    );
    // LFS over SSH: the LFS verb's level is **not a constant**, it comes from the third
    // token. Both directions are asserted, because a `Requirement::Fixed` on
    // this row would satisfy either one of them alone.
    assert_eq!(
        parse(b"git-lfs-transfer /nordisk/osm-katana.git download")
            .unwrap()
            .required,
        Access::Read
    );
    assert_eq!(
        parse(b"git-lfs-transfer /nordisk/osm-katana.git upload")
            .unwrap()
            .required,
        Access::Write
    );
}

/// **Which operation, not merely that one parsed.**
///
/// The failure this guards is silent and total: a parser that dropped the third
/// token, or that returned a fixed `Operation`, would still accept every legal
/// LFS form and still produce a `GitCommand`, and then every `upload` connection
/// would be served as a `download`, or worse, every `download` would be granted
/// Write. Both halves are asserted against the *same* path so nothing but the
/// operation differs between them.
#[test]
fn the_lfs_operation_and_the_grant_it_implies_both_come_from_the_exec_line() {
    let download = parse(b"git-lfs-transfer /nordisk/osm-katana.git download").unwrap();
    let upload = parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").unwrap();

    assert_eq!(
        download.service,
        GitService::LfsTransfer(Operation::Download)
    );
    assert_eq!(upload.service, GitService::LfsTransfer(Operation::Upload));
    assert_eq!(download.service.operation(), Some(Operation::Download));
    assert_eq!(upload.service.operation(), Some(Operation::Upload));

    // The two must differ. `assert_eq!` on each alone would pass against a
    // parser that returned the same value for both if the expectations were
    // ever "fixed" to match it.
    assert_ne!(download.service, upload.service);
    assert_ne!(download.required, upload.required);
    assert_eq!(download.required, Access::Read);
    assert_eq!(upload.required, Access::Write);

    // Same repository either way; the operation must not have been eaten out
    // of the path, nor the path out of the operation.
    assert_eq!(download.repo.key(), "nordisk/osm-katana");
    assert_eq!(upload.repo.key(), download.repo.key());

    // And the two git verbs carry no operation at all, so `operation()` is not
    // simply answering `Some(_)` for everything.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .service
            .operation(),
        None
    );
}

/// The malformed third token, refused by *name* rather than merely refused.
///
/// `MissingOperation` and `UnknownOperation` are different diagnoses and both
/// are reachable; a parser that collapsed them, or that folded either into
/// `MissingPath`, would still refuse the input and still pass
/// `hostile_forms_are_refused`.
#[test]
fn a_malformed_lfs_operation_is_refused_by_name() {
    // Two tokens: the whole argument is a path, there is no operation.
    assert!(
        matches!(
            parse(b"git-lfs-transfer /nordisk/osm-katana.git"),
            Err(CommandError::MissingOperation {
                verb: "git-lfs-transfer",
                ..
            })
        ),
        "got {:?}",
        parse(b"git-lfs-transfer /nordisk/osm-katana.git")
    );

    // A third token that is not an operation. Note the path is valid, so this
    // cannot be passing for a path reason.
    for bad in [
        &b"git-lfs-transfer /nordisk/osm-katana.git UPLOAD"[..],
        b"git-lfs-transfer /nordisk/osm-katana.git Download",
        b"git-lfs-transfer /nordisk/osm-katana.git lock",
        b"git-lfs-transfer /nordisk/osm-katana.git upload2",
        b"git-lfs-transfer /nordisk/osm-katana.git ''",
    ] {
        match parse(bad) {
            Err(CommandError::UnknownOperation { verb, .. }) => {
                assert_eq!(verb, "git-lfs-transfer")
            }
            other => panic!(
                "{:?} should have been UnknownOperation, got {other:?}",
                String::from_utf8_lossy(bad)
            ),
        }
    }

    // A trailing operation with nothing before it is a missing *path*, and it
    // must not be mistaken for a missing operation: `rsplit_once` finds the
    // space and hands the path half an empty string.
    assert!(
        matches!(
            parse(b"git-lfs-transfer  upload"),
            Err(CommandError::MissingPath("git-lfs-transfer"))
        ),
        "got {:?}",
        parse(b"git-lfs-transfer  upload")
    );

    // And the whole verb with nothing at all.
    assert!(matches!(
        parse(b"git-lfs-transfer"),
        Err(CommandError::MissingPath("git-lfs-transfer"))
    ));

    // The identity-value half: the well-formed forms these are variations of
    // must still be accepted, or every assertion above would pass against a
    // parser that refused the verb outright.
    assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").is_ok());
    assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git download").is_ok());
}

/// Everything that must be refused, with why.
fn rejection_table() -> Vec<(String, &'static str)> {
    let mut cases: Vec<(String, &'static str)> = vec![
        // ── not a git command at all ───────────────────────────────────────
        ("bash".into(), "a shell"),
        ("bash -c 'id'".into(), "a shell with arguments"),
        ("sh".into(), "a shell"),
        ("/bin/sh -i".into(), "an absolute shell path"),
        ("scp -t /tmp".into(), "another exec-based protocol"),
        (
            "git-upload-archive 'ns/repo.git'".into(),
            "a git verb gunnar does not serve",
        ),
        (
            "git upload-pack 'ns/repo.git'".into(),
            "the spaced spelling, which is a different verb",
        ),
        ("GIT-UPLOAD-PACK 'ns/repo.git'".into(), "case is not folded"),
        (
            "git-upload-pack2 'ns/repo.git'".into(),
            "a verb with a suffix",
        ),
        (
            "xgit-upload-pack 'ns/repo.git'".into(),
            "a verb with a prefix",
        ),
        ("".into(), "an empty request"),
        // ── a legal verb, no usable path ───────────────────────────────────
        ("git-upload-pack".into(), "no path at all"),
        ("git-receive-pack".into(), "no path at all"),
        ("git-upload-pack ''".into(), "an empty quoted path"),
        ("git-upload-pack '/'".into(), "a bare root"),
        ("git-upload-pack '//'".into(), "a doubled root"),
        (
            "git-upload-pack 'repo.git'".into(),
            "no namespace (multi-tenancy)",
        ),
        // `a/b/c.git` used to be here and is now LEGAL, a nested
        // namespace. What is still refused is deeper than MAX_COMPONENTS.
        (
            "git-upload-pack 'a/b/c/d/e.git'".into(),
            "deeper than MAX_COMPONENTS",
        ),
        // Merged in from the deleted `gunnar_ssh::path`.
        (
            "git-upload-pack 'ns/repo.lock'".into(),
            "git's lock-file suffix",
        ),
        (
            "git-upload-pack 'ns.lock/repo.git'".into(),
            "the lock suffix on a namespace",
        ),
        // ── traversal ──────────────────────────────────────────────────────
        (
            "git-upload-pack '../../etc/passwd'".into(),
            "parent traversal",
        ),
        (
            "git-upload-pack 'ns/../../etc/shadow'".into(),
            "embedded traversal",
        ),
        ("git-upload-pack 'ns/..'".into(), "a `..` component"),
        (
            "git-upload-pack '.git/../..'".into(),
            "the spec's traversal case",
        ),
        ("git-upload-pack '~/ns/repo.git'".into(), "home-relative"),
        (
            "git-upload-pack '~root/ns/repo.git'".into(),
            "another user's home",
        ),
        // ── shell metacharacters, in case anything downstream ever forgets ─
        (
            "git-upload-pack 'ns/repo.git; id'".into(),
            "a command separator",
        ),
        (
            "git-upload-pack 'ns/repo.git && id'".into(),
            "a command chain",
        ),
        (
            "git-upload-pack 'ns/$(id).git'".into(),
            "command substitution",
        ),
        (
            "git-upload-pack 'ns/`id`.git'".into(),
            "backtick substitution",
        ),
        ("git-upload-pack 'ns/repo.git|nc host 1'".into(), "a pipe"),
        (
            "git-upload-pack 'ns/repo.git > /etc/cron.d/x'".into(),
            "a redirect",
        ),
        ("git-upload-pack 'ns/*'".into(), "a glob"),
        // ── quoting ────────────────────────────────────────────────────────
        (
            "git-upload-pack 'ns/repo.git".into(),
            "an unterminated quote",
        ),
        (
            "git-upload-pack 'ns/repo.git' extra".into(),
            "trailing bytes",
        ),
        (
            "git-upload-pack 'ns/repo.git''".into(),
            "a doubled closing quote",
        ),
        (
            "git-upload-pack \"ns/repo.git\"".into(),
            "double quotes, which git does not send",
        ),
        // ── unicode ────────────────────────────────────────────────────────
        (
            "git-upload-pack 'nordisk/\u{0441}sm-katana.git'".into(),
            "a Cyrillic homoglyph of an ASCII name",
        ),
        (
            "git-upload-pack 'nordisk/repo\u{202e}tig.'".into(),
            "a right-to-left override",
        ),
        (
            "git-upload-pack 'nordisk/re\u{00ad}po.git'".into(),
            "a soft hyphen",
        ),
    ];

    // ── NUL and control bytes ──────────────────────────────────────────────
    cases.push((
        String::from_utf8(b"git-upload-pack 'ns/repo\0.git'".to_vec()).unwrap(),
        "an embedded NUL",
    ));
    cases.push((
        String::from_utf8(b"git-upload-pack 'ns/repo.git'\0bash".to_vec()).unwrap(),
        "a NUL-hidden second command",
    ));
    cases.push((
        "git-upload-pack 'ns/re\npo.git'".into(),
        "an embedded newline",
    ));
    cases.push((
        "git-upload-pack\t'ns/repo.git'".into(),
        "a tab where the separator should be",
    ));

    // ── absurd length ──────────────────────────────────────────────────────
    cases.push((
        format!("git-upload-pack 'ns/{}.git'", "a".repeat(MAX_EXEC_LEN)),
        "over the exec length limit",
    ));
    cases.push((
        format!("git-upload-pack 'ns/{}.git'", "a".repeat(200)),
        "over the component length limit",
    ));
    cases.push((
        format!("{} 'ns/repo.git'", "x".repeat(MAX_EXEC_LEN)),
        "an absurd verb",
    ));

    // ── git-lfs-transfer (LFS over SSH) ────────────────────────────────────
    //
    // The verb whose argument is `<path> <operation>` rather than `<path>`.
    // Every rule the two git verbs are held to has to hold here too, and the
    // path half is the one at risk: it is split off by `rsplit_once(' ')` rather
    // than being the whole argument, so it reaches the sandbox by a different
    // route.
    for bad_operation in ["", "UPLOAD", "Upload", "up load", "upload extra", "lock"] {
        cases.push((
            format!("git-lfs-transfer /ns/repo.git {bad_operation}"),
            "not an lfs operation",
        ));
    }
    cases.push((
        "git-lfs-transfer /ns/repo.git".into(),
        "no operation at all",
    ));
    cases.push((
        "git-lfs-transfer upload".into(),
        "an operation with no path: the whole argument is one token",
    ));
    cases.push((
        "git-lfs-transfer  upload".into(),
        "an empty path before the operation",
    ));
    // The sandbox must run on the LFS path exactly as it does on a git path.
    // These are the same escapes the git verbs are tested with, sent through the
    // three-token form.
    for escape in [
        "/ns/../../etc/passwd",
        "~/ns/repo.git",
        "//ns/repo.git",
        "repo.git",
        "a/b/c/d/e.git",
        "ns/repo.lock",
        "ns/.hidden",
        "ns/-rf",
    ] {
        cases.push((
            format!("git-lfs-transfer {escape} download"),
            "a path the sandbox must refuse, through the lfs verb",
        ));
    }
    cases.push((
        "git-lfs-transfer /ns/re\npo.git upload".into(),
        "an embedded newline in an lfs path",
    ));
    cases.push((
        String::from_utf8(b"git-lfs-transfer /ns/repo.git\0 upload".to_vec()).unwrap(),
        "a NUL in an lfs path",
    ));
    cases.push((
        "git-lfs-transfer-x /ns/repo.git upload".into(),
        "a verb that merely starts with the lfs verb",
    ));

    cases
}

#[test]
fn hostile_forms_are_refused() {
    for (raw, why) in rejection_table() {
        assert!(
            parse(raw.as_bytes()).is_err(),
            "should have refused {raw:?} ({why})"
        );
    }
}

/// **Unquoting happens here, and the sandbox never sees a quote.**
///
/// git wraps the path in single quotes and gunnar has no shell to take them
/// off, so this crate does, and `gunnar-core` then refuses a quote as the
/// forbidden character it is. Both halves are asserted, because either one
/// alone would look fine: if the parser stopped unquoting, every quoted form
/// would be refused (caught by `legal_forms_are_accepted`); if the sandbox
/// started unquoting again, the double-quoted form below would be *accepted*.
#[test]
fn the_parser_unquotes_and_the_sandbox_refuses_what_it_is_handed() {
    // Single quotes come off here, so what reaches the sandbox is bare.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .expect("unquoted here")
            .repo
            .key(),
        "nordisk/osm-katana"
    );

    // Double quotes are NOT git's quoting, are passed through as literal
    // characters, and the sandbox refuses them. This is what a second, weaker
    // unquoter inside the sandbox would silently have accepted.
    assert_eq!(
        parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
            .expect_err("git does not send double quotes"),
        CommandError::Path {
            reason: Some(PathReject::ForbiddenCharacter),
            message: parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
                .unwrap_err()
                .to_string(),
        }
    );
}

/// The sandbox's *reason* travels out through the parser, so a test can assert
/// which rule fired. A path refused for the wrong reason is one refactor away
/// from being accepted.
#[test]
fn the_sandbox_reason_reaches_the_command_error() {
    let cases: &[(&[u8], PathReject)] = &[
        (b"git-upload-pack 'ns/../../etc/passwd'", PathReject::DotDot),
        (
            b"git-upload-pack '~/ns/repo.git'",
            PathReject::HomeReference,
        ),
        (b"git-upload-pack '//ns/repo.git'", PathReject::Absolute),
        (b"git-upload-pack 'repo.git'", PathReject::TooFewComponents),
        (
            b"git-upload-pack 'a/b/c/d/e.git'",
            PathReject::TooManyComponents,
        ),
        (b"git-upload-pack 'ns/repo.lock'", PathReject::LockSuffix),
        // `.git` comes off the end before the components are validated, so
        // `ns/.git` is refused as `ns/`: an empty component, not a dot
        // component. Refused either way; pinned here so the *reason* cannot
        // drift unnoticed.
        (b"git-upload-pack 'ns/.git'", PathReject::EmptyComponent),
        (b"git-upload-pack 'ns/.hidden'", PathReject::DotComponent),
        (b"git-upload-pack 'ns/-rf'", PathReject::LeadingDash),
    ];
    for (raw, expected) in cases {
        match parse(raw) {
            Err(CommandError::Path { reason, .. }) => assert_eq!(
                reason,
                Some(*expected),
                "{:?} was refused for the wrong reason",
                String::from_utf8_lossy(raw)
            ),
            other => panic!(
                "{:?} should have been a sandbox rejection, got {other:?}",
                String::from_utf8_lossy(raw)
            ),
        }
    }
}

#[test]
fn a_shell_is_refused_as_an_unknown_command_not_as_a_bad_path() {
    // The distinction matters on the wire: an unknown command gets a channel
    // failure and no reply, because there is no git client on the other end to
    // read one.
    assert!(matches!(
        parse(b"bash"),
        Err(CommandError::UnknownCommand(verb)) if verb == "bash"
    ));
    assert!(matches!(
        parse(b"git-upload-pack"),
        Err(CommandError::MissingPath("git-upload-pack"))
    ));
    assert!(matches!(
        parse(b"git-upload-pack 'ns/repo\0'"),
        Err(CommandError::ControlByte { byte: 0, .. })
    ));
    assert!(matches!(
        parse(format!("git-upload-pack '{}'", "a".repeat(MAX_EXEC_LEN)).as_bytes()),
        Err(CommandError::TooLong { .. })
    ));
}

/// Measure, never assert: the rejection table must be seen to fail against a parser that is
/// wrong in the way real parsers are wrong.
#[test]
fn the_rejection_table_can_go_red() {
    /// What a naive implementation looks like: split on the first space, accept
    /// anything that starts with a known verb, strip the quotes. It is not a
    /// straw man; it is the shape the parser would have if the path rules and
    /// the byte rules were left to "the store will not find it anyway".
    fn naive(raw: &[u8]) -> Result<(), ()> {
        let text = String::from_utf8_lossy(raw);
        let (verb, rest) = text.split_once(' ').ok_or(())?;
        if !SERVICES.iter().any(|spec| spec.verb == verb) {
            return Err(());
        }
        let path = rest.trim_matches('\'');
        if path.is_empty() {
            return Err(());
        }
        Ok(())
    }

    let table = rejection_table();
    let survivors: Vec<&str> = table
        .iter()
        .filter(|(raw, _)| naive(raw.as_bytes()).is_ok())
        .map(|(raw, _)| raw.as_str())
        .collect();

    assert!(
        survivors.len() >= 20,
        "the rejection table barely tests anything: only {} of {} cases survive a naive parser",
        survivors.len(),
        table.len()
    );

    // And the acceptance half must be accepted by the naive parser too.
    // Otherwise the table would be distinguishing "correct" from "broken" by
    // accident of the legal cases rather than by the hostile ones.
    for (raw, _, _, _) in LEGAL {
        assert!(
            naive(raw.as_bytes()).is_ok(),
            "the legal case {raw:?} is not what separates the two parsers"
        );
    }
}

/// The line range of the `SERVICES` const in `src/command.rs`, 1-based and
/// inclusive.
///
/// Found by reading the source rather than written down, so it cannot go stale
/// the way a hardcoded row count does.
fn services_block() -> (String, usize, usize) {
    let path = format!("{}/src/command.rs", env!("CARGO_MANIFEST_DIR"));
    let text = std::fs::read_to_string(&path).expect("the dispatch module is readable");
    let lines: Vec<&str> = text.lines().collect();
    let first = lines
        .iter()
        .position(|line| line.starts_with("pub const SERVICES"))
        .expect("the dispatch table is declared as `pub const SERVICES`");
    let last = first
        + lines[first..]
            .iter()
            .position(|line| line.trim_end() == "];")
            .expect("the dispatch table is closed by `];` in column 0");
    (path, first + 1, last + 1)
}

/// Every `.rs` file under this crate's `src/`, as (path, contents).
fn crate_sources() -> Vec<(String, String)> {
    fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) {
        for entry in std::fs::read_dir(dir).expect("the source tree is readable") {
            let path = entry.expect("a readable directory entry").path();
            if path.is_dir() {
                walk(&path, out);
            } else if path.extension().is_some_and(|ext| ext == "rs") {
                let text = std::fs::read_to_string(&path).expect("a readable source file");
                out.push((path.display().to_string(), text));
            }
        }
    }
    let mut out = Vec::new();
    walk(
        std::path::Path::new(&format!("{}/src", env!("CARGO_MANIFEST_DIR"))),
        &mut out,
    );
    assert!(
        out.len() > 5,
        "the source walk found only {} files, so it is not looking at the crate",
        out.len()
    );
    out
}

#[test]
fn the_dispatch_table_is_the_only_place_a_verb_is_named() {
    // `git-lfs-transfer` (LFS over SSH) landed, and it was one row, which is what this
    // test was written to make visible. It has to change when the table does,
    // and that is the point: a verb that appeared anywhere else would not show
    // up here at all.
    assert_eq!(SERVICES.len(), 3);
    assert_eq!(SERVICES[0].verb, "git-upload-pack");
    assert_eq!(SERVICES[1].verb, "git-receive-pack");
    // Spelled out rather than compared to `gunnar_lfs::TRANSFER_VERB`, which
    // the row is built from: comparing a constant to itself would pass however
    // it were spelled, and this verb is fixed by the real git-lfs client
    // (`GetLFSExeAndArgs`), not by us.
    assert_eq!(SERVICES[2].verb, "git-lfs-transfer");

    // The other direction. `verb()` goes through `row_for`, which finds a row
    // by *discriminant*, so both operations of the one LFS row must land on
    // that row rather than only the representative value the table holds.
    assert_eq!(GitService::UploadPack.verb(), "git-upload-pack");
    assert_eq!(GitService::ReceivePack.verb(), "git-receive-pack");
    assert_eq!(
        GitService::LfsTransfer(Operation::Download).verb(),
        "git-lfs-transfer"
    );
    assert_eq!(
        GitService::LfsTransfer(Operation::Upload).verb(),
        "git-lfs-transfer"
    );

    // **And the property the name claims**, which no row count can express: a
    // verb may be spelled as a string literal in the table and nowhere else in
    // the crate. A second spelling somewhere else is how a verb comes to be
    // dispatched without the grant level that travels with its row, which is
    // the failure this whole module is shaped to prevent. It would not show up
    // in any assertion above, because those only ever read the table.
    //
    // Doc comments name the verbs freely (the module docs quote the exec lines
    // git actually sends), so only code is searched.
    let (table_path, first, last) = services_block();
    for (path, text) in crate_sources() {
        for (index, line) in text.lines().enumerate() {
            let code = line.split("//").next().unwrap_or("");
            for spec in SERVICES {
                if !code.contains(&format!("{:?}", spec.verb)) {
                    continue;
                }
                let number = index + 1;
                let in_table = path == table_path && (first..=last).contains(&number);
                assert!(
                    in_table,
                    "`{}` is named at {path}:{number}, outside the dispatch table at \
                     {table_path}:{first}-{last}. A verb spelled twice is a verb that can be \
                     dispatched without the grant level its row carries.",
                    spec.verb
                );
            }
        }
    }
}