gunnar-proto 3.0.0

The .proto files for gunnar's gRPC control-plane surface, and their tonic-build output.
Documentation
//! The two halves of "what is a legal repository path" must agree.
//!
//! Nested namespaces are first class: `gunnar_core::sandbox` resolves
//! `team/sub/repo.git`, `RepoCatalog::create_namespace` accepts `team/sub`, and
//! the SSH listener serves such a repository. `gunnar-proto`'s [`RepoPath`] is
//! the only thing that ever disagreed: it rejected a `/` inside a namespace.
//! It is also the choke point every repo-scoped RPC goes through
//! (`gunnar_grpc::status::repo_path`). A repository the sandbox accepts and
//! `RepoPath` rejects is pushable, clonable, and **invisible to every gRPC
//! consumer**: it cannot be listed, deleted, or have its grants audited.
//!
//! # Why this is a test and not shared code
//!
//! One implementation means reuse rather than twin, and the fix would ideally
//! be one validator called from both sides. It cannot be: `gunnar-proto` is a
//! leaf that dwarves and korp link for the control-plane types, and
//! `gunnar_core::sandbox` arrives behind `gunnar-store` and `gunnar-pack`.
//! Neither crate can take the other as a normal dependency without putting a
//! storage stack in a client or a tonic stack in the appliance's model layer.
//!
//! So the second-best construction: the rule is stated twice, and this test
//! (the only place both crates are in scope) reads `gunnar-core`'s **own**
//! corpus and asserts the two statements agree. It cannot be satisfied by
//! patching one example, and it goes red the day either side moves.

use gunnar_core::sandbox::red::{ESCAPES, LEGITIMATE};
use gunnar_core::sandbox::{resolve_repo_path, MAX_COMPONENTS as SANDBOX_MAX_COMPONENTS};
use gunnar_proto::{RepoPath, RepoPathError, MAX_COMPONENTS};

#[test]
fn every_path_the_ssh_sandbox_accepts_is_expressible_as_a_repo_path() {
    // Anti-vacuous, and the whole reason this test exists: if the corpus
    // carried no nested namespace it would pass against the validator that
    // rejects every one of them. Assert the corpus has teeth before trusting
    // what it says.
    let nested = LEGITIMATE
        .iter()
        .filter(|(_, namespace, _)| namespace.contains('/'))
        .count();
    assert!(
        nested >= 2,
        "the sandbox's LEGITIMATE table has {nested} nested-namespace rows; with fewer than two \
         this test would pass against a `RepoPath` that rejects nesting outright, which is the \
         bug this is about"
    );

    for (input, namespace, name) in LEGITIMATE {
        // The corpus is gunnar-core's, so re-derive rather than trust it: if
        // the sandbox stopped resolving one of its own rows, the disagreement
        // this test reports would be about the wrong thing.
        let resolved = resolve_repo_path(input).unwrap_or_else(|err| {
            panic!("the sandbox no longer resolves its own row {input:?}: {err}")
        });
        assert_eq!(
            (resolved.namespace(), resolved.name()),
            (*namespace, *name),
            "the sandbox resolves {input:?} somewhere else now"
        );

        // The invariant that matters: a repository that exists on disk must be
        // addressable over the control plane. `validate`, not `parse`, is
        // what guards it, because a request arrives already split.
        RepoPath::new(*namespace, *name)
            .validate()
            .unwrap_or_else(|err| {
                panic!(
                    "the sandbox serves {input:?} as {namespace}/{name}, but the control plane \
                     refuses to name it: {err}. Such a repository is clonable over SSH and \
                     invisible to gRPC."
                )
            });

        // A leading `/` is the `ssh://` URL form; the control plane never sees
        // one, and `parse` deliberately keeps rejecting an empty first
        // component. Every other row must round-trip through the SSH spelling.
        if !input.starts_with('/') {
            assert_eq!(
                RepoPath::parse(input).unwrap_or_else(|err| panic!("{input:?} rejected: {err}")),
                RepoPath::new(*namespace, *name),
                "the SSH spelling and the gRPC spelling must name the same repository"
            );
        }
    }
}

#[test]
fn the_component_bound_is_the_sandboxs_and_not_a_second_number() {
    // The one constant that had to be restated in this crate. There is no
    // construction that keeps them equal, so this is the guard that says they
    // are; it was seen red by changing either.
    assert_eq!(
        MAX_COMPONENTS, SANDBOX_MAX_COMPONENTS,
        "gunnar-proto's component bound has drifted from the sandbox's; a path the sandbox \
         accepts would be unnameable over gRPC, or one it refuses would be nameable"
    );
}

#[test]
fn accepting_nesting_did_not_open_the_structural_escapes() {
    // Nesting is now allowed, so the checks that used to fall out of "exactly
    // two segments" have to be made on purpose. Each row below is an
    // `ESCAPES` row that this change could plausibly have turned into an
    // accept; a `RepoPath` that took any of them would hand `RepoRef::new` a
    // path the sandbox refuses, or exceed the depth the catalog bounds.
    for (input, expected) in [
        ("a/b/c/d/e", RepoPathError::TooManyComponents),
        ("ns//repo", RepoPathError::EmptyNamespace),
        (
            "ns/../../etc/passwd",
            RepoPathError::IllegalCharacter("`.` or `..`"),
        ),
        (
            "ns/repo/../../../root",
            RepoPathError::IllegalCharacter("`.` or `..`"),
        ),
        (
            "ns/repo/../other",
            RepoPathError::IllegalCharacter("`.` or `..`"),
        ),
        (
            "ns/.git/../..",
            RepoPathError::IllegalCharacter("`.` or `..`"),
        ),
        (
            "ns/..\\..\\etc",
            RepoPathError::IllegalCharacter("a backslash"),
        ),
    ] {
        // Every one of these is in the sandbox's escape table, so a `RepoPath`
        // that accepted one would be laxer than the thing it fronts.
        assert!(
            ESCAPES.iter().any(|(row, _)| *row == input),
            "{input:?} is asserted here as an escape but is not in the sandbox's ESCAPES \
             table. One of the two is wrong"
        );
        assert_eq!(
            RepoPath::parse(input)
                .map(|p| p.to_string())
                .expect_err(&format!("{input:?} must be rejected")),
            expected,
            "wrong reason for {input:?}"
        );
    }
}

#[test]
fn a_nested_namespace_survives_the_split_and_the_suffix() {
    // The concrete case from the finding, spelled out rather than left to the
    // corpus: `team/sub/repo.git` must split at the LAST `/`.
    let path = RepoPath::parse("team/sub/repo.git").expect("a nested namespace is legal over SSH");
    assert_eq!(path.namespace, "team/sub");
    assert_eq!(path.repo, "repo");
    assert_eq!(path.to_string(), "team/sub/repo");
    // Three namespace levels plus the name is exactly MAX_COMPONENTS.
    let deep = RepoPath::parse("team/sub/deeper/repo.git").expect("four components is the bound");
    assert_eq!(deep.namespace, "team/sub/deeper");
    assert_eq!(deep.repo, "repo");
}