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/transcript.rs

//! Byte-exact replay of what **git-lfs 3.7.1 really sent**, and byte-exact
//! comparison of what gunnar really answered.
//!
//! `tests/interop.rs` drives the live client and is the stronger proof, but it
//! needs `git-lfs` on the machine. This one needs nothing, and it is stricter in
//! the one dimension that matters for interop: it compares **the whole output
//! stream, byte for byte**, so a stray delim-pkt or a reordered `size=`
//! argument goes red here even though every semantic assertion would still pass.
//! Those are exactly the two differences `src/wire.rs` documents as the ones
//! that break the real client.
//!
//! See `tests/transcripts/README.md` for how the fixtures were captured.

use std::io::BufReader;

use gunnar_lfs::{MemLfsStore, Operation, Session};

fn transcript(name: &str) -> Vec<u8> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/transcripts")
        .join(name);
    std::fs::read(&path).unwrap_or_else(|err| panic!("{}: {err}", path.display()))
}

/// The two objects in every fixture, exactly as the client had them.
const SMALL: &[u8] = b"gunnar lfs\n";
const LARGER: &[u8] = b"the second object, a little longer so the two are plainly different\n";

/// Replay `<name>.client-to-server.bin` and hand back what the session wrote.
fn replay(name: &str, store: &MemLfsStore, operation: Operation) -> (Vec<u8>, gunnar_lfs::Summary) {
    let recorded = transcript(&format!("{name}.client-to-server.bin"));
    let mut input = BufReader::new(std::io::Cursor::new(recorded));
    let mut output = Vec::new();
    let summary = Session::new(store, operation)
        .serve(&mut input, &mut output)
        .expect("the recorded conversation is well-framed");
    (output, summary)
}

/// A diff that names the first differing byte, because "left != right" over 400
/// bytes of pkt-line is unreadable and the whole point of this test is that the
/// *bytes* are the contract.
fn assert_wire_eq(actual: &[u8], expected: &[u8], what: &str) {
    if actual == expected {
        return;
    }
    let at = actual
        .iter()
        .zip(expected)
        .position(|(a, b)| a != b)
        .unwrap_or_else(|| actual.len().min(expected.len()));
    let from = at.saturating_sub(24);
    panic!(
        "{what}: the wire differs from what git-lfs 3.7.1 was answered.\n\
         first difference at byte {at} (actual {} bytes, recorded {} bytes)\n\
         actual   …{}\n\
         recorded …{}",
        actual.len(),
        expected.len(),
        String::from_utf8_lossy(&actual[from..(at + 40).min(actual.len())]),
        String::from_utf8_lossy(&expected[from..(at + 40).min(expected.len())]),
    );
}

#[test]
fn the_recorded_upload_replays_byte_for_byte_and_the_objects_land() {
    let store = MemLfsStore::new();
    let (output, summary) = replay("upload", &store, Operation::Upload);

    assert_wire_eq(
        &output,
        &transcript("upload.server-to-client.bin"),
        "upload",
    );

    // Applied output, not a state round-trip: the bytes the client sent are in
    // the store, under the oids the client named them with.
    assert_eq!(store.len(), 2, "both objects committed");
    let small =
        gunnar_lfs::Oid::parse("e2e5ffeb6125407ed45479208193c5e438f1554f0da8cee77a0f10b1f99578c6")
            .unwrap();
    let larger =
        gunnar_lfs::Oid::parse("4e73c693de0152ab39b91a62461943231d70eaa78c84ac1e9f196f202c29c53b")
            .unwrap();
    assert_eq!(store.get(&small).as_deref(), Some(SMALL));
    assert_eq!(store.get(&larger).as_deref(), Some(LARGER));

    assert_eq!(summary.objects_received, 2);
    assert_eq!(summary.bytes_received, (SMALL.len() + LARGER.len()) as u64);
    assert_eq!(summary.batches, 1);
    assert_eq!(summary.batch_objects, 2);
    assert!(summary.clean_quit, "the client said `quit`");
}

#[test]
fn the_recorded_download_replays_byte_for_byte() {
    let store = MemLfsStore::new();
    // The server has to already hold what the client is about to ask for.
    store.insert(SMALL);
    store.insert(LARGER);

    let (output, summary) = replay("download", &store, Operation::Download);
    assert_wire_eq(
        &output,
        &transcript("download.server-to-client.bin"),
        "download",
    );
    assert_eq!(summary.objects_sent, 2);
    assert_eq!(summary.bytes_sent, (SMALL.len() + LARGER.len()) as u64);
    assert!(summary.clean_quit);
}

#[test]
fn the_recorded_lock_session_is_answered_501_and_not_405() {
    // This is the session `git lfs push` opens alongside the transfer. The
    // status code is the whole content of the fixture: 501 makes git-lfs
    // disable lock verification silently, any other 4xx makes it print
    // "Remote does not support the Git LFS locking API" on every push.
    let store = MemLfsStore::new();
    let (output, summary) = replay("lock", &store, Operation::Upload);
    assert_wire_eq(&output, &transcript("lock.server-to-client.bin"), "lock");
    assert!(
        String::from_utf8_lossy(&output).contains("status 501"),
        "the fixture must be the 501 answer, or this test guards nothing"
    );
    // The client hangs up without `quit` here, and that is not an error.
    assert!(!summary.clean_quit);
    assert_eq!(summary.commands, 2);
}

/// The identity-value half. Every assertion above compares two byte strings, and
/// all three would pass just as happily against a session that echoed its
/// fixture back. This one drives the *download* transcript through a store that
/// does not have the objects, and requires the wire to change.
#[test]
fn a_server_that_answers_differently_produces_different_bytes() {
    let empty = MemLfsStore::new();
    let (output, summary) = replay("download", &empty, Operation::Download);
    let recorded = transcript("download.server-to-client.bin");
    assert_ne!(
        output, recorded,
        "an empty server answered a fetch with the same bytes as a full one; \
         the comparison above is not looking at anything"
    );
    assert!(
        String::from_utf8_lossy(&output).contains("status 404"),
        "a missing object must be 404, got: {}",
        String::from_utf8_lossy(&output)
    );
    // And the batch must have said `download` for the object it does not have,
    // not `noop` — the pre-arrival half of the same decision, and the one that
    // decides whether the client ever *asks*.
    //
    // This assertion read `contains(" noop")` until 2026-08-05, pinning the
    // answer that made `git lfs fetch` exit 0 having fetched nothing: `noop`
    // leaves the transfer with no action, and `tq/transfer_queue.go` skips a
    // no-action object silently. `get-object`'s 404 above is unreachable from a
    // real client unless the batch hands back an action first, so the two
    // assertions are one guard. See `Session::batch`.
    let wire = String::from_utf8_lossy(&output);
    assert!(
        wire.contains(" download"),
        "the batch must offer `download` for an absent object, or the client \
         never asks and never learns it is absent; got: {wire}"
    );
    assert!(
        !wire.contains(" noop"),
        "a download batch answered `noop`, which git-lfs reads as \
         \"nothing to do\" and reports as success; got: {wire}"
    );
    assert_eq!(summary.objects_sent, 0);
    assert_eq!(summary.bytes_sent, 0);
}