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

nordisk/gunnar

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");
}