flint_store/lib.rs
1//! The object store behind a trait — L2 step 4 (design review A13,
2//! with A6's conditional-publish contract baked into the signatures).
3//!
4//! Only generation ASSEMBLY is backend-specific; everything above this
5//! trait (interval log, flush pipeline, eviction, hydration, epoch
6//! state machine) is backend-neutral. Backends:
7//!
8//! - `memory` — the in-process test double, implementing the FULL
9//! conditional semantics (412/409, stamps, CRC validation, MPU
10//! state, epoch CAS). Every backend-neutral tier test runs here.
11//! - `s3` — MPU + UploadPartCopy + conditional PUT/Complete per A6/A8.
12//! - Azure Blob (second, per A13): Put Block List with committed-block
13//! reuse and NATIVE leases for the epoch. GCS later.
14//!
15//! Contract points the signatures enforce:
16//!
17//! - **No unconditional publish exists.** Every write carries either
18//! `IfMatch` (update of a known generation) or `IfNoneMatchAny`
19//! (first generation of a new key). A caller that wants an unguarded
20//! overwrite has made a design error upstream (A6).
21//! - **The multipart ETag is NOT a content hash** and never appears in
22//! any integrity role; content identity is the full-object
23//! CRC-64/NVME, computed by the flusher from LOCAL truth and
24//! validated server-side at publish (a mismatch fails the publish,
25//! it does not warn).
26//! - **Assembly failure aborts the assembly** (A9): no backend may
27//! leave a partial MPU behind on an error path it can reach.
28//!
29//! Extracted from `spdk-csi-driver/src/tier/store/` (2026-08-25) so
30//! flint-lean can depend on the store WITHOUT the hub crate's build
31//! (13 binaries, the aws/kube/nfs dep trees). The hub crate re-exports
32//! this crate at its old path (`crate::tier::store::*`), so every
33//! existing import keeps working. The `s3` backend is feature-gated —
34//! test loops that only need the memory double never build the AWS
35//! SDK.
36
37pub mod counters;
38pub mod gate;
39
40pub use counters::{RequestCounter, RequestCounts, RequestKind};
41pub mod layout;
42pub mod memory;
43pub mod probe;
44#[cfg(feature = "s3")]
45pub mod s3;
46#[cfg(feature = "s3")]
47pub mod rawread;
48
49use async_trait::async_trait;
50use bytes::Bytes;
51use std::collections::HashMap;
52
53// ── CRC-64/NVME ──────────────────────────────────────────────────────
54//
55// The tier's one content-identity algorithm (A6): linearly combinable
56// (so S3 can compute it FULL_OBJECT across multipart, copied parts
57// included) and supported end-to-end by both S3 and the local flusher.
58// Hand-rolled table CRC — 30 lines beats a dependency; the test pins
59// the catalog check value.
60
61/// Reflected form of the CRC-64/NVME polynomial 0xAD93D23594C935A9.
62const CRC64_NVME_POLY: u64 = 0x9A6C_9329_AC4B_C9B5;
63
64/// Slice-by-8: eight tables, so `update` consumes a u64 word per round
65/// instead of a byte. Table 0 is the classic byte table; table k is
66/// table k-1 advanced one more zero byte. Same polynomial, same digest
67/// — the catalog check value and the split-update property both pin it.
68///
69/// This is the checkout resume path's inner loop: a restarted container
70/// re-CRCs every present file before it can serve, so the byte-at-a-time
71/// form put a full tree re-hash on the agent-blocking path.
72fn crc64_tables8() -> &'static [[u64; 256]; 8] {
73 static TABLES: std::sync::OnceLock<[[u64; 256]; 8]> = std::sync::OnceLock::new();
74 TABLES.get_or_init(|| {
75 let mut t = [[0u64; 256]; 8];
76 t[0] = *crc64_table();
77 let mut i = 0usize;
78 while i < 256 {
79 let mut k = 1usize;
80 while k < 8 {
81 let prev = t[k - 1][i];
82 t[k][i] = (prev >> 8) ^ t[0][(prev & 0xFF) as usize];
83 k += 1;
84 }
85 i += 1;
86 }
87 t
88 })
89}
90
91fn crc64_table() -> &'static [u64; 256] {
92 static TABLE: std::sync::OnceLock<[u64; 256]> = std::sync::OnceLock::new();
93 TABLE.get_or_init(|| {
94 let mut t = [0u64; 256];
95 let mut i = 0usize;
96 while i < 256 {
97 let mut crc = i as u64;
98 let mut b = 0;
99 while b < 8 {
100 crc = if crc & 1 == 1 { (crc >> 1) ^ CRC64_NVME_POLY } else { crc >> 1 };
101 b += 1;
102 }
103 t[i] = crc;
104 i += 1;
105 }
106 t
107 })
108}
109
110/// Streaming CRC-64/NVME (the flusher feeds file ranges through this).
111#[derive(Clone)]
112pub struct Crc64Nvme(u64);
113
114impl Default for Crc64Nvme {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl Crc64Nvme {
121 pub fn new() -> Self {
122 Crc64Nvme(u64::MAX)
123 }
124 pub fn update(&mut self, data: &[u8]) {
125 let t = crc64_tables8();
126 let mut crc = self.0;
127 let mut rest = data;
128 // Eight bytes per round off the aligned body...
129 while let Some((head, tail)) = rest.split_first_chunk::<8>() {
130 let w = u64::from_le_bytes(*head) ^ crc;
131 crc = t[7][(w & 0xFF) as usize]
132 ^ t[6][((w >> 8) & 0xFF) as usize]
133 ^ t[5][((w >> 16) & 0xFF) as usize]
134 ^ t[4][((w >> 24) & 0xFF) as usize]
135 ^ t[3][((w >> 32) & 0xFF) as usize]
136 ^ t[2][((w >> 40) & 0xFF) as usize]
137 ^ t[1][((w >> 48) & 0xFF) as usize]
138 ^ t[0][((w >> 56) & 0xFF) as usize];
139 rest = tail;
140 }
141 // ...then the <8-byte tail, byte-at-a-time as before. `update`
142 // is a STREAMING call: the tail runs per chunk, so an arbitrary
143 // split must give the same digest as one pass. The test pins it.
144 for &b in rest {
145 crc = t[0][((crc ^ b as u64) & 0xFF) as usize] ^ (crc >> 8);
146 }
147 self.0 = crc;
148 }
149 pub fn finalize(self) -> u64 {
150 self.0 ^ u64::MAX
151 }
152}
153
154/// One column of the GF(2) operator applied to a register.
155fn gf2_times(mat: &[u64; 64], mut v: u64) -> u64 {
156 let mut sum = 0u64;
157 let mut i = 0usize;
158 while v != 0 {
159 if v & 1 == 1 {
160 sum ^= mat[i];
161 }
162 v >>= 1;
163 i += 1;
164 }
165 sum
166}
167
168/// `out` becomes the operator that applies `mat` twice.
169fn gf2_square(out: &mut [u64; 64], mat: &[u64; 64]) {
170 for n in 0..64 {
171 out[n] = gf2_times(mat, mat[n]);
172 }
173}
174
175/// CRC-64/NVME of `A || B`, from the CRCs of `A` and `B` and the LENGTH
176/// of `B`. Bytes are never re-read.
177///
178/// This exists so a full-object checksum can be assembled from parts
179/// computed INDEPENDENTLY — and therefore concurrently. Without it a
180/// caller that wants the store to compute the checksum has to hand it
181/// the parts in order, one at a time, because a CRC taken out of order
182/// is a different number; the alternative was a whole extra pass over
183/// the file before the upload. Now it is neither.
184///
185/// The identity it rests on: for a reflected CRC whose init and xorout
186/// are both all-ones, appending `n` zero bytes is a LINEAR operator `Z`
187/// on the register, and `F(A||B) = Z_n(F(A)) ^ F(B)` — the init and
188/// xorout terms cancel because `Z(X) ^ Z(I) = Z(X ^ I) = Z(0) = 0`.
189/// Structure follows zlib's `crc32_combine`, widened to 64 bits.
190pub fn crc64_combine(crc_a: u64, crc_b: u64, len_b: u64) -> u64 {
191 if len_b == 0 {
192 return crc_a;
193 }
194 let mut odd = [0u64; 64];
195 let mut even = [0u64; 64];
196 // One zero BIT.
197 odd[0] = CRC64_NVME_POLY;
198 let mut row = 1u64;
199 for n in 1..64 {
200 odd[n] = row;
201 row <<= 1;
202 }
203 gf2_square(&mut even, &odd); // two bits
204 gf2_square(&mut odd, &even); // four bits
205 let mut crc = crc_a;
206 let mut len = len_b;
207 loop {
208 gf2_square(&mut even, &odd); // eight bits == one zero BYTE
209 if len & 1 == 1 {
210 crc = gf2_times(&even, crc);
211 }
212 len >>= 1;
213 if len == 0 {
214 break;
215 }
216 gf2_square(&mut odd, &even);
217 if len & 1 == 1 {
218 crc = gf2_times(&odd, crc);
219 }
220 len >>= 1;
221 if len == 0 {
222 break;
223 }
224 }
225 crc ^ crc_b
226}
227
228/// One-shot convenience.
229pub fn crc64_nvme(data: &[u8]) -> u64 {
230 let mut c = Crc64Nvme::new();
231 c.update(data);
232 c.finalize()
233}
234
235/// The inverse of [`crc64_to_b64`]. `None` for anything that is not a
236/// 12-character base64 encoding of exactly 8 bytes — a caller that gets
237/// `None` has been handed something other than a CRC-64, and guessing a
238/// number for it would validate a publish against a checksum nobody
239/// computed.
240pub fn crc64_from_b64(s: &str) -> Option<u64> {
241 const AL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
242 let s = s.trim();
243 if s.len() != 12 || !s.ends_with('=') {
244 return None;
245 }
246 let mut acc: u32 = 0;
247 let mut bits = 0u32;
248 let mut out: Vec<u8> = Vec::with_capacity(8);
249 for c in s.bytes() {
250 if c == b'=' {
251 break;
252 }
253 let v = AL.iter().position(|&a| a == c)? as u32;
254 acc = (acc << 6) | v;
255 bits += 6;
256 if bits >= 8 {
257 bits -= 8;
258 out.push((acc >> bits) as u8);
259 }
260 }
261 if out.len() != 8 {
262 return None;
263 }
264 let mut b = [0u8; 8];
265 b.copy_from_slice(&out);
266 Some(u64::from_be_bytes(b))
267}
268
269/// S3's wire form: base64 of the checksum's 8 big-endian bytes.
270pub fn crc64_to_b64(crc: u64) -> String {
271 const AL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
272 let bytes = crc.to_be_bytes();
273 let mut out = String::with_capacity(12);
274 for chunk in bytes.chunks(3) {
275 let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
276 let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
277 out.push(AL[(n >> 18) as usize & 63] as char);
278 out.push(AL[(n >> 12) as usize & 63] as char);
279 out.push(if chunk.len() > 1 { AL[(n >> 6) as usize & 63] as char } else { '=' });
280 out.push(if chunk.len() > 2 { AL[n as usize & 63] as char } else { '=' });
281 }
282 out
283}
284
285// ── stamps and metadata ──────────────────────────────────────────────
286
287/// The A6 publish stamps (`x-amz-meta-flint-*` on S3; each backend maps
288/// the bare keys to its metadata namespace). HEAD-based 412 arbitration
289/// keys off these — they are how a torn OWN flush is told apart from a
290/// genuinely foreign object.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct GenerationStamps {
293 /// (`gen` is reserved in edition 2024 — spelled out.)
294 pub generation: u64,
295 pub epoch: u64,
296 pub flush_uuid: String,
297 /// The coherence provenance of a lean manifest citation
298 /// (boundary-verbs plan §2.4.1): `sentinel`, `quiescence`,
299 /// `forced-lag-cap`, `forced-backlog-cap`, `cadence`, `drain`,
300 /// `recovered`. Stamped on the OBJECT so a downstream consumer —
301 /// checkout, a sibling cluster's sync, HITL — can tell a
302 /// declared-coherent citation from a forced possibly-torn one from
303 /// the bucket alone, for the price of a HEAD rather than a GET of a
304 /// manifest that can run to hundreds of MiB. `None` on every
305 /// non-manifest object.
306 pub boundary_source: Option<String>,
307 /// A12: POSIX metadata riding on the object, so a bucket reader —
308 /// and the DR import — can restore mode/ownership/mtime without
309 /// the manifest. OPTIONAL both ways: absence never makes an object
310 /// foreign (steps 4–11 published without it).
311 pub posix: Option<PosixStamps>,
312}
313
314/// The A12 per-object POSIX stamps (`flint-mode` is octal; times are
315/// unix seconds). Enumerated NON-goals of the round-trip: hard-link
316/// structure, sockets/FIFOs/devices, and sparseness — see
317/// `tier::manifest`.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub struct PosixStamps {
320 /// Full st_mode (type bits included; restore applies the 07777).
321 pub mode: u32,
322 pub uid: u32,
323 pub gid: u32,
324 pub mtime_unix: i64,
325}
326
327impl PosixStamps {
328 pub const META_MODE: &'static str = "flint-mode";
329 pub const META_UID: &'static str = "flint-uid";
330 pub const META_GID: &'static str = "flint-gid";
331 pub const META_MTIME: &'static str = "flint-mtime";
332
333 #[cfg(unix)]
334 pub fn from_metadata(m: &std::fs::Metadata) -> PosixStamps {
335 use std::os::unix::fs::MetadataExt;
336 PosixStamps { mode: m.mode(), uid: m.uid(), gid: m.gid(), mtime_unix: m.mtime() }
337 }
338
339 fn to_meta(self) -> Vec<(String, String)> {
340 vec![
341 (Self::META_MODE.into(), format!("{:o}", self.mode)),
342 (Self::META_UID.into(), self.uid.to_string()),
343 (Self::META_GID.into(), self.gid.to_string()),
344 (Self::META_MTIME.into(), self.mtime_unix.to_string()),
345 ]
346 }
347
348 /// Lenient: any stamp absent/malformed ⇒ None as a set (never a
349 /// foreignness signal).
350 pub fn from_meta(meta: &HashMap<String, String>) -> Option<PosixStamps> {
351 Some(PosixStamps {
352 mode: u32::from_str_radix(meta.get(Self::META_MODE)?, 8).ok()?,
353 uid: meta.get(Self::META_UID)?.parse().ok()?,
354 gid: meta.get(Self::META_GID)?.parse().ok()?,
355 mtime_unix: meta.get(Self::META_MTIME)?.parse().ok()?,
356 })
357 }
358}
359
360impl GenerationStamps {
361 pub const META_GEN: &'static str = "flint-gen";
362 pub const META_EPOCH: &'static str = "flint-epoch";
363 pub const META_FLUSH_UUID: &'static str = "flint-flush-uuid";
364 pub const META_BOUNDARY_SOURCE: &'static str = "flint-boundary-source";
365
366 pub fn to_meta(&self) -> Vec<(String, String)> {
367 let mut v = vec![
368 (Self::META_GEN.into(), self.generation.to_string()),
369 (Self::META_EPOCH.into(), self.epoch.to_string()),
370 (Self::META_FLUSH_UUID.into(), self.flush_uuid.clone()),
371 ];
372 if let Some(b) = &self.boundary_source {
373 v.push((Self::META_BOUNDARY_SOURCE.into(), b.clone()));
374 }
375 if let Some(p) = self.posix {
376 v.extend(p.to_meta());
377 }
378 v
379 }
380
381 /// Parse stamps back out of object metadata; None if any IDENTITY
382 /// stamp is absent or malformed (an unstamped object is by
383 /// definition foreign). The posix set parses leniently.
384 pub fn from_meta(meta: &HashMap<String, String>) -> Option<GenerationStamps> {
385 Some(GenerationStamps {
386 generation: meta.get(Self::META_GEN)?.parse().ok()?,
387 epoch: meta.get(Self::META_EPOCH)?.parse().ok()?,
388 flush_uuid: meta.get(Self::META_FLUSH_UUID)?.clone(),
389 boundary_source: meta.get(Self::META_BOUNDARY_SOURCE).cloned(),
390 posix: PosixStamps::from_meta(meta),
391 })
392 }
393}
394
395/// One version of one key (boundary-verbs plan D8's reclamation and DR
396/// surfacing).
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct ListedVersion {
399 pub key: String,
400 pub version_id: String,
401 pub etag: String,
402 pub size: u64,
403 pub is_current: bool,
404 /// A delete marker carries no bytes; it makes the key read absent
405 /// while every prior version stays fetchable by id.
406 pub is_delete_marker: bool,
407 pub last_modified_unix: Option<u64>,
408}
409
410/// What HEAD/GET/publish return about an object.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct ObjectMeta {
413 pub etag: String,
414 pub size: u64,
415 /// Full-object CRC-64/NVME in wire (base64) form, when the store
416 /// has one (objects written by this tier always do).
417 pub crc64_b64: Option<String>,
418 /// User metadata with backend prefixes stripped (bare keys, e.g.
419 /// `flint-gen`).
420 pub meta: HashMap<String, String>,
421 pub last_modified_unix: Option<u64>,
422 /// Backend storage class (S3: None ≡ STANDARD). The A11 IA
423 /// copy-source guard reads this: a non-Standard base pays
424 /// retrieval on every CLEAN byte copied, silently inverting the
425 /// tier's saving — the flusher refuses BaseCopy from it.
426 pub storage_class: Option<String>,
427 /// The object VERSION this metadata describes, on a versioned
428 /// bucket (boundary-verbs plan D7). S3 already returns
429 /// `x-amz-version-id` on PUT and this crate used to discard it;
430 /// gated citation needs it and therefore costs no extra request.
431 ///
432 /// `None` means the backend returned no version id — an
433 /// unversioned bucket, or a proxy that STRIPS the header. The
434 /// second case is why gated mode probes for it and REFUSES rather
435 /// than degrading: falling back to etag semantics on a key whose
436 /// current version is uncited is precisely the torn view the mode
437 /// exists to prevent.
438 pub version_id: Option<String>,
439}
440
441impl ObjectMeta {
442 /// May this object serve as a server-side copy source without
443 /// per-byte retrieval charges? (A11's IA copy-source guard.)
444 pub fn copy_source_allowed(&self) -> bool {
445 matches!(self.storage_class.as_deref(), None | Some("STANDARD"))
446 }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct ListedObject {
451 pub key: String,
452 pub size: u64,
453 pub etag: String,
454 pub last_modified_unix: Option<u64>,
455}
456
457/// An in-progress multipart assembly (A9's hygiene surface).
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct PendingUpload {
460 pub key: String,
461 pub upload_id: String,
462 pub initiated_unix: Option<u64>,
463}
464
465// ── publish conditions ───────────────────────────────────────────────
466
467/// A6: every publish is conditional. There is deliberately NO
468/// unconditional variant — a caller wanting one has an upstream design
469/// error.
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub enum PutCondition {
472 /// Publish generation g+1 only if the object is still generation
473 /// g's bytes (its ETag). The 412 this produces is an internal
474 /// fencing event — see `tier::arbitrate`.
475 IfMatch(String),
476 /// First generation of a new key: fail if ANY object exists
477 /// (closes the create race with outside writers).
478 IfNoneMatchAny,
479 /// No precondition: create, or overwrite whatever is there.
480 ///
481 /// Deliberately narrow. It exists for CONTENT-ADDRESSED objects,
482 /// where the rewrite is byte-identical and the POINT is to refresh
483 /// the object's age rather than to change its contents: an adopted
484 /// manifest chunk is an old object that a new publish is about to
485 /// make live, and a sweep that reads age would otherwise collect it
486 /// as the orphan it still looks like (flint-lean chunked-manifest
487 /// design §8.1, machine-checked by `LeanChunkGCAdoptSkips`).
488 ///
489 /// Never reach for this where a concurrent writer's content could
490 /// DIFFER from yours — that is what the other two variants are for,
491 /// and losing that distinction is how a last-writer-wins overwrite
492 /// gets reintroduced.
493 Unconditional,
494}
495
496// ── generation assembly ──────────────────────────────────────────────
497
498/// One part of a composed generation, in object order.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub enum PartSource {
501 /// Dirty range: bytes [offset, offset+len) read from the local
502 /// file and uploaded.
503 Local { offset: u64, len: u64 },
504 /// Clean range: server-side copy of [offset, offset+len) from the
505 /// base generation object at the SAME key, guarded by
506 /// copy-source-if-match on the base ETag (detects foreign bucket
507 /// overwrite; structurally cannot detect local staleness — that is
508 /// capture's job, finding C5).
509 BaseCopy { offset: u64, len: u64 },
510}
511
512/// The flusher's order for one generation publish.
513#[derive(Debug, Clone)]
514pub struct ComposeSpec<'a> {
515 pub key: &'a str,
516 /// Local source of `Local` parts. Clean ranges are byte-identical
517 /// local and remote by definition, so `crc64` is computable from
518 /// this file alone.
519 pub local_path: &'a std::path::Path,
520 /// Contiguous from offset 0, sizes within the backend's part
521 /// granularity (the A11 part-size grid upstream owns that).
522 pub parts: Vec<PartSource>,
523 /// Where the base generation LIVES, when it is not `key` — the A7
524 /// re-key flush publishes a renamed file under its new key while
525 /// clean ranges still copy from the old one. None = same as `key`.
526 pub base_key: Option<&'a str>,
527 /// Base generation's ETag — required iff any part is `BaseCopy`.
528 pub base_etag: Option<String>,
529 pub condition: PutCondition,
530 pub stamps: GenerationStamps,
531 /// Full-object CRC-64/NVME of the composed content, from local
532 /// truth. Backends hand it to the store for SERVER-SIDE validation
533 /// at publish: a mismatch fails the publish, never warns.
534 ///
535 /// `None` asks the store to accumulate it from the `Local` parts
536 /// as it reads them for upload. Parts go up in order, one at a
537 /// time, so a streaming CRC over them in that loop IS the CRC of
538 /// the object — the same local truth, without a second pass over
539 /// the file (a 40 GiB pack's pre-pass was ~70 s on forge's push
540 /// path, 2026-09-05). A `BaseCopy` part is never read locally, so
541 /// a spec with one and `None` is refused before any part moves.
542 pub crc64: Option<u64>,
543 /// Bytes landed so far, for a caller that has to prove LIVENESS
544 /// while a long compose runs: forge's lease renewer renews a push
545 /// only while this advances, so a wedged upload lets the token go
546 /// quiet and a challenger take over — which is the one takeover a
547 /// wedged holder can get. Advanced by `len` after every part,
548 /// `Local` and `BaseCopy` alike. `None` = nobody is watching.
549 pub progress: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
550}
551
552impl ComposeSpec<'_> {
553 /// Record `bytes` more of the object as landed.
554 pub fn note_progress(&self, bytes: u64) {
555 if let Some(p) = &self.progress {
556 p.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
557 }
558 }
559}
560
561// ── errors ───────────────────────────────────────────────────────────
562
563#[derive(Debug, thiserror::Error)]
564pub enum StoreError {
565 /// 412 — the guarded condition failed. Route to arbitration, never
566 /// to an operator runbook (A6).
567 #[error("precondition failed (412): {0}")]
568 PreconditionFailed(String),
569 /// 409 — a concurrent conditional writer; retry-after-arbitrate.
570 #[error("conditional conflict (409): {0}")]
571 Conflict(String),
572 #[error("not found: {0}")]
573 NotFound(String),
574 /// The assembly was fenced (aborted) under us — A8's takeover
575 /// sweep produces exactly this at a deposed hub's Complete.
576 #[error("no such upload: {0}")]
577 NoSuchUpload(String),
578 /// Server-side content validation failed — the store computed a
579 /// different full-object checksum than local truth claims.
580 #[error("checksum mismatch: {0}")]
581 ChecksumMismatch(String),
582 /// 401/403, or an S3 code that means the same thing: the request
583 /// was well-formed and we are not allowed to make it.
584 ///
585 /// Split out of `Other` because the caller's response is different
586 /// in kind. A transient 5xx or a connection reset is "the store is
587 /// having a moment, keep trying"; this is "the credentials are
588 /// wrong, expired, or the policy changed", and no amount of retrying
589 /// fixes it. It reached the epoch heartbeat as an ordinary renew
590 /// failure, so a rotated key produced a fence and an exit whose log
591 /// line talked about lease windows — pointing an operator at the
592 /// bucket, at contention, at anything but the credential that had
593 /// actually expired.
594 ///
595 /// `RequestTimeTooSkewed` belongs here too: S3 answers 403, and the
596 /// cause is the node's clock rather than the key, which is
597 /// diagnosable only if the error says so.
598 #[error("not authorized: {0}")]
599 Auth(String),
600 #[error("store: {0}")]
601 Other(String),
602}
603
604pub type StoreResult<T> = Result<T, StoreError>;
605
606// ── the epoch surface (A8; the step-7 state machine drives these) ────
607
608/// Observed epoch state (read side — takeover judgment input).
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct EpochState {
611 pub holder_id: String,
612 pub epoch: u64,
613 /// Backend token guarding the next transition (S3: the epoch
614 /// object's ETag; Azure: the lease id).
615 pub token: String,
616 /// The store's own clock for the last renewal (S3 Last-Modified) —
617 /// A8: takeover is judged against the STORE's clock, not ours.
618 pub last_renew_unix: Option<u64>,
619 /// An opaque observed-state echo the holder wrote with its last
620 /// heartbeat, if any ([`LeaseEcho`]). Read-only to everyone but the
621 /// holder: it is the ONE fleet-visible surface that reports what a
622 /// syncer is actually *doing* rather than what its spec asked for,
623 /// and it costs nothing — the heartbeat CAS was already happening.
624 /// `None` = the holder wrote none (an older binary, a clean
625 /// release, or a backend that cannot carry it).
626 pub echo: Option<String>,
627 /// The holder shut down cleanly and will never publish under this
628 /// epoch again — a successor supersedes immediately instead of
629 /// waiting out the lease. Written by [`ObjectStore::epoch_release`].
630 pub released: bool,
631 /// The FIFO of holders waiting for the cell (lean's per-barrier
632 /// lease, design 2026-09-13 §4.2). A claimant that finds the cell
633 /// held, or released-and-handed to someone else, appends itself
634 /// ONCE ([`ObjectStore::epoch_enqueue`]); the holder's handoff pops
635 /// the head into `handoff`. Empty on every cell written by a holder
636 /// that never queues (the tier hub), which degrades to today's
637 /// behaviour and never to a second holder.
638 pub waiters: Vec<String>,
639 /// Who a RELEASED cell is reserved for: the queue head at the time
640 /// of the handoff. Only that holder may acquire it — or anyone,
641 /// once it has been quiet long enough to be judged dead. `None` =
642 /// first come, first served.
643 pub handoff: Option<String>,
644}
645
646/// A held lease (write side).
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct EpochLease {
649 pub holder_id: String,
650 pub epoch: u64,
651 pub token: String,
652 /// The queue as the holder last saw it. A renew rewrites the whole
653 /// body, so it must carry the waiters forward or a heartbeat would
654 /// erase the queue; a 412 on renew re-reads the cell and adopts
655 /// whatever the waiters appended meanwhile.
656 pub waiters: Vec<String>,
657}
658
659/// What a live lean syncer echoes into its lease-heartbeat cell
660/// (boundary-verbs plan §2.6). The schema lives here, beside the cell
661/// that carries it, because the WRITER (the syncer) and the READER
662/// (the operator) are in different crates and a duplicated mirror
663/// struct on either side would drift silently — which is the failure
664/// this field exists to detect, not to reproduce.
665///
666/// It answers a question no spec field can: which binary, at which
667/// protocol, is actually serving that workspace. An old syncer reads a
668/// FIXED env list, so a knob it predates is ignored in silence, and the
669/// echo is the only place an operator can see that.
670#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
671pub struct LeaseEcho {
672 /// The syncer binary's own version — the mixed-fleet tell.
673 #[serde(default)]
674 pub syncer_version: String,
675 /// `.flint/capabilities.json`'s protocol number.
676 #[serde(default)]
677 pub protocol: u32,
678 /// The last citation this syncer installed.
679 #[serde(default)]
680 pub last_cited_seq: u64,
681 #[serde(default)]
682 pub last_cited_unix: u64,
683 /// Whether the boundary verbs are live in this workspace. The
684 /// pre-flight that turns them off (an app already owns `.flint/`)
685 /// runs INSIDE the pod against the agent's own tree, so this is the
686 /// only surface on which an operator can see the answer.
687 #[serde(default)]
688 pub sentinel_verbs_active: bool,
689 /// `None` = exposition not enabled. `Some(false)` = enabled and the
690 /// port was TAKEN — the workspace is fully operable and nothing is
691 /// scraping it, which is a degradation only this field can report.
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub metrics_bound: Option<bool>,
694}
695
696// ── the trait ────────────────────────────────────────────────────────
697
698#[async_trait]
699pub trait ObjectStore: Send + Sync {
700 /// Whole-object publish (below the multipart threshold).
701 /// `crc64` is validated server-side; stamps ride as metadata.
702 async fn put_whole(
703 &self,
704 key: &str,
705 body: Bytes,
706 condition: &PutCondition,
707 stamps: &GenerationStamps,
708 crc64: u64,
709 ) -> StoreResult<ObjectMeta>;
710
711 /// Assemble and publish one generation from local dirty bytes plus
712 /// server-side clean copies. MUST abort its partial assembly on
713 /// every failure path (A9) — an error return means nothing is left
714 /// pending under the key by this call.
715 /// Server-side copy of a WHOLE object to a DIFFERENT key.
716 ///
717 /// Exists because a key IS a path in every layout this crate serves,
718 /// so moving a file moves bytes. Without it a rename is a download
719 /// and a re-upload, and `PartSource::BaseCopy` cannot stand in: it
720 /// copies ranges from the SAME key by construction, so a destination
721 /// that has no generation of its own has nothing to copy from.
722 ///
723 /// Two rules the implementations must agree on, and the memory
724 /// double must model or it will pass a test the real store fails:
725 ///
726 /// - the destination's full-object CRC **is** the source's. A copy
727 /// is byte-identical; a checksum that changed means the bytes did.
728 /// - the destination's **stamps are not** the source's. `generation`,
729 /// `epoch` and `flush_uuid` describe a publish, and inheriting them
730 /// files one object's history under another object's key — a
731 /// reader asking "which flush wrote this?" would be told about a
732 /// flush that wrote something else.
733 ///
734 /// `src_if_match` guards the SOURCE: copying an object that moved
735 /// since the caller read it copies something the caller never saw.
736 /// `condition` guards the DESTINATION, exactly as on `put_whole`.
737 async fn copy_object(
738 &self,
739 src_key: &str,
740 src_if_match: Option<&str>,
741 dst_key: &str,
742 condition: &PutCondition,
743 stamps: &GenerationStamps,
744 ) -> StoreResult<ObjectMeta>;
745
746 async fn compose_generation(&self, spec: &ComposeSpec<'_>) -> StoreResult<ObjectMeta>;
747
748 async fn head(&self, key: &str) -> StoreResult<ObjectMeta>;
749
750 /// Whole-object read, optionally guarded (hydration always guards:
751 /// a 412 here is S3-WINS — the foreign-overwrite posture, opposite
752 /// of the publish path).
753 async fn get_whole(&self, key: &str, if_match: Option<&str>) -> StoreResult<(ObjectMeta, Bytes)>;
754
755 /// Ranged read (step 11's streaming restore — a multi-GiB
756 /// hydration must never buffer the whole object). ALWAYS guarded:
757 /// each chunk's If-Match pins the same object version end-to-end;
758 /// a 412 mid-restore is the S3-wins foreign-overwrite signal. The
759 /// caller never requests past the object's end.
760 async fn get_range(
761 &self,
762 key: &str,
763 offset: u64,
764 len: u64,
765 if_match: &str,
766 ) -> StoreResult<Bytes>;
767
768 /// The same ranged read, WITHOUT flattening the response into one
769 /// contiguous buffer.
770 ///
771 /// `get_range` ends in `AggregatedBytes::into_bytes`, which for any
772 /// multi-frame body — i.e. always — takes `SegmentedBuf`'s general
773 /// branch: `BytesMut::with_capacity(len); res.put(self.take(len))`.
774 /// That is one full-size allocation and one full-size copy per
775 /// range, on top of the frames the SDK is already holding. At a 16
776 /// MiB chunk the allocation goes through mmap and is returned on
777 /// free, so the next range faults in fresh zeroed pages: measured
778 /// on i4i.large as system time beating user time 6:1.
779 ///
780 /// The DEFAULT deliberately wraps `get_range`, so a store with
781 /// nothing to stream (the in-memory double) behaves exactly as it
782 /// did and no implementation is forced to care.
783 async fn get_range_segments(
784 &self,
785 key: &str,
786 offset: u64,
787 len: u64,
788 if_match: &str,
789 ) -> StoreResult<Vec<Bytes>> {
790 Ok(vec![self.get_range(key, offset, len, if_match).await?])
791 }
792
793 async fn list(&self, prefix: &str) -> StoreResult<Vec<ListedObject>>;
794
795 async fn delete(&self, key: &str) -> StoreResult<()>;
796
797 /// DELETE `key` only while its current ETag is `etag` (`If-Match`):
798 /// `PreconditionFailed` once the object has moved on, `NotFound`
799 /// when there is nothing to delete.
800 ///
801 /// This is the garbage collector's delete. A HEAD that recognizes an
802 /// etag followed by a plain DELETE leaves a window in which another
803 /// writer's guarded PUT replaces the object, and the DELETE then
804 /// removes bytes that writer's next commit cites. With one writer per
805 /// workspace nothing could land there; with several writers holding
806 /// the lease only for their commit sections, an upload can.
807 ///
808 /// The default REFUSES rather than falling back to [`delete`]: a
809 /// fallback would reopen exactly that window without a word, on
810 /// whichever backend happened not to override this.
811 ///
812 /// [`delete`]: ObjectStore::delete
813 async fn delete_if_match(&self, key: &str, etag: &str) -> StoreResult<()> {
814 let _ = (key, etag);
815 Err(StoreError::Other("this backend has no conditional DELETE".into()))
816 }
817
818 // ── version-scoped operations (boundary-verbs plan D7/D8) ────────
819 //
820 // On a versioned bucket an in-place PUT destroys nothing: the cited
821 // generation survives as a version, the manifest cites
822 // (key, version_id, etag), and readers resolve the cited version
823 // exactly. The version id ADDRESSES; the etag ATTESTS.
824 //
825 // Backends that cannot express these answer `Other` and the caller
826 // refuses gated mode — never degrades into it.
827
828 /// HEAD one specific version.
829 async fn head_version(&self, key: &str, version_id: &str) -> StoreResult<ObjectMeta> {
830 let _ = (key, version_id);
831 Err(StoreError::Other("this backend has no version-scoped HEAD".into()))
832 }
833
834 /// GET one specific version, when the backend keeps versions. Lean's
835 /// readers resolve citations by etag alone (version-pinned reads went
836 /// with gated mode, 2026-09-13); the method stays for tools and probes.
837 async fn get_version(&self, key: &str, version_id: &str) -> StoreResult<(ObjectMeta, Bytes)> {
838 let _ = (key, version_id);
839 Err(StoreError::Other("this backend has no version-scoped GET".into()))
840 }
841
842 /// DELETE one specific version — the exact reclamation flint's own
843 /// per-citation GC uses. Free, and the ONLY reaper that can tell
844 /// cited from uncited; lifecycle cannot, which is why the noncurrent
845 /// rule is a long backstop rather than the mechanism (D8).
846 async fn delete_version(&self, key: &str, version_id: &str) -> StoreResult<()> {
847 let _ = (key, version_id);
848 Err(StoreError::Other("this backend has no version-scoped DELETE".into()))
849 }
850
851 /// Every version under a prefix, current and noncurrent, oldest
852 /// first per key. The claim-time/DR fallback when `orphans.json` is
853 /// missing or stale — the expensive path, which is why the durable
854 /// summary is written eagerly.
855 async fn list_versions(&self, prefix: &str) -> StoreResult<Vec<ListedVersion>> {
856 let _ = prefix;
857 Err(StoreError::Other("this backend cannot list object versions".into()))
858 }
859
860 /// A time-limited URL that GETs `key` with no credential of the
861 /// caller's own.
862 ///
863 /// One consumer, and it is worth naming because it is what makes
864 /// the URL's lifetime a design constraint rather than a parameter:
865 /// flint forge advertises a clone BUNDLE this way, so that a
866 /// thousand agents cloning at once pull the bytes from the object
867 /// store instead of through one pod's NIC (forge design §8). The
868 /// URL is handed to a git client, which may hold it for the length
869 /// of a clone, and S3 caps a SigV4 presigned URL at seven days —
870 /// so the caller must re-sign on a schedule shorter than the TTL it
871 /// asked for, and this returns the URL rather than storing it
872 /// anywhere.
873 ///
874 /// Defaulted to a refusal: a backend that cannot presign must say
875 /// so, and the caller must fall back to serving the bytes itself
876 /// rather than advertising a URL that will not resolve.
877 async fn presign_get(&self, key: &str, ttl_secs: u64) -> StoreResult<String> {
878 let _ = (key, ttl_secs);
879 Err(StoreError::Other("this backend cannot presign a URL".into()))
880 }
881
882 /// A time-limited URL that PUTs `key` with no credential of the
883 /// caller's own.
884 ///
885 /// The counterpart of [`ObjectStore::presign_get`], and it exists
886 /// for the same consumer: flint forge's LFS batch API hands a git
887 /// client one of these so a multi-gigabyte checkpoint travels
888 /// straight to the object store and never crosses the repository
889 /// server's network interface.
890 ///
891 /// **The URL carries no content check.** Whoever holds it may put
892 /// anything at that key until it expires, so the caller must issue
893 /// it only for a key whose NAME already constrains the content —
894 /// a content-addressed one — and must verify after the fact.
895 async fn presign_put(&self, key: &str, ttl_secs: u64) -> StoreResult<String> {
896 let _ = (key, ttl_secs);
897 Err(StoreError::Other("this backend cannot presign a URL".into()))
898 }
899
900 /// Every lifecycle rule on the bucket, reduced to [`LifecycleView`].
901 ///
902 /// Defaulted to a refusal rather than to an empty list: "no rules"
903 /// and "I cannot see the rules" must never be the same answer here.
904 /// An empty list is a POSITIVE claim that nothing is reaping
905 /// noncurrent versions, and gated mode is accepted on the strength
906 /// of it (D8).
907 async fn lifecycle_rules(&self) -> StoreResult<Vec<LifecycleView>> {
908 Err(StoreError::Other("this backend cannot read lifecycle rules".into()))
909 }
910
911 /// Provision the noncurrent-version retention backstop on `prefix`
912 /// with read-merge-append, and report what is installed.
913 ///
914 /// `PutBucketLifecycleConfiguration` is FULL-REPLACE: an
915 /// implementation that writes its rule alone silently deletes the
916 /// MPU-abort rule and every rule the customer owns. Read, merge,
917 /// append — the discipline `bootstrap_lifecycle` already follows.
918 async fn ensure_noncurrent_retention(
919 &self,
920 prefix: &str,
921 days: u64,
922 ) -> StoreResult<RetentionOutcome> {
923 let _ = (prefix, days);
924 Err(StoreError::Other("this backend cannot write lifecycle rules".into()))
925 }
926
927 /// A9 hygiene: every in-progress assembly under the prefix.
928 async fn list_uploads(&self, prefix: &str) -> StoreResult<Vec<PendingUpload>>;
929
930 /// Abort one assembly. Aborting an already-absent upload is Ok —
931 /// the sweep and the abort-on-failure paths both race the
932 /// lifecycle rule.
933 async fn abort_upload(&self, key: &str, upload_id: &str) -> StoreResult<()>;
934
935 /// A9: verify/create the bucket posture (lifecycle abort rule,
936 /// versioning, encryption notes, IAM probe). Named prefix so
937 /// probes stay under the tier's namespace.
938 async fn bootstrap(&self, prefix: &str) -> StoreResult<BootstrapReport>;
939
940 // Epoch (A8). S3 implements CAS re-PUT on the epoch object; Azure
941 // will use native blob leases (strictly better — A13).
942
943 async fn epoch_read(&self, key: &str) -> StoreResult<Option<EpochState>>;
944
945 /// Claim: `supersede: None` creates (If-None-Match:*); `Some`
946 /// replaces the OBSERVED state via CAS on its token — the caller
947 /// (step 7) is responsible for having JUDGED that holder dead
948 /// against the store's clock. The new epoch is observed+1.
949 async fn epoch_acquire(
950 &self,
951 key: &str,
952 holder_id: &str,
953 supersede: Option<&EpochState>,
954 ) -> StoreResult<EpochLease>;
955
956 /// Heartbeat CAS. `PreconditionFailed` means deposed: self-fence.
957 ///
958 /// `echo` rides the same write (see [`EpochState::echo`]) — the
959 /// observed-state surface is deliberately a passenger on a request
960 /// the holder already pays for every ≤30 s. A caller with nothing
961 /// to report passes `None`, which CLEARS any previous echo rather
962 /// than preserving it: a stale echo read as live is exactly the
963 /// mixed-version hole the field exists to close.
964 async fn epoch_renew(
965 &self,
966 key: &str,
967 lease: &EpochLease,
968 echo: Option<&str>,
969 ) -> StoreResult<EpochLease>;
970
971 /// Mark the cell released: a clean handoff. The epoch NUMBER must
972 /// survive (deleting the cell would restart numbering at 1 and
973 /// break the monotonicity every publish stamp depends on), and the
974 /// write must be guarded on `lease`'s own token so a deposed
975 /// holder cannot mark a live successor's cell.
976 async fn epoch_release(&self, key: &str, lease: &EpochLease) -> StoreResult<()>;
977
978 /// Append `holder_id` to the cell's FIFO of waiters, by CAS on the
979 /// OBSERVED token, and return the cell as rewritten. A holder that
980 /// is already queued is not appended twice. The write moves the
981 /// token: a live holder's next renew/handoff 412s, re-reads, and
982 /// adopts the new token with the longer queue (lean `lease.rs`).
983 /// Default: unsupported — a backend without a queue serialises
984 /// claimants by the quiet-poll rule alone.
985 async fn epoch_enqueue(
986 &self,
987 key: &str,
988 observed: &EpochState,
989 holder_id: &str,
990 ) -> StoreResult<EpochState> {
991 let _ = (key, observed, holder_id);
992 Err(StoreError::Other("this store carries no lease queue".into()))
993 }
994
995 /// The per-BARRIER release (lean, design 2026-09-13 §4): mark the
996 /// cell released like [`ObjectStore::epoch_release`], but hand it to
997 /// the queue head (`handoff = waiters[0]`, the rest stay queued) and
998 /// KEEP `echo` — the cell is at rest between every barrier now, so
999 /// the last holder's echo is the only thing that tells an operator
1000 /// which binary ran the last boundary. Guarded on the lease token
1001 /// like every other transition. Default: the plain release.
1002 async fn epoch_handoff(
1003 &self,
1004 key: &str,
1005 lease: &EpochLease,
1006 echo: Option<&str>,
1007 ) -> StoreResult<()> {
1008 let _ = echo;
1009 self.epoch_release(key, lease).await
1010 }
1011
1012 /// Backend part granularity for the A11 part-size grid.
1013 fn min_part_size(&self) -> u64;
1014 fn max_parts(&self) -> usize;
1015
1016 /// The store's upload bytes-in-flight gate ([`gate::ByteGate`]),
1017 /// when it was built with one. The store charges every local part
1018 /// of a compose to it; a caller that reads whole bodies ITSELF
1019 /// before `put_whole` (lean's `upload_one`) takes this so those
1020 /// bodies are charged to the SAME budget, and the bound is one
1021 /// number rather than two that must be set together. `None` = the
1022 /// upload path is bounded only by its counts.
1023 fn upload_gate(&self) -> Option<std::sync::Arc<gate::ByteGate>> {
1024 None
1025 }
1026
1027 /// Requests this store has sent since it was built, by kind
1028 /// (`counters.rs`). `None` = a store that does not count.
1029 fn request_counts(&self) -> Option<RequestCounts> {
1030 None
1031 }
1032}
1033
1034/// One lifecycle rule, reduced to what VERSION RETENTION cares about
1035/// (lean boundary-verbs plan D8).
1036///
1037/// Gated staging makes the CITED version noncurrent the moment a newer
1038/// generation stages, so a `NoncurrentVersionExpiration` rule covering
1039/// `<prefix>/files/` runs a clock against live cited data — and never
1040/// against the newest uncited bytes, which are current and which no
1041/// lifecycle rule can reach. That inversion is why the rules have to be
1042/// READABLE and not merely writable: a customer sitting at
1043/// noncurrent-days=1 arms the destroyer without touching flint at all.
1044#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct LifecycleView {
1046 pub id: String,
1047 pub enabled: bool,
1048 /// The rule's prefix filter; `""` = the whole bucket.
1049 pub prefix: String,
1050 /// `NoncurrentVersionExpiration.NoncurrentDays`, when the rule has
1051 /// one. `None` = this rule expires no noncurrent versions.
1052 pub noncurrent_days: Option<u64>,
1053 /// Whether the rule also expires orphaned delete markers.
1054 pub expired_delete_marker: bool,
1055}
1056
1057/// What [`ObjectStore::ensure_noncurrent_retention`] found or did.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059pub struct RetentionOutcome {
1060 pub rule_id: String,
1061 pub noncurrent_days: u64,
1062 /// False ⇒ a conforming rule was already installed and nothing was
1063 /// written (the read-merge-append path never runs blind).
1064 pub created: bool,
1065}
1066
1067/// What bootstrap found/did (A9). `errors` non-empty ⇒ the tier must
1068/// refuse to start; `warnings` are loud-degrade.
1069#[derive(Debug, Default, Clone)]
1070pub struct BootstrapReport {
1071 pub notes: Vec<String>,
1072 pub warnings: Vec<String>,
1073 pub errors: Vec<String>,
1074}
1075
1076impl BootstrapReport {
1077 pub fn ok(&self) -> bool {
1078 self.errors.is_empty()
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn crc64_nvme_catalog_check_value() {
1088 // CRC-64/NVME check input from the reveng catalog.
1089 assert_eq!(crc64_nvme(b"123456789"), 0xAE8B_1486_0A79_9888);
1090 // Streaming across split updates must agree with one-shot.
1091 let mut c = Crc64Nvme::new();
1092 c.update(b"1234");
1093 c.update(b"56789");
1094 assert_eq!(c.finalize(), 0xAE8B_1486_0A79_9888);
1095 assert_eq!(crc64_nvme(b""), 0);
1096 }
1097
1098 #[test]
1099 fn crc64_slice_by_8_agrees_with_the_byte_at_a_time_form() {
1100 // `update` consumes 8-byte words with a byte-at-a-time tail, and
1101 // it is a STREAMING call — so the word/tail boundary can land
1102 // anywhere a caller happens to split. That makes split ALIGNMENT
1103 // load-bearing in a way the 4/5 split above never exercised:
1104 // every arbitrary split must give the one-shot digest.
1105 fn byte_at_a_time(data: &[u8]) -> u64 {
1106 let t = crc64_table();
1107 let mut crc = u64::MAX;
1108 for &b in data {
1109 crc = t[((crc ^ b as u64) & 0xFF) as usize] ^ (crc >> 8);
1110 }
1111 crc ^ u64::MAX
1112 }
1113 let data: Vec<u8> =
1114 (0..1000u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();
1115
1116 // Every length across the word boundary, against the reference.
1117 for n in 0..40usize {
1118 assert_eq!(crc64_nvme(&data[..n]), byte_at_a_time(&data[..n]), "len {n}");
1119 }
1120 assert_eq!(crc64_nvme(&data), byte_at_a_time(&data));
1121
1122 // Every single split point of the full buffer.
1123 let want = crc64_nvme(&data);
1124 for cut in 0..data.len() {
1125 let mut c = Crc64Nvme::new();
1126 c.update(&data[..cut]);
1127 c.update(&data[cut..]);
1128 assert_eq!(c.finalize(), want, "split at {cut}");
1129 }
1130
1131 // A three-way split at two unaligned points.
1132 let mut c = Crc64Nvme::new();
1133 c.update(&data[..13]);
1134 c.update(&data[13..457]);
1135 c.update(&data[457..]);
1136 assert_eq!(c.finalize(), want);
1137 }
1138
1139 #[test]
1140 fn crc64_b64_is_8_bytes_big_endian_base64() {
1141 // 0xAE8B14860A799888 → bytes AE 8B 14 86 0A 79 98 88.
1142 assert_eq!(crc64_to_b64(0xAE8B_1486_0A79_9888), "rosUhgp5mIg=");
1143 assert_eq!(crc64_to_b64(0), "AAAAAAAAAAA=");
1144 }
1145
1146 #[test]
1147 fn stamps_roundtrip_and_reject_partial() {
1148 let s = GenerationStamps { generation: 7, epoch: 3, flush_uuid: "u-1".into(), boundary_source: None, posix: None };
1149 let m: HashMap<String, String> = s.to_meta().into_iter().collect();
1150 assert_eq!(GenerationStamps::from_meta(&m), Some(s));
1151 let mut partial = m.clone();
1152 partial.remove(GenerationStamps::META_FLUSH_UUID);
1153 assert_eq!(
1154 GenerationStamps::from_meta(&partial),
1155 None,
1156 "an unstamped/partially-stamped object is foreign by definition"
1157 );
1158 }
1159
1160 #[test]
1161 fn posix_stamps_roundtrip_and_stay_lenient() {
1162 let p = PosixStamps { mode: 0o100644, uid: 501, gid: 20, mtime_unix: 1_723_000_000 };
1163 let s = GenerationStamps {
1164 generation: 2,
1165 epoch: 1,
1166 flush_uuid: "u-2".into(),
1167 boundary_source: None,
1168 posix: Some(p),
1169 };
1170 let m: HashMap<String, String> = s.to_meta().into_iter().collect();
1171 assert_eq!(m.get(PosixStamps::META_MODE).map(String::as_str), Some("100644"));
1172 assert_eq!(GenerationStamps::from_meta(&m), Some(s));
1173 // A pre-step-12 object (identity stamps only) parses with
1174 // posix: None — NEVER foreign.
1175 let mut old = m.clone();
1176 old.remove(PosixStamps::META_UID);
1177 let parsed = GenerationStamps::from_meta(&old).expect("identity stamps intact");
1178 assert_eq!(parsed.posix, None);
1179 }
1180}
1181
1182#[cfg(test)]
1183mod combine_tests {
1184 use super::{crc64_combine, crc64_nvme};
1185
1186 /// The one-shot CRC over the concatenation is the oracle. Nothing
1187 /// here asserts a hand-computed constant: the existing
1188 /// implementation is the ground truth, and `combine` either agrees
1189 /// with it on every split or it is wrong.
1190 #[test]
1191 fn combining_two_parts_equals_hashing_the_whole() {
1192 // Non-uniform bytes: a run of one value would hide an operator
1193 // that shifted by the wrong number of positions.
1194 let whole: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();
1195 for split in [0usize, 1, 2, 3, 7, 8, 9, 255, 256, 257, 1023, 2048, 4095, 4096] {
1196 let (a, b) = whole.split_at(split);
1197 assert_eq!(
1198 crc64_combine(crc64_nvme(a), crc64_nvme(b), b.len() as u64),
1199 crc64_nvme(&whole),
1200 "split at {split}"
1201 );
1202 }
1203 }
1204
1205 #[test]
1206 fn combining_many_parts_folds_left_to_right() {
1207 let whole: Vec<u8> = (0..10_000u32).map(|i| (i ^ (i >> 5)) as u8).collect();
1208 // An 8 MiB-style grid in miniature: unequal parts, last one short.
1209 let sizes = [1usize, 999, 4096, 1, 4903]; // sums to exactly 10_000
1210 let mut off = 0usize;
1211 let mut acc: Option<(u64, u64)> = None; // (crc, len)
1212 for sz in sizes {
1213 let part = &whole[off..off + sz];
1214 let c = crc64_nvme(part);
1215 acc = Some(match acc {
1216 None => (c, sz as u64),
1217 Some((pc, plen)) => (crc64_combine(pc, c, sz as u64), plen + sz as u64),
1218 });
1219 off += sz;
1220 }
1221 let (crc, len) = acc.unwrap();
1222 assert_eq!(len, whole.len() as u64, "the grid must cover the object");
1223 assert_eq!(crc, crc64_nvme(&whole), "folded parts must equal one pass");
1224 }
1225
1226 #[test]
1227 fn an_empty_tail_changes_nothing() {
1228 let a = b"some bytes";
1229 assert_eq!(crc64_combine(crc64_nvme(a), crc64_nvme(b""), 0), crc64_nvme(a));
1230 }
1231}
1232
1233#[cfg(test)]
1234mod fold_order_tests {
1235 use super::{crc64_combine, crc64_nvme};
1236
1237 /// The compose path sorts parts by number BEFORE folding, and this
1238 /// is why: `crc64_combine` composes A-then-B, so a grid folded in
1239 /// completion order rather than part order yields the checksum of a
1240 /// DIFFERENT object. Without this test the sort reads as being only
1241 /// about what CompleteMultipartUpload accepts.
1242 #[test]
1243 fn folding_out_of_order_yields_a_different_object() {
1244 let a = b"first-part-bytes";
1245 let b = b"second";
1246 let c = b"third-part!";
1247 let whole = [&a[..], &b[..], &c[..]].concat();
1248
1249 let fold = |parts: &[&[u8]]| {
1250 let mut acc: Option<u64> = None;
1251 for p in parts {
1252 let x = crc64_nvme(p);
1253 acc = Some(match acc {
1254 None => x,
1255 Some(prev) => crc64_combine(prev, x, p.len() as u64),
1256 });
1257 }
1258 acc.unwrap()
1259 };
1260
1261 assert_eq!(fold(&[a, b, c]), crc64_nvme(&whole), "part order IS the object");
1262 assert_ne!(
1263 fold(&[b, a, c]),
1264 crc64_nvme(&whole),
1265 "a shuffled fold must NOT coincidentally agree, or the sort proves nothing"
1266 );
1267 }
1268}