gunnar_wire/error.rs
1//! What can go wrong on the wire.
2
3use gix_hash::ObjectId;
4
5/// Everything `gunnar-wire` can fail with.
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8 /// The framing layer rejected a line.
9 #[error("pkt-line framing: {0}")]
10 PktLine(#[from] gunnar_pktline::Error),
11
12 /// The transport failed.
13 #[error(transparent)]
14 Io(#[from] std::io::Error),
15
16 /// The storage layer failed.
17 #[error(transparent)]
18 Store(#[from] gunnar_store::Error),
19
20 /// Writing or indexing a packfile failed.
21 ///
22 /// A **message**, not another crate's error type. It was
23 /// `#[from] gunnar_pack::Error` until 2026-08-10, and that one `#[from]`
24 /// was the last thing making `gunnar-pack` — and behind it `gix-pack` and
25 /// ten more gix crates — a normal dependency of a crate that has not called
26 /// a pack writer or a pack indexer since the engine seam landed. See
27 /// [`crate::pack`]. Nothing under `src/` constructs this; a packfile is
28 /// somebody else's to write, and the somebody is an engine behind the
29 /// contract.
30 #[error("packfile: {0}")]
31 Pack(String),
32
33 /// An object the protocol requires is not in the repository.
34 ///
35 /// On the serving side this means a corrupt repository; on the receiving
36 /// side it means a push whose connectivity does not close.
37 #[error("object {oid} is not in this repository{}", context_suffix(.context))]
38 ObjectMissing {
39 /// The object that could not be found.
40 oid: ObjectId,
41 /// What was being done when it was needed.
42 context: Option<String>,
43 },
44
45 /// An object could not be parsed as the kind it claims to be.
46 #[error("object {oid} could not be decoded: {reason}")]
47 CorruptObject {
48 /// The object that would not decode.
49 oid: ObjectId,
50 /// Why not.
51 reason: String,
52 },
53
54 /// The peer sent something the protocol does not allow here.
55 #[error("protocol error: {0}")]
56 Protocol(String),
57
58 /// **A fetch's packfile passed the byte ceiling it was held to**
59 /// ([`FetchPack::with_ceiling`](crate::fetch_pack::FetchPack::with_ceiling))
60 /// and was cut mid-transfer; the ingest was handed at most the ceiling and
61 /// one byte, and nothing was stored. The text is the ceiling's own refusal,
62 /// verbatim — the sentence whoever set it composed, the same one a push past
63 /// the same ceiling is told — never "the pack is corrupt", which is what the
64 /// indexer would have made of the cut stream.
65 #[error("{0}")]
66 OverCeiling(String),
67 /// **A policy refused the pushed pack before any of it was kept** — the
68 /// encrypted-repository gate, run on the pack in quarantine (gunnar-server
69 /// `encrypted_quarantine`). Its sentence is the client's, word for word:
70 /// it names the file and the fix, and it reveals nothing the pusher did
71 /// not send.
72 #[error("{0}")]
73 Refused(String),
74
75 /// The peer named a hash algorithm this repository does not use.
76 ///
77 /// Getting this wrong silently is the documented, session-costing bug of
78 /// Object-format negotiation: a sha1 answer to a sha256 repository produces an *empty*
79 /// response rather than an error, so it is made loud here.
80 #[error("the client asked for object-format={theirs}, but this repository is {ours}")]
81 ObjectFormatMismatch {
82 /// The repository's actual format name.
83 ours: &'static str,
84 /// What the client asked for.
85 theirs: String,
86 },
87
88 /// **The same mismatch as [`Error::ObjectFormatMismatch`], from the client's
89 /// chair.**
90 ///
91 /// Two variants and not one because the *sentence* is the point. The other
92 /// says "the client asked for X, but this repository is Y", which is what a
93 /// server tells a client; an operator reading that about their own mirror
94 /// would go looking for a client that does not exist. This one names the
95 /// remote as the remote.
96 ///
97 /// The failure it prevents is object-format negotiation's, unchanged: a sha1 client against a
98 /// sha256 remote does not error, it negotiates an *empty* answer, so
99 /// `fetch_pack` refuses at the advertisement rather than reporting a
100 /// successful fetch of nothing.
101 #[error("this repository is object-format={ours}, but the remote advertised {theirs}")]
102 RemoteObjectFormat {
103 /// The local repository's format name.
104 ours: &'static str,
105 /// What the remote said.
106 theirs: String,
107 },
108
109 /// The remote reported a fatal condition on sideband 3.
110 ///
111 /// Verbatim, because it is the only thing the remote will ever say about
112 /// why: band 3 is how `upload-pack` reports *"repository not found"* or
113 /// *"want is not a valid object"* once a sideband is open, and a client that
114 /// turned it into "the transfer failed" would throw away the whole
115 /// diagnosis.
116 #[error("the remote refused the transfer: {0}")]
117 Remote(String),
118
119 /// An argument that is not in this command's argument table.
120 #[error("`{command}` does not accept the argument {argument:?}")]
121 UnknownArgument {
122 /// Which v2 command was being parsed.
123 command: &'static str,
124 /// The offending argument line.
125 argument: String,
126 },
127
128 /// A `filter` specification gunnar does not implement.
129 #[error("filter {spec:?} is not supported: {reason}")]
130 UnsupportedFilter {
131 /// The specification as sent.
132 spec: String,
133 /// Why it was refused.
134 reason: &'static str,
135 },
136
137 /// A `want` naming something that is not an advertised reference tip.
138 ///
139 /// Serving arbitrary object ids leaks objects that are not reachable from
140 /// any reference, which is how "deleted" branches stay fetchable.
141 #[error("want {oid} is not an advertised reference tip")]
142 WantNotAdvertised {
143 /// The object the client asked for.
144 oid: ObjectId,
145 },
146
147 /// The peer hung up in the middle of a message.
148 #[error("the peer hung up while gunnar was reading {0}")]
149 UnexpectedEof(&'static str),
150
151 /// A traversal ran past its object budget.
152 ///
153 /// A budget rather than an unbounded walk because the object graph comes
154 /// from the network: a crafted repository must not be able to make the
155 /// server allocate without limit.
156 #[error("traversal exceeded its budget of {limit} objects")]
157 WalkBudget {
158 /// The budget that was hit.
159 limit: usize,
160 },
161
162 /// A traversal would have held more object bookkeeping than
163 /// [`Limits::walk_bytes`](crate::Limits::walk_bytes) allows.
164 ///
165 /// The bytes are the ids the walk is holding **live**, charged at
166 /// [`WALK_BYTES_PER_OBJECT`](crate::WALK_BYTES_PER_OBJECT), which is a
167 /// measured rate and not a struct size. Nothing is inflated to arrive at it:
168 /// charging a walk by decompressing what it walks over is the defect that
169 /// cost `gix-odb` half its read-side CPU.
170 #[error(
171 "this traversal would hold {held} bytes of object bookkeeping, above the {limit}-byte \
172 ceiling on one request"
173 )]
174 WalkTooLarge {
175 /// What the walk would have been holding.
176 held: usize,
177 /// The ceiling that was hit.
178 limit: usize,
179 },
180
181 /// The server-wide [`WalkPool`](crate::WalkPool) had no room left.
182 ///
183 /// Not the same refusal as [`Error::WalkTooLarge`], and telling them apart
184 /// is the point: this one says the request was reasonable and the *machine*
185 /// is full, so a client that waits and retries will get served. The other
186 /// says this request will never fit.
187 #[error(
188 "the server is holding {in_flight} of {limit} bytes of object bookkeeping for transfers \
189 already in flight and cannot reserve {wanted} more; retry"
190 )]
191 WalkPoolFull {
192 /// What this walk asked the pool for.
193 wanted: usize,
194 /// What the pool had already handed out.
195 in_flight: usize,
196 /// The pool's ceiling.
197 limit: usize,
198 },
199
200 /// The peer's request would have cost more memory than
201 /// [`Limits::request_bytes`](crate::Limits::request_bytes) allows.
202 ///
203 /// The budget is spent **as the request is framed**, so this is raised with
204 /// the ceiling's worth of bytes held and not a byte more. A client that
205 /// sends `have` lines forever is refused here rather than at the point where
206 /// the allocator gives up.
207 #[error("the peer's {what} exceeded the {limit}-byte request ceiling")]
208 RequestTooLarge {
209 /// The ceiling that was hit.
210 limit: usize,
211 /// Which part of the request overspent.
212 what: &'static str,
213 },
214
215 /// An object is larger than [`Limits::object_bytes`](crate::Limits::object_bytes)
216 /// allows gunnar to materialise while serving a fetch.
217 ///
218 /// Read from the object's **header**, before its content, so the ceiling
219 /// refuses the transfer rather than reporting on an allocation that has
220 /// already happened. Refused rather than skipped: a pack missing an object
221 /// its trees name is a corrupt clone, which is worse than no clone.
222 #[error(
223 "object {oid} is {size} bytes, above the {limit}-byte ceiling on what gunnar will \
224 materialise to serve a fetch"
225 )]
226 ObjectTooLarge {
227 /// The object that is too big.
228 oid: ObjectId,
229 /// Its size, from its header.
230 size: u64,
231 /// The ceiling that was hit.
232 limit: u64,
233 },
234}
235
236fn context_suffix(context: &Option<String>) -> String {
237 match context {
238 Some(what) => format!(" ({what})"),
239 None => String::new(),
240 }
241}
242
243impl Error {
244 /// A packfile could not be written or indexed.
245 ///
246 /// `pub` rather than `pub(crate)` because the only callers are outside this
247 /// crate: a [`PackIngest`](crate::receive_pack::PackIngest) is
248 /// caller-supplied, so whoever indexes the pack is also whoever names why
249 /// it would not index. See [`Error::Pack`].
250 pub fn pack(what: impl std::fmt::Display) -> Self {
251 Error::Pack(what.to_string())
252 }
253
254 pub(crate) fn protocol(what: impl std::fmt::Display) -> Self {
255 Error::Protocol(what.to_string())
256 }
257
258 pub(crate) fn missing(oid: ObjectId, context: impl Into<String>) -> Self {
259 Error::ObjectMissing {
260 oid,
261 context: Some(context.into()),
262 }
263 }
264
265 /// The one-line text this error is worth sending to a client, without
266 /// leaking server paths or internals.
267 ///
268 /// # Why this is not `to_string()`
269 ///
270 /// It was, and the doc comment above it made the claim anyway. Variants of
271 /// this enum are `#[error(transparent)]` over another crate's error —
272 /// [`Error::Io`] and [`Error::Store`], and until 2026-08-10
273 /// [`Error::Pack`] as well — so whatever the storage layer said went out
274 /// verbatim. `gunnar_store::Error::StoreLocked`
275 /// renders the **absolute path of the lock file**, and
276 /// [`crate::receive_pack`] puts `client_message()` straight into the
277 /// `unpack` line of a push report, so `git push` printed a server path to
278 /// whoever pushed. `gunnar-ssh` and `gunnar-lfs` both redact at this seam
279 /// already (`DispatchError::client_message`, `gunnar_lfs::Error::client_message`);
280 /// this crate was the one that did not.
281 ///
282 /// Everything else stays verbatim, and that is deliberate: an oid, a
283 /// `filter` spec, an argument name and a byte ceiling are either the
284 /// client's own words or facts about the protocol, and they are what makes
285 /// a refusal actionable without a server login. git's own `upload-pack`
286 /// tells a client which object it wanted and could not have.
287 ///
288 /// The unredacted text is still what the server **logs** — see
289 /// `git.upload_pack.failed`, which carries `%err` and not this.
290 pub fn client_message(&self) -> String {
291 match self {
292 Error::Io(_) => "the connection failed".to_owned(),
293 Error::Store(_) => "the repository is unavailable; try again".to_owned(),
294 Error::Pack(_) => "the packfile could not be processed".to_owned(),
295 other => other.to_string(),
296 }
297 }
298}
299
300/// This crate's result type.
301pub type Result<T> = std::result::Result<T, Error>;