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