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

nordisk/gunnar

crates/gunnar-lfs/tests/interop.rs

//! The **real `git-lfs` binary**, pushing to and fetching from gunnar over the
//! pure-SSH transfer.
//!
//! # Why a fake `ssh` and not a fake client
//!
//! git-lfs only ever reaches a server by exec'ing a command over SSH, and it
//! *chooses* the pure-SSH transfer by trying `git-lfs-transfer <path> <op>`
//! first and falling back to `git-lfs-authenticate` + HTTP when that fails
//! (`attempting pure SSH protocol connection` / `pure SSH protocol connection
//! failed`, both in the 3.7.1 binary). So the only way to put gunnar on the
//! other end of a real client is to substitute `ssh` itself, via
//! `GIT_SSH_COMMAND`, and dispatch the exec line to `gunnar-lfs-serve`.
//!
//! That substitution is also what makes the fallback visible: if gunnar's
//! capability advertisement were wrong, git-lfs would quietly go and look for an
//! HTTP endpoint, the wrapper would log a refusal instead of a dispatch, and
//! [`assert_transfer_was_execed`] goes red. A test that only checked "the push
//! command exited 0" could not tell that apart from success.
//!
//! # This test hard-fails when `git-lfs` is absent. Deliberately.
//!
//! `#[ignore]`, an early `return`, or a `if !have_git_lfs() { return }` would
//! make a machine without the binary report a green it never earned, the
//! failure mode this tree has already shipped twice. If `git-lfs` is not here,
//! this panics and says so. `tests/transcript.rs` is the guard that *is* meant
//! to run everywhere, and it needs nothing installed.

use std::path::PathBuf;
use std::process::Command;

/// The object sizes. 3 MiB is over 48 pkt-lines of `CHUNK_BYTES` on the way out
/// and ~96 of the client's 32 KiB frames on the way in, so both directions are
/// genuinely multi-frame; the small one keeps a single-frame object covered.
const BIG_BYTES: usize = 3 * 1024 * 1024;

struct Fixture {
    _dir: tempfile::TempDir,
    root: PathBuf,
    work: PathBuf,
    server_git: PathBuf,
    ssh_log: PathBuf,
}

impl Fixture {
    fn new() -> Fixture {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = dir.path().to_path_buf();
        let work = root.join("work");
        let server_git = root.join("server.git");
        std::fs::create_dir_all(&work).unwrap();
        std::fs::create_dir_all(&server_git).unwrap();
        let fixture = Fixture {
            _dir: dir,
            root: root.clone(),
            work,
            server_git,
            ssh_log: root.join("ssh.log"),
        };
        fixture.write_fake_ssh();
        fixture
    }

    /// A shell script that stands in for `ssh`. git-lfs hands it
    /// `[options] <host> <command string>`; it dispatches only the LFS verb and
    /// **logs and refuses everything else**, so the log is a record of what the
    /// client actually tried rather than of what it was expected to try.
    fn write_fake_ssh(&self) {
        let script = self.root.join("fake-ssh.sh");
        std::fs::write(
            &script,
            r#"#!/bin/sh
last=""
for a in "$@"; do last="$a"; done
case "$last" in
  *git-lfs-transfer*)
    op=${last##* }
    echo "dispatch $op" >> "$FAKESSH_LOG"
    exec "$GUNNAR_LFS_SERVE" "$GUNNAR_LFS_GITDIR" "$op" 2>> "$FAKESSH_LOG"
    ;;
  *)
    echo "refused $last" >> "$FAKESSH_LOG"
    exit 66
    ;;
esac
"#,
        )
        .expect("write fake ssh");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
    }

    fn git(&self, args: &[&str]) -> std::process::Output {
        let out = Command::new("git")
            .args(args)
            .current_dir(&self.work)
            .env("FAKESSH_LOG", &self.ssh_log)
            .env("GUNNAR_LFS_SERVE", env!("CARGO_BIN_EXE_gunnar-lfs-serve"))
            .env("GUNNAR_LFS_GITDIR", &self.server_git)
            .env("GIT_SSH_COMMAND", self.root.join("fake-ssh.sh"))
            .env("GIT_TERMINAL_PROMPT", "0")
            .output()
            .unwrap_or_else(|err| panic!("git {args:?}: {err}"));
        out
    }

    fn git_ok(&self, args: &[&str]) -> String {
        let out = self.git(args);
        assert!(
            out.status.success(),
            "git {args:?} failed ({})\nstdout: {}\nstderr: {}\nssh log:\n{}",
            out.status,
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr),
            self.ssh_log(),
        );
        String::from_utf8_lossy(&out.stdout).to_string()
    }

    fn ssh_log(&self) -> String {
        std::fs::read_to_string(&self.ssh_log).unwrap_or_default()
    }

    /// Where gunnar filed an object, per `fs_store`'s fan-out.
    fn server_object(&self, oid: &str) -> PathBuf {
        self.server_git
            .join("lfs/objects")
            .join(&oid[0..2])
            .join(&oid[2..4])
            .join(oid)
    }

    /// A repository with two LFS-tracked files, committed. Returns
    /// `(oid, path)` for each, in `git lfs ls-files` order.
    fn init_repo(&self) -> Vec<(String, PathBuf)> {
        self.git_ok(&["init", "-q", "-b", "main", "."]);
        self.git_ok(&["config", "user.email", "interop@example.invalid"]);
        self.git_ok(&["config", "user.name", "interop"]);
        self.git_ok(&["lfs", "install", "--local"]);
        self.git_ok(&["lfs", "track", "*.bin"]);

        // Deterministic, high-entropy-ish, and generated rather than read from
        // /dev/urandom so a failure is reproducible.
        let mut big = Vec::with_capacity(BIG_BYTES);
        let mut state: u64 = 0x9e3779b97f4a7c15;
        while big.len() < BIG_BYTES {
            state = state
                .wrapping_mul(6364136223846793005)
                .wrapping_add(1442695040888963407);
            big.extend_from_slice(&state.to_le_bytes());
        }
        big.truncate(BIG_BYTES);
        std::fs::write(self.work.join("big.bin"), &big).unwrap();
        std::fs::write(self.work.join("small.bin"), b"one frame is enough\n").unwrap();

        self.git_ok(&["add", ".gitattributes", "big.bin", "small.bin"]);
        self.git_ok(&["commit", "-qm", "lfs fixture"]);
        self.git_ok(&[
            "remote",
            "add",
            "origin",
            "ssh://git@example.invalid/repo.git",
        ]);
        // Without this git-lfs tries to set up an OpenSSH ControlMaster against
        // the wrapper, which is not OpenSSH.
        self.git_ok(&["config", "lfs.ssh.automultiplex", "false"]);
        // **Time, not behaviour.** `tq/ssh.go` wraps *every* non-2xx from
        // `get-object` in `errors.NewRetriableError` — there is no status a
        // server can send that escapes it — so an object the server genuinely
        // does not have walks the whole backoff ladder before the failure is
        // reported. MEASURED against git-lfs 3.7.1 at the default
        // `lfs.transfer.maxretries` of 8: waits of 0.25, 0.5, 1, 2, 4, 8, 10 and
        // 10 seconds, **35.75 s** wall clock, all inside one SSH session (the
        // retries re-batch, so N absent objects cost that once between them, not
        // once each). One retry is enough to exercise the same code path and the
        // same reported outcome, and 1 is the floor git-lfs accepts
        // (`tq/manifest.go`: `if m.maxRetries < 1 { m.maxRetries = 8 }`).
        self.git_ok(&["config", "lfs.transfer.maxretries", "1"]);

        let listing = self.git_ok(&["lfs", "ls-files", "-l"]);
        let parsed: Vec<(String, PathBuf)> = listing
            .lines()
            .filter_map(|line| {
                let (oid, rest) = line.split_once(' ')?;
                let name = rest.split_once(' ').map(|(_, n)| n).unwrap_or(rest);
                Some((oid.to_string(), self.work.join(name.trim())))
            })
            .collect();
        assert_eq!(
            parsed.len(),
            2,
            "git lfs ls-files -l did not list two objects:\n{listing}"
        );
        parsed
    }
}

/// git-lfs must be here. See the module docs for why this is not a skip.
fn assert_git_lfs_is_installed() -> String {
    let out = Command::new("git")
        .args(["lfs", "version"])
        .output()
        .unwrap_or_else(|err| {
            panic!(
                "this test proves interop against the real git-lfs client and \
                 `git lfs version` could not be run: {err}. Install git-lfs. \
                 It is not skipped, because a skipped interop test reports a \
                 green it did not earn."
            )
        });
    assert!(
        out.status.success(),
        "`git lfs version` failed ({}). Install git-lfs; this test is not skipped.",
        out.status
    );
    let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
    assert!(
        version.starts_with("git-lfs/"),
        "`git lfs version` printed {version:?}, which is not a git-lfs banner"
    );
    version
}

/// The client really took the pure-SSH path, rather than falling back.
fn assert_transfer_was_execed(fixture: &Fixture, operation: &str) {
    let log = fixture.ssh_log();
    assert!(
        log.contains(&format!("dispatch {operation}")),
        "git-lfs never execed `git-lfs-transfer … {operation}`. It did not take \
         the pure-SSH path, so nothing here was proved. ssh log:\n{log}"
    );
}

fn sha256_hex(bytes: &[u8]) -> String {
    // Through the same hasher the server uses, reached the same way. There is
    // no second answer to "which sha256" in this tree.
    let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha256);
    hasher.update(bytes);
    match hasher.try_finalize().expect("sha256 cannot fail") {
        gix_hash::ObjectId::Sha256(digest) => digest.iter().map(|b| format!("{b:02x}")).collect(),
        other => unreachable!("{other:?}"),
    }
}

#[test]
fn the_real_git_lfs_client_pushes_and_fetches_over_pure_ssh() {
    let version = assert_git_lfs_is_installed();
    eprintln!("interop against {version}");

    let fixture = Fixture::new();
    let objects = fixture.init_repo();

    // ── upload ─────────────────────────────────────────────────────────────
    let mut push = vec!["lfs", "push", "--object-id", "origin"];
    for (oid, _) in &objects {
        push.push(oid);
    }
    fixture.git_ok(&push);
    assert_transfer_was_execed(&fixture, "upload");

    // APPLIED OUTPUT: the bytes on gunnar's disk, not "the command exited 0".
    for (oid, source) in &objects {
        let stored = fixture.server_object(oid);
        let on_disk = std::fs::read(&stored).unwrap_or_else(|err| {
            panic!(
                "git-lfs reported success but {} is not on the server: {err}\nssh log:\n{}",
                stored.display(),
                fixture.ssh_log()
            )
        });
        let sent = std::fs::read(source).unwrap();
        assert_eq!(
            on_disk.len(),
            sent.len(),
            "{} is {} bytes on the server and {} bytes locally",
            oid,
            on_disk.len(),
            sent.len()
        );
        assert!(
            on_disk == sent,
            "{oid} is not byte-identical on the server; something on the path \
             transformed it (compression, line endings, framing)"
        );
        // Content-addressing actually holds: the file's own hash is its name.
        assert_eq!(
            &sha256_hex(&on_disk),
            oid,
            "{oid} is filed under a name its bytes do not hash to"
        );
    }
    assert!(
        objects
            .iter()
            .any(|(_, p)| std::fs::metadata(p).unwrap().len() as usize == BIG_BYTES),
        "the multi-frame object is not in the fixture, so only single-frame \
         framing was exercised"
    );

    // The red half of "everything the client named is here": something it did
    // NOT name must be absent, or the loop above would pass against a server
    // that reported every path as present.
    let never_pushed = sha256_hex(b"this object was never sent to the server");
    assert!(
        std::fs::metadata(fixture.server_object(&never_pushed)).is_err(),
        "an object that was never uploaded is on the server"
    );

    // ── download ───────────────────────────────────────────────────────────
    std::fs::remove_dir_all(fixture.work.join(".git/lfs/objects")).expect("drop the local cache");
    fixture.git_ok(&["lfs", "fetch", "--all", "origin"]);
    assert_transfer_was_execed(&fixture, "download");

    for (oid, source) in &objects {
        let cached = fixture
            .work
            .join(".git/lfs/objects")
            .join(&oid[0..2])
            .join(&oid[2..4])
            .join(oid);
        let back = std::fs::read(&cached).unwrap_or_else(|err| {
            panic!(
                "git lfs fetch reported success but {} was not restored: {err}\nssh log:\n{}",
                cached.display(),
                fixture.ssh_log()
            )
        });
        assert!(
            back == std::fs::read(source).unwrap(),
            "{oid} came back from gunnar different from what went up"
        );
    }
}

/// The guard's own red. A harness in which `git lfs fetch` "succeeds" having
/// moved nothing looks *exactly* like a working one, so this proves the harness
/// can report a failure at all: the same fetch, against a server that has never
/// been given the objects, must not leave them in the local cache.
#[test]
fn a_fetch_from_an_empty_server_restores_nothing() {
    assert_git_lfs_is_installed();
    let fixture = Fixture::new();
    let objects = fixture.init_repo();
    std::fs::remove_dir_all(fixture.work.join(".git/lfs/objects")).expect("drop the local cache");

    // Not `git_ok`: git-lfs is entitled to report this however it likes. What
    // is asserted is the applied output: the cache stays empty.
    let out = fixture.git(&["lfs", "fetch", "--all", "origin"]);
    assert_transfer_was_execed(&fixture, "download");

    for (oid, _) in &objects {
        let cached = fixture
            .work
            .join(".git/lfs/objects")
            .join(&oid[0..2])
            .join(&oid[2..4])
            .join(oid);
        assert!(
            std::fs::metadata(&cached).is_err(),
            "an empty server produced {}, so the interop assertions are not \
             looking at anything.\nstdout: {}\nstderr: {}",
            cached.display(),
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr),
        );
    }
    // The server's own count of what really left it, from the summary
    // `gunnar-lfs-serve` prints on stderr. Zero objects, zero bytes.
    assert!(
        fixture.ssh_log().contains("sent=0/0B"),
        "the session should have sent nothing; ssh log:\n{}",
        fixture.ssh_log()
    );
}

/// **A fetch of an object the server does not hold must FAIL, and must still
/// deliver the objects the server does hold.**
///
/// That sentence is not gunnar's invention. It is git-lfs's own definition of
/// correct, from its test suite, `t/t-fetch.sh`, "fetch with missing object":
///
/// ```text
/// # should return non-zero, but should also download all the other valid files too
/// set +e
/// git lfs fetch origin main newbranch
/// fetch_exit=$?
/// set -e
/// [ "$fetch_exit" != "0" ]
/// assert_local_object "$contents_oid" 1
/// refute_local_object "$b_oid"
/// ```
///
/// Upstream runs that over HTTP only, because over HTTP the batch response can
/// carry a per-object `{"error":{"code":404}}` while the batch itself stays 200
/// (`docs/api/batch.md`). The pure-SSH grammar has no such production — see the
/// comment on [`crate::session`]'s batch handler — so the same outcome has to be
/// reached the only other way the protocol offers: hand back the `download`
/// action and answer the `get-object` with a 404.
///
/// Both halves are asserted, and each is the other's red:
///
/// * without the failure half, a server that answered `noop` for the absent
///   object would pass — that is exactly what gunnar did until 2026-08-05, and
///   `git lfs fetch` exited **0** having fetched nothing;
/// * without the delivery half, refusing the whole batch would pass — and one
///   absent object would cost a client every other object in the fetch.
#[test]
fn a_fetch_of_an_absent_object_fails_and_still_delivers_the_present_one() {
    assert_git_lfs_is_installed();
    let fixture = Fixture::new();
    let objects = fixture.init_repo();
    let (present_oid, present_source) = objects[0].clone();
    let (absent_oid, _) = objects[1].clone();
    assert_ne!(present_oid, absent_oid);

    // Exactly one of the two goes up. The other is a real oid the client will
    // ask for and the server genuinely does not have.
    fixture.git_ok(&["lfs", "push", "--object-id", "origin", &present_oid]);
    assert_transfer_was_execed(&fixture, "upload");
    assert!(
        std::fs::metadata(fixture.server_object(&present_oid)).is_ok(),
        "the fixture never got the one object onto the server, so nothing below \
         is being measured"
    );
    assert!(
        std::fs::metadata(fixture.server_object(&absent_oid)).is_err(),
        "the object this test needs to be absent is on the server"
    );

    std::fs::remove_dir_all(fixture.work.join(".git/lfs/objects")).expect("drop the local cache");
    let out = fixture.git(&["lfs", "fetch", "--all", "origin"]);
    assert_transfer_was_execed(&fixture, "download");
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();

    let cached = |oid: &str| {
        fixture
            .work
            .join(".git/lfs/objects")
            .join(&oid[0..2])
            .join(&oid[2..4])
            .join(oid)
    };

    // Half one: the client was TOLD. Applied output — the process exit status a
    // script or a CI job would branch on, not a log line.
    assert!(
        !out.status.success(),
        "git lfs fetch exited 0 for an object the server does not hold. The user \
         has been told the fetch worked and it did not.\nstdout: {stdout}\n\
         stderr: {stderr}\nssh log:\n{}",
        fixture.ssh_log()
    );
    // …and it was told about the right object.
    assert!(
        stdout.contains(&absent_oid) || stderr.contains(&absent_oid),
        "the failure never names the object that is missing, so a user cannot \
         act on it.\nstdout: {stdout}\nstderr: {stderr}"
    );

    // Half two: the fetch still did its job for everything else.
    let back = std::fs::read(cached(&present_oid)).unwrap_or_else(|err| {
        panic!(
            "one absent object cost the fetch an object the server DID hold \
             ({present_oid}): {err}\nstdout: {stdout}\nstderr: {stderr}\n\
             ssh log:\n{}",
            fixture.ssh_log()
        )
    });
    assert!(
        back == std::fs::read(&present_source).unwrap(),
        "{present_oid} came back different from what went up"
    );
    assert!(
        std::fs::metadata(cached(&absent_oid)).is_err(),
        "the object the server does not hold was created locally anyway"
    );
}

/// A `download` connection must not be able to write. MEASURED 2026-08-03,
/// re-measured 2026-08-05, and the way it does not is **not** the way it looks
/// like it would.
///
/// # What was expected, what actually happens, and why the difference matters
///
/// The wrapper below forces every exec to a `download` session whatever the
/// client asked for, i.e. an SSH grant narrower than the operation the client
/// wants. The expectation was `put-object` → 405 → `git lfs push` fails. What
/// the real client does instead:
///
/// ```text
/// client → batch  transfer=ssh hash-algo=sha256 refname=refs/heads/main
///          <oid> 11
/// server → status 200 / <oid> 11 download    ← a DOWNLOAD session answering
/// client → quit                              ← and exits 0
/// ```
///
/// `put-object` is never reached, and no status code the server could pick would
/// change that. The client's queue is running in the **upload** direction; it
/// looks up `Rel("upload")` on what came back, finds no such action — the server
/// answered as the download server it was told to be — and
/// `tq/transfer_queue.go` skips a no-action object **silently**:
///
/// ```text
/// } else if a == nil && manifest.standaloneTransferAgent == "" {
///     q.Skip(o.Size)
///     q.wait.Done()
/// }
/// ```
///
/// so `git lfs push` **reports success having uploaded nothing**.
///
/// This is the same client-side hole as the one fixed for the download
/// direction on 2026-08-05, seen from the other side, and it is the half a
/// server **cannot** close. Over pure SSH the operation lives in the exec line,
/// not in the batch: `git-lfs-transfer <path> upload`. A session serving
/// `download` has no way to learn that the client wanted to upload, so it has
/// nothing to refuse. (The line above said `noop` until 2026-08-05, for a
/// different reason — the object was absent — and the outcome was identical.)
///
/// So the security property holds completely (zero bytes written, and that is
/// what is asserted here) while the *diagnostic* is bad. It is not exploitable
/// in gunnar: `gunnar_ssh::command::parse` demands Write before an `upload`
/// exec line is ever accepted, so a Read-only principal cannot reach an upload
/// session to be silently no-op'd. It is pinned here because the day someone
/// makes the SSH layer looser, this is the shape the failure will take, and a
/// green `git lfs push` is the worst possible way to find out.
///
/// The wire-level refusal (`put-object` on a `download` session → 405, nothing
/// committed) is proved in `tests/authorisation.rs`, which can send the command
/// the real client declines to send.
#[test]
fn a_download_grant_writes_nothing_though_git_lfs_calls_it_success() {
    assert_git_lfs_is_installed();
    let fixture = Fixture::new();
    let objects = fixture.init_repo();

    // Force the served operation to `download` whatever the client asked for,
    // and record BOTH in the log, the client's ask and what it actually got,
    // so neither half of the mismatch can be assumed.
    let script = fixture.root.join("fake-ssh.sh");
    let body = std::fs::read_to_string(&script).unwrap();
    std::fs::write(
        &script,
        body.replace(
            r#"exec "$GUNNAR_LFS_SERVE" "$GUNNAR_LFS_GITDIR" "$op""#,
            r#"echo "forced download" >> "$FAKESSH_LOG"; exec "$GUNNAR_LFS_SERVE" "$GUNNAR_LFS_GITDIR" download"#,
        ),
    )
    .unwrap();

    let mut push = vec!["lfs", "push", "--object-id", "origin"];
    for (oid, _) in &objects {
        push.push(oid);
    }
    let out = fixture.git(&push);
    // The client asked for `upload` over the pure-SSH path…
    assert_transfer_was_execed(&fixture, "upload");
    // …and was served a `download` session instead.
    assert!(
        fixture.ssh_log().contains("forced download"),
        "the wrapper did not force the narrower operation; ssh log:\n{}",
        fixture.ssh_log()
    );

    // APPLIED OUTPUT, the only thing that matters, and the thing the exit
    // status is not telling the truth about.
    for (oid, _) in &objects {
        assert!(
            std::fs::metadata(fixture.server_object(oid)).is_err(),
            "{oid} was written by a download-only session.\nstdout: {}\nstderr: {}",
            String::from_utf8_lossy(&out.stdout),
            String::from_utf8_lossy(&out.stderr),
        );
    }
    // And the server's own count agrees: nothing arrived, nothing left.
    let log = fixture.ssh_log();
    assert!(
        log.contains("received=0/0B") && log.contains("sent=0/0B"),
        "the session should have moved nothing in either direction; ssh log:\n{log}"
    );
    // The pinned surprise. If a future git-lfs starts failing here, or gunnar
    // starts refusing the batch outright, this line goes red and the doc comment
    // above is the thing to revisit, rather than the finding being lost.
    assert!(
        out.status.success(),
        "git-lfs used to report this silent no-op push as a SUCCESS and now does \
         not. That is an improvement, but the comment on this test is now stale.\n\
         stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
}