crates/gunnar-lfs/src/bin/serve.rs
//! `gunnar-lfs-serve <git-dir> <upload|download>` — one `git-lfs-transfer`
//! session on stdin/stdout, against [`FsLfsStore`] under `<git-dir>/lfs/`.
//!
//! This is the *same* two calls `gunnar-cli`'s SSH transport makes
//! (`crates/gunnar-cli/src/server/transport.rs`, the `GitService::LfsTransfer`
//! arm): `FsLfsStore::open_or_init` then `Session::new(&store, op).serve(…)`.
//! It exists because the real `git-lfs` client reaches a server by *exec'ing a
//! command over SSH*, so proving interop needs something a fake `ssh` wrapper
//! can exec. It is deliberately thin — no second protocol implementation, no
//! second authorisation decision (one implementation). The operation is taken from argv
//! exactly as `gunnar_ssh::command::parse` takes it from the exec line.
//!
//! # Recording
//!
//! With `GUNNAR_LFS_TRANSCRIPT_DIR` set, every byte in each direction is
//! copied to `<pid>.<operation>.client-to-server.bin` and
//! `<pid>.<operation>.server-to-client.bin` in that directory, *as it passes*.
//! That is what turns a live `git-lfs` run into a byte-exact fixture the replay
//! test can hold to forever, on a machine that may not have `git-lfs` on it.
//!
//! **The pid is in the name because it has to be.** `git lfs push --all` opens
//! *two* transfer sessions — MEASURED 2026-08-03: one that moves the objects
//! and one that sends `version` and hangs up — and with fixed names the second
//! silently truncated the first's recording. A fixture captured that way is a
//! fixture of the wrong conversation.
//!
//! The tee is O(one pkt-line) in memory like everything else on this path: it
//! writes each slice straight through, it never accumulates.
use std::fs::File;
use std::io::{self, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use gunnar_lfs::{FsLfsStore, Operation, Session};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let (git_dir, operation) = match args.as_slice() {
[git_dir, operation] => (PathBuf::from(git_dir), operation.as_str()),
_ => {
eprintln!("usage: gunnar-lfs-serve <git-dir> <upload|download>");
return ExitCode::from(64);
}
};
let Some(operation) = Operation::parse(operation) else {
eprintln!("gunnar-lfs-serve: `{operation}` is not an lfs operation");
return ExitCode::from(64);
};
let store = match FsLfsStore::open_or_init(&git_dir) {
Ok(store) => store,
Err(err) => {
eprintln!("gunnar-lfs-serve: {err}");
return ExitCode::from(69);
}
};
let transcript = std::env::var_os("GUNNAR_LFS_TRANSCRIPT_DIR").map(PathBuf::from);
let stem = format!("{}.{operation}", std::process::id());
let tee_in = transcript.as_ref().map(|dir| {
File::create(dir.join(format!("{stem}.client-to-server.bin"))).expect("transcript")
});
let tee_out = transcript.as_ref().map(|dir| {
File::create(dir.join(format!("{stem}.server-to-client.bin"))).expect("transcript")
});
let mut input = BufReader::new(TeeRead {
inner: io::stdin(),
tee: tee_in,
});
let mut output = TeeWrite {
inner: io::stdout(),
tee: tee_out,
};
match Session::new(&store, operation).serve(&mut input, &mut output) {
Ok(summary) => {
let _ = output.flush();
// Applied output on stderr, where it cannot corrupt the protocol
// stream: what really moved, not what was asked for.
eprintln!(
"gunnar-lfs-serve: commands={} batches={} batch_objects={} \
sent={}/{}B received={}/{}B clean_quit={}",
summary.commands,
summary.batches,
summary.batch_objects,
summary.objects_sent,
summary.bytes_sent,
summary.objects_received,
summary.bytes_received,
summary.clean_quit,
);
ExitCode::SUCCESS
}
Err(err) => {
let _ = output.flush();
eprintln!("gunnar-lfs-serve: session failed: {err}");
ExitCode::from(70)
}
}
}
struct TeeRead<R: Read> {
inner: R,
tee: Option<File>,
}
impl<R: Read> Read for TeeRead<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
if let Some(tee) = self.tee.as_mut() {
tee.write_all(&buf[..n])?;
}
Ok(n)
}
}
struct TeeWrite<W: Write> {
inner: W,
tee: Option<File>,
}
impl<W: Write> Write for TeeWrite<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
if let Some(tee) = self.tee.as_mut() {
tee.write_all(&buf[..n])?;
}
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
if let Some(tee) = self.tee.as_mut() {
tee.flush()?;
}
self.inner.flush()
}
}