1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! Failures, and the HTTP status each becomes on the wire.
//!
//! The pure-SSH protocol answers every command with an HTTP status code. The
//! same codes the HTTP batch API uses, deliberately, so a server can proxy to an
//! HTTP backend. The mapping lives here, once, so no call site invents a code.
/// What went wrong.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The stream failed, or ended mid-frame. **Fatal to the session**: there
/// is no status to answer with, because there is no longer a channel to
/// answer on.
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
/// The framing was malformed. Also fatal: once a pkt-line boundary is lost
/// there is no way to find the next command, and answering "400" into a
/// desynced stream would put four more bytes of garbage on it.
#[error("pkt-line framing: {0}")]
Framing(String),
/// The client sent something this protocol does not define. Answered, and
/// the session continues.
#[error("{0}")]
BadRequest(String),
/// The client asked for something the operation does not admit, a
/// `put-object` on a `download` session for instance.
///
/// **405 and not the 403 `docs/api/batch.md` names, and the difference is
/// measured.** That table's 403 is *"the user has **read**, but not
/// **write** access. Only applicable when the `operation` in the request is
/// upload"*, and it governs the **batch endpoint**. This is the object
/// endpoint, and there git-lfs reads 403 as something else entirely:
/// `tq/ssh.go`, in `SSHAdapter.upload`,
///
/// ```text
/// // A status code of 403 likely means that an authentication token for the
/// // upload has expired. This can be safely retried.
/// if status == 403 {
/// return errors.NewRetriableError(err)
/// }
/// ```
///
/// so 403 buys `lfs.transfer.maxretries` (default 8, `tq/manifest.go`)
/// futile round trips before the same refusal is reported, because an SSH
/// grant that is download-only will still be download-only on the eighth
/// try. 405 is reported at once. The reference server,
/// `charmbracelet/git-lfs-transfer`, also answers 405 here
/// (`ErrNotAllowed` → `StatusMethodNotAllowed`, `transfer/processor.go`).
///
/// The condition the table's 403 describes — a *batch* refused because the
/// grant does not permit writing — cannot arise here: over pure SSH the
/// operation is in the exec line, and `gunnar_ssh::command::parse` has
/// already demanded Write before an `upload` session exists at all.
#[error("{0}")]
NotAllowed(String),
/// The client and the server cannot agree on a parameter of the request.
///
/// Today that is only the hash algorithm, and 409 is the code
/// `docs/api/batch.md` assigns it by name: *"409 - The specified hash
/// algorithm disagrees with the server's acceptable options."* It is the
/// only code the specification gives that condition. The reference server
/// puts it in its generic `ErrNotAllowed` bucket and answers 405; the client
/// treats any non-200 batch status the same way (`tq/ssh.go`, `Batch`:
/// `if status != 200 { return errors.New(...) }`, not retriable either way),
/// so saying what the specification says costs nothing.
#[error("{0}")]
Conflict(String),
/// The object is not here.
#[error("{0}")]
NotFound(String),
/// A verb this server does not implement: the three lock commands.
///
/// **501 and not 405, and the difference is measured.** git-lfs's
/// `SearchLocksVerifiable` maps 501 (and 404) to `NewNotImplementedError`,
/// which `lockVerifier::Verify` turns into `disableFor(url)`: locking
/// switches itself off and the push proceeds silently. Any other 4xx takes
/// the branch that prints *"Remote %q does not support the Git LFS locking
/// API. Consider disabling it with: git config lfs.<url>.locksverify false"*
/// on every push. Same refusal, one is quiet and correct.
#[error("{0}")]
NotImplemented(String),
/// The object is larger than this server accepts. **Answered before the
/// bytes arrive** wherever the size is declared up front.
#[error("{0}")]
TooLarge(String),
/// The bytes did not hash to the oid they were sent under, or did not match
/// the declared size. The object is discarded, never committed.
#[error("{0}")]
Corrupt(String),
/// The backing store failed.
#[error("storage: {0}")]
Store(String),
/// The store has no room. `ENOSPC`, `EDQUOT`, or a file the filesystem will
/// not let grow any further.
///
/// **507, which `docs/api/batch.md` lists by name** — *"the server has
/// insufficient storage capacity to complete the request"* — and it is a
/// different fact from [`Error::TooLarge`]'s 413. 413 is *"this object is
/// bigger than I accept"*, a policy the operator set, identical tomorrow.
/// 507 is *"the disk is full"*, which the operator can fix and which says
/// nothing about the object. Answering 500 for it, as this crate did until
/// 2026-08-05, told an operator their server was broken when what they
/// needed was a bigger volume.
///
/// It is **not fatal**, which is the larger half of the fix. A full disk
/// used to surface as [`Error::Io`] straight out of `write_all`, and `Io` is
/// fatal and has no [`Error::final_report`] — so `git lfs push` onto a full
/// volume got a **dropped SSH channel and no status line at all**, the same
/// shape as the refusal the drain used to swallow. Now the session answers
/// `507`, drains, and stays up.
#[error("{0}")]
OutOfSpace(String),
/// **The TENANT has no room left**, as distinct from [`Error::OutOfSpace`]'s
/// *the server* has none. Same 507, and deliberately so; a different string
/// reaching the client, and deliberately so.
///
/// # Why not simply reuse `OutOfSpace`
///
/// Tried first, and it failed on the one thing that matters. `OutOfSpace`'s
/// [`Error::client_message`] is a fixed sentence — *"the server has
/// insufficient storage capacity for this object"* — precisely because its
/// payload is an operator's diagnostic that may name a path, and paths do
/// not go on the wire. That is right for `ENOSPC`. It is wrong here, and
/// twice over:
///
/// 1. It tells the customer the WRONG THING. Their push failed because their
/// plan is spent, which they can fix in a minute with a card; "the server
/// has insufficient storage capacity" says the operator's disk is full,
/// which they cannot fix at all and which invites a support ticket
/// instead of a purchase.
/// 2. It throws away the only sentence that was composed FOR the client.
/// [`crate::Allowance::refusal`] arrives already naming the plan, the cap,
/// what is held and the pay page — assembled by `gunnar-server` from the
/// signed entitlement fact, the same way `gunnar_wire::PackCeiling`
/// carries the pack door's refusal to where the bytes are.
///
/// So: one status code, because from the client's side of the wire "there is
/// no room for this" is one fact and git-lfs should treat it identically;
/// two variants, because what the human reads afterwards is a different
/// sentence with a different remedy. `client_message` passes this one
/// through verbatim — it contains nothing the server composed for itself.
#[error("{0}")]
NoRoomLeft(String),
/// A refusal the server could not resynchronise the stream after.
///
/// # Why the refusal is not simply lost
///
/// `put-object`'s refusals are decided from the arguments, while the client
/// is already writing the object. To stay in sync the server drains the
/// payload it just refused, and that drain is bounded
/// ([`Limits::max_drain_bytes`](crate::Limits::max_drain_bytes), two
/// chunks). The drain used to be spelled `self.drain_data_section(input)?`,
/// so once the client sent past the budget the drain's own `Framing` error
/// **replaced** the refusal — and `Framing` is fatal, so the session ended
/// having written no status line at all.
///
/// The budget is 128 KiB and the quota is 8 GiB, so *every* refusal of an
/// object big enough to be worth refusing took that path: `git lfs push` of
/// a 9 GiB file got a dropped SSH channel where a `413` and its message
/// were already composed. Only the smallest possible case survived, which is
/// the only case `tests/quota.rs` sent — one chunk, exactly under the
/// budget, with a comment saying it was two.
///
/// So the two facts are kept apart: the refusal is what the client is told,
/// the reason for closing is why the session then ends.
///
/// # A second reason to close, added 2026-09-11 (GUN-017)
///
/// The desync above is one way a refused request must not simply be
/// followed by another. The other is a client that CHARGED the namespace's
/// allowance and then did not deliver what it charged for: `put-object`
/// admits against the declared size before the payload arrives, the ledger
/// is a monotone counter with nothing to give back (deliberately — see
/// `ledger.rs`), and a non-fatal refusal let the same client declare again
/// immediately. So the field is "why the session then ends" rather than
/// "the desync", and `Display` no longer asserts which of the two it was.
#[error("{refusal} (and the session is closing: {closing})")]
RefusedThenClosed {
/// What the request was refused for. This is what the client is told.
refusal: Box<Error>,
/// Why the session cannot simply carry on afterwards. Log only.
closing: Box<Error>,
},
}
impl Error {
/// The failure a **store** operation's `io::Error` becomes.
///
/// One function, called by every backend, so no backend decides for itself
/// whether a full disk is a 500 or a 507 (one implementation). `context`
/// is the operator's half — `"write big.bin"`, `"rename … → …"` — and
/// never reaches the client; [`Error::client_message`] replaces it.
///
/// The three kinds that mean *no room* are separated from every other I/O
/// failure. They are all stable since Rust 1.83 and the crate's MSRV is 1.85:
///
/// * `StorageFull` — `ENOSPC`, the volume is full;
/// * `QuotaExceeded` — `EDQUOT`, this user's or project's quota is;
/// * `FileTooLarge` — `EFBIG`, past what the filesystem will let one file be.
///
/// Everything else — a permissions problem, a failing disk, an unmounted
/// path — stays [`Error::Store`] and 500, because those really are *"this
/// server is broken"*.
pub fn store_io(context: impl std::fmt::Display, err: std::io::Error) -> Error {
use std::io::ErrorKind;
match err.kind() {
ErrorKind::StorageFull | ErrorKind::QuotaExceeded | ErrorKind::FileTooLarge => {
Error::OutOfSpace(format!("{context}: {err}"))
}
_ => Error::Store(format!("{context}: {err}")),
}
}
/// The HTTP status code this failure is reported as.
pub fn status(&self) -> u16 {
match self {
// Fatal ones still have a code, for the log and for the one case
// where a partially-written response is better than none.
Error::Io(_) | Error::Framing(_) => 500,
Error::BadRequest(_) => 400,
Error::NotAllowed(_) => 405,
Error::NotImplemented(_) => 501,
Error::NotFound(_) => 404,
Error::Conflict(_) => 409,
Error::TooLarge(_) => 413,
// One code for two facts, on purpose: git-lfs must treat "there
// is no room for this" the same way whoever ran out of it. See
// `Error::NoRoomLeft`.
Error::OutOfSpace(_) | Error::NoRoomLeft(_) => 507,
// 422 and not 400: the request was well-formed, the *content* was
// wrong. git-lfs's HTTP API uses 422 for exactly this, and the
// client treats 4xx as non-retriable, which is right, since
// resending the same bytes would fail the same way.
Error::Corrupt(_) => 422,
Error::Store(_) => 500,
// The refusal's code, not 500. Which code the client sees is the
// whole point of the variant.
Error::RefusedThenClosed { refusal, .. } => refusal.status(),
}
}
/// Does this failure end the session, or merely the command?
///
/// **Only framing and I/O are fatal.** Everything else is a decision about
/// one command, and a server that dropped the connection on a 404 would
/// make a single missing object fail a whole fetch.
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::Io(_) | Error::Framing(_) | Error::RefusedThenClosed { .. }
)
}
/// The status line a **fatal** failure still owes the client before the
/// session closes, if it has one.
///
/// `None` for [`Error::Io`] and [`Error::Framing`]: the first means the
/// channel is already gone, and the second means the server no longer knows
/// where a packet boundary is, so anything it writes lands in the middle of
/// something. [`Error::RefusedThenClosed`] is the case where the server knows
/// exactly what it decided and simply cannot keep talking afterwards — and
/// there, saying it is the difference between `413 object … is 9663676416
/// bytes; this server accepts at most 8589934592` and a dropped connection.
pub fn final_report(&self) -> Option<(u16, String)> {
match self {
Error::RefusedThenClosed { refusal, .. } => {
Some((refusal.status(), refusal.client_message()))
}
_ => None,
}
}
/// What the client is told. Never carries a path, a principal or a store
/// detail. The server log keeps those.
pub fn client_message(&self) -> String {
match self {
Error::Io(_) | Error::Framing(_) => "the connection failed".to_string(),
Error::Store(_) => "the object store is unavailable".to_string(),
// The one store failure the client IS told the nature of. It carries
// no path and no principal — "the disk is full" is not a leak, and
// "retrying will not help, tell your administrator" is exactly what
// the person watching the push needs to know.
Error::OutOfSpace(_) => {
"the server has insufficient storage capacity for this object".to_string()
}
// The refusal's words. The desync is the server's problem and
// carries a byte budget the client has no use for.
Error::RefusedThenClosed { refusal, .. } => refusal.client_message(),
other => other.to_string(),
}
}
}
impl From<gunnar_pktline::Error> for Error {
fn from(err: gunnar_pktline::Error) -> Self {
match err {
gunnar_pktline::Error::Io(io) => Error::Io(io),
other => Error::Framing(other.to_string()),
}
}
}
/// This crate's result type.
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_failure_maps_to_a_distinct_and_correct_status() {
assert_eq!(Error::BadRequest("x".into()).status(), 400);
assert_eq!(Error::NotFound("x".into()).status(), 404);
assert_eq!(Error::NotAllowed("x".into()).status(), 405);
assert_eq!(Error::NotImplemented("x".into()).status(), 501);
assert_eq!(Error::TooLarge("x".into()).status(), 413);
assert_eq!(Error::Corrupt("x".into()).status(), 422);
assert_eq!(Error::Store("x".into()).status(), 500);
// The red half: a `status()` returning a constant would pass a test
// that only checked one variant.
assert_ne!(
Error::NotFound("x".into()).status(),
Error::TooLarge("x".into()).status()
);
}
#[test]
fn only_transport_failures_are_fatal() {
assert!(Error::Framing("x".into()).is_fatal());
assert!(Error::Io(std::io::Error::other("x")).is_fatal());
// A 404 that killed the session would fail a whole fetch over one
// absent object.
for survivable in [
Error::BadRequest("x".into()),
Error::NotAllowed("x".into()),
Error::NotImplemented("x".into()),
Error::NotFound("x".into()),
Error::TooLarge("x".into()),
Error::Corrupt("x".into()),
Error::Store("x".into()),
] {
assert!(!survivable.is_fatal(), "{survivable:?} must not be fatal");
}
}
}