gunnar-lfs 1.6.0

Git LFS over pure SSH: the git-lfs 3.0 `git-lfs-transfer` protocol, server side, streaming and without HTTP.
Documentation
//! Failures, and the HTTP status each becomes on the wire.
//!
//! The pure-SSH protocol answers every command with an HTTP status code. The
//! same codes the HTTP batch API uses, deliberately, so a server can proxy to an
//! HTTP backend. The mapping lives here, once, so no call site invents a code.

/// What went wrong.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// The stream failed, or ended mid-frame. **Fatal to the session**: there
    /// is no status to answer with, because there is no longer a channel to
    /// answer on.
    #[error("i/o error: {0}")]
    Io(#[from] std::io::Error),

    /// The framing was malformed. Also fatal: once a pkt-line boundary is lost
    /// there is no way to find the next command, and answering "400" into a
    /// desynced stream would put four more bytes of garbage on it.
    #[error("pkt-line framing: {0}")]
    Framing(String),

    /// The client sent something this protocol does not define. Answered, and
    /// the session continues.
    #[error("{0}")]
    BadRequest(String),

    /// The client asked for something the operation does not admit, a
    /// `put-object` on a `download` session for instance.
    ///
    /// **405 and not the 403 `docs/api/batch.md` names, and the difference is
    /// measured.** That table's 403 is *"the user has **read**, but not
    /// **write** access. Only applicable when the `operation` in the request is
    /// upload"*, and it governs the **batch endpoint**. This is the object
    /// endpoint, and there git-lfs reads 403 as something else entirely:
    /// `tq/ssh.go`, in `SSHAdapter.upload`,
    ///
    /// ```text
    /// // A status code of 403 likely means that an authentication token for the
    /// // upload has expired. This can be safely retried.
    /// if status == 403 {
    ///     return errors.NewRetriableError(err)
    /// }
    /// ```
    ///
    /// so 403 buys `lfs.transfer.maxretries` (default 8, `tq/manifest.go`)
    /// futile round trips before the same refusal is reported, because an SSH
    /// grant that is download-only will still be download-only on the eighth
    /// try. 405 is reported at once. The reference server,
    /// `charmbracelet/git-lfs-transfer`, also answers 405 here
    /// (`ErrNotAllowed` → `StatusMethodNotAllowed`, `transfer/processor.go`).
    ///
    /// The condition the table's 403 describes — a *batch* refused because the
    /// grant does not permit writing — cannot arise here: over pure SSH the
    /// operation is in the exec line, and `gunnar_ssh::command::parse` has
    /// already demanded Write before an `upload` session exists at all.
    #[error("{0}")]
    NotAllowed(String),

    /// The client and the server cannot agree on a parameter of the request.
    ///
    /// Today that is only the hash algorithm, and 409 is the code
    /// `docs/api/batch.md` assigns it by name: *"409 - The specified hash
    /// algorithm disagrees with the server's acceptable options."* It is the
    /// only code the specification gives that condition. The reference server
    /// puts it in its generic `ErrNotAllowed` bucket and answers 405; the client
    /// treats any non-200 batch status the same way (`tq/ssh.go`, `Batch`:
    /// `if status != 200 { return errors.New(...) }`, not retriable either way),
    /// so saying what the specification says costs nothing.
    #[error("{0}")]
    Conflict(String),

    /// The object is not here.
    #[error("{0}")]
    NotFound(String),

    /// A verb this server does not implement: the three lock commands.
    ///
    /// **501 and not 405, and the difference is measured.** git-lfs's
    /// `SearchLocksVerifiable` maps 501 (and 404) to `NewNotImplementedError`,
    /// which `lockVerifier::Verify` turns into `disableFor(url)`: locking
    /// switches itself off and the push proceeds silently. Any other 4xx takes
    /// the branch that prints *"Remote %q does not support the Git LFS locking
    /// API. Consider disabling it with: git config lfs.<url>.locksverify false"*
    /// on every push. Same refusal, one is quiet and correct.
    #[error("{0}")]
    NotImplemented(String),

    /// The object is larger than this server accepts. **Answered before the
    /// bytes arrive** wherever the size is declared up front.
    #[error("{0}")]
    TooLarge(String),

    /// The bytes did not hash to the oid they were sent under, or did not match
    /// the declared size. The object is discarded, never committed.
    #[error("{0}")]
    Corrupt(String),

    /// The backing store failed.
    #[error("storage: {0}")]
    Store(String),

    /// The store has no room. `ENOSPC`, `EDQUOT`, or a file the filesystem will
    /// not let grow any further.
    ///
    /// **507, which `docs/api/batch.md` lists by name** — *"the server has
    /// insufficient storage capacity to complete the request"* — and it is a
    /// different fact from [`Error::TooLarge`]'s 413. 413 is *"this object is
    /// bigger than I accept"*, a policy the operator set, identical tomorrow.
    /// 507 is *"the disk is full"*, which the operator can fix and which says
    /// nothing about the object. Answering 500 for it, as this crate did until
    /// 2026-08-05, told an operator their server was broken when what they
    /// needed was a bigger volume.
    ///
    /// It is **not fatal**, which is the larger half of the fix. A full disk
    /// used to surface as [`Error::Io`] straight out of `write_all`, and `Io` is
    /// fatal and has no [`Error::final_report`] — so `git lfs push` onto a full
    /// volume got a **dropped SSH channel and no status line at all**, the same
    /// shape as the refusal the drain used to swallow. Now the session answers
    /// `507`, drains, and stays up.
    #[error("{0}")]
    OutOfSpace(String),

    /// **The TENANT has no room left**, as distinct from [`Error::OutOfSpace`]'s
    /// *the server* has none. Same 507, and deliberately so; a different string
    /// reaching the client, and deliberately so.
    ///
    /// # Why not simply reuse `OutOfSpace`
    ///
    /// Tried first, and it failed on the one thing that matters. `OutOfSpace`'s
    /// [`Error::client_message`] is a fixed sentence — *"the server has
    /// insufficient storage capacity for this object"* — precisely because its
    /// payload is an operator's diagnostic that may name a path, and paths do
    /// not go on the wire. That is right for `ENOSPC`. It is wrong here, and
    /// twice over:
    ///
    /// 1. It tells the customer the WRONG THING. Their push failed because their
    ///    plan is spent, which they can fix in a minute with a card; "the server
    ///    has insufficient storage capacity" says the operator's disk is full,
    ///    which they cannot fix at all and which invites a support ticket
    ///    instead of a purchase.
    /// 2. It throws away the only sentence that was composed FOR the client.
    ///    [`crate::Allowance::refusal`] arrives already naming the plan, the cap,
    ///    what is held and the pay page — assembled by `gunnar-server` from the
    ///    signed entitlement fact, the same way `gunnar_wire::PackCeiling`
    ///    carries the pack door's refusal to where the bytes are.
    ///
    /// So: one status code, because from the client's side of the wire "there is
    /// no room for this" is one fact and git-lfs should treat it identically;
    /// two variants, because what the human reads afterwards is a different
    /// sentence with a different remedy. `client_message` passes this one
    /// through verbatim — it contains nothing the server composed for itself.
    #[error("{0}")]
    NoRoomLeft(String),

    /// A refusal the server could not resynchronise the stream after.
    ///
    /// # Why the refusal is not simply lost
    ///
    /// `put-object`'s refusals are decided from the arguments, while the client
    /// is already writing the object. To stay in sync the server drains the
    /// payload it just refused, and that drain is bounded
    /// ([`Limits::max_drain_bytes`](crate::Limits::max_drain_bytes), two
    /// chunks). The drain used to be spelled `self.drain_data_section(input)?`,
    /// so once the client sent past the budget the drain's own `Framing` error
    /// **replaced** the refusal — and `Framing` is fatal, so the session ended
    /// having written no status line at all.
    ///
    /// The budget is 128 KiB and the quota is 8 GiB, so *every* refusal of an
    /// object big enough to be worth refusing took that path: `git lfs push` of
    /// a 9 GiB file got a dropped SSH channel where a `413` and its message
    /// were already composed. Only the smallest possible case survived, which is
    /// the only case `tests/quota.rs` sent — one chunk, exactly under the
    /// budget, with a comment saying it was two.
    ///
    /// So the two facts are kept apart: the refusal is what the client is told,
    /// the reason for closing is why the session then ends.
    ///
    /// # A second reason to close, added 2026-09-11 (GUN-017)
    ///
    /// The desync above is one way a refused request must not simply be
    /// followed by another. The other is a client that CHARGED the namespace's
    /// allowance and then did not deliver what it charged for: `put-object`
    /// admits against the declared size before the payload arrives, the ledger
    /// is a monotone counter with nothing to give back (deliberately — see
    /// `ledger.rs`), and a non-fatal refusal let the same client declare again
    /// immediately. So the field is "why the session then ends" rather than
    /// "the desync", and `Display` no longer asserts which of the two it was.
    #[error("{refusal} (and the session is closing: {closing})")]
    RefusedThenClosed {
        /// What the request was refused for. This is what the client is told.
        refusal: Box<Error>,
        /// Why the session cannot simply carry on afterwards. Log only.
        closing: Box<Error>,
    },
}

impl Error {
    /// The failure a **store** operation's `io::Error` becomes.
    ///
    /// One function, called by every backend, so no backend decides for itself
    /// whether a full disk is a 500 or a 507 (one implementation). `context`
    /// is the operator's half — `"write big.bin"`, `"rename … → …"` — and
    /// never reaches the client; [`Error::client_message`] replaces it.
    ///
    /// The three kinds that mean *no room* are separated from every other I/O
    /// failure. They are all stable since Rust 1.83 and the crate's MSRV is 1.85:
    ///
    /// * `StorageFull` — `ENOSPC`, the volume is full;
    /// * `QuotaExceeded` — `EDQUOT`, this user's or project's quota is;
    /// * `FileTooLarge` — `EFBIG`, past what the filesystem will let one file be.
    ///
    /// Everything else — a permissions problem, a failing disk, an unmounted
    /// path — stays [`Error::Store`] and 500, because those really are *"this
    /// server is broken"*.
    pub fn store_io(context: impl std::fmt::Display, err: std::io::Error) -> Error {
        use std::io::ErrorKind;
        match err.kind() {
            ErrorKind::StorageFull | ErrorKind::QuotaExceeded | ErrorKind::FileTooLarge => {
                Error::OutOfSpace(format!("{context}: {err}"))
            }
            _ => Error::Store(format!("{context}: {err}")),
        }
    }

    /// The HTTP status code this failure is reported as.
    pub fn status(&self) -> u16 {
        match self {
            // Fatal ones still have a code, for the log and for the one case
            // where a partially-written response is better than none.
            Error::Io(_) | Error::Framing(_) => 500,
            Error::BadRequest(_) => 400,
            Error::NotAllowed(_) => 405,
            Error::NotImplemented(_) => 501,
            Error::NotFound(_) => 404,
            Error::Conflict(_) => 409,
            Error::TooLarge(_) => 413,
            // One code for two facts, on purpose: git-lfs must treat "there
            // is no room for this" the same way whoever ran out of it. See
            // `Error::NoRoomLeft`.
            Error::OutOfSpace(_) | Error::NoRoomLeft(_) => 507,
            // 422 and not 400: the request was well-formed, the *content* was
            // wrong. git-lfs's HTTP API uses 422 for exactly this, and the
            // client treats 4xx as non-retriable, which is right, since
            // resending the same bytes would fail the same way.
            Error::Corrupt(_) => 422,
            Error::Store(_) => 500,
            // The refusal's code, not 500. Which code the client sees is the
            // whole point of the variant.
            Error::RefusedThenClosed { refusal, .. } => refusal.status(),
        }
    }

    /// Does this failure end the session, or merely the command?
    ///
    /// **Only framing and I/O are fatal.** Everything else is a decision about
    /// one command, and a server that dropped the connection on a 404 would
    /// make a single missing object fail a whole fetch.
    pub fn is_fatal(&self) -> bool {
        matches!(
            self,
            Error::Io(_) | Error::Framing(_) | Error::RefusedThenClosed { .. }
        )
    }

    /// The status line a **fatal** failure still owes the client before the
    /// session closes, if it has one.
    ///
    /// `None` for [`Error::Io`] and [`Error::Framing`]: the first means the
    /// channel is already gone, and the second means the server no longer knows
    /// where a packet boundary is, so anything it writes lands in the middle of
    /// something. [`Error::RefusedThenClosed`] is the case where the server knows
    /// exactly what it decided and simply cannot keep talking afterwards — and
    /// there, saying it is the difference between `413 object … is 9663676416
    /// bytes; this server accepts at most 8589934592` and a dropped connection.
    pub fn final_report(&self) -> Option<(u16, String)> {
        match self {
            Error::RefusedThenClosed { refusal, .. } => {
                Some((refusal.status(), refusal.client_message()))
            }
            _ => None,
        }
    }

    /// What the client is told. Never carries a path, a principal or a store
    /// detail. The server log keeps those.
    pub fn client_message(&self) -> String {
        match self {
            Error::Io(_) | Error::Framing(_) => "the connection failed".to_string(),
            Error::Store(_) => "the object store is unavailable".to_string(),
            // The one store failure the client IS told the nature of. It carries
            // no path and no principal — "the disk is full" is not a leak, and
            // "retrying will not help, tell your administrator" is exactly what
            // the person watching the push needs to know.
            Error::OutOfSpace(_) => {
                "the server has insufficient storage capacity for this object".to_string()
            }
            // The refusal's words. The desync is the server's problem and
            // carries a byte budget the client has no use for.
            Error::RefusedThenClosed { refusal, .. } => refusal.client_message(),
            other => other.to_string(),
        }
    }
}

impl From<gunnar_pktline::Error> for Error {
    fn from(err: gunnar_pktline::Error) -> Self {
        match err {
            gunnar_pktline::Error::Io(io) => Error::Io(io),
            other => Error::Framing(other.to_string()),
        }
    }
}

/// This crate's result type.
pub type Result<T> = std::result::Result<T, Error>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_failure_maps_to_a_distinct_and_correct_status() {
        assert_eq!(Error::BadRequest("x".into()).status(), 400);
        assert_eq!(Error::NotFound("x".into()).status(), 404);
        assert_eq!(Error::NotAllowed("x".into()).status(), 405);
        assert_eq!(Error::NotImplemented("x".into()).status(), 501);
        assert_eq!(Error::TooLarge("x".into()).status(), 413);
        assert_eq!(Error::Corrupt("x".into()).status(), 422);
        assert_eq!(Error::Store("x".into()).status(), 500);
        // The red half: a `status()` returning a constant would pass a test
        // that only checked one variant.
        assert_ne!(
            Error::NotFound("x".into()).status(),
            Error::TooLarge("x".into()).status()
        );
    }

    #[test]
    fn only_transport_failures_are_fatal() {
        assert!(Error::Framing("x".into()).is_fatal());
        assert!(Error::Io(std::io::Error::other("x")).is_fatal());
        // A 404 that killed the session would fail a whole fetch over one
        // absent object.
        for survivable in [
            Error::BadRequest("x".into()),
            Error::NotAllowed("x".into()),
            Error::NotImplemented("x".into()),
            Error::NotFound("x".into()),
            Error::TooLarge("x".into()),
            Error::Corrupt("x".into()),
            Error::Store("x".into()),
        ] {
            assert!(!survivable.is_fatal(), "{survivable:?} must not be fatal");
        }
    }
}