crates/gunnar-wire/src/error.rs
//! 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>;