Skip to main content

mkit_core/
protocol.rs

1//! Cross-transport types: error taxonomy, the [`Transport`] trait, the
2//! [`PackKey`] digest wrapper, and the retry/backoff helpers used by
3//! every transport implementation (memory, file, HTTP, S3, SSH, enc).
4//! [`retrying`] is the single shared driver of the SPEC-TRANSPORT §7
5//! ladder — every transport's `retrying`/`with_retry` method is a thin
6//! wrapper around it, so the backoff/classification policy lives in
7//! exactly one place instead of being reimplemented per crate.
8//!
9//! The SSH wire format is defined in `mkit-rpc`'s `ssh.proto` and
10//! lives in `mkit_rpc::mkit::rpc::v1::ssh`; transport-ssh consumes
11//! the schema directly. The hand-rolled `OP_HELLO` byte format that
12//! used to live in this module has been retired.
13
14// SPEC-TRANSPORT §7 calls out the exponential ladder in seconds
15// (1, 2, 4, …, 300). Expressing those values with `Duration::from_secs`
16// is deliberate — switching to `from_mins` loses the one-to-one match
17// with the spec text.
18#![allow(clippy::duration_suboptimal_units)]
19
20use core::fmt;
21use core::time::Duration;
22
23use crate::hash::{FromHexError, Hash, to_hex};
24use crate::refs::Ref;
25pub use crate::refs::RefWriteCondition;
26
27// ---------------------------------------------------------------------------
28// Error taxonomy
29// ---------------------------------------------------------------------------
30
31/// Errors that any transport may surface across the [`Transport`]
32/// boundary. Implementations MAY wrap transport-specific errors
33/// internally but MUST map them to one of these variants before
34/// returning.
35#[derive(Debug, thiserror::Error)]
36pub enum TransportError {
37    /// `download_pack` called on a digest the remote does not hold.
38    #[error("pack not found on remote")]
39    PackNotFound,
40    /// Authentication or ACL failure (HTTP 401/403, SSH auth refusal,
41    /// S3 `SignatureDoesNotMatch`, …).
42    #[error("access denied by remote")]
43    AccessDenied,
44    /// Catch-all remote-side failure carrying an advisory message. The
45    /// message is for operators; programs MUST NOT pattern-match on its
46    /// contents.
47    #[error("remote error: {0}")]
48    RemoteError(String),
49    /// `update_ref` CAS precondition was not satisfied. Per
50    /// SPEC-TRANSPORT §7, callers MUST treat this as
51    /// "possibly-success on retry" for `.missing` / `.match` and
52    /// confirm with `read_ref`.
53    #[error("ref CAS precondition failed")]
54    RefConflict,
55    /// Caller passed a ref name failing SPEC-REFS §3.
56    #[error("invalid ref name: {0}")]
57    InvalidRef(String),
58    /// Network-level failure: DNS, TCP connect, TLS handshake, SSH
59    /// subprocess spawn. Retryable (see [`is_retryable`]).
60    #[error("connection to remote failed")]
61    ConnectionFailed,
62    /// Unexpected HTTP status or transport-protocol error. 5xx and 429
63    /// are retryable; 4xx (except 401/403/404/409/412) is not.
64    #[error("server error (status {status})")]
65    ServerError {
66        /// Numeric status code. HTTP uses its native codes; transports
67        /// without a status integer use `0`.
68        status: u16,
69    },
70    /// Server response did not match the wire contract (truncated
71    /// frame, unknown opcode, bad JSON, …).
72    #[error("invalid response from remote")]
73    InvalidResponse,
74    /// Generic protocol-level failure — malformed frame, unexpected
75    /// opcode order, or failed handshake.
76    #[error("protocol error")]
77    ProtocolError,
78    /// Payload exceeded a transport-specific cap.
79    #[error("payload too large: {0} bytes")]
80    PayloadTooLarge(usize),
81    /// An insecure URL scheme (plain `http://`) was supplied for a
82    /// non-loopback host. Plain HTTP is restricted to loopback addresses
83    /// (`127.0.0.1`, `::1`, `localhost`) so production traffic is never
84    /// transported in the clear.
85    #[error("insecure scheme: plain http:// is allowed only for loopback hosts")]
86    InsecureScheme,
87}
88
89/// Result alias used throughout this module.
90pub type TransportResult<T> = Result<T, TransportError>;
91
92// ---------------------------------------------------------------------------
93// PackKey — 32-byte digest wrapper
94// ---------------------------------------------------------------------------
95
96/// A 32-byte pack digest used as the content-address for an uploaded
97/// pack. This is the same 32 bytes as [`Hash`](tyalias@Hash) but wrapped so pack
98/// digests and object hashes do not silently cross purposes at API
99/// boundaries.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
101pub struct PackKey(pub [u8; 32]);
102
103impl PackKey {
104    /// Build a [`PackKey`] from a raw 32-byte digest.
105    #[must_use]
106    pub const fn new(bytes: [u8; 32]) -> Self {
107        Self(bytes)
108    }
109
110    /// Borrow the underlying 32 bytes.
111    #[must_use]
112    pub const fn as_bytes(&self) -> &[u8; 32] {
113        &self.0
114    }
115
116    /// Lowercase 64-char hex.
117    #[must_use]
118    pub fn to_hex(&self) -> String {
119        to_hex(&self.0)
120    }
121
122    /// Build a [`PackKey`] from a [`Hash`](tyalias@Hash) (alias for [`From`]).
123    #[must_use]
124    pub const fn from_hash(h: Hash) -> Self {
125        Self(h)
126    }
127
128    /// Convert back to a plain [`Hash`](tyalias@Hash).
129    #[must_use]
130    pub const fn into_hash(self) -> Hash {
131        self.0
132    }
133}
134
135impl From<Hash> for PackKey {
136    fn from(h: Hash) -> Self {
137        Self(h)
138    }
139}
140
141impl From<PackKey> for Hash {
142    fn from(k: PackKey) -> Hash {
143        k.0
144    }
145}
146
147impl fmt::Display for PackKey {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(&self.to_hex())
150    }
151}
152
153/// Parse a [`PackKey`] from a 64-char lowercase hex string.
154///
155/// Accepts uppercase too (matches the permissive [`crate::hash::from_hex`]
156/// semantics); callers that require lowercase MUST validate the input
157/// independently.
158pub fn pack_key_from_hex(s: &str) -> Result<PackKey, FromHexError> {
159    let h = crate::hash::from_hex(s)?;
160    Ok(PackKey(h))
161}
162
163// ---------------------------------------------------------------------------
164// Retry / backoff
165// ---------------------------------------------------------------------------
166
167/// Return `true` if a transport should retry after seeing `err`.
168///
169/// Retryable per SPEC-TRANSPORT §7:
170/// - [`TransportError::ConnectionFailed`]
171/// - [`TransportError::ServerError`] with a 5xx status OR HTTP 429.
172///
173/// Explicitly non-retryable:
174/// - [`TransportError::PackNotFound`]
175/// - [`TransportError::AccessDenied`]
176/// - [`TransportError::RefConflict`] (CAS retry is a caller-level policy)
177/// - [`TransportError::InvalidRef`]
178/// - [`TransportError::InvalidResponse`] / [`TransportError::ProtocolError`]
179/// - [`TransportError::PayloadTooLarge`]
180/// - [`TransportError::RemoteError`] — the remote chose not to be specific;
181///   we do not guess.
182/// - [`TransportError::ServerError`] with any 4xx status.
183#[must_use]
184pub fn is_retryable(err: &TransportError) -> bool {
185    match err {
186        TransportError::ConnectionFailed => true,
187        TransportError::ServerError { status } => *status >= 500 || *status == 429,
188        _ => false,
189    }
190}
191
192/// Max attempts for the default backoff ladder.
193///
194/// SPEC-TRANSPORT §7: `attempt = 1; while attempt ≤ 5`.
195pub const BACKOFF_MAX_ATTEMPTS: u32 = 5;
196
197/// Initial sleep between attempts.
198pub const BACKOFF_INITIAL: Duration = Duration::from_secs(1);
199
200/// Upper bound on any individual sleep.
201pub const BACKOFF_CAP: Duration = Duration::from_secs(300);
202
203/// Per-pack body size ceiling enforced by every transport that ingests
204/// pack bytes (HTTP `Content-Length`, S3 `GetObject`, SSH
205/// `DownloadPackHeader.total_bytes`). On 64-bit targets, 4 GiB matches
206/// the pack-format addressable range; pointer-width-limited targets cap
207/// at their maximum addressable buffer size instead of failing to compile.
208#[cfg(target_pointer_width = "64")]
209pub const PACK_BODY_LIMIT: u64 = 4 * 1024 * 1024 * 1024;
210#[cfg(not(target_pointer_width = "64"))]
211pub const PACK_BODY_LIMIT: u64 = usize::MAX as u64;
212
213/// `usize`-typed mirror of [`PACK_BODY_LIMIT`] for `Vec`-shaped buffer
214/// caps. The assertion below prevents silent truncation on any target.
215#[allow(clippy::cast_possible_truncation)]
216pub const PACK_BODY_LIMIT_USIZE: usize = PACK_BODY_LIMIT as usize;
217const _: () = assert!(
218    (PACK_BODY_LIMIT_USIZE as u64) == PACK_BODY_LIMIT,
219    "PACK_BODY_LIMIT does not fit in usize on this target",
220);
221
222/// Exponential-backoff iterator used by all transports.
223///
224/// Yields `[1s, 2s, 4s, 8s, 16s]` (5 attempts) for the default ladder,
225/// doubling each step and capping at 300s. This is the ladder mandated
226/// by SPEC-TRANSPORT §7 for `ConnectionFailed`, 5xx, and HTTP 429.
227///
228/// The iterator is self-contained — it holds no reference to a clock,
229/// so it can be constructed in tests and exhaustively enumerated.
230#[derive(Debug, Clone)]
231pub struct BackoffIterator {
232    next_delay: Duration,
233    attempts_remaining: u32,
234    cap: Duration,
235}
236
237impl BackoffIterator {
238    /// Default ladder: 5 attempts, starting at 1s, doubling, capped at 300s.
239    #[must_use]
240    pub const fn new() -> Self {
241        Self {
242            next_delay: BACKOFF_INITIAL,
243            attempts_remaining: BACKOFF_MAX_ATTEMPTS,
244            cap: BACKOFF_CAP,
245        }
246    }
247
248    /// Custom ladder for tests.
249    #[must_use]
250    pub const fn with(initial: Duration, cap: Duration, attempts: u32) -> Self {
251        Self {
252            next_delay: initial,
253            attempts_remaining: attempts,
254            cap,
255        }
256    }
257}
258
259impl Default for BackoffIterator {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl Iterator for BackoffIterator {
266    type Item = Duration;
267
268    fn next(&mut self) -> Option<Self::Item> {
269        if self.attempts_remaining == 0 {
270            return None;
271        }
272        self.attempts_remaining -= 1;
273        let current = self.next_delay;
274        let doubled = current.saturating_mul(2);
275        self.next_delay = if doubled > self.cap {
276            self.cap
277        } else {
278            doubled
279        };
280        Some(current)
281    }
282}
283
284/// Transport-agnostic retry driver shared by every [`Transport`]
285/// implementation, so the SPEC-TRANSPORT §7 ladder lives in exactly one
286/// place instead of being reimplemented per crate. Extracted from what
287/// was `HttpTransport::retrying` in `mkit-transport-http`.
288///
289/// `op` is re-invoked from scratch on every attempt — it MUST perform a
290/// fresh, self-contained unit of work each call (a new HTTP request on
291/// the existing connection pool, a freshly-reconnected SSH child, a
292/// redialed encrypted session, …) rather than assuming any state left
293/// over from a failed prior attempt is still valid. This matters most
294/// for connection-oriented transports: a frame-level failure can leave
295/// a stream mid-message-desynced, so `op` reconnecting before retrying
296/// (rather than resuming on the same broken handle) is what makes the
297/// retry safe, not just present.
298///
299/// `backoff` is a ladder *factory* (not a live iterator) so a fresh
300/// ladder starts on every call to `retrying` — production uses
301/// [`BackoffIterator::new`], tests inject a short/deterministic ladder.
302/// `sleep` is the delay hook between attempts; production sleeps for
303/// the full duration, tests typically inject a no-op or recorder.
304///
305/// Retries only the classes [`is_retryable`] accepts
306/// (`ConnectionFailed`, `ServerError{5xx}`, `ServerError{429}`); every
307/// other error returns immediately on the first attempt.
308pub fn retrying<T>(
309    mut op: impl FnMut() -> TransportResult<T>,
310    backoff: fn() -> BackoffIterator,
311    sleep: fn(Duration),
312) -> TransportResult<T> {
313    let mut ladder = backoff();
314    loop {
315        match op() {
316            Ok(v) => return Ok(v),
317            Err(err) => {
318                if is_retryable(&err)
319                    && let Some(delay) = ladder.next()
320                {
321                    sleep(delay);
322                    continue;
323                }
324                return Err(err);
325            }
326        }
327    }
328}
329
330// ---------------------------------------------------------------------------
331// PackChunk — transport-agnostic streaming segment
332// ---------------------------------------------------------------------------
333
334/// One bounded-size segment of a streamed pack transfer.
335///
336/// Mirrors the wire-level `PackChunk` shape shared by the SSH and enc
337/// transports (`offset`, `data`, `last` — see
338/// `mkit-rpc/proto/mkit/rpc/v1/ssh/ssh.proto`) and the `mkit.transport.v1`
339/// Connect proto being designed for the HTTP
340/// reference worker, without this crate depending on any
341/// protobuf-generated type: `mkit-core` is the dependency root that
342/// `mkit-rpc` builds on, not the reverse (see this module's header
343/// comment), so the canonical protobuf `PackChunk` cannot be named here.
344/// Transports convert 1:1 between this type and their own wire
345/// representation.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct PackChunk {
348    /// Byte offset of `data` within the pack. Consecutive chunks in one
349    /// transfer MUST have ascending, contiguous offsets starting at 0 —
350    /// i.e. chunk *n*'s `offset` equals the sum of every prior chunk's
351    /// `data.len()`.
352    pub offset: u64,
353    /// Chunk payload. Transports typically bound this to a fixed
354    /// per-frame maximum (e.g. `mkit_rpc::CHUNK_DATA_MAX`, 800 KiB for
355    /// SSH/enc) so no single chunk forces a large allocation.
356    pub data: Vec<u8>,
357    /// `true` on the final chunk of the stream. An empty pack is still
358    /// represented as exactly one chunk with `last = true` and empty
359    /// `data` — a stream MUST NOT end silently without a `last = true`
360    /// chunk.
361    pub last: bool,
362}
363
364// ---------------------------------------------------------------------------
365// Transport trait
366// ---------------------------------------------------------------------------
367
368/// The mkit transport vtable.
369///
370/// Every transport (memory, file, HTTP, S3, SSH) implements this trait.
371/// Methods are synchronous and take `&self`; transports that need
372/// interior mutability (e.g. connection pools) MUST use a `Mutex` /
373/// `RwLock` internally. This keeps the trait object-safe.
374///
375/// All implementations MUST honour the retry policy in
376/// SPEC-TRANSPORT §7 internally OR document that the caller is
377/// responsible — the abstract trait takes no position. The
378/// [`is_retryable`] and [`BackoffIterator`] helpers are provided for
379/// implementations that embed the policy.
380pub trait Transport: Send + Sync {
381    /// Upload a pack. The digest is computed by the caller (BLAKE3 of
382    /// the full pack bytes) and used as the object key — servers MAY
383    /// dedupe on this key.
384    fn upload_pack(&self, bytes: &[u8], key: &PackKey) -> TransportResult<()>;
385
386    /// Download a pack by its digest.
387    ///
388    /// Returns [`TransportError::PackNotFound`] if the remote does not
389    /// hold this digest.
390    fn download_pack(&self, key: &PackKey) -> TransportResult<Vec<u8>>;
391
392    /// Upload a pack by streaming bounded-size [`PackChunk`]s instead of
393    /// requiring the whole pack materialized as one `&[u8]` up front.
394    ///
395    /// `total_bytes` is the caller-declared pack length — the wire
396    /// header most streaming transports send before the first chunk.
397    /// `chunks` MUST yield its segments in ascending contiguous `offset`
398    /// order and end with exactly one item whose `last` field is `true`
399    /// (an empty pack still yields one `last = true` chunk with empty
400    /// `data`); the accumulated `data` length across every yielded chunk
401    /// MUST equal `total_bytes`. The digest (`key`) is still computed by
402    /// the caller up front, exactly as for [`Self::upload_pack`] — this
403    /// method does not hash the stream itself.
404    ///
405    /// This is an additive, opt-in entry point: no existing transport is
406    /// forced to implement real streaming. The default impl buffers
407    /// `chunks` into one `Vec` (bounded by [`PACK_BODY_LIMIT_USIZE`]) and
408    /// delegates to [`Self::upload_pack`], so every transport gets a
409    /// working implementation with zero code — callers may always use
410    /// this entry point, even against a transport that has not opted
411    /// into streaming. Transports that can forward chunks directly to
412    /// their own wire (SSH, enc — see `mkit-transport-ssh`'s existing
413    /// `PackChunk` frame loop) SHOULD override this to avoid the buffer
414    /// and stay in bounded memory regardless of pack size.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`TransportError::ProtocolError`] if `chunks` never
419    /// yields a `last = true` item, or if the accumulated byte count
420    /// does not equal `total_bytes`. Returns
421    /// [`TransportError::PayloadTooLarge`] if `total_bytes` (or the
422    /// accumulated count) would exceed [`PACK_BODY_LIMIT`]. Propagates
423    /// any error yielded by `chunks` itself (e.g. the caller's own I/O
424    /// error while reading a pack off disk).
425    fn upload_pack_streaming(
426        &self,
427        key: &PackKey,
428        total_bytes: u64,
429        chunks: &mut dyn Iterator<Item = TransportResult<PackChunk>>,
430    ) -> TransportResult<()> {
431        if total_bytes > PACK_BODY_LIMIT {
432            return Err(TransportError::PayloadTooLarge(PACK_BODY_LIMIT_USIZE));
433        }
434        // `total_bytes <= PACK_BODY_LIMIT` was just checked, and
435        // `PACK_BODY_LIMIT_USIZE as u64 == PACK_BODY_LIMIT` is asserted
436        // at the constant's definition, so this conversion never
437        // truncates — `try_from` (rather than `as`) makes that provable
438        // to clippy instead of asserted in a comment.
439        let initial = usize::try_from(total_bytes).unwrap_or(PACK_BODY_LIMIT_USIZE);
440        let mut buf = Vec::with_capacity(initial);
441        let mut saw_last = false;
442        for chunk in chunks {
443            let c = chunk?;
444            if buf.len().saturating_add(c.data.len()) > PACK_BODY_LIMIT_USIZE {
445                return Err(TransportError::PayloadTooLarge(PACK_BODY_LIMIT_USIZE));
446            }
447            buf.extend_from_slice(&c.data);
448            if c.last {
449                saw_last = true;
450                break;
451            }
452        }
453        if !saw_last || buf.len() as u64 != total_bytes {
454            return Err(TransportError::ProtocolError);
455        }
456        self.upload_pack(&buf, key)
457    }
458
459    /// Download a pack as a lazy stream of bounded-size [`PackChunk`]s
460    /// instead of one big `Vec<u8>`.
461    ///
462    /// This is an additive, opt-in entry point mirroring
463    /// [`Self::upload_pack_streaming`]. The default impl calls
464    /// [`Self::download_pack`] eagerly (so it does not save memory by
465    /// itself) and wraps the whole result as a single `last = true`
466    /// chunk — every transport gets a working implementation with zero
467    /// code. Transports that can read their own wire incrementally (SSH,
468    /// enc) SHOULD override this to yield each wire chunk as it arrives,
469    /// keeping memory bounded to roughly one chunk at a time regardless
470    /// of total pack size.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`TransportError::PackNotFound`] immediately if the
475    /// remote does not hold `key` — a conforming implementation never
476    /// returns a stream that then fails its first item with
477    /// `PackNotFound`. Errors surfacing mid-stream (a malformed frame, a
478    /// connection drop) are yielded as `Err` items from the returned
479    /// iterator rather than failing this call itself, since an
480    /// overridden implementation may not know the transfer will fail
481    /// until partway through.
482    fn download_pack_streaming(
483        &self,
484        key: &PackKey,
485    ) -> TransportResult<Box<dyn Iterator<Item = TransportResult<PackChunk>> + '_>> {
486        let bytes = self.download_pack(key)?;
487        Ok(Box::new(core::iter::once(Ok(PackChunk {
488            offset: 0,
489            data: bytes,
490            last: true,
491        }))))
492    }
493
494    /// HEAD-check a pack. Cheaper than [`Self::download_pack`] on
495    /// network transports.
496    fn pack_exists(&self, key: &PackKey) -> TransportResult<bool>;
497
498    /// Upload a content-addressed **auxiliary blob** — transfer metadata
499    /// that is NOT a packfile (e.g. a packlist chain node, SPEC-PACKFILE is
500    /// silent on these). The key is BLAKE3 of `bytes`, exactly like a pack.
501    ///
502    /// Auxiliary blobs share the digest-keyed content-addressed store with
503    /// packs (the store is a general blob store; "pack" is just the primary
504    /// content kind), so the default impl delegates to [`Self::upload_pack`].
505    /// The distinct verb keeps the *kind* explicit at the call site so a
506    /// caller never has to infer "is this blob a packfile or metadata?".
507    fn upload_blob(&self, bytes: &[u8], key: &PackKey) -> TransportResult<()> {
508        self.upload_pack(bytes, key)
509    }
510
511    /// Download an auxiliary blob by digest. Counterpart to
512    /// [`Self::upload_blob`]; default impl delegates to
513    /// [`Self::download_pack`]. Returns [`TransportError::PackNotFound`] if
514    /// the remote does not hold this digest.
515    fn download_blob(&self, key: &PackKey) -> TransportResult<Vec<u8>> {
516        self.download_pack(key)
517    }
518
519    /// Unconditional ref write — equivalent to
520    /// `update_ref(name, RefWriteCondition::Any, hash)`.
521    ///
522    /// Default impl delegates to [`Self::update_ref`] so transports only
523    /// implement one entry point.
524    fn write_ref(&self, name: &str, hash: &Hash) -> TransportResult<()> {
525        self.update_ref(name, RefWriteCondition::Any, hash)
526    }
527
528    /// CAS ref write. See [`RefWriteCondition`].
529    ///
530    /// On `.missing` / `.match` CAS failure, returns
531    /// [`TransportError::RefConflict`]. Callers retrying after a
532    /// timeout MUST follow up with [`Self::read_ref`] to confirm
533    /// whether the first attempt actually landed (SPEC-TRANSPORT §7).
534    fn update_ref(
535        &self,
536        name: &str,
537        condition: RefWriteCondition,
538        hash: &Hash,
539    ) -> TransportResult<()>;
540
541    /// Read the current value of a ref, or `None` if it does not exist.
542    fn read_ref(&self, name: &str) -> TransportResult<Option<Hash>>;
543
544    /// List refs whose full name starts with `prefix`. Returned names
545    /// have `prefix` stripped per SPEC-REFS §4. An empty prefix lists
546    /// every ref.
547    fn list_refs(&self, prefix: &str) -> TransportResult<Vec<Ref>>;
548
549    /// Advance a branch by updating its **head ref and its packmap ref
550    /// together**, each under its own CAS precondition.
551    ///
552    /// This exists so the delta-transfer invariant — "if `head_ref` resolves
553    /// to T, the packmap reconstructs `closure(T)`" — can be upheld without a
554    /// window where the two refs disagree. A transport backed by a
555    /// transactional ref store SHOULD override this to apply both writes in
556    /// ONE transaction: then a failed advance changes nothing, and the head
557    /// is never observed past a packmap that can't yet reconstruct it.
558    ///
559    /// The default impl is the safe non-transactional approximation used by
560    /// stores without multi-ref transactions: it writes the **packmap first**
561    /// (durable before the head moves) then the head. A crash in between
562    /// leaves the head at its prior value and the packmap a superset — still
563    /// consistent for fetch. The [`AdvanceOutcome`] distinguishes a packmap
564    /// precondition failure (caller re-reads and retries the chain) from a
565    /// head precondition failure (caller treats it as non-fast-forward),
566    /// which a single `RefConflict` could not.
567    fn advance_refs(
568        &self,
569        head_ref: &str,
570        head_condition: RefWriteCondition,
571        head_value: &Hash,
572        packmap_ref: &str,
573        packmap_condition: RefWriteCondition,
574        packmap_value: &Hash,
575    ) -> TransportResult<AdvanceOutcome> {
576        match self.update_ref(packmap_ref, packmap_condition, packmap_value) {
577            Ok(()) => {}
578            Err(TransportError::RefConflict) => return Ok(AdvanceOutcome::PackmapConflict),
579            Err(e) => return Err(e),
580        }
581        match self.update_ref(head_ref, head_condition, head_value) {
582            Ok(()) => Ok(AdvanceOutcome::Committed),
583            Err(TransportError::RefConflict) => Ok(AdvanceOutcome::HeadConflict),
584            Err(e) => Err(e),
585        }
586    }
587
588    /// Whether [`Self::advance_refs`] commits the head + packmap advance as
589    /// one indivisible transaction, rather than the default's ordered
590    /// packmap-then-head writes.
591    ///
592    /// The default (non-transactional) `advance_refs` is safe for an
593    /// **appending** packmap write: per its doc comment, a crash or lost
594    /// head-CAS race between the two writes leaves the packmap a strict
595    /// superset of what the (unmoved) head needs — still reconstructable.
596    /// That safety argument does NOT extend to a packmap **reset** (a fresh
597    /// node with `prev = None`, produced by the pack-chain re-baseline,
598    /// mkit #406): a reset is not a superset of the prior chain, so a
599    /// packmap write that commits while the paired head write loses its CAS
600    /// would strand the (still-unmoved) head pointing at a commit whose
601    /// closure the reset packmap can no longer reconstruct (mkit #521).
602    ///
603    /// Callers MUST treat `false` (the default) as "never request a
604    /// packmap reset against this transport" — see
605    /// `remote_dispatch::push_branch`'s re-baseline gate. Override to
606    /// `true` ONLY when [`Self::advance_refs`] is overridden with a
607    /// genuinely transactional implementation (e.g. the HTTP transport's
608    /// single-request `/refs/advance` endpoint, mkit #408).
609    fn supports_atomic_advance(&self) -> bool {
610        false
611    }
612}
613
614/// Result of [`Transport::advance_refs`] — a two-ref branch advance.
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub enum AdvanceOutcome {
617    /// Both refs were updated.
618    Committed,
619    /// The head precondition did not hold (the branch moved under us). An
620    /// atomic transport leaves nothing changed; callers treat this as a
621    /// non-fast-forward.
622    HeadConflict,
623    /// The packmap precondition did not hold (a concurrent pusher advanced
624    /// the chain). Callers re-read the packmap and retry.
625    PackmapConflict,
626}
627
628// ---------------------------------------------------------------------------
629// async_shim — sync/async bridge for transports that wrap an async cipher
630// ---------------------------------------------------------------------------
631
632/// Sync-over-async shim for transports whose underlying cipher / I/O is
633/// async (e.g. `commonware-stream::encrypted`) but whose
634/// [`Transport`] trait surface is intentionally sync.
635///
636/// Lives in `mkit-core` (the trait crate) because it is generic
637/// infrastructure — multiple transports and sparse-checkout's transport
638/// layer will reuse the same plug-in point. It does **not** depend on
639/// `tokio`, `commonware-runtime`, or any concrete executor; callers
640/// pick the runner.
641///
642/// # Why a trait
643///
644/// `mkit-transport-enc` and (once its transport layer lands) `mkit-core::sparse` need to
645/// drive `async fn` bodies from a sync method. Hard-coding
646/// `tokio::runtime::Handle::block_on` would bleed tokio across the
647/// workspace; hard-coding `commonware_runtime::deterministic` would
648/// mean production = tests. A pluggable `Executor` keeps the
649/// runtime-choice at the consumer crate.
650pub mod async_shim {
651    /// Drives an async future to completion synchronously. Pluggable so
652    /// callers can choose between `tokio`, `commonware-runtime`'s
653    /// deterministic runner (tests), or the planned production tokio
654    /// runner without `mkit-core` having to compile-time depend on a
655    /// specific runtime crate.
656    ///
657    /// Implementations MUST be re-entrancy-safe in the sense expected
658    /// by the chosen runtime — calling `block_on` from inside an
659    /// already-running task on the same runtime will typically panic
660    /// or deadlock. The shim's contract is "synchronous external API
661    /// wraps async internals", not "arbitrary async-from-sync
662    /// recursion".
663    pub trait Executor: Send + Sync {
664        /// Block the current thread until `fut` resolves.
665        fn block_on<F, T>(&self, fut: F) -> T
666        where
667            F: core::future::Future<Output = T> + Send,
668            T: Send;
669    }
670}
671
672// ---------------------------------------------------------------------------
673// Tests
674// ---------------------------------------------------------------------------
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn pack_key_hex_roundtrip() {
682        let bytes = [0x42u8; 32];
683        let pk = PackKey::new(bytes);
684        let hex = pk.to_hex();
685        assert_eq!(hex.len(), 64);
686        let pk2 = pack_key_from_hex(&hex).unwrap();
687        assert_eq!(pk, pk2);
688    }
689
690    #[test]
691    fn is_retryable_matches_spec() {
692        assert!(is_retryable(&TransportError::ConnectionFailed));
693        assert!(is_retryable(&TransportError::ServerError { status: 500 }));
694        assert!(is_retryable(&TransportError::ServerError { status: 503 }));
695        assert!(is_retryable(&TransportError::ServerError { status: 429 }));
696        assert!(!is_retryable(&TransportError::ServerError { status: 404 }));
697        assert!(!is_retryable(&TransportError::ServerError { status: 401 }));
698        assert!(!is_retryable(&TransportError::PackNotFound));
699        assert!(!is_retryable(&TransportError::AccessDenied));
700        assert!(!is_retryable(&TransportError::RefConflict));
701    }
702
703    #[test]
704    fn backoff_default_ladder_is_1_2_4_8_16() {
705        let delays: Vec<Duration> = BackoffIterator::new().collect();
706        assert_eq!(
707            delays,
708            vec![
709                Duration::from_secs(1),
710                Duration::from_secs(2),
711                Duration::from_secs(4),
712                Duration::from_secs(8),
713                Duration::from_secs(16),
714            ]
715        );
716    }
717
718    #[test]
719    fn backoff_caps_at_max() {
720        let cap = Duration::from_secs(10);
721        let delays: Vec<Duration> = BackoffIterator::with(Duration::from_secs(8), cap, 5).collect();
722        // 8s, then cap (16s would exceed 10s cap; clamped to 10s)
723        assert_eq!(delays[0], Duration::from_secs(8));
724        for d in &delays[1..] {
725            assert!(*d <= cap);
726        }
727    }
728
729    // -----------------------------------------------------------------
730    // upload_pack_streaming / download_pack_streaming default impls
731    // -----------------------------------------------------------------
732
733    /// Minimal in-memory [`Transport`] that only implements the
734    /// required whole-buffer methods, so its `*_streaming` behavior is
735    /// entirely the trait's default impl under test.
736    #[derive(Default)]
737    struct RecordingTransport {
738        uploaded: std::sync::Mutex<Option<(Vec<u8>, PackKey)>>,
739        stored: std::sync::Mutex<std::collections::HashMap<[u8; 32], Vec<u8>>>,
740    }
741
742    impl Transport for RecordingTransport {
743        fn upload_pack(&self, bytes: &[u8], key: &PackKey) -> TransportResult<()> {
744            *self.uploaded.lock().unwrap() = Some((bytes.to_vec(), *key));
745            self.stored
746                .lock()
747                .unwrap()
748                .insert(*key.as_bytes(), bytes.to_vec());
749            Ok(())
750        }
751
752        fn download_pack(&self, key: &PackKey) -> TransportResult<Vec<u8>> {
753            self.stored
754                .lock()
755                .unwrap()
756                .get(key.as_bytes())
757                .cloned()
758                .ok_or(TransportError::PackNotFound)
759        }
760
761        fn pack_exists(&self, _key: &PackKey) -> TransportResult<bool> {
762            unimplemented!("not exercised by these tests")
763        }
764
765        fn update_ref(
766            &self,
767            _name: &str,
768            _condition: RefWriteCondition,
769            _hash: &Hash,
770        ) -> TransportResult<()> {
771            unimplemented!("not exercised by these tests")
772        }
773
774        fn read_ref(&self, _name: &str) -> TransportResult<Option<Hash>> {
775            unimplemented!("not exercised by these tests")
776        }
777
778        fn list_refs(&self, _prefix: &str) -> TransportResult<Vec<Ref>> {
779            unimplemented!("not exercised by these tests")
780        }
781    }
782
783    fn chunks_of(data: &[u8], chunk_len: usize) -> Vec<PackChunk> {
784        if data.is_empty() {
785            return vec![PackChunk {
786                offset: 0,
787                data: Vec::new(),
788                last: true,
789            }];
790        }
791        let mut out = Vec::new();
792        let mut offset = 0usize;
793        while offset < data.len() {
794            let end = core::cmp::min(offset + chunk_len, data.len());
795            out.push(PackChunk {
796                offset: offset as u64,
797                data: data[offset..end].to_vec(),
798                last: end == data.len(),
799            });
800            offset = end;
801        }
802        out
803    }
804
805    #[test]
806    fn upload_pack_streaming_default_delegates_to_upload_pack() {
807        let t = RecordingTransport::default();
808        let payload = b"hello mkit pack bytes".repeat(100);
809        let key = PackKey::new([0x11; 32]);
810        let mut it = chunks_of(&payload, 7).into_iter().map(Ok);
811
812        t.upload_pack_streaming(&key, payload.len() as u64, &mut it)
813            .expect("streaming upload via default impl");
814
815        let (got_bytes, got_key) = t.uploaded.lock().unwrap().clone().expect("upload recorded");
816        assert_eq!(got_bytes, payload);
817        assert_eq!(got_key, key);
818    }
819
820    #[test]
821    fn upload_pack_streaming_default_rejects_missing_last_chunk() {
822        let t = RecordingTransport::default();
823        let key = PackKey::new([0x22; 32]);
824        // No chunk at all — total_bytes = 0 still requires one `last =
825        // true` chunk per the trait contract.
826        let mut it = core::iter::empty();
827
828        let err = t
829            .upload_pack_streaming(&key, 0, &mut it)
830            .expect_err("must reject a stream with no last=true chunk");
831        assert!(matches!(err, TransportError::ProtocolError));
832    }
833
834    #[test]
835    fn upload_pack_streaming_default_rejects_total_bytes_mismatch() {
836        let t = RecordingTransport::default();
837        let key = PackKey::new([0x33; 32]);
838        let mut it = core::iter::once(Ok(PackChunk {
839            offset: 0,
840            data: vec![1, 2, 3],
841            last: true,
842        }));
843
844        // Declared total (10) does not match the 3 bytes actually
845        // streamed.
846        let err = t
847            .upload_pack_streaming(&key, 10, &mut it)
848            .expect_err("must reject a total_bytes/accumulated-length mismatch");
849        assert!(matches!(err, TransportError::ProtocolError));
850    }
851
852    #[test]
853    fn upload_pack_streaming_default_propagates_chunk_error() {
854        let t = RecordingTransport::default();
855        let key = PackKey::new([0x44; 32]);
856        let mut it = core::iter::once(Err(TransportError::ConnectionFailed));
857
858        let err = t
859            .upload_pack_streaming(&key, 0, &mut it)
860            .expect_err("must propagate an error yielded mid-stream");
861        assert!(matches!(err, TransportError::ConnectionFailed));
862    }
863
864    #[test]
865    fn upload_pack_streaming_default_rejects_oversize_total() {
866        let t = RecordingTransport::default();
867        let key = PackKey::new([0x55; 32]);
868        let mut it = core::iter::empty();
869
870        let err = t
871            .upload_pack_streaming(&key, PACK_BODY_LIMIT + 1, &mut it)
872            .expect_err("must reject total_bytes above PACK_BODY_LIMIT");
873        assert!(matches!(err, TransportError::PayloadTooLarge(_)));
874    }
875
876    #[test]
877    fn download_pack_streaming_default_wraps_whole_pack() {
878        let t = RecordingTransport::default();
879        let key = PackKey::new([0x66; 32]);
880        let payload = vec![9u8; 4096];
881        t.upload_pack(&payload, &key).unwrap();
882
883        let mut stream = t.download_pack_streaming(&key).expect("stream opens");
884        let first = stream.next().expect("one chunk").expect("no error");
885        assert_eq!(first.data, payload);
886        assert!(first.last);
887        assert!(
888            stream.next().is_none(),
889            "default impl yields exactly one chunk"
890        );
891    }
892
893    #[test]
894    fn download_pack_streaming_default_propagates_not_found() {
895        let t = RecordingTransport::default();
896        let key = PackKey::new([0x77; 32]);
897        // `Box<dyn Iterator<..>>`'s `Ok` type isn't `Debug`, so match
898        // instead of `expect_err`.
899        match t.download_pack_streaming(&key) {
900            Err(TransportError::PackNotFound) => {}
901            Err(other) => panic!("expected PackNotFound, got {other:?}"),
902            Ok(_) => panic!("missing pack must fail before any chunk is produced"),
903        }
904    }
905
906    // -----------------------------------------------------------------
907    // retrying() driver
908    // -----------------------------------------------------------------
909
910    fn test_backoff() -> BackoffIterator {
911        BackoffIterator::with(Duration::from_millis(1), Duration::from_millis(1), 5)
912    }
913
914    fn no_sleep(_delay: Duration) {}
915
916    #[test]
917    fn retrying_succeeds_on_first_try_without_sleeping() {
918        use core::sync::atomic::{AtomicUsize, Ordering};
919
920        fn record_sleep(_delay: Duration) {
921            SLEEPS.fetch_add(1, Ordering::SeqCst);
922        }
923
924        static CALLS: AtomicUsize = AtomicUsize::new(0);
925        static SLEEPS: AtomicUsize = AtomicUsize::new(0);
926        CALLS.store(0, Ordering::SeqCst);
927        SLEEPS.store(0, Ordering::SeqCst);
928
929        let result = retrying::<u32>(
930            || {
931                CALLS.fetch_add(1, Ordering::SeqCst);
932                Ok(7)
933            },
934            test_backoff,
935            record_sleep,
936        );
937
938        assert_eq!(result.unwrap(), 7);
939        assert_eq!(CALLS.load(Ordering::SeqCst), 1);
940        assert_eq!(SLEEPS.load(Ordering::SeqCst), 0);
941    }
942
943    #[test]
944    fn retrying_recovers_after_transient_connection_failures() {
945        use core::sync::atomic::{AtomicUsize, Ordering};
946        static CALLS: AtomicUsize = AtomicUsize::new(0);
947        CALLS.store(0, Ordering::SeqCst);
948
949        let result = retrying::<u32>(
950            || {
951                let n = CALLS.fetch_add(1, Ordering::SeqCst);
952                if n < 3 {
953                    Err(TransportError::ConnectionFailed)
954                } else {
955                    Ok(42)
956                }
957            },
958            test_backoff,
959            no_sleep,
960        );
961
962        assert_eq!(result.unwrap(), 42);
963        assert_eq!(CALLS.load(Ordering::SeqCst), 4);
964    }
965
966    #[test]
967    fn retrying_gives_up_after_ladder_exhausts() {
968        use core::sync::atomic::{AtomicUsize, Ordering};
969        static CALLS: AtomicUsize = AtomicUsize::new(0);
970        CALLS.store(0, Ordering::SeqCst);
971
972        let result = retrying::<u32>(
973            || {
974                CALLS.fetch_add(1, Ordering::SeqCst);
975                Err(TransportError::ConnectionFailed)
976            },
977            test_backoff,
978            no_sleep,
979        );
980
981        assert!(matches!(result, Err(TransportError::ConnectionFailed)));
982        // 5-attempt ladder => 1 initial + 5 retries = 6 total calls.
983        assert_eq!(CALLS.load(Ordering::SeqCst), 6);
984    }
985
986    #[test]
987    fn retrying_does_not_retry_non_retryable_errors() {
988        use core::sync::atomic::{AtomicUsize, Ordering};
989        static CALLS: AtomicUsize = AtomicUsize::new(0);
990        CALLS.store(0, Ordering::SeqCst);
991
992        let result = retrying::<u32>(
993            || {
994                CALLS.fetch_add(1, Ordering::SeqCst);
995                Err(TransportError::PackNotFound)
996            },
997            test_backoff,
998            no_sleep,
999        );
1000
1001        assert!(matches!(result, Err(TransportError::PackNotFound)));
1002        assert_eq!(CALLS.load(Ordering::SeqCst), 1);
1003    }
1004}