gunnar-wire 1.5.0

git smart-protocol state machines: upload-pack (v2 with a v0 fallback) and receive-pack (v0), transport-agnostic
Documentation
//! 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>;