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

nordisk/gunnar

crates/gunnar-client/tests/head_target.rs

//! **A remote does not get to choose the contents of `.git/HEAD`** (GUN-041).
//!
//! `RemoteIdentity::head_target` and `unborn_head` are strings the SERVER chose
//! — the v0 advertisement's `symref=HEAD:<target>` and v2's `ls-refs` symref
//! line — and `init_clone_dir` wrote `ref: {branch}` into the new clone's
//! `.git/HEAD` verbatim. Whatever the remote said became the contents of a file
//! in the user's working tree.
//!
//! `ref:` targets resolve relative to `$GIT_DIR`, so a remote answering
//! `../../../../tmp/x` points the clone's HEAD at a path outside the
//! repository, which the next commit writes through. A target carrying a
//! newline puts a second line in the file that nothing in the repository put
//! there.
//!
//! Driven through `init_clone_dir` itself, which is the ONE writer for a
//! freshly-initialised clone and is what both callers reach — the empty-remote
//! path and the shallow/filtered path.

use gunnar_client::fetch::init_clone_dir;

const URL: &str = "ssh://example.invalid/team/project.git";

/// An error and every `source()` under it, joined.
///
/// Load-bearing rather than tidiness: `gunnar_client::Error::Repo` Displays as
/// the bare word `repository` and carries the sentence as its source, which is
/// the trap the mirror work already recorded ("the FAILED job an operator reads
/// named nothing they could act on"). A test asserting on `{err}` here would
/// assert on the word `repository` and pass for any refusal at all.
fn chain(err: &dyn std::error::Error) -> String {
    let mut text = err.to_string();
    let mut source = err.source();
    while let Some(next) = source {
        text.push_str(": ");
        text.push_str(&next.to_string());
        source = next.source();
    }
    text
}

fn init(branch: &str) -> Result<std::path::PathBuf, String> {
    let dir = tempfile::tempdir().expect("a temp dir");
    let dest = dir.path().join("clone");
    let outcome = init_clone_dir(&dest, URL, gix::hash::Kind::Sha1, branch)
        .map(|_| dest.clone())
        .map_err(|err| chain(&err));
    // The directory has to outlive the call, so the caller can read the file;
    // leaking one tempdir per case in a test is the cheap way to do that.
    std::mem::forget(dir);
    outcome
}

/// The accept side, first. Without it every refusal below would be satisfied by
/// an `init_clone_dir` that refused everything — and that would be a clone
/// command that cannot clone.
#[test]
fn a_real_branch_name_is_written_into_head() {
    let dest = init("refs/heads/main").expect("a legal full ref name must be accepted");
    assert_eq!(
        std::fs::read_to_string(dest.join(".git").join("HEAD")).expect("read HEAD"),
        "ref: refs/heads/main\n",
        "the clone's HEAD is not on the branch the remote named"
    );
}

/// **A target that climbs out of the repository is refused, and nothing is
/// left behind.**
///
/// `..` is the whole of it: git resolves `ref: <name>` against `$GIT_DIR`, so
/// this is a HEAD that names a file outside the clone, and the next `git
/// commit` writes an object id into it.
#[test]
fn a_target_that_escapes_the_repository_is_refused_and_leaves_no_clone() {
    let err = init("../../../../tmp/gunnar-head-escape")
        .expect_err("a target with `..` in it must not reach .git/HEAD");
    assert!(
        err.contains("gunnar-head-escape") && err.contains("not a reference name"),
        "the refusal must quote what the remote sent, or a user has nothing to \
         report: {err}"
    );
}

/// A target carrying a newline would put a second line into `.git/HEAD`.
#[test]
fn a_target_carrying_a_newline_is_refused() {
    let err = init("refs/heads/main\nref: refs/heads/attacker")
        .expect_err("a target with a newline must not reach .git/HEAD");
    assert!(
        err.contains("not a reference name"),
        "refused for the wrong reason: {err}"
    );
}

/// And a bare branch name is refused too — not a security case, a correctness
/// one, and it is here because it is the shape a caller is most likely to pass
/// by mistake. `init_clone_dir`'s doc says `branch` is the FULL name, and
/// before this check that sentence was a comment rather than a rule: `ref: main`
/// is a HEAD git cannot resolve, and the clone would have been created broken.
#[test]
fn a_partial_name_is_refused_rather_than_written_as_one() {
    let err = init("main").expect_err("`main` is not a fully-qualified reference name");
    assert!(err.contains("not a reference name"), "{err}");
}