crates/gunnar-proto/tests/nested_namespace.rs
//! 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");
}