crates/gunnar-lfs/tests/authorisation.rs
//! The grant boundary, driven at the wire.
//!
//! `tests/interop.rs` proves what the *real* client does; it turns out git-lfs
//! never sends `put-object` on a `download` session, because `batch` on that
//! session cannot offer it an `upload` action — the operation is in the SSH exec
//! line, so a download session answers as a download server — and the client
//! gives up politely, silently, and with exit 0. So the refusal itself, which is the
//! property that actually has to hold against a client that is not being polite
//! only thing worth proving, can only be proved by sending the command the real
//! client declines to send.
//! That is this file.
//!
//! Every assertion here is on **applied output**: the status code on the wire,
//! and whether the store gained an object. Not `Result::is_err`.
use std::io::BufReader;
use gunnar_lfs::{MemLfsStore, Oid, Operation, Session};
/// Frame a conversation the way a client would.
#[derive(Default)]
struct Wire(Vec<u8>);
impl Wire {
fn text(mut self, line: &str) -> Self {
gunnar_pktline::write_text(&mut self.0, line.as_bytes()).unwrap();
self
}
fn data(mut self, bytes: &[u8]) -> Self {
gunnar_pktline::write_data(&mut self.0, bytes).unwrap();
self
}
fn delim(mut self) -> Self {
gunnar_pktline::write_delim(&mut self.0).unwrap();
self
}
fn flush(mut self) -> Self {
gunnar_pktline::write_flush(&mut self.0).unwrap();
self
}
/// `version 1`, negotiated, so the session will accept anything after it.
fn hello() -> Self {
Wire::default().text("version 1").flush()
}
}
fn serve(script: Wire, store: &MemLfsStore, operation: Operation) -> String {
let mut input = BufReader::new(std::io::Cursor::new(script.0));
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()
}
/// Every `status <code>` in the order they were written.
fn statuses(wire: &str) -> Vec<u16> {
wire.split("status ")
.skip(1)
.filter_map(|rest| rest.split(['\n', '\r']).next()?.trim().parse().ok())
.collect()
}
const PAYLOAD: &[u8] = b"an object a download session must not be able to write";
/// A well-formed oid that is **deliberately not** `sha256(PAYLOAD)`.
///
/// Well-formed because the refusal under test must be the *grant* refusal, not
/// a parse error; wrong because that is what makes the `upload`-session control
/// in the same test answer 422 rather than 200.
const PAYLOAD_OID: &str = "8b4a4d5a5b9a06b9e2b9dbe1e6a3b7ff4a4d0d4f11d2ff9e0e2f1d9e0e2f1d9e";
#[test]
fn a_download_session_refuses_put_object_and_stores_nothing() {
let store = MemLfsStore::new();
let script = Wire::hello()
.text(&format!("put-object {PAYLOAD_OID}"))
.text(&format!("size={}", PAYLOAD.len()))
.delim()
.data(PAYLOAD)
.flush()
.text("quit")
.flush();
let wire = serve(script, &store, Operation::Download);
assert_eq!(
statuses(&wire),
vec![200, 405, 200],
"version → put-object → quit; the middle one must be 405.\nwire: {wire}"
);
assert!(
wire.contains("only available on an `upload` connection"),
"the refusal must say why: {wire}"
);
// Applied output: nothing landed.
assert!(store.is_empty(), "a download session wrote an object");
// The identity-value half. The *same script* on an `upload` session must be
// answered differently, or "405" above would be what this server says to
// everyone and the test would be measuring nothing. It is refused too, with
// 422, because PAYLOAD_OID is not what PAYLOAD hashes to, and that is the
// point: a different code, reached through a different decision.
let store = MemLfsStore::new();
let upload = Wire::hello()
.text(&format!("put-object {PAYLOAD_OID}"))
.text(&format!("size={}", PAYLOAD.len()))
.delim()
.data(PAYLOAD)
.flush()
.text("quit")
.flush();
let wire = serve(upload, &store, Operation::Upload);
assert_eq!(statuses(&wire), vec![200, 422, 200], "wire: {wire}");
assert!(store.is_empty());
}
#[test]
fn an_upload_session_refuses_get_object_and_sends_no_bytes() {
let store = MemLfsStore::new();
let oid = store.insert(PAYLOAD);
let script = Wire::hello()
.text(&format!("get-object {oid}"))
.flush()
.text("quit")
.flush();
let wire = serve(script, &store, Operation::Upload);
assert_eq!(
statuses(&wire),
vec![200, 405, 200],
"an upload session served a read.\nwire: {wire}"
);
assert!(
!wire.contains(std::str::from_utf8(PAYLOAD).unwrap()),
"the object's bytes went out on a session that may not read: {wire}"
);
// The identity-value half: the same request on a `download` session must
// actually produce the object, or the assertion above would pass against a
// server that never serves anything.
let script = Wire::hello()
.text(&format!("get-object {oid}"))
.flush()
.text("quit")
.flush();
let wire = serve(script, &store, Operation::Download);
assert_eq!(statuses(&wire), vec![200, 200, 200], "wire: {wire}");
assert!(wire.contains(&format!("size={}", PAYLOAD.len())));
assert!(wire.contains(std::str::from_utf8(PAYLOAD).unwrap()));
}
#[test]
fn an_upload_session_refuses_verify_object_it_has_no_business_with() {
// `verify-object` is upload-only too. On a download session it is 405, and
// on an upload session for an object that is not there it is 404, so two
// different refusals, which is what makes each of them mean something.
let store = MemLfsStore::new();
let absent = Oid::parse(&"a".repeat(64)).unwrap();
let script = Wire::hello()
.text(&format!("verify-object {absent}"))
.text("size=4")
.flush()
.text("quit")
.flush();
assert_eq!(
statuses(&serve(script, &store, Operation::Download)),
vec![200, 405, 200]
);
let script = Wire::hello()
.text(&format!("verify-object {absent}"))
.text("size=4")
.flush()
.text("quit")
.flush();
assert_eq!(
statuses(&serve(script, &store, Operation::Upload)),
vec![200, 404, 200]
);
// And an object that IS there, at the right size, verifies.
let oid = store.insert(PAYLOAD);
let script = Wire::hello()
.text(&format!("verify-object {oid}"))
.text(&format!("size={}", PAYLOAD.len()))
.flush()
.text("quit")
.flush();
assert_eq!(
statuses(&serve(script, &store, Operation::Upload)),
vec![200, 200, 200]
);
// …and the same object at a lied-about size does not.
let script = Wire::hello()
.text(&format!("verify-object {oid}"))
.text("size=1")
.flush()
.text("quit")
.flush();
assert_eq!(
statuses(&serve(script, &store, Operation::Upload)),
vec![200, 422, 200]
);
}
#[test]
fn a_refused_put_object_is_drained_so_the_next_command_is_still_read() {
// The desync trap. A 405 that did not read the object the client is already
// writing would meet object bytes where the next command belongs, and the
// session would fail on framing instead of answering. The proof is that a
// command sent *after* the refused upload is still answered.
let store = MemLfsStore::new();
let script = Wire::hello()
.text(&format!("put-object {PAYLOAD_OID}"))
.text(&format!("size={}", PAYLOAD.len()))
.delim()
.data(PAYLOAD)
.data(PAYLOAD)
.flush()
// The resynchronisation proof: an unknown command, answered 400.
.text("this-is-not-a-command")
.flush()
.text("quit")
.flush();
let wire = serve(script, &store, Operation::Download);
// 200 version, 405 put-object (payload drained), 400 unknown command, 400
// for that command's own trailing flush-pkt, 200 quit.
//
// The **second 400 is the contract, not a wart**: `dispatch` deliberately
// does not drain an unknown command's section, because it cannot know
// whether it has one and guessing wrong swallows the next real command
// (`src/session.rs`). The stray terminator is answered in turn and the
// stream resynchronises there. What matters is that `quit`, sent after all of
// it, is still read as `quit`.
assert_eq!(
statuses(&wire),
vec![200, 405, 400, 400, 200],
"the session lost sync after refusing an upload.\nwire: {wire}"
);
assert!(
wire.contains("expected a command, got a flush-pkt"),
"the resynchronisation step must be visible, not inferred: {wire}"
);
assert!(store.is_empty());
}
#[test]
fn nothing_is_served_before_the_version_is_negotiated() {
let store = MemLfsStore::new();
let oid = store.insert(PAYLOAD);
// No `version` first.
let script = Wire::default()
.text(&format!("get-object {oid}"))
.flush()
.text("version 1")
.flush()
.text(&format!("get-object {oid}"))
.flush()
.text("quit")
.flush();
let wire = serve(script, &store, Operation::Download);
// 400 for the un-negotiated `get-object`, 400 for the flush-pkt it left
// behind (see the drain test for why that is the contract), then 200 for
// `version 1`, 200 for the read that is now allowed, 200 for `quit`.
assert_eq!(
statuses(&wire),
vec![400, 400, 200, 200, 200],
"the pre-negotiation read was served.\nwire: {wire}"
);
assert!(
wire.contains("the first command must be `version`"),
"wire: {wire}"
);
// The object appears exactly once, from the request after `version 1`.
assert_eq!(
wire.matches(std::str::from_utf8(PAYLOAD).unwrap()).count(),
1,
"wire: {wire}"
);
}