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