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
//! What can go wrong on the wire.
use gix_hash::ObjectId;
/// Everything `gunnar-wire` can fail with.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The framing layer rejected a line.
#[error("pkt-line framing: {0}")]
PktLine(#[from] gunnar_pktline::Error),
/// The transport failed.
#[error(transparent)]
Io(#[from] std::io::Error),
/// The storage layer failed.
#[error(transparent)]
Store(#[from] gunnar_store::Error),
/// Writing or indexing a packfile failed.
///
/// A **message**, not another crate's error type. It was
/// `#[from] gunnar_pack::Error` until 2026-08-10, and that one `#[from]`
/// was the last thing making `gunnar-pack` — and behind it `gix-pack` and
/// ten more gix crates — a normal dependency of a crate that has not called
/// a pack writer or a pack indexer since the engine seam landed. See
/// [`crate::pack`]. Nothing under `src/` constructs this; a packfile is
/// somebody else's to write, and the somebody is an engine behind the
/// contract.
#[error("packfile: {0}")]
Pack(String),
/// An object the protocol requires is not in the repository.
///
/// On the serving side this means a corrupt repository; on the receiving
/// side it means a push whose connectivity does not close.
#[error("object {oid} is not in this repository{}", context_suffix(.context))]
ObjectMissing {
/// The object that could not be found.
oid: ObjectId,
/// What was being done when it was needed.
context: Option<String>,
},
/// An object could not be parsed as the kind it claims to be.
#[error("object {oid} could not be decoded: {reason}")]
CorruptObject {
/// The object that would not decode.
oid: ObjectId,
/// Why not.
reason: String,
},
/// The peer sent something the protocol does not allow here.
#[error("protocol error: {0}")]
Protocol(String),
/// **A fetch's packfile passed the byte ceiling it was held to**
/// ([`FetchPack::with_ceiling`](crate::fetch_pack::FetchPack::with_ceiling))
/// and was cut mid-transfer; the ingest was handed at most the ceiling and
/// one byte, and nothing was stored. The text is the ceiling's own refusal,
/// verbatim — the sentence whoever set it composed, the same one a push past
/// the same ceiling is told — never "the pack is corrupt", which is what the
/// indexer would have made of the cut stream.
#[error("{0}")]
OverCeiling(String),
/// **A policy refused the pushed pack before any of it was kept** — the
/// encrypted-repository gate, run on the pack in quarantine (gunnar-server
/// `encrypted_quarantine`). Its sentence is the client's, word for word:
/// it names the file and the fix, and it reveals nothing the pusher did
/// not send.
#[error("{0}")]
Refused(String),
/// The peer named a hash algorithm this repository does not use.
///
/// Getting this wrong silently is the documented, session-costing bug of
/// Object-format negotiation: a sha1 answer to a sha256 repository produces an *empty*
/// response rather than an error, so it is made loud here.
#[error("the client asked for object-format={theirs}, but this repository is {ours}")]
ObjectFormatMismatch {
/// The repository's actual format name.
ours: &'static str,
/// What the client asked for.
theirs: String,
},
/// **The same mismatch as [`Error::ObjectFormatMismatch`], from the client's
/// chair.**
///
/// Two variants and not one because the *sentence* is the point. The other
/// says "the client asked for X, but this repository is Y", which is what a
/// server tells a client; an operator reading that about their own mirror
/// would go looking for a client that does not exist. This one names the
/// remote as the remote.
///
/// The failure it prevents is object-format negotiation's, unchanged: a sha1 client against a
/// sha256 remote does not error, it negotiates an *empty* answer, so
/// `fetch_pack` refuses at the advertisement rather than reporting a
/// successful fetch of nothing.
#[error("this repository is object-format={ours}, but the remote advertised {theirs}")]
RemoteObjectFormat {
/// The local repository's format name.
ours: &'static str,
/// What the remote said.
theirs: String,
},
/// The remote reported a fatal condition on sideband 3.
///
/// Verbatim, because it is the only thing the remote will ever say about
/// why: band 3 is how `upload-pack` reports *"repository not found"* or
/// *"want is not a valid object"* once a sideband is open, and a client that
/// turned it into "the transfer failed" would throw away the whole
/// diagnosis.
#[error("the remote refused the transfer: {0}")]
Remote(String),
/// An argument that is not in this command's argument table.
#[error("`{command}` does not accept the argument {argument:?}")]
UnknownArgument {
/// Which v2 command was being parsed.
command: &'static str,
/// The offending argument line.
argument: String,
},
/// A `filter` specification gunnar does not implement.
#[error("filter {spec:?} is not supported: {reason}")]
UnsupportedFilter {
/// The specification as sent.
spec: String,
/// Why it was refused.
reason: &'static str,
},
/// A `want` naming something that is not an advertised reference tip.
///
/// Serving arbitrary object ids leaks objects that are not reachable from
/// any reference, which is how "deleted" branches stay fetchable.
#[error("want {oid} is not an advertised reference tip")]
WantNotAdvertised {
/// The object the client asked for.
oid: ObjectId,
},
/// The peer hung up in the middle of a message.
#[error("the peer hung up while gunnar was reading {0}")]
UnexpectedEof(&'static str),
/// A traversal ran past its object budget.
///
/// A budget rather than an unbounded walk because the object graph comes
/// from the network: a crafted repository must not be able to make the
/// server allocate without limit.
#[error("traversal exceeded its budget of {limit} objects")]
WalkBudget {
/// The budget that was hit.
limit: usize,
},
/// A traversal would have held more object bookkeeping than
/// [`Limits::walk_bytes`](crate::Limits::walk_bytes) allows.
///
/// The bytes are the ids the walk is holding **live**, charged at
/// [`WALK_BYTES_PER_OBJECT`](crate::WALK_BYTES_PER_OBJECT), which is a
/// measured rate and not a struct size. Nothing is inflated to arrive at it:
/// charging a walk by decompressing what it walks over is the defect that
/// cost `gix-odb` half its read-side CPU.
#[error(
"this traversal would hold {held} bytes of object bookkeeping, above the {limit}-byte \
ceiling on one request"
)]
WalkTooLarge {
/// What the walk would have been holding.
held: usize,
/// The ceiling that was hit.
limit: usize,
},
/// The server-wide [`WalkPool`](crate::WalkPool) had no room left.
///
/// Not the same refusal as [`Error::WalkTooLarge`], and telling them apart
/// is the point: this one says the request was reasonable and the *machine*
/// is full, so a client that waits and retries will get served. The other
/// says this request will never fit.
#[error(
"the server is holding {in_flight} of {limit} bytes of object bookkeeping for transfers \
already in flight and cannot reserve {wanted} more; retry"
)]
WalkPoolFull {
/// What this walk asked the pool for.
wanted: usize,
/// What the pool had already handed out.
in_flight: usize,
/// The pool's ceiling.
limit: usize,
},
/// The peer's request would have cost more memory than
/// [`Limits::request_bytes`](crate::Limits::request_bytes) allows.
///
/// The budget is spent **as the request is framed**, so this is raised with
/// the ceiling's worth of bytes held and not a byte more. A client that
/// sends `have` lines forever is refused here rather than at the point where
/// the allocator gives up.
#[error("the peer's {what} exceeded the {limit}-byte request ceiling")]
RequestTooLarge {
/// The ceiling that was hit.
limit: usize,
/// Which part of the request overspent.
what: &'static str,
},
/// An object is larger than [`Limits::object_bytes`](crate::Limits::object_bytes)
/// allows gunnar to materialise while serving a fetch.
///
/// Read from the object's **header**, before its content, so the ceiling
/// refuses the transfer rather than reporting on an allocation that has
/// already happened. Refused rather than skipped: a pack missing an object
/// its trees name is a corrupt clone, which is worse than no clone.
#[error(
"object {oid} is {size} bytes, above the {limit}-byte ceiling on what gunnar will \
materialise to serve a fetch"
)]
ObjectTooLarge {
/// The object that is too big.
oid: ObjectId,
/// Its size, from its header.
size: u64,
/// The ceiling that was hit.
limit: u64,
},
}
fn context_suffix(context: &Option<String>) -> String {
match context {
Some(what) => format!(" ({what})"),
None => String::new(),
}
}
impl Error {
/// A packfile could not be written or indexed.
///
/// `pub` rather than `pub(crate)` because the only callers are outside this
/// crate: a [`PackIngest`](crate::receive_pack::PackIngest) is
/// caller-supplied, so whoever indexes the pack is also whoever names why
/// it would not index. See [`Error::Pack`].
pub fn pack(what: impl std::fmt::Display) -> Self {
Error::Pack(what.to_string())
}
pub(crate) fn protocol(what: impl std::fmt::Display) -> Self {
Error::Protocol(what.to_string())
}
pub(crate) fn missing(oid: ObjectId, context: impl Into<String>) -> Self {
Error::ObjectMissing {
oid,
context: Some(context.into()),
}
}
/// The one-line text this error is worth sending to a client, without
/// leaking server paths or internals.
///
/// # Why this is not `to_string()`
///
/// It was, and the doc comment above it made the claim anyway. Variants of
/// this enum are `#[error(transparent)]` over another crate's error —
/// [`Error::Io`] and [`Error::Store`], and until 2026-08-10
/// [`Error::Pack`] as well — so whatever the storage layer said went out
/// verbatim. `gunnar_store::Error::StoreLocked`
/// renders the **absolute path of the lock file**, and
/// [`crate::receive_pack`] puts `client_message()` straight into the
/// `unpack` line of a push report, so `git push` printed a server path to
/// whoever pushed. `gunnar-ssh` and `gunnar-lfs` both redact at this seam
/// already (`DispatchError::client_message`, `gunnar_lfs::Error::client_message`);
/// this crate was the one that did not.
///
/// Everything else stays verbatim, and that is deliberate: an oid, a
/// `filter` spec, an argument name and a byte ceiling are either the
/// client's own words or facts about the protocol, and they are what makes
/// a refusal actionable without a server login. git's own `upload-pack`
/// tells a client which object it wanted and could not have.
///
/// The unredacted text is still what the server **logs** — see
/// `git.upload_pack.failed`, which carries `%err` and not this.
pub fn client_message(&self) -> String {
match self {
Error::Io(_) => "the connection failed".to_owned(),
Error::Store(_) => "the repository is unavailable; try again".to_owned(),
Error::Pack(_) => "the packfile could not be processed".to_owned(),
other => other.to_string(),
}
}
}
/// This crate's result type.
pub type Result<T> = std::result::Result<T, Error>;