1use mkit_core::hash::Hash;
10use mkit_core::protocol::AdvanceOutcome;
11
12use crate::error::Code;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct ReplayKey(pub Hash);
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ReplayRecord {
22 pub fingerprint: Hash,
24 pub expires_at_ms: i64,
26 pub state: ReplayState,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ReplayState {
33 InFlight {
36 resumable: bool,
38 },
39 Committed(StoredResult),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum StoredResult {
49 UpdateRef(UpdateRefResult),
51 AdvanceRefs(AdvanceOutcome),
53 BeginUpload(BeginUploadResult),
55 UploadPack,
57 RepoVisibility,
59 Rejected(StoredRejection),
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum BeginUploadResult {
66 AlreadyPresent,
68 Ticket {
70 id: Hash,
72 part_size: u64,
74 expires_at_ms: u64,
76 token: Vec<u8>,
78 },
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum UpdateRefResult {
84 Committed,
86 Conflict {
88 current: Option<Hash>,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct StoredRejection {
97 code: Code,
98 message: String,
99}
100
101impl StoredRejection {
102 #[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 #[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 #[must_use]
134 pub fn code(&self) -> Code {
135 self.code
136 }
137
138 #[must_use]
140 pub fn message(&self) -> &str {
141 &self.message
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ReplayDecision {
148 New,
150 Return(StoredResult),
152 Resume,
154 RetryLater,
156 FingerprintMismatch,
158}
159
160#[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 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 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 Code::Unavailable,
269 Code::Aborted,
270 Code::ResourceExhausted,
271 Code::Canceled,
273 Code::DeadlineExceeded,
274 Code::Internal,
276 Code::Unknown,
277 Code::DataLoss,
278 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}