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