alkgit 0.1.0

Git smart protocol for the alk family: pkt-line substrates, protocol V2 serving (upload-pack/receive-pack), producer/consumer halves on alkcall channels, and the backend seam traits — the shipped alkstore+alkblobs composition is the alkgit-substrate crate (ADR-010/025)
Documentation
//! The substrate layer's shared vocabulary: the service selector (ADR-016)
//! and the transport-level error taxonomy (ADR-005 §4).
//!
//! Every session substrate — duplex ([`crate::duplex`]) and stateless
//! alike — receives the git *service* explicitly at establishment
//! (ADR-005 §1 as amended by ADR-016: the open-op params `{repo,
//! service}` on the channels path, the git-daemon request line on the
//! direct-ALPN path, and the door's route on the http path). The
//! service is *the* state-machine selector: fetch is V2-only (ADR-003)
//! and push is V0-framed unconditionally (ADR-013 §1), so the service
//! alone fully determines the advertisement and state machine.
//!
//! Errors ([`SubstrateError`]) are a transport-level taxonomy per
//! ADR-005 §4: transport failures are terminal — the session ends, and
//! on duplex it closes; protocol-shaped failures map to pkt-line error
//! bands (duplex) or http statuses (stateless, later wave) and then end
//! the session as well. Machinery-misuse errors are neither: they are
//! recoverable by correct use of the substrate and never reach the
//! wire.

use std::fmt;
use std::io;
use std::str::FromStr;

use thiserror::Error;

/// The git service a session serves (ADR-016 §3): the dimension that
/// selects the advertisement and the state machine before any protocol
/// byte flows.
///
/// The wire strings are the ADR-016 enum — `"git-upload-pack"` /
/// `"git-receive-pack"` — shared across every door and preamble
/// carrier. `git-upload-archive` is deliberately absent: no door or
/// gate serves it (ADR-016 §1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Service {
    /// `git-upload-pack` — fetch (protocol V2, ADR-003).
    UploadPack,
    /// `git-receive-pack` — push (V0-framed, ADR-013).
    ReceivePack,
}

impl Service {
    /// The ADR-016 wire string for this service.
    pub fn as_str(self) -> &'static str {
        match self {
            Service::UploadPack => "git-upload-pack",
            Service::ReceivePack => "git-receive-pack",
        }
    }

    /// Whether the service's authorization tier is the write side:
    /// fetch (`git-upload-pack`) authorizes as `read`, push
    /// (`git-receive-pack`) as `write` (ADR-011/015) — the boolean the
    /// wire layer hands the `authorize` policy evaluation; the
    /// three-action enumeration's third tier (`manage`) is the op
    /// family's, not a service's.
    pub fn is_write(self) -> bool {
        matches!(self, Service::ReceivePack)
    }
}

impl fmt::Display for Service {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The failure of [`Service::from_str`]: the wire value was not one of
/// the ADR-016 enum strings.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error(
    "unknown git service {0:?} (expected \"git-upload-pack\" or \"git-receive-pack\", ADR-016)"
)]
pub struct ServiceParseError(pub String);

impl FromStr for Service {
    type Err = ServiceParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "git-upload-pack" => Ok(Service::UploadPack),
            "git-receive-pack" => Ok(Service::ReceivePack),
            other => Err(ServiceParseError(other.to_owned())),
        }
    }
}

/// The transport-level substrate error taxonomy (ADR-005 §4).
///
/// Semantics by variant:
///
/// - [`SubstrateError::Io`] — **terminal**. The transport failed; the
///   session ends immediately and closes. No read is retried on a
///   terminal transport: after a terminal error the session's reader
///   stays inert (the unconditional error-break rule — an error-shaped
///   read that resets and retries spins a worker at 100%, the POC-3
///   recorded bug class).
/// - [`SubstrateError::Protocol`] — **terminal, band-mappable**. A
///   protocol-level failure (malformed pkt-line, a client ERR line, a
///   grammar violation). The machine maps it onto a pkt-line error band
///   via [`crate::duplex::Session::write_error_band`] before ending the
///   session — the error-band mapping is documented ADR-005 §4 behavior;
///   the enum carries the human-readable message only (the packetline
///   raw decode type never surfaces past the substrate).
/// - [`SubstrateError::AccessDenied`] — **terminal, the generic wire
///   denial** (the serving entries' pre-protocol gate): a resolution
///   failure and an authorization denial collapse to this one variant
///   (unknown ≡ unauthorized — ADR-008/N-2), and every denial carries
///   the same wire shape, so no caller can distinguish the causes (no
///   existence oracle).
/// - [`SubstrateError::ResetRequired`], [`SubstrateError::WriterTaken`],
///   and [`SubstrateError::RawTailUnavailable`] — **machinery
///   misuse**, not terminal: they are recoverable by correctly using
///   the substrate API and never map onto the wire.
#[derive(Debug, Error)]
pub enum SubstrateError {
    /// A transport failure — terminal, the session ends (ADR-005 §4).
    #[error("transport io: {0}")]
    Io(#[from] io::Error),
    /// A protocol-level failure — map onto a pkt-line error band (or an
    /// http status, stateless substrate) before ending the session.
    #[error("protocol error: {0}")]
    Protocol(String),
    /// The request reader ended its previous request with the client's
    /// terminating flush; [`crate::duplex::Session::reset_request`] must
    /// re-arm it before the next request can be read.
    ///
    /// This is the loud form of the stop-delimiter discipline: without
    /// reset the packetline iterator is inert and would silently swallow
    /// the next command (POC-1's second hang) — the substrate refuses
    /// instead of swallowing.
    #[error(
        "the request reader completed a request; reset_request is required before the next request"
    )]
    ResetRequired,
    /// The response writer is held by a sideband sink; finish the sink
    /// (restoring the writer) before any text/error-band write.
    #[error(
        "the response writer is held by a sideband sink; finish_sideband must restore it first"
    )]
    WriterTaken,
    /// The raw-tail escape ([`crate::duplex::Session::take_raw_tail`] /
    /// [`crate::stateless::StatelessRequest::take_raw_tail`]) was
    /// refused — machinery misuse, never a wire shape: the escape
    /// requires a completed request parse, is one-shot (the reader is
    /// gone once surrendered), and later pkt-line reads after the
    /// surrender are the same misuse.
    #[error("the raw tail is unavailable: {0}")]
    RawTailUnavailable(&'static str),
    /// The generic session denial (ADR-008/N-2): the resolve→authorize
    /// gate collapsed to denial — unknown-repo and every other registry
    /// resolution failure and a failed policy evaluation are
    /// indistinguishable here. On the duplex substrate the denial is the
    /// generic error band + close; the variant reports it to the caller
    /// (metrics/telemetry), never re-entering the session.
    #[error("access denied")]
    AccessDenied,
}

impl SubstrateError {
    /// Whether this error terminates the session (ADR-005 §4: io errors
    /// are terminal; protocol errors are band-mapped and then the
    /// session ends; the access denial ends the session too; machinery
    /// misuse is recoverable).
    pub fn is_terminal(&self) -> bool {
        matches!(
            self,
            SubstrateError::Io(_) | SubstrateError::Protocol(_) | SubstrateError::AccessDenied
        )
    }
}

impl Clone for SubstrateError {
    /// `io::Error` is cloned lossily as (kind, message) — the first
    /// occurrence keeps the original; clones re-materialize it. This
    /// exists so a cached terminal error can be re-reported without
    /// re-entering the transport (the error-break rule).
    fn clone(&self) -> Self {
        match self {
            SubstrateError::Io(e) => SubstrateError::Io(io::Error::new(e.kind(), e.to_string())),
            SubstrateError::Protocol(m) => SubstrateError::Protocol(m.clone()),
            SubstrateError::ResetRequired => SubstrateError::ResetRequired,
            SubstrateError::WriterTaken => SubstrateError::WriterTaken,
            SubstrateError::RawTailUnavailable(m) => SubstrateError::RawTailUnavailable(m),
            SubstrateError::AccessDenied => SubstrateError::AccessDenied,
        }
    }
}

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

    /// ADR-016 §1/§3: the service enum is exactly the two wire strings;
    /// git-upload-archive is absent by ADR-016 §1's fixed refusal.
    #[test]
    fn service_wire_strings_round_trip() {
        for (service, wire) in [
            (Service::UploadPack, "git-upload-pack"),
            (Service::ReceivePack, "git-receive-pack"),
        ] {
            assert_eq!(service.as_str(), wire);
            assert_eq!(wire.parse::<Service>().unwrap(), service);
            assert_eq!(Service::from_str(wire).unwrap(), service);
            assert_eq!(service.to_string(), wire);
        }
    }

    #[test]
    fn service_refuses_unknown_stages() {
        let err = "git-upload-archive".parse::<Service>().unwrap_err();
        assert_eq!(err, ServiceParseError("git-upload-archive".to_owned()));
        assert!(
            err.to_string().contains("git-upload-archive"),
            "the message names the offending value: {err}"
        );
    }

    #[test]
    fn service_is_the_write_tier_selector() {
        assert!(!Service::UploadPack.is_write());
        assert!(Service::ReceivePack.is_write());
    }

    /// ADR-005 §4: io and protocol failures are terminal; the access
    /// denial terminates too; machinery misuse is recoverable and never
    /// reaches the wire.
    #[test]
    fn taxonomy_labels_only_transport_and_protocol_errors_terminal() {
        assert!(SubstrateError::Io(io::Error::other("gone")).is_terminal());
        assert!(SubstrateError::Protocol("no command line".into()).is_terminal());
        assert!(SubstrateError::AccessDenied.is_terminal());
        assert!(!SubstrateError::ResetRequired.is_terminal());
        assert!(!SubstrateError::WriterTaken.is_terminal());
        assert!(!SubstrateError::RawTailUnavailable("gone").is_terminal());
        assert!(
            SubstrateError::RawTailUnavailable("gone")
                .clone()
                .to_string()
                .contains("gone"),
            "the misuse variant clones with its refusal message"
        );
    }

    /// The Clone lossiness contract: a cached Io terminal re-reports
    /// with the same kind (the error-break rule relies on re-reporting
    /// without re-entering the transport).
    #[test]
    fn io_errors_clone_preserves_kind() {
        let original = SubstrateError::Io(io::Error::new(
            io::ErrorKind::ConnectionReset,
            "peer reset the stream",
        ));
        let cloned = original.clone();
        assert!(matches!(cloned, SubstrateError::Io(_)));
        let SubstrateError::Io(ref e) = cloned else {
            unreachable!()
        };
        assert_eq!(e.kind(), io::ErrorKind::ConnectionReset);
        assert!(e.to_string().contains("peer reset the stream"));
    }
}