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`");
}