Skip to main content

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