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