crates/gunnar-lfs/tests/wrong_terminator.rs
//! A command that ended with the wrong packet is a **400**, not an EOF.
//!
//! `Error::Framing` means *the pkt-line boundary is lost and there is no way to
//! find the next command*, and it is fatal. Four sites raised it where that was
//! not true: `read_args` had already consumed the section up to and including
//! its terminator and handed the caller which one arrived, so the reader knew
//! exactly where it stood. The client got a closed channel where the protocol
//! defines a status code — the same shape as the quota refusal that `refuse!`'s
//! drain error replaced.
//!
//! Every test here asserts **two** things, because either alone is blind:
//!
//! * the 400 is on the wire, and
//! * the session survives to answer the `quit` that follows it.
//!
//! A session that answered 400 and then died would pass the first. A session
//! that silently ignored the malformed terminator would pass the second.
use std::io::BufRead;
use gunnar_lfs::{DiscardingLfsStore, MemLfsStore, Oid, Operation, Session};
fn oid() -> Oid {
Oid::parse(&"1".repeat(64)).unwrap()
}
fn statuses(wire: &[u8]) -> Vec<u16> {
String::from_utf8_lossy(wire)
.split("status ")
.skip(1)
.filter_map(|rest| rest.split('\n').next()?.trim().parse().ok())
.collect()
}
/// `version 1` terminated properly, so the malformed command under test is not
/// also the un-negotiated one.
fn negotiated(script: &mut Vec<u8>) {
gunnar_pktline::write_text(script, b"version 1").unwrap();
gunnar_pktline::write_flush(script).unwrap();
}
fn quit(script: &mut Vec<u8>) {
gunnar_pktline::write_text(script, b"quit").unwrap();
gunnar_pktline::write_flush(script).unwrap();
}
/// Drive one conversation to completion against a store that accepts nothing.
fn serve(script: Vec<u8>, operation: Operation) -> (Vec<u16>, gunnar_lfs::Summary) {
let store = DiscardingLfsStore::new();
let mut input = std::io::BufReader::new(std::io::Cursor::new(script));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
let summary = Session::new(&store, operation)
.serve(reader, &mut output)
.expect("the stream is in sync throughout; the session must not end");
(statuses(&output), summary)
}
/// `version` ended with a delim-pkt. A delim promises a further section, so the
/// section is read out and the stream stays in sync.
#[test]
fn version_ending_in_a_delim_is_answered_and_the_session_continues() {
let mut s = Vec::new();
gunnar_pktline::write_text(&mut s, b"version 1").unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"a stray line").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
negotiated(&mut s);
quit(&mut s);
let (statuses, summary) = serve(s, Operation::Download);
assert_eq!(
statuses,
vec![400, 200, 200],
"the malformed version must be answered 400, then the well-formed one 200, then quit"
);
assert!(
summary.clean_quit,
"the session died instead of surviving to read `quit`"
);
}
/// `batch` ended with a flush-pkt where a delim belongs. Nothing follows a
/// flush, so there is nothing to drain and the next packet is the next command.
#[test]
fn batch_ending_in_a_flush_is_answered_and_the_session_continues() {
let mut s = Vec::new();
negotiated(&mut s);
gunnar_pktline::write_text(&mut s, b"batch").unwrap();
gunnar_pktline::write_text(&mut s, b"hash-algo=sha256").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
quit(&mut s);
let (statuses, summary) = serve(s, Operation::Upload);
assert_eq!(statuses, vec![200, 400, 200]);
assert!(summary.clean_quit);
assert_eq!(summary.batches, 0, "no batch was actually served");
}
/// `get-object`, on a session that could otherwise have served it.
#[test]
fn get_object_ending_in_a_delim_is_answered_and_the_object_still_arrives_next() {
let store = MemLfsStore::new();
let body = b"the object body".to_vec();
// The store computes the oid from the bytes, so this is the real one and
// not a placeholder that would make `get-object` a 404 either way.
let present = store.insert(&body);
let mut s = Vec::new();
negotiated(&mut s);
// Malformed: delim where a flush belongs, plus the section it promised.
gunnar_pktline::write_text(&mut s, format!("get-object {present}").as_bytes()).unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"stray").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
// Well-formed, immediately after. This is the assertion that matters: the
// reader is still on a command boundary.
gunnar_pktline::write_text(&mut s, format!("get-object {present}").as_bytes()).unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
quit(&mut s);
let mut input = std::io::BufReader::new(std::io::Cursor::new(s));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
let summary = Session::new(&store, Operation::Download)
.serve(reader, &mut output)
.expect("the stream stays in sync");
assert_eq!(
statuses(&output),
vec![200, 400, 200, 200],
"version, the malformed get-object, the good get-object, quit"
);
assert_eq!(
summary.objects_sent, 1,
"the object after the malformed command did not come out"
);
assert_eq!(summary.bytes_sent, body.len() as u64);
assert!(summary.clean_quit);
assert!(
output.windows(body.len()).any(|w| w == body),
"the object body is not on the wire at all"
);
}
/// `verify-object`, the fourth site.
#[test]
fn verify_object_ending_in_a_delim_is_answered_and_the_session_continues() {
let mut s = Vec::new();
negotiated(&mut s);
gunnar_pktline::write_text(&mut s, format!("verify-object {}", oid()).as_bytes()).unwrap();
gunnar_pktline::write_delim(&mut s).unwrap();
gunnar_pktline::write_text(&mut s, b"size=1").unwrap();
gunnar_pktline::write_flush(&mut s).unwrap();
quit(&mut s);
let (statuses, summary) = serve(s, Operation::Upload);
assert_eq!(statuses, vec![200, 400, 200]);
assert!(summary.clean_quit);
}
/// **The control.** A genuinely lost boundary must still be fatal, or all that
/// has been shown is that `Framing` was replaced everywhere rather than at the
/// four places where the stream was in sync.
#[test]
fn a_response_end_packet_where_a_command_belongs_is_still_fatal() {
let store = DiscardingLfsStore::new();
let mut s = Vec::new();
negotiated(&mut s);
gunnar_pktline::write_response_end(&mut s).unwrap();
quit(&mut s);
let mut input = std::io::BufReader::new(std::io::Cursor::new(s));
let mut output = Vec::new();
let reader: &mut dyn BufRead = &mut input;
let err = Session::new(&store, Operation::Download)
.serve(reader, &mut output)
.expect_err("a response-end-pkt is not something this protocol can resynchronise from");
assert!(
matches!(err, gunnar_lfs::Error::Framing(_)),
"the control failed for the wrong reason; got {err:?}"
);
}