Skip to main content

mkit_server/
replay.rs

1//! The replay-ledger state model (PRD ยง5.4 stages 0 and 4).
2//!
3//! One record per signed write, keyed by its auth v2 scope. Stage 0 looks
4//! the record up and [`classify`]s it; stage 4 reserves it `in_flight`; the
5//! apply that commits the write stores its [`StoredResult`]. Admission
6//! challenges and `pending_verification` are never stored: no
7//! [`StoredResult`] can represent them.
8
9use mkit_core::hash::Hash;
10use mkit_core::protocol::AdvanceOutcome;
11
12use crate::error::Code;
13
14/// The replay key: auth v2 `Authorized.scope`,
15/// `BLAKE3(audience \n repository \n pubkey \n nonce)`.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct ReplayKey(pub Hash);
18
19/// A replay-ledger record.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ReplayRecord {
22    /// Digest of every signed field; a retry must match it.
23    pub fingerprint: Hash,
24    /// When the record may be pruned: the envelope's expiry, Unix ms.
25    pub expires_at_ms: i64,
26    /// Where the operation stands.
27    pub state: ReplayState,
28}
29
30/// Where a recorded operation stands.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ReplayState {
33    /// Reserved, not yet committed. `resumable`: a retry may resume it
34    /// (`UploadPack` only, overview Q5); otherwise a retry is told to wait.
35    InFlight {
36        /// Whether a retry resumes the operation.
37        resumable: bool,
38    },
39    /// Committed; a retry gets this result back.
40    Committed(StoredResult),
41}
42
43/// A final result a retry gets back. There is deliberately no variant for
44/// an admission challenge or `pending_verification`, and a
45/// [`StoredRejection`] holds only final codes and no error detail, so
46/// neither can ever be stored.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum StoredResult {
49    /// `UpdateRef`.
50    UpdateRef(UpdateRefResult),
51    /// `AdvanceRefs`.
52    AdvanceRefs(AdvanceOutcome),
53    /// `BeginUpload` completed.
54    BeginUpload(BeginUploadResult),
55    /// `UploadPack` succeeded.
56    UploadPack,
57    /// `SetRepoVisibility` committed.
58    RepoVisibility,
59    /// A final rejection after the reservation, e.g. a policy denial.
60    Rejected(StoredRejection),
61}
62
63/// A replayable ticket-opening result. Tokens are retained verbatim.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum BeginUploadResult {
66    /// The repository already holds the pack.
67    AlreadyPresent,
68    /// A live upload reservation.
69    Ticket {
70        /// Reservation-derived ticket identifier.
71        id: Hash,
72        /// Upload geometry in bytes.
73        part_size: u64,
74        /// Business-clock expiry.
75        expires_at_ms: u64,
76        /// Stateless authenticated claims, including the MAC.
77        token: Vec<u8>,
78    },
79}
80
81/// The result of an `UpdateRef` compare-and-swap.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum UpdateRefResult {
84    /// The ref moved.
85    Committed,
86    /// The expectation did not hold; `current` is what the ref held.
87    Conflict {
88        /// The ref's value at commit, if any.
89        current: Option<Hash>,
90    },
91}
92
93/// A storable rejection: a final code and its public message, with no
94/// error detail (so never an admission challenge).
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct StoredRejection {
97    code: Code,
98    message: String,
99}
100
101impl StoredRejection {
102    /// Whether a retry of the same nonce may get `code` back forever: only
103    /// outcomes that re-running cannot change. Retryable codes
104    /// (`unavailable`, which covers `pending_verification`, `aborted`,
105    /// `resource_exhausted`), transient or server-side ones (`canceled`,
106    /// `deadline_exceeded`, `internal`, `unknown`, `data_loss`) and
107    /// `unauthenticated` are re-run instead.
108    #[must_use]
109    pub const fn is_storable(code: Code) -> bool {
110        matches!(
111            code,
112            Code::InvalidArgument
113                | Code::NotFound
114                | Code::AlreadyExists
115                | Code::PermissionDenied
116                | Code::FailedPrecondition
117                | Code::OutOfRange
118                | Code::Unimplemented
119        )
120    }
121
122    /// A rejection a retry of the same nonce gets back forever; `None`
123    /// unless [`Self::is_storable`].
124    #[must_use]
125    pub fn new(code: Code, message: impl Into<String>) -> Option<Self> {
126        Self::is_storable(code).then(|| Self {
127            code,
128            message: message.into(),
129        })
130    }
131
132    /// The error code.
133    #[must_use]
134    pub fn code(&self) -> Code {
135        self.code
136    }
137
138    /// The public message.
139    #[must_use]
140    pub fn message(&self) -> &str {
141        &self.message
142    }
143}
144
145/// What stage 0 does with a request, given its record.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ReplayDecision {
148    /// No record: a new operation, which continues to authorization.
149    New,
150    /// Committed: return the stored result without running any hook.
151    Return(StoredResult),
152    /// In flight and resumable (`UploadPack`).
153    Resume,
154    /// In flight: retryable `aborted`, before admission.
155    RetryLater,
156    /// The nonce was used for a different operation: `invalid_argument`.
157    FingerprintMismatch,
158}
159
160/// Classify a request with `fingerprint` against its record, if any.
161#[must_use]
162pub fn classify(existing: Option<&ReplayRecord>, fingerprint: &Hash) -> ReplayDecision {
163    match existing {
164        None => ReplayDecision::New,
165        Some(record) if record.fingerprint != *fingerprint => ReplayDecision::FingerprintMismatch,
166        Some(record) => match &record.state {
167            ReplayState::Committed(result) => ReplayDecision::Return(result.clone()),
168            ReplayState::InFlight { resumable: true } => ReplayDecision::Resume,
169            ReplayState::InFlight { resumable: false } => ReplayDecision::RetryLater,
170        },
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    const FP: Hash = [7; 32];
179
180    fn record(state: ReplayState) -> ReplayRecord {
181        ReplayRecord {
182            fingerprint: FP,
183            expires_at_ms: 1_000,
184            state,
185        }
186    }
187
188    #[test]
189    fn classify_none_is_new() {
190        assert_eq!(classify(None, &FP), ReplayDecision::New);
191    }
192
193    #[test]
194    fn classify_other_fingerprint_is_mismatch() {
195        for state in [
196            ReplayState::InFlight { resumable: true },
197            ReplayState::Committed(StoredResult::UploadPack),
198        ] {
199            assert_eq!(
200                classify(Some(&record(state)), &[8; 32]),
201                ReplayDecision::FingerprintMismatch
202            );
203        }
204    }
205
206    #[test]
207    fn classify_committed_returns_stored_result() {
208        let result = StoredResult::UpdateRef(UpdateRefResult::Conflict {
209            current: Some([1; 32]),
210        });
211        let rec = record(ReplayState::Committed(result.clone()));
212        assert_eq!(classify(Some(&rec), &FP), ReplayDecision::Return(result));
213    }
214
215    #[test]
216    fn classify_inflight_resumable_is_resume() {
217        let rec = record(ReplayState::InFlight { resumable: true });
218        assert_eq!(classify(Some(&rec), &FP), ReplayDecision::Resume);
219    }
220
221    #[test]
222    fn classify_inflight_not_resumable_is_retry_later() {
223        let rec = record(ReplayState::InFlight { resumable: false });
224        assert_eq!(classify(Some(&rec), &FP), ReplayDecision::RetryLater);
225    }
226
227    #[test]
228    fn no_stored_result_variant_for_challenge() {
229        // Exhaustive on purpose: a new variant must be checked against the
230        // "never store a challenge or pending_verification" rule.
231        fn is_final(result: &StoredResult) -> bool {
232            match result {
233                StoredResult::UpdateRef(_)
234                | StoredResult::AdvanceRefs(_)
235                | StoredResult::BeginUpload(_)
236                | StoredResult::UploadPack
237                | StoredResult::RepoVisibility => true,
238                StoredResult::Rejected(r) => StoredRejection::is_storable(r.code()),
239            }
240        }
241        // A challenge is `permission_denied` with HTTP 402 and a typed
242        // detail; a stored rejection holds neither. `pending_verification`
243        // is `unavailable`, which is refused.
244        let denied = StoredRejection::new(Code::PermissionDenied, "denied").unwrap();
245        assert_eq!(
246            (denied.code(), denied.message()),
247            (Code::PermissionDenied, "denied")
248        );
249        assert!(is_final(&StoredResult::Rejected(denied)));
250    }
251
252    #[test]
253    fn stored_rejection_admits_only_final_codes() {
254        let final_codes = [
255            Code::InvalidArgument,
256            Code::NotFound,
257            Code::AlreadyExists,
258            Code::PermissionDenied,
259            Code::FailedPrecondition,
260            Code::OutOfRange,
261            Code::Unimplemented,
262        ];
263        for code in final_codes {
264            assert!(StoredRejection::new(code, "m").is_some(), "{code:?}");
265        }
266        let refused = [
267            // Retryable: re-run, never replay (covers pending_verification).
268            Code::Unavailable,
269            Code::Aborted,
270            Code::ResourceExhausted,
271            // Transient or client-side cancellation.
272            Code::Canceled,
273            Code::DeadlineExceeded,
274            // Server-side faults.
275            Code::Internal,
276            Code::Unknown,
277            Code::DataLoss,
278            // Credentials: a retry may present valid ones.
279            Code::Unauthenticated,
280        ];
281        for code in refused {
282            assert_eq!(StoredRejection::new(code, "m"), None, "{code:?}");
283        }
284        assert_eq!(
285            final_codes.len() + refused.len(),
286            16,
287            "every Code is classified"
288        );
289    }
290}