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

//! **A full disk is a 507 the client is told about, not a dropped channel.**
//!
//! `docs/api/batch.md` lists the code by name — *"507 - The server has
//! insufficient storage capacity to complete the request"* — and it is a
//! different fact from the 413 `tests/quota.rs` covers. 413 says *this object is
//! bigger than I accept*, a policy the operator chose and which will say the
//! same thing tomorrow. 507 says *the disk is full*, which the operator can fix
//! and which says nothing about the object.
//!
//! Until 2026-08-05 this crate could say neither. An `ENOSPC` out of the store's
//! `write` arrived at [`Session`] as an `io::Error`, `?` turned it into
//! `Error::Io`, and `Error::Io` is **fatal with no `final_report`** — so
//! `git lfs push` onto a full volume got a closed SSH channel and no status line
//! at all. Same shape as the refusal the drain used to swallow, one layer down.
//!
//! Every assertion here is on the **status codes that reached the wire**, in
//! order, and on the session still being alive afterwards. A returned `Result`
//! would have looked fine throughout.

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

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

const PAYLOAD: &[u8] = b"an object that will not fit\n";

/// Where the store fails.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FailAt {
    /// `begin_write` — the temp file could not even be created.
    Begin,
    /// `write` — the volume filled up mid-object. The common case.
    Write,
    /// Nowhere. The identity value: without this arm, a store that returned
    /// 507 for everything would pass every other case in this file.
    Nothing,
}

/// A store that fails with a chosen `io::ErrorKind` at a chosen point.
///
/// The kind is a parameter rather than hard-coded, because the whole claim under
/// test is that [`Error::store_io`] **discriminates**: `StorageFull` must become
/// 507 and `PermissionDenied` must not.
struct FullStore {
    at: FailAt,
    kind: std::io::ErrorKind,
    committed: std::sync::atomic::AtomicU64,
    /// Bytes of the last committed object, so a commit is visible **through the
    /// protocol** (`verify-object`) and not only through a counter this file
    /// owns. A store whose `size_of` always said `None` would make every
    /// `verify-object` a 404 and hide the difference between "committed" and
    /// "refused".
    stored_bytes: std::sync::atomic::AtomicU64,
}

impl FullStore {
    fn new(at: FailAt, kind: std::io::ErrorKind) -> FullStore {
        FullStore {
            at,
            kind,
            committed: std::sync::atomic::AtomicU64::new(0),
            stored_bytes: std::sync::atomic::AtomicU64::new(u64::MAX),
        }
    }

    fn committed(&self) -> u64 {
        self.committed.load(std::sync::atomic::Ordering::Relaxed)
    }
}

impl LfsStore for FullStore {
    fn size_of(&self, _oid: &Oid) -> Result<Option<u64>> {
        match self.stored_bytes.load(std::sync::atomic::Ordering::Relaxed) {
            u64::MAX => Ok(None),
            size => Ok(Some(size)),
        }
    }

    fn open(&self, _oid: &Oid) -> Result<Option<LfsObject>> {
        Ok(None)
    }

    fn begin_write(&self, _oid: &Oid, _declared: u64) -> Result<Box<dyn PendingWrite + '_>> {
        if self.at == FailAt::Begin {
            // Through the same classifier the real backend uses. A test that
            // built `Error::OutOfSpace` by hand would prove only that the
            // session can carry a variant, not that a disk-full `io::Error`
            // ever becomes one (one implementation).
            return Err(Error::store_io(
                "create incomplete/x.tmp",
                std::io::Error::from(self.kind),
            ));
        }
        Ok(Box::new(FullPendingWrite {
            store: self,
            written: 0,
        }))
    }
}

struct FullPendingWrite<'a> {
    store: &'a FullStore,
    written: u64,
}

impl Write for FullPendingWrite<'_> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        if self.store.at == FailAt::Write {
            return Err(std::io::Error::from(self.store.kind));
        }
        self.written += data.len() as u64;
        Ok(data.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

impl PendingWrite for FullPendingWrite<'_> {
    fn commit(self: Box<Self>) -> Result<()> {
        self.store
            .committed
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        self.store
            .stored_bytes
            .store(self.written, std::sync::atomic::Ordering::Relaxed);
        Ok(())
    }
}

fn oid_of(bytes: &[u8]) -> Oid {
    let mut hasher = gix_hash::hasher(gix_hash::Kind::Sha256);
    hasher.update(bytes);
    match hasher.try_finalize().unwrap() {
        gix_hash::ObjectId::Sha256(digest) => Oid::from_digest(digest),
        other => unreachable!("{other:?}"),
    }
}

/// Every `status <n>` line the server put on the wire, in order.
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` → `put-object` (in `chunks` payload frames) → `verify-object` →
/// `quit`.
///
/// The trailing `verify-object` is not decoration: it is how this file proves
/// the session **survived** the refusal. A fatal error writes no status and
/// answers nothing after it, so "the session is still there" and "the client was
/// told" are the same assertion made twice.
fn put_script(chunks: usize) -> Vec<u8> {
    let oid = oid_of(PAYLOAD);
    let mut s = Vec::new();
    gunnar_pktline::write_text(&mut s, b"version 1").unwrap();
    gunnar_pktline::write_flush(&mut s).unwrap();
    gunnar_pktline::write_text(&mut s, format!("put-object {oid}").as_bytes()).unwrap();
    gunnar_pktline::write_text(
        &mut s,
        format!("size={}", PAYLOAD.len() * chunks).as_bytes(),
    )
    .unwrap();
    gunnar_pktline::write_delim(&mut s).unwrap();
    for _ in 0..chunks {
        gunnar_pktline::write_data(&mut s, PAYLOAD).unwrap();
    }
    gunnar_pktline::write_flush(&mut s).unwrap();
    gunnar_pktline::write_text(&mut s, format!("verify-object {oid}").as_bytes()).unwrap();
    gunnar_pktline::write_text(&mut s, b"size=1").unwrap();
    gunnar_pktline::write_flush(&mut s).unwrap();
    gunnar_pktline::write_text(&mut s, b"quit").unwrap();
    gunnar_pktline::write_flush(&mut s).unwrap();
    s
}

fn serve(script: Vec<u8>, store: &FullStore) -> Vec<u8> {
    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 _ = Session::new(store, Operation::Upload).serve(reader, &mut output);
    output
}

/// **The defect.** The volume fills up mid-object; the client must be told 507,
/// and the session must still be there afterwards.
#[test]
fn a_volume_that_fills_up_mid_upload_answers_507_and_the_session_survives() {
    let store = FullStore::new(FailAt::Write, std::io::ErrorKind::StorageFull);
    let wire = serve(put_script(1), &store);

    assert_eq!(
        statuses(&wire),
        // version, put-object → 507, verify-object → 404 (nothing was stored),
        // quit → 200. The last two exist only to prove the session lived.
        vec![200, 507, 404, 200],
        "a full disk must be a delivered 507, not a dropped channel.\nwire: {}",
        String::from_utf8_lossy(&wire).escape_debug()
    );
    assert!(
        String::from_utf8_lossy(&wire).contains("insufficient storage capacity"),
        "the 507 carries no reason a human can act on:\n{}",
        String::from_utf8_lossy(&wire).escape_debug()
    );
    assert_eq!(
        store.committed(),
        0,
        "an object that never fitted was committed anyway"
    );
}

/// The same failure at `begin_write`, before a byte of payload is read. A
/// different call site, so a fix applied at only one of them shows up here.
#[test]
fn a_store_that_cannot_even_open_a_temp_file_answers_507() {
    let store = FullStore::new(FailAt::Begin, std::io::ErrorKind::StorageFull);
    let wire = serve(put_script(1), &store);
    assert_eq!(
        statuses(&wire),
        vec![200, 507, 404, 200],
        "wire: {}",
        String::from_utf8_lossy(&wire).escape_debug()
    );
}

/// **The red half that matters most: 507 must not be what this server says to
/// everything.** The identical script against a store that is merely *broken*
/// must be 500, and against a store that works must be 200.
#[test]
fn only_a_no_room_failure_is_507() {
    for (kind, expected) in [
        (std::io::ErrorKind::StorageFull, 507u16),
        (std::io::ErrorKind::QuotaExceeded, 507),
        (std::io::ErrorKind::FileTooLarge, 507),
        // Not a capacity problem. The operator needs "your server is broken",
        // not "buy a bigger disk".
        (std::io::ErrorKind::PermissionDenied, 500),
        (std::io::ErrorKind::BrokenPipe, 500),
    ] {
        let store = FullStore::new(FailAt::Write, kind);
        let wire = serve(put_script(1), &store);
        assert_eq!(
            statuses(&wire).get(1).copied(),
            Some(expected),
            "{kind:?} must be answered {expected}.\nwire: {}",
            String::from_utf8_lossy(&wire).escape_debug()
        );
    }

    // The identity value. Every case above asserts a *refusal*; without this
    // one they would all pass against a session that refused unconditionally,
    // and `put-object` would be dead for everybody.
    let working = FullStore::new(FailAt::Nothing, std::io::ErrorKind::StorageFull);
    let wire = serve(put_script(1), &working);
    assert_eq!(
        statuses(&wire),
        // put-object → 200, and now `verify-object size=1` is a 422 rather than
        // a 404: the object IS there, and it is not one byte long.
        vec![200, 200, 422, 200],
        "an upload that fits must still go through.\nwire: {}",
        String::from_utf8_lossy(&wire).escape_debug()
    );
    assert_eq!(working.committed(), 1);
}

/// The delivery half, and the reason this file exists rather than a unit test on
/// `Error::status`.
///
/// A real client writes the **whole object** before it reads a byte of the
/// response, so by the time the server decides on 507 the client is still
/// sending. The server has to read that out to stay in sync, and the drain is
/// bounded at [`gunnar_lfs::Limits::max_drain_bytes`]. On the two paths this fix
/// travels — `begin_write` failing and the sink rejecting a chunk — the drain
/// was still spelled `self.drain_data_section(input)?`, so past the budget its
/// own fatal `Framing` error **replaced** the 507 and the channel dropped. That
/// is the identical bug `Error::RefusedThenClosed` was created for, in the two
/// places the original fix did not reach.
#[test]
fn a_507_reaches_a_client_that_keeps_sending_past_the_drain_budget() {
    let budget = gunnar_lfs::Limits::default().max_drain_bytes as usize;
    let chunks = (budget / PAYLOAD.len()) + 2;
    assert!(
        chunks * PAYLOAD.len() > budget,
        "this case must exceed the drain budget or it proves nothing"
    );

    let store = FullStore::new(FailAt::Write, std::io::ErrorKind::StorageFull);
    let wire = serve(put_script(chunks), &store);

    // The session does end here — the client would not stop — but it says what
    // it decided on the way out. That is the whole contract.
    assert_eq!(
        statuses(&wire).get(1).copied(),
        Some(507),
        "the 507 was swallowed by the drain; the client got a closed channel.\n\
         wire: {}",
        String::from_utf8_lossy(&wire).escape_debug()
    );
    assert_eq!(store.committed(), 0);
}