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/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()
    }
}