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

//! What the session does when the **store itself** is wrong.
//!
//! Every other test in this crate holds the store honest and pushes hostile
//! input at the wire. This one inverts it: the wire is well-formed and the
//! backend lies. That matters because two of the session's answers are computed
//! entirely from what the store said — `verify-object` compares the client's
//! `size=` against [`LfsStore::size_of`], and `get-object` writes the store's
//! declared size into the `size=` argument *before* a byte of the object moves.
//! Neither has any other source of truth.
//!
//! A store that lies is not a hypothetical: [`FsLfsStore`](gunnar_lfs::FsLfsStore)
//! answers `size_of` from `stat`, and a file can be truncated by a full disk,
//! by a crash between `write` and `rename`, or by anything else with the path.

use std::io::{BufRead, Read};

use gunnar_lfs::{Error, LfsObject, LfsStore, Oid, Operation, PendingWrite, Result, Session};

const PAYLOAD: &[u8] = b"twenty-nine bytes of object.\n";

/// A store holding exactly one object, which reports whatever size it is told
/// to report rather than the one it has.
struct LyingStore {
    oid: Oid,
    bytes: Vec<u8>,
    /// What `size_of` and `open` will claim. `None` means "tell the truth".
    claimed: Option<u64>,
}

impl LyingStore {
    fn new(claimed: Option<u64>) -> LyingStore {
        let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha256);
        hasher.update(PAYLOAD);
        let oid = match hasher.try_finalize().unwrap() {
            gix_hash::ObjectId::Sha256(d) => Oid::from_digest(d),
            other => unreachable!("{other:?}"),
        };
        LyingStore {
            oid,
            bytes: PAYLOAD.to_vec(),
            claimed,
        }
    }

    fn reported(&self) -> u64 {
        self.claimed.unwrap_or(self.bytes.len() as u64)
    }
}

impl LfsStore for LyingStore {
    fn size_of(&self, oid: &Oid) -> Result<Option<u64>> {
        Ok((*oid == self.oid).then(|| self.reported()))
    }

    fn open(&self, oid: &Oid) -> Result<Option<LfsObject>> {
        if *oid != self.oid {
            return Ok(None);
        }
        // The declared size is the lie; the reader still hands over the real
        // bytes. That is exactly the shape of a truncated file on disk.
        Ok(Some(LfsObject {
            size: self.reported(),
            reader: Box::new(std::io::Cursor::new(self.bytes.clone())),
        }))
    }

    fn begin_write(&self, _oid: &Oid, _declared: u64) -> Result<Box<dyn PendingWrite + '_>> {
        Err(Error::Store("this store is read-only".to_string()))
    }
}

fn frame(lines: &[&str]) -> Vec<u8> {
    let mut out = Vec::new();
    for line in lines {
        if line.is_empty() {
            gunnar_pktline::write_flush(&mut out).unwrap();
        } else {
            gunnar_pktline::write_text(&mut out, line.as_bytes()).unwrap();
        }
    }
    out
}

fn serve(script: Vec<u8>, store: &LyingStore, operation: Operation) -> String {
    let mut input: Box<dyn BufRead> =
        Box::new(std::io::BufReader::new(std::io::Cursor::new(script)));
    let mut output = Vec::new();
    Session::new(store, operation)
        .serve(&mut *input, &mut output)
        .expect("a well-framed conversation must not fail the session");
    String::from_utf8_lossy(&output).to_string()
}

fn statuses(wire: &str) -> Vec<u16> {
    wire.split("status ")
        .skip(1)
        .filter_map(|rest| rest.split('\n').next()?.trim().parse().ok())
        .collect()
}

/// **`verify-object` must not take the store's word for it when the store is
/// wrong.** The client says 29 bytes, the store says 4, and the answer must be
/// 422 rather than 200 — this is the last check before a client considers an
/// upload durable and stops holding the only other copy.
#[test]
fn verify_object_refuses_when_the_store_reports_a_different_size() {
    let store = LyingStore::new(Some(4));
    let wire = serve(
        frame(&[
            "version 1",
            "",
            &format!("verify-object {}", store.oid),
            &format!("size={}", PAYLOAD.len()),
            "",
            "quit",
            "",
        ]),
        &store,
        Operation::Upload,
    );
    assert_eq!(
        statuses(&wire),
        vec![200, 422, 200],
        "a store reporting the wrong size must fail verification.\nwire: {wire}"
    );
    assert!(
        wire.contains("is 4 bytes on this server, not 29"),
        "the diagnostic must name BOTH numbers, or an operator cannot tell which \
         side is wrong: {wire}"
    );
}

/// The identity-value half, and the reason the test above is not vacuous: the
/// SAME conversation against a store telling the truth must answer 200. Without
/// this, a `verify-object` that refused everything would pass above.
#[test]
fn verify_object_accepts_when_the_store_is_honest() {
    let store = LyingStore::new(None);
    let wire = serve(
        frame(&[
            "version 1",
            "",
            &format!("verify-object {}", store.oid),
            &format!("size={}", PAYLOAD.len()),
            "",
            "quit",
            "",
        ]),
        &store,
        Operation::Upload,
    );
    assert_eq!(statuses(&wire), vec![200, 200, 200], "wire: {wire}");
}

/// A store that reports a size **larger** than it holds is the truncation case,
/// and it is the one the session cannot fix — `size=` has already gone out.
///
/// What is asserted is therefore not a refusal but the applied output: the
/// server sends the bytes it actually has, the summary counts what actually
/// moved rather than what was promised, and the two disagree. That disagreement
/// is the whole signal, and `get_object` logs it at `error`. Pinned here so that
/// if anyone ever makes `bytes_sent` echo the declared size instead, this test
/// says so — a counter that reports the promise rather than the delivery is a
/// counter that can never report a fault.
#[test]
fn a_short_read_is_counted_as_what_moved_not_as_what_was_promised() {
    let store = LyingStore::new(Some(4096));
    let mut input = std::io::BufReader::new(std::io::Cursor::new(frame(&[
        "version 1",
        "",
        &format!("get-object {}", store.oid),
        "",
        "quit",
        "",
    ])));
    let mut output = Vec::new();
    let summary = Session::new(&store, Operation::Download)
        .serve(&mut input, &mut output)
        .expect("the session survives a lying store");

    let wire = String::from_utf8_lossy(&output).to_string();
    assert!(
        wire.contains("size=4096"),
        "the declared size goes out before any byte does: {wire}"
    );
    assert_eq!(
        summary.bytes_sent,
        PAYLOAD.len() as u64,
        "the summary must count the bytes that really left, not the 4096 the \
         store claimed"
    );
    assert_ne!(
        summary.bytes_sent, 4096,
        "if these are equal the counter is echoing the promise"
    );
    assert_eq!(summary.objects_sent, 1);
    assert!(summary.clean_quit);
}

/// `batch` answers from `size_of` too, and it must echo the size the CLIENT
/// named — not the store's. git-lfs matches the response line against what it
/// sent; a server that silently rewrote the size would make the client's own
/// bookkeeping disagree with its pointer file.
#[test]
fn batch_echoes_the_clients_size_not_the_stores() {
    let store = LyingStore::new(Some(999));
    // Framed by hand rather than through `frame`: a `batch` carries a delim-pkt
    // between its arguments and its object lines, which the line helper cannot
    // express.
    let mut script = Vec::new();
    gunnar_pktline::write_text(&mut script, b"version 1").unwrap();
    gunnar_pktline::write_flush(&mut script).unwrap();
    gunnar_pktline::write_text(&mut script, b"batch").unwrap();
    gunnar_pktline::write_text(&mut script, b"hash-algo=sha256").unwrap();
    gunnar_pktline::write_delim(&mut script).unwrap();
    gunnar_pktline::write_text(&mut script, format!("{} 29", store.oid).as_bytes()).unwrap();
    gunnar_pktline::write_flush(&mut script).unwrap();
    gunnar_pktline::write_text(&mut script, b"quit").unwrap();
    gunnar_pktline::write_flush(&mut script).unwrap();

    let mut input = std::io::BufReader::new(std::io::Cursor::new(script));
    let mut output = Vec::new();
    Session::new(&store, Operation::Download)
        .serve(&mut input, &mut output)
        .unwrap();
    let wire = String::from_utf8_lossy(&output).to_string();
    assert!(
        wire.contains(&format!("{} 29 download", store.oid)),
        "batch must echo the size the client sent: {wire}"
    );
    assert!(
        !wire.contains("999"),
        "the store's own idea of the size must not appear on the wire: {wire}"
    );
}

/// A reader that stops early, which is what a file truncated *after* `stat` does.
struct ShortReader(usize);

impl Read for ShortReader {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let n = buf.len().min(self.0);
        self.0 -= n;
        buf[..n].fill(b'x');
        Ok(n)
    }
}

/// The same fault arriving through the reader rather than through `size_of`:
/// `stat` was right when it was called and the file shrank afterwards. The
/// session must still finish and still count only what moved.
#[test]
fn a_reader_that_ends_early_does_not_hang_or_over_count() {
    struct Racy;
    impl LfsStore for Racy {
        fn size_of(&self, _: &Oid) -> Result<Option<u64>> {
            Ok(Some(1_000_000))
        }
        fn open(&self, _: &Oid) -> Result<Option<LfsObject>> {
            Ok(Some(LfsObject {
                size: 1_000_000,
                reader: Box::new(ShortReader(10)),
            }))
        }
        fn begin_write(&self, _: &Oid, _: u64) -> Result<Box<dyn PendingWrite + '_>> {
            Err(Error::Store("read-only".into()))
        }
    }
    let oid = Oid::parse(&"a".repeat(64)).unwrap();
    let mut input = std::io::BufReader::new(std::io::Cursor::new(frame(&[
        "version 1",
        "",
        &format!("get-object {oid}"),
        "",
        "quit",
        "",
    ])));
    let mut output = Vec::new();
    let summary = Session::new(&Racy, Operation::Download)
        .serve(&mut input, &mut output)
        .expect("a short reader must not fail the session");
    assert_eq!(summary.bytes_sent, 10, "only the bytes that existed");
    assert!(summary.clean_quit, "the session must reach `quit`");
}