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}