Skip to main content

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