crates/gunnar-core/tests/sandbox.rs
//! The path sandbox, red first (measured, not asserted).
//!
//! Three things have to be true at once, and any two of them without the third
//! is a sandbox that looks fine and is not:
//!
//! 1. every escape in the table is refused;
//! 2. every legitimate path in the table still resolves, or else "rejects
//! everything" passes (1) perfectly;
//! 3. the table has teeth: a deliberately broken resolver is **seen to accept**
//! every row of (1), so a green in (1) means the checks fired rather than
//! that the rows were harmless.
use std::path::Path;
use gunnar_core::sandbox::red::{broken_resolve_repo_path, ESCAPES, LEGITIMATE};
use gunnar_core::sandbox::{MAX_COMPONENT_BYTES, MAX_PATH_BYTES};
use gunnar_core::{resolve_repo_path, PathReject, RepoRef};
#[test]
fn every_escape_is_refused() {
let mut accepted = Vec::new();
for (input, why) in ESCAPES {
if let Ok(resolved) = resolve_repo_path(input) {
accepted.push(format!("{input:?} ({why}) resolved to {resolved}"));
}
}
assert!(
accepted.is_empty(),
"the sandbox accepted {} escape(s):\n {}",
accepted.len(),
accepted.join("\n ")
);
}
/// The other half, and the one that is usually missing: a sandbox that rejects
/// everything passes `every_escape_is_refused` and is useless. This is the
/// identity-value trap.
#[test]
fn every_legitimate_path_still_resolves() {
for (input, namespace, name) in LEGITIMATE {
let resolved = resolve_repo_path(input)
.unwrap_or_else(|err| panic!("legitimate path {input:?} was refused: {err}"));
assert_eq!(resolved.namespace(), *namespace, "namespace of {input:?}");
assert_eq!(resolved.name(), *name, "name of {input:?}");
}
}
/// **The guard is seen to be able to fail.**
///
/// `broken_resolve_repo_path` is the naive implementation with every check
/// stripped out. If it accepts a row that the real resolver rejects, that row
/// is a genuine escape and the real resolver's rejection came from a check,
/// not from the row being harmless.
///
/// If someone makes `resolve_repo_path` always return `Ok`, this test still
/// passes, but `every_escape_is_refused` goes red, which is the point. If
/// someone instead deletes a row's teeth, this test goes red.
#[test]
fn the_escape_table_has_teeth() {
let mut toothless = Vec::new();
for (input, why) in ESCAPES {
let broken = broken_resolve_repo_path(input);
let strict = resolve_repo_path(input);
if broken.is_err() {
toothless.push(format!(
"{input:?} ({why}) is rejected even by the broken resolver, \
so its rejection proves nothing"
));
}
if strict.is_ok() {
toothless.push(format!(
"{input:?} ({why}) is accepted by the real resolver"
));
}
}
assert!(
toothless.is_empty(),
"the escape table is not proving what it claims:\n {}",
toothless.join("\n ")
);
}
/// And the red half of *that*: the broken resolver must really be broken. If
/// it ever starts rejecting things, `the_escape_table_has_teeth` would be
/// comparing two working implementations and would mean nothing.
#[test]
fn the_broken_resolver_really_accepts_everything() {
for (input, why) in ESCAPES {
assert!(
broken_resolve_repo_path(input).is_ok(),
"the deliberately broken resolver refused {input:?} ({why}); it is supposed to be \
the thing that lets escapes through"
);
}
}
/// What a **stock git client actually sends**, byte for byte.
///
/// Captured on 2026-08-03 from git 2.53.0 with a `GIT_SSH_COMMAND` that only
/// echoes its arguments:
///
/// ```text
/// $ git ls-remote ssh://git@host/ns/repo.git
/// SSH ARGS: git@host git-upload-pack '/ns/repo.git'
/// $ git ls-remote git@host:ns/repo.git
/// SSH ARGS: git@host git-upload-pack 'ns/repo.git'
/// ```
///
/// Two things fall out of that transcript, and both are load-bearing:
/// the **quotes are part of the string** the server receives (there is no
/// shell on the gunnar side to strip them), and the `ssh://` form carries a
/// **leading slash**. A sandbox that rejected the leading slash would reject
/// every `ssh://` clone.
///
/// The quotes are taken off by `gunnar_ssh::command`, not here, so
/// this test strips them the way that parser does and then asserts what the
/// sandbox is actually handed. `gunnar-ssh`'s `tests/command_parsing.rs` asserts
/// the same three lines end to end, quotes and all, through the real parser.
#[test]
fn what_a_stock_git_client_sends_resolves() {
for (sent, namespace, name) in [
("git-upload-pack 'ns/repo.git'", "ns", "repo"),
("git-upload-pack '/ns/repo.git'", "ns", "repo"),
("git-receive-pack 'team/sub/repo.git'", "team/sub", "repo"),
] {
let quoted = sent.split_once(' ').expect("a command and its argument").1;
let argument = quoted
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
.expect("stock git single-quotes the path");
let resolved = resolve_repo_path(argument).unwrap_or_else(|err| {
panic!("stock git sent {argument:?} and gunnar refused it: {err}")
});
assert_eq!(resolved.namespace(), namespace);
assert_eq!(resolved.name(), name);
}
}
/// The sandbox does **not** unquote, and must not grow a second, weaker
/// unquoter beside the one in `gunnar_ssh::command` (one implementation).
///
/// This is the guard for that: a quote reaching here is a forbidden character.
/// It is also the red half of the parser's unquoting: if `gunnar_ssh::command`
/// ever stopped stripping the quotes, the path would arrive here and be refused
/// rather than silently resolving to something.
#[test]
fn the_sandbox_does_not_unquote() {
for quoted in ["'ns/repo.git'", "\"ns/repo.git\"", "'ns/repo'", "''"] {
assert!(
resolve_repo_path(quoted).is_err(),
"{quoted:?} must not resolve: unquoting is the SSH command parser's job"
);
}
// …and the same path without them does resolve, so the rejection above is
// about the quotes and not about the path.
assert!(resolve_repo_path("ns/repo.git").is_ok());
}
#[test]
fn the_reason_each_escape_gives_is_the_right_one() {
let cases: &[(&str, PathReject)] = &[
("ns/../../etc/passwd", PathReject::DotDot),
("ns/.git/../..", PathReject::DotComponent),
("//etc/passwd", PathReject::Absolute),
("~/repo", PathReject::HomeReference),
("ns/re\0po", PathReject::Nul),
("ns/re po", PathReject::Control),
("café/repo", PathReject::NonAscii),
("ns/repo", PathReject::NonAscii),
("ns/re\\po", PathReject::ForbiddenCharacter),
// A backslash with no slash at all reads as one component, so the
// count check fires first. Still refused, for a different reason.
("ns\\repo", PathReject::TooFewComponents),
("", PathReject::Empty),
("repo.git", PathReject::TooFewComponents),
("ns//repo", PathReject::EmptyComponent),
("ns/", PathReject::EmptyComponent),
("a/b/c/d/e", PathReject::TooManyComponents),
("ns/.hidden", PathReject::DotComponent),
("ns/repo.", PathReject::TrailingDot),
("ns/-rf", PathReject::LeadingDash),
("ns/repo:extra", PathReject::ForbiddenCharacter),
// Merged in from the deleted `gunnar_ssh::path`.
("ns/repo.lock", PathReject::LockSuffix),
("ns.lock/repo", PathReject::LockSuffix),
("'ns/repo.git'", PathReject::ForbiddenCharacter),
("\"ns/repo.git\"", PathReject::ForbiddenCharacter),
];
for (input, expected) in cases {
let err = resolve_repo_path(input).expect_err("must be refused");
assert_eq!(
err.path_reject(),
Some(*expected),
"{input:?} was refused for the wrong reason: {err}"
);
assert!(err.is_path_rejection());
}
}
#[test]
fn the_length_limits_are_where_they_say_they_are() {
let ok = "a".repeat(MAX_COMPONENT_BYTES);
let too_long = "a".repeat(MAX_COMPONENT_BYTES + 1);
assert!(resolve_repo_path(&format!("ns/{ok}")).is_ok());
assert_eq!(
resolve_repo_path(&format!("ns/{too_long}"))
.unwrap_err()
.path_reject(),
Some(PathReject::ComponentTooLong)
);
// Over the whole-path limit, in components that are each individually fine.
let long_path = format!(
"{}/{}/{}/{}",
"a".repeat(64),
"b".repeat(64),
"c".repeat(64),
"d".repeat(64)
);
assert!(long_path.len() > MAX_PATH_BYTES);
assert_eq!(
resolve_repo_path(&long_path).unwrap_err().path_reject(),
Some(PathReject::TooLong)
);
}
/// A NUL must be caught before anything downstream can truncate at it. The
/// prefix on its own is a perfectly legal path, which is the whole trick.
#[test]
fn a_nul_is_not_a_terminator() {
assert!(resolve_repo_path("ns/repo").is_ok());
assert_eq!(
resolve_repo_path("ns/repo\0/../../etc/passwd")
.unwrap_err()
.path_reject(),
Some(PathReject::Nul)
);
}
#[test]
fn the_direct_constructor_validates_too() {
// `RepoRef::new` is the other door into the type and must not be a way
// around the checks.
assert!(RepoRef::new("ns", "repo").is_ok());
assert!(RepoRef::new("team/sub", "repo").is_ok());
assert!(RepoRef::new("..", "repo").is_err());
assert!(RepoRef::new("ns", "..").is_err());
assert!(RepoRef::new("ns/../..", "repo").is_err());
assert!(RepoRef::new("", "repo").is_err());
assert!(RepoRef::new("ns", "").is_err());
assert!(RepoRef::new("a/b/c/d", "repo").is_err());
assert!(RepoRef::new("ns", "re\0po").is_err());
}
#[test]
fn a_resolved_path_round_trips_through_its_own_ssh_form() {
for (input, _, _) in LEGITIMATE {
let once = resolve_repo_path(input).expect("legitimate");
let twice = resolve_repo_path(&once.to_ssh_path()).expect("its own output is legitimate");
assert_eq!(once, twice, "{input:?} did not round-trip");
assert_eq!(once.key(), format!("{once}"));
}
}
// ── the filesystem half: symlinks ─────────────────────────────────────────
//
// The string checks cannot see a symlink. `ns` may be an entirely ordinary
// name that happens to be a link to /etc. That is a property of the
// filesystem, so it is checked where the path is actually built, and only the
// `fs` backend ever gets there.
#[test]
fn a_symlinked_namespace_escaping_the_root_is_refused() {
let root = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("good")).expect("mkdir");
std::os::unix::fs::symlink(outside.path(), root.path().join("evil")).expect("symlink");
let good = RepoRef::new("good", "repo").expect("legal");
let evil = RepoRef::new("evil", "repo").expect("legal as a string — that is the point");
let resolved = good.path_under(root.path()).expect("stays inside");
assert!(resolved.starts_with(root.path().canonicalize().expect("canonical")));
let err = evil
.path_under(root.path())
.expect_err("a symlinked namespace must not resolve");
assert!(err.is_path_rejection(), "{err}");
assert!(
format!("{err}").contains(&outside.path().canonicalize().unwrap().display().to_string()),
"the error should name where it actually went: {err}"
);
}
#[test]
fn a_symlinked_repository_directory_escaping_the_root_is_refused() {
let root = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("ns")).expect("mkdir");
std::fs::create_dir_all(outside.path().join("stolen.git")).expect("mkdir");
std::os::unix::fs::symlink(
outside.path().join("stolen.git"),
root.path().join("ns").join("repo.git"),
)
.expect("symlink");
let path = RepoRef::new("ns", "repo").expect("legal");
assert!(
path.path_under(root.path()).is_err(),
"a repository directory that is a link out of the root must not resolve"
);
}
/// The red half of the symlink guard: with no symlink in the way, the very
/// same call must succeed, including for a repository that does not exist
/// yet, which is what `create` needs.
#[test]
fn an_ordinary_path_under_the_root_resolves() {
let root = tempfile::tempdir().expect("tempdir");
let path = RepoRef::new("team/sub", "repo").expect("legal");
let resolved = path.path_under(root.path()).expect("nothing is in the way");
let real_root = root.path().canonicalize().expect("canonical");
assert_eq!(
resolved,
real_root.join("team").join("sub").join("repo.git")
);
assert!(!Path::new(&resolved).exists(), "nothing was created");
}