Skip to main content

flint_store/
memory.rs

1//! The in-process ObjectStore test double.
2//!
3//! Implements the FULL conditional contract — 412 on both put flavors,
4//! copy-source-if-match at part-copy time, server-side CRC-64/NVME
5//! validation at publish, MPU state with list/abort, epoch CAS — so
6//! every backend-neutral tier test (arbitration, flush pipeline,
7//! hydration) runs against semantics, not stubs. Failure injection
8//! covers the two adversarial publish shapes A6's arbitration must
9//! tell apart:
10//!
11//! - [`inject_torn_complete`]: the Complete LANDS server-side but the
12//!   response is lost (network tear) — the object exists at g+1 with
13//!   our stamps while the client saw an error.
14//! - [`inject_crash_before_complete`]: the assembly dies before
15//!   Complete — parts pending, nothing published, base object intact.
16//!
17//! [`inject_torn_complete`]: MemoryStore::inject_torn_complete
18//! [`inject_crash_before_complete`]: MemoryStore::inject_crash_before_complete
19
20use super::*;
21use sha2::{Digest, Sha256};
22use std::collections::BTreeMap;
23use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
24use std::sync::Mutex;
25
26fn now_unix() -> u64 {
27    std::time::SystemTime::now()
28        .duration_since(std::time::UNIX_EPOCH)
29        .map(|d| d.as_secs())
30        .unwrap_or(0)
31}
32
33#[derive(Clone)]
34struct StoredObject {
35    bytes: Bytes,
36    etag: String,
37    crc64: u64,
38    meta: HashMap<String, String>,
39    last_modified_unix: u64,
40    /// The version's identity (boundary-verbs plan D7). Minted from a
41    /// per-store COUNTER, never a uuid or a clock: a reproducible
42    /// battery is the whole reason this double exists, and a
43    /// nondeterministic id would make every version-citing assertion
44    /// order-dependent in a way that only shows up under load.
45    version_id: String,
46    /// A delete marker. Under versioning a DELETE never destroys data:
47    /// it makes the key read as absent while every prior version stays
48    /// fetchable by id.
49    deleted: bool,
50}
51
52impl StoredObject {
53    fn to_meta(&self) -> ObjectMeta {
54        ObjectMeta {
55            etag: self.etag.clone(),
56            size: self.bytes.len() as u64,
57            crc64_b64: Some(crc64_to_b64(self.crc64)),
58            meta: self.meta.clone(),
59            last_modified_unix: Some(self.last_modified_unix),
60            // The fake models STANDARD only; the IA guard's unit tests
61            // exercise copy_allowed=false at the planner level.
62            storage_class: None,
63            version_id: Some(self.version_id.clone()),
64        }
65    }
66}
67
68struct Mpu {
69    key: String,
70    parts: BTreeMap<usize, Bytes>,
71    initiated_unix: u64,
72}
73
74/// A key's version chain: oldest first, LAST is current. Nothing in
75/// this double ever overwrites in place — that is the entire point of
76/// modelling versions, since an in-place overwrite is the destructive
77/// design versioned staging exists to avoid (§2.4.2).
78#[derive(Default, Clone)]
79struct VersionChain {
80    versions: Vec<StoredObject>,
81}
82
83impl VersionChain {
84    /// The current version, or None if the key reads as absent (empty
85    /// chain, or a delete marker on top).
86    fn current(&self) -> Option<&StoredObject> {
87        match self.versions.last() {
88            Some(o) if !o.deleted => Some(o),
89            _ => None,
90        }
91    }
92}
93
94#[derive(Default)]
95struct Inner {
96    chains: BTreeMap<String, VersionChain>,
97    uploads: HashMap<String, Mpu>,
98    /// Deterministic version-id source (see `StoredObject::version_id`).
99    version_seq: u64,
100    /// The bucket's lifecycle rules. Modelled because D8's hazard is a
101    /// rule the CUSTOMER already owns — the battery has to be able to
102    /// plant one (`plant_lifecycle_rule`) and watch gated mode refuse.
103    lifecycle: Vec<LifecycleView>,
104}
105
106impl Inner {
107    fn next_version_id(&mut self) -> String {
108        self.version_seq += 1;
109        format!("v{:012}", self.version_seq)
110    }
111
112    /// The current version of `key`, or None if absent.
113    fn current(&self, key: &str) -> Option<&StoredObject> {
114        self.chains.get(key).and_then(|c| c.current())
115    }
116
117    /// Append a version and make it current.
118    fn push(&mut self, key: &str, mut obj: StoredObject) -> String {
119        let vid = self.next_version_id();
120        obj.version_id = vid.clone();
121        self.chains.entry(key.to_string()).or_default().versions.push(obj);
122        vid
123    }
124
125    /// A keyed DELETE: a delete marker, never destruction.
126    fn push_delete_marker(&mut self, key: &str) {
127        if self.chains.get(key).map(|c| c.current().is_none()).unwrap_or(true) {
128            return; // already absent: S3 still adds a marker; we keep the chain quiet
129        }
130        let vid = self.next_version_id();
131        self.chains.entry(key.to_string()).or_default().versions.push(StoredObject {
132            bytes: Bytes::new(),
133            etag: String::new(),
134            crc64: 0,
135            meta: HashMap::new(),
136            last_modified_unix: now_unix(),
137            version_id: vid,
138            deleted: true,
139        });
140    }
141
142    fn version(&self, key: &str, version_id: &str) -> Option<&StoredObject> {
143        self.chains
144            .get(key)?
145            .versions
146            .iter()
147            .find(|o| o.version_id == version_id && !o.deleted)
148    }
149
150    /// Version-scoped DELETE: removes exactly that version. Removing
151    /// the current version promotes the one beneath it, exactly as S3
152    /// does.
153    fn remove_version(&mut self, key: &str, version_id: &str) {
154        if let Some(chain) = self.chains.get_mut(key) {
155            chain.versions.retain(|o| o.version_id != version_id);
156            if chain.versions.is_empty() {
157                self.chains.remove(key);
158            }
159        }
160    }
161}
162
163const INJECT_NONE: u8 = 0;
164const INJECT_TORN_COMPLETE: u8 = 1;
165const INJECT_CRASH_BEFORE_COMPLETE: u8 = 2;
166const INJECT_SWEPT: u8 = 3;
167
168pub struct MemoryStore {
169    inner: Mutex<Inner>,
170    upload_seq: AtomicU64,
171    /// One-shot failure injection for the NEXT compose publish.
172    inject: AtomicU8,
173    /// Set by the crash injection: the failing compose must LEAVE its
174    /// orphan MPU (the A9 sweep's test state) instead of aborting it.
175    leave_orphan: AtomicBool,
176    /// Step-11 drill injections: counted get_range failures / stall.
177    fail_get_range_count: AtomicU64,
178    /// Counted HEAD failures — the sweep's transient-throttle drill.
179    fail_head_count: AtomicU64,
180    /// Counted `epoch_renew` failures that are NOT a 412: the store
181    /// unreachable from the holder (forge X13's drill).
182    fail_epoch_renew_count: AtomicU64,
183    /// The next n whole PUTs LAND and then report 409. See
184    /// `inject_put_lands_then_fails`.
185    fail_put_after_land_count: AtomicU64,
186    fail_put_after_land_key: Mutex<String>,
187    stall_next_get_range_ms: AtomicU64,
188    /// A latency every get_range pays, in ms: the double's stand-in for
189    /// a network round trip, so a test can observe whether fetches
190    /// OVERLAP rather than time them.
191    get_range_delay_ms: AtomicU64,
192    /// A latency every whole PUT pays, in ms: the write side's
193    /// `get_range_delay_ms`, so a test can observe whether whole-body
194    /// uploads OVERLAP — and how many bytes they hold while they do.
195    put_whole_delay_ms: AtomicU64,
196    /// A latency EVERY request pays, in ms (`inject_request_delay_ms`): the
197    /// double's stand-in for a store's round trip, on every call a real
198    /// store would send — GETs, HEADs, LISTs, PUTs, DELETEs, the lease
199    /// cell's reads and writes — so a measurement can show what a chain of
200    /// sequential requests costs against one that overlaps.
201    request_delay_ms: AtomicU64,
202    /// Requests inside that delay right now, and the most there have ever
203    /// been at once: overlap, observed rather than timed.
204    inflight_requests: AtomicU64,
205    peak_requests: AtomicU64,
206    /// While set, a LIST whose prefix contains this answers 403: a
207    /// credential whose policy grants reads but not the listing.
208    deny_list_matching: Mutex<Option<String>>,
209    /// While set, every whole PUT parks at the door until
210    /// `release_held_puts`: a stalled upload a test can run something
211    /// BESIDE (a batch beside a fold's upload), with the ordering held
212    /// by the gate rather than by a sleep the scheduler may outlast.
213    hold_puts: AtomicBool,
214    /// When set, only a whole PUT whose key contains this parks: a test
215    /// can stall one upload while the lease's own writes go through.
216    hold_puts_matching: Mutex<Option<String>>,
217    held_puts: Mutex<Vec<tokio::sync::oneshot::Sender<()>>>,
218    /// get_range calls in flight, and the most there have ever been at
219    /// once: a fan-out's bound, observed from both sides.
220    inflight_get_range: AtomicU64,
221    peak_get_range: AtomicU64,
222    /// Model a project-scoped proxy that STRIPS `x-amz-version-id`:
223    /// every write still succeeds and reports no version. This is D8's
224    /// silent-degradation hazard, and the only way to test that the
225    /// conformance probe refuses instead of degrading.
226    strip_version_ids: AtomicBool,
227    /// Model a bucket whose lifecycle rules are readable but not
228    /// writable (a scoped operator principal): the backstop cannot be
229    /// provisioned, which is a DEGRADATION, not a torn view.
230    fail_lifecycle_writes: AtomicBool,
231    /// How a conditional write answers `If-Match` on a key that does not
232    /// exist. Real S3 answers 404 `NoSuchKey`; Ozone 2.2.1 answers 412
233    /// (both measured 2026-09-13 with the AWS CLI). The double used to give
234    /// only Ozone's answer, so the syncer's "a vanished base is a create"
235    /// rule — written for the 412 — was green here and wedged every
236    /// barrier on S3 (drill host leg H2). Default: S3's 404.
237    if_match_missing_412: AtomicBool,
238    /// Per-operation call counts. Cost measurement is otherwise
239    /// guesswork: the request SHAPE of a tick is the thing the plan
240    /// prices, and it is not derivable from the code by reading.
241    ops: Mutex<std::collections::BTreeMap<&'static str, u64>>,
242    pub min_part: u64,
243    pub max_parts: usize,
244    /// Parts of one compose read and staged concurrently — the S3
245    /// backend's `part_parallelism`, mirrored so the double runs the
246    /// same window shape. 1 = sequential.
247    part_parallelism: usize,
248    /// The upload byte gate, when `with_upload_inflight_max_bytes` set
249    /// one: the same charge at the same points as the S3 backend, so a
250    /// test can read the bound off the gate's high-water mark.
251    upload_gate: Option<std::sync::Arc<crate::gate::ByteGate>>,
252}
253
254/// Leaves the in-flight count on drop, so an early `return` still
255/// leaves.
256struct InFlight<'a>(&'a AtomicU64);
257impl Drop for InFlight<'_> {
258    fn drop(&mut self) {
259        self.0.fetch_sub(1, Ordering::SeqCst);
260    }
261}
262
263impl Default for MemoryStore {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl MemoryStore {
270    /// Age an object's last-modified stamp. Liveness here is judged
271    /// against the STORE's clock (A8), so a test about a dead holder
272    /// has to be able to move that clock rather than sleep.
273    pub fn backdate_epoch(&self, key: &str, secs: u64) {
274        let mut inner = self.inner.lock().unwrap();
275        if let Some(chain) = inner.chains.get_mut(key) {
276            for v in chain.versions.iter_mut() {
277                v.last_modified_unix = v.last_modified_unix.saturating_sub(secs);
278            }
279        }
280    }
281
282    /// Age an in-progress multipart upload's initiation stamp, so a
283    /// test of an age-gated sweep need not sleep past its threshold.
284    pub fn backdate_upload(&self, upload_id: &str, secs: u64) {
285        if let Some(m) = self.inner.lock().unwrap().uploads.get_mut(upload_id) {
286            m.initiated_unix = m.initiated_unix.saturating_sub(secs);
287        }
288    }
289
290    /// Plant a lifecycle rule the way a customer's own fleet policy
291    /// would. D8's destroyer is a rule flint never wrote.
292    pub fn plant_lifecycle_rule(&self, rule: LifecycleView) {
293        self.inner.lock().unwrap().lifecycle.push(rule);
294    }
295
296    pub fn new() -> Self {
297        MemoryStore {
298            ops: Mutex::new(Default::default()),
299            inner: Mutex::new(Inner::default()),
300            upload_seq: AtomicU64::new(1),
301            inject: AtomicU8::new(INJECT_NONE),
302            leave_orphan: AtomicBool::new(false),
303            fail_get_range_count: AtomicU64::new(0),
304            fail_head_count: AtomicU64::new(0),
305            fail_epoch_renew_count: AtomicU64::new(0),
306            fail_put_after_land_count: AtomicU64::new(0),
307            fail_put_after_land_key: Mutex::new(String::new()),
308            stall_next_get_range_ms: AtomicU64::new(0),
309            get_range_delay_ms: AtomicU64::new(0),
310            put_whole_delay_ms: AtomicU64::new(0),
311            request_delay_ms: AtomicU64::new(0),
312            inflight_requests: AtomicU64::new(0),
313            peak_requests: AtomicU64::new(0),
314            deny_list_matching: Mutex::new(None),
315            hold_puts: AtomicBool::new(false),
316            hold_puts_matching: Mutex::new(None),
317            held_puts: Mutex::new(Vec::new()),
318            inflight_get_range: AtomicU64::new(0),
319            peak_get_range: AtomicU64::new(0),
320            strip_version_ids: AtomicBool::new(false),
321            fail_lifecycle_writes: AtomicBool::new(false),
322            if_match_missing_412: AtomicBool::new(false),
323            // Tiny granularity by default so tests compose small
324            // files; S3's real limits live in the S3 backend.
325            min_part: 1,
326            max_parts: 10_000,
327            part_parallelism: 1,
328            upload_gate: None,
329        }
330    }
331
332    /// Answer `If-Match` on a missing key with 412, as Ozone's S3 gateway
333    /// does, instead of S3's 404 `NoSuchKey`. See `if_match_missing_412`.
334    pub fn with_if_match_missing_as_412(self) -> Self {
335        self.if_match_missing_412.store(true, Ordering::SeqCst);
336        self
337    }
338
339    /// Stage this many parts of one compose concurrently (the S3
340    /// backend's `with_part_parallelism`). Clamped to at least 1.
341    pub fn with_part_parallelism(mut self, n: usize) -> Self {
342        self.part_parallelism = n.max(1);
343        self
344    }
345
346    /// Bound the bytes the upload path holds at once (the S3 backend's
347    /// `with_upload_inflight_max_bytes`; see `gate.rs`). `0` = no gate.
348    pub fn with_upload_inflight_max_bytes(mut self, n: u64) -> Self {
349        self.upload_gate = (n > 0).then(|| std::sync::Arc::new(crate::gate::ByteGate::new(n)));
350        self
351    }
352
353    /// Every whole PUT from now on sleeps `ms` first — the write side's
354    /// `inject_get_range_delay_ms`: whole bodies that overlap are in
355    /// flight together for the whole delay, so the upload byte gate's
356    /// high-water mark reads the caller's bound rather than the
357    /// scheduler's luck.
358    pub fn inject_put_whole_delay_ms(&self, ms: u64) {
359        self.put_whole_delay_ms.store(ms, Ordering::SeqCst);
360    }
361
362    /// Every request from now on sleeps `ms` first (0 turns it off). The
363    /// per-kind delays above stay as they are and add to this one.
364    pub fn inject_request_delay_ms(&self, ms: u64) {
365        self.request_delay_ms.store(ms, Ordering::SeqCst);
366    }
367
368    /// The most requests that were ever inside the injected delay at once.
369    /// Only a delay makes overlap observable: with none, a request never
370    /// yields, so this stays at 0 or 1.
371    pub fn peak_requests_in_flight(&self) -> u64 {
372        self.peak_requests.load(Ordering::SeqCst)
373    }
374
375    pub fn reset_peak_requests_in_flight(&self) {
376        self.peak_requests.store(0, Ordering::SeqCst);
377    }
378
379    /// Every LIST whose prefix contains `m` answers 403 (`StoreError::Auth`)
380    /// from now on; `None` lets them through again.
381    pub fn deny_list_matching(&self, m: Option<&str>) {
382        *self.deny_list_matching.lock().unwrap() = m.map(str::to_string);
383    }
384
385    /// The injected round trip (`inject_request_delay_ms`).
386    async fn pace(&self) {
387        let ms = self.request_delay_ms.load(Ordering::SeqCst);
388        if ms == 0 {
389            return;
390        }
391        let now = self.inflight_requests.fetch_add(1, Ordering::SeqCst) + 1;
392        self.peak_requests.fetch_max(now, Ordering::SeqCst);
393        let _left = InFlight(&self.inflight_requests);
394        tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
395    }
396
397    /// Every write from here on reports no version id — a proxy that
398    /// strips the header (D8's refusal arm).
399    pub fn strip_version_ids(&self, on: bool) {
400        self.strip_version_ids.store(on, Ordering::SeqCst);
401    }
402
403    /// Lifecycle writes fail from here on; reads keep working.
404    pub fn fail_lifecycle_writes(&self, on: bool) {
405        self.fail_lifecycle_writes.store(on, Ordering::SeqCst);
406    }
407
408    /// Next compose: Complete LANDS but the response is lost.
409    pub fn inject_torn_complete(&self) {
410        self.inject.store(INJECT_TORN_COMPLETE, Ordering::SeqCst);
411    }
412
413    /// Next compose: dies before Complete — MPU left pending.
414    pub fn inject_crash_before_complete(&self) {
415        self.inject.store(INJECT_CRASH_BEFORE_COMPLETE, Ordering::SeqCst);
416    }
417
418    /// Next compose: the operator's abort sweep takes the assembly
419    /// before Complete — `NoSuchUpload`, S3's answer, and nothing lands.
420    pub fn inject_compose_swept(&self) {
421        self.inject.store(INJECT_SWEPT, Ordering::SeqCst);
422    }
423
424    /// Step-11 drills: the NEXT get_range fails with a transport
425    /// error — a single failure is absorbed by the chunk-retry loop.
426    pub fn inject_get_range_failure(&self) {
427        self.inject_get_range_failures(1);
428    }
429
430    /// The next `n` get_range calls fail with a transport error —
431    /// exceed the chunk-retry budget to force the truncate-back path.
432    pub fn inject_get_range_failures(&self, n: u64) {
433        self.fail_get_range_count.store(n, Ordering::SeqCst);
434    }
435
436    /// Every get_range from now on sleeps `ms` first. Unlike the
437    /// one-shot stall this is persistent, and it is what makes a
438    /// fan-out OBSERVABLE: calls that overlap are in flight together
439    /// for the whole delay, so `peak_get_range_in_flight` reads the
440    /// caller's bound rather than the scheduler's luck.
441    pub fn inject_get_range_delay_ms(&self, ms: u64) {
442        self.get_range_delay_ms.store(ms, Ordering::SeqCst);
443    }
444
445    /// Every whole PUT from now on parks until `release_held_puts`
446    /// (`false` lets new PUTs through again; a PUT already parked stays
447    /// parked). Checked at the start of each PUT, so a test can stall
448    /// one task's upload, let the next caller's through at once, and
449    /// release the stalled one when it chooses.
450    pub fn inject_put_hold(&self, on: bool) {
451        *self.hold_puts_matching.lock().unwrap() = None;
452        self.hold_puts.store(on, Ordering::SeqCst);
453    }
454
455    /// Like [`inject_put_hold`], but only for keys containing
456    /// `key_contains`. `inject_put_hold(false)` ends it.
457    ///
458    /// [`inject_put_hold`]: MemoryStore::inject_put_hold
459    pub fn inject_put_hold_matching(&self, key_contains: &str) {
460        *self.hold_puts_matching.lock().unwrap() = Some(key_contains.to_string());
461        self.hold_puts.store(true, Ordering::SeqCst);
462    }
463
464    /// How many whole PUTs are parked right now.
465    pub fn held_puts(&self) -> usize {
466        self.held_puts.lock().unwrap().len()
467    }
468
469    /// Let every parked PUT land.
470    pub fn release_held_puts(&self) {
471        for tx in self.held_puts.lock().unwrap().drain(..) {
472            let _ = tx.send(());
473        }
474    }
475
476    /// The most get_range calls ever in flight at once since the last
477    /// reset. A fan-out bounded at N reads exactly N here when there is
478    /// work for N — and exactly 1 when it is not a fan-out at all.
479    pub fn peak_get_range_in_flight(&self) -> u64 {
480        self.peak_get_range.load(Ordering::SeqCst)
481    }
482    pub fn reset_peak_get_range_in_flight(&self) {
483        self.peak_get_range.store(0, Ordering::SeqCst);
484    }
485
486    /// The next `n` HEAD calls fail with a transport error.
487    ///
488    /// The condition this models is not exotic: the foreign-key sweep
489    /// issues one HEAD per unknown object, which on a large bucket is
490    /// exactly the shape that earns a 503 SlowDown. The SDK's default
491    /// retry absorbs a blip; a sustained throttle outlives it.
492    pub fn inject_head_failures(&self, n: u64) {
493        self.fail_head_count.store(n, Ordering::SeqCst);
494    }
495
496    /// The next `n` renewals fail as a transport error — not a 412, so
497    /// the holder cannot tell "deposed" from "cut off". Zero clears it.
498    pub fn inject_epoch_renew_failures(&self, n: u64) {
499        self.fail_epoch_renew_count.store(n, Ordering::SeqCst);
500    }
501
502    /// The next `n` whole PUTs STORE THE OBJECT and then report 409.
503    ///
504    /// This is the one failure a conditional writer cannot interpret.
505    /// S3 answers a contended conditional write with 409
506    /// `ConditionalRequestConflict`, and a lost response to a PUT that
507    /// landed looks the same from the client: the write is durable and
508    /// the caller was told it failed. Every other injector here models
509    /// a failure that did NOT happen server-side; this one models the
510    /// failure that DID.
511    /// `key_contains` narrows it to one object — a batch uploads its
512    /// packs BEFORE the snapshot CAS, so an untargeted injection would
513    /// break a pack upload and never reach the write under test.
514    pub fn inject_put_lands_then_fails(&self, key_contains: &str, n: u64) {
515        *self.fail_put_after_land_key.lock().unwrap() = key_contains.to_string();
516        self.fail_put_after_land_count.store(n, Ordering::SeqCst);
517    }
518
519    /// Step-11 drills: the NEXT get_range stalls `ms` before serving —
520    /// a slow hydration to race concurrent I/O and priority against.
521    pub fn inject_get_range_stall(&self, ms: u64) {
522        self.stall_next_get_range_ms.store(ms, Ordering::SeqCst);
523    }
524
525    /// Test surface: the bytes under a key change while its etag and its
526    /// stored checksum claim do NOT — bit-rot, a broken gateway, a cache
527    /// serving the wrong body. Nothing on the wire says so: If-Match
528    /// still matches, and a store that never carried a checksum header
529    /// validates nothing. Only a reader that checks the body against
530    /// what the MANIFEST cites can see it.
531    pub fn inject_corrupt_body(&self, key: &str, mutate: impl FnOnce(&mut Vec<u8>)) {
532        let mut inner = self.inner.lock().unwrap();
533        let chain = inner.chains.get_mut(key).expect("corrupting a key that exists");
534        let obj = chain.versions.last_mut().expect("a version to corrupt");
535        let mut b = obj.bytes.to_vec();
536        mutate(&mut b);
537        obj.bytes = Bytes::from(b);
538    }
539
540    /// Test surface: plant an object directly (foreign writers, torn
541    /// own states, import fixtures). Stamps go in as given.
542    pub fn raw_put(&self, key: &str, bytes: Bytes, meta: Vec<(String, String)>) -> ObjectMeta {
543        let obj = StoredObject {
544            etag: put_etag(&bytes),
545            crc64: crc64_nvme(&bytes),
546            meta: meta.into_iter().collect(),
547            last_modified_unix: now_unix(),
548            bytes,
549            version_id: String::new(),
550            deleted: false,
551        };
552        let mut inner = self.inner.lock().unwrap();
553        let vid = inner.push(key, obj);
554        let mut m = inner.current(key).map(|o| o.to_meta()).expect("just pushed");
555        m.version_id = Some(vid);
556        m
557    }
558
559    /// Test surface: REDEEM a URL from [`ObjectStore::presign_put`] the
560    /// way S3 does, so a test can play the client — or the attacker —
561    /// who holds it.
562    ///
563    /// Without this the double's presigned URL was a string nothing
564    /// ever used, and "the URL grants only the bytes it was issued for"
565    /// could not be asked of it at all. What S3 enforces, and so what
566    /// this enforces (and nothing more):
567    ///
568    /// - every header the URL SIGNED must be presented, with the signed
569    ///   value, or the signature does not match (403). An unsigned
570    ///   header binds nothing: the holder simply leaves it out.
571    /// - a presented `x-amz-checksum-sha256` must be the base64 SHA-256
572    ///   of the body (400 BadDigest).
573    /// - a presented `If-None-Match: *` refuses a key that exists (412).
574    ///
575    /// Header names are compared case-insensitively, as HTTP does.
576    pub fn redeem_presigned_put(
577        &self,
578        url: &str,
579        headers: &BTreeMap<String, String>,
580        body: Bytes,
581    ) -> StoreResult<ObjectMeta> {
582        let rest = url
583            .strip_prefix("memory://presigned-put/")
584            .ok_or_else(|| StoreError::Other(format!("not a memory presigned PUT: {url}")))?;
585        let (key, query) = rest.split_once('?').unwrap_or((rest, ""));
586        let presented: BTreeMap<String, &String> =
587            headers.iter().map(|(k, v)| (k.to_ascii_lowercase(), v)).collect();
588        for param in query.split('&') {
589            if let Some((name, want)) = param.split_once('=').and_then(|(k, v)| Some((k.strip_prefix("h.")?, v))) {
590                if presented.get(name).map(|v| v.as_str()) != Some(want) {
591                    return Err(StoreError::Auth(format!(
592                        "SignatureDoesNotMatch: signed header {name} not presented as signed"
593                    )));
594                }
595            }
596        }
597        if let Some(sum) = presented.get("x-amz-checksum-sha256") {
598            let actual = crate::base64_std(&Sha256::digest(&body));
599            if **sum != actual {
600                return Err(StoreError::ChecksumMismatch(format!(
601                    "BadDigest: {key}: x-amz-checksum-sha256 {sum} but the body hashes to {actual}"
602                )));
603            }
604        }
605        if presented.get("if-none-match").map(|v| v.as_str()) == Some("*")
606            && self.inner.lock().unwrap().current(key).is_some()
607        {
608            return Err(StoreError::PreconditionFailed(format!("{key} exists")));
609        }
610        Ok(self.raw_put(key, body, Vec::new()))
611    }
612
613    /// Test surface: the NONCURRENT-VERSION LIFECYCLE BACKSTOP (D8),
614    /// modelled as an explicit method because it is a timer in reality
615    /// and an untestable one if left implicit.
616    ///
617    /// The hazard it exists to demonstrate is INVERTED from the
618    /// eager-prefix design's: gated staging makes the **cited** version
619    /// noncurrent the moment a newer generation is staged, so a
620    /// `NoncurrentVersionExpiration` rule on `files/` runs a clock
621    /// against live cited data — and never against the newest uncited
622    /// bytes, which are current and which no lifecycle rule can reach.
623    /// Reaping a cited noncurrent version dangles the manifest; that is
624    /// `Inv_CitedVersionLives`'s required-firing mutation and leg B23's
625    /// abandoned-mid-stage endgame.
626    ///
627    /// Returns the (key, version_id) pairs reaped.
628    pub fn expire_noncurrent(&self, older_than_secs: u64) -> Vec<(String, String)> {
629        let now = now_unix();
630        let mut inner = self.inner.lock().unwrap();
631        let mut reaped = vec![];
632        let keys: Vec<String> = inner.chains.keys().cloned().collect();
633        for k in keys {
634            let Some(chain) = inner.chains.get_mut(&k) else { continue };
635            let last = chain.versions.len().saturating_sub(1);
636            let doomed: Vec<String> = chain
637                .versions
638                .iter()
639                .enumerate()
640                .filter(|(i, o)| {
641                    *i != last && now.saturating_sub(o.last_modified_unix) >= older_than_secs
642                })
643                .map(|(_, o)| o.version_id.clone())
644                .collect();
645            for vid in doomed {
646                chain.versions.retain(|o| o.version_id != vid);
647                reaped.push((k.clone(), vid));
648            }
649        }
650        reaped
651    }
652
653    /// Test surface: how many versions a key currently holds (leg B21's
654    /// oracle — steady state must return to ONE live version per key,
655    /// reclaimed by flint's exact per-citation GC, NOT by waiting for
656    /// the backstop).
657    pub fn version_count(&self, key: &str) -> usize {
658        self.inner.lock().unwrap().chains.get(key).map(|c| c.versions.len()).unwrap_or(0)
659    }
660
661    /// Test surface: a DEPOSED writer's late CompleteMultipartUpload.
662    /// Assembles whatever parts exist (unconditionally — the deposed
663    /// straggler of the A8 drill carries no working guard); answers
664    /// `NoSuchUpload` if the assembly was fenced away, exactly S3's
665    /// answer after the takeover abort-sweep.
666    pub fn raw_complete_upload(&self, upload_id: &str) -> StoreResult<ObjectMeta> {
667        let mut inner = self.inner.lock().unwrap();
668        let Some(mpu) = inner.uploads.remove(upload_id) else {
669            return Err(StoreError::NoSuchUpload(format!("upload {}", upload_id)));
670        };
671        let mut bytes = Vec::new();
672        let n_parts = mpu.parts.len().max(1);
673        for part in mpu.parts.values() {
674            bytes.extend_from_slice(part);
675        }
676        let bytes = Bytes::from(bytes);
677        let obj = StoredObject {
678            etag: mpu_etag(&bytes, n_parts),
679            crc64: crc64_nvme(&bytes),
680            meta: HashMap::new(),
681            last_modified_unix: now_unix(),
682            bytes,
683                   version_id: String::new(),
684            deleted: false,
685        };
686        let mkey = mpu.key.clone();
687        let vid = inner.push(&mkey, obj);
688        let mut m = inner.current(&mkey).expect("just pushed").to_meta();
689        m.version_id = Some(vid);
690        Ok(m)
691    }
692
693    /// Test surface: an orphan MPU (for the A9 sweep tests).
694    pub fn raw_begin_upload(&self, key: &str) -> String {
695        let id = format!("mpu-{}", self.upload_seq.fetch_add(1, Ordering::SeqCst));
696        self.inner.lock().unwrap().uploads.insert(
697            id.clone(),
698            Mpu { key: key.to_string(), parts: BTreeMap::new(), initiated_unix: now_unix() },
699        );
700        id
701    }
702
703    fn check_condition(
704        &self,
705        existing: Option<&StoredObject>,
706        condition: &PutCondition,
707    ) -> StoreResult<()> {
708        match (condition, existing) {
709            (PutCondition::IfMatch(want), Some(o)) if o.etag == *want => Ok(()),
710            (PutCondition::IfMatch(_), Some(_)) => {
711                Err(StoreError::PreconditionFailed("If-Match: etag differs".into()))
712            }
713            (PutCondition::IfMatch(_), None) if self.if_match_missing_412.load(Ordering::SeqCst) => {
714                Err(StoreError::PreconditionFailed("If-Match: no object".into()))
715            }
716            (PutCondition::IfMatch(_), None) => Err(StoreError::NotFound("NoSuchKey: If-Match on a missing key".into())),
717            (PutCondition::IfNoneMatchAny, None) => Ok(()),
718            (PutCondition::IfNoneMatchAny, Some(_)) => {
719                Err(StoreError::PreconditionFailed("If-None-Match: object exists".into()))
720            }
721            (PutCondition::Unconditional, _) => Ok(()),
722        }
723    }
724}
725
726fn put_etag(bytes: &[u8]) -> String {
727    let mut h = Sha256::new();
728    h.update(bytes);
729    format!("\"{:x}\"", h.finalize())
730}
731
732/// Multipart etag: content hash + part count — deliberately a DIFFERENT
733/// string than a whole-put of the same bytes, mirroring S3's "the
734/// multipart ETag is not a content hash".
735fn mpu_etag(bytes: &[u8], parts: usize) -> String {
736    let mut h = Sha256::new();
737    h.update(bytes);
738    format!("\"{:x}-{}\"", h.finalize(), parts)
739}
740
741#[derive(serde::Serialize, serde::Deserialize)]
742struct EpochBody {
743    holder_id: String,
744    epoch: u64,
745    /// Clean-shutdown mark — see `ObjectStore::epoch_release`.
746    #[serde(default)]
747    released: bool,
748    /// The holder's observed-state echo (`LeaseEcho`), opaque here.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    echo: Option<String>,
751    /// The claim queue and the handoff reservation (see the S3 twin).
752    #[serde(default, skip_serializing_if = "Vec::is_empty")]
753    waiters: Vec<String>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    handoff: Option<String>,
756}
757
758impl MemoryStore {
759    /// One epoch transition: CAS on `condition`, then store `body`. The
760    /// etag is SALTED with a sequence so two writes of the same body
761    /// (a renew, a re-enqueue) still rotate the CAS token — a stale
762    /// holder's heartbeat must fail even when nothing in it changed.
763    fn epoch_store(&self, key: &str, body: EpochBody, condition: &PutCondition) -> StoreResult<String> {
764        let bytes = Bytes::from(serde_json::to_vec(&body).unwrap());
765        let mut inner = self.inner.lock().unwrap();
766        self.check_condition(inner.current(key), condition)?;
767        let mut salted = bytes.to_vec();
768        salted.extend_from_slice(&self.upload_seq.fetch_add(1, Ordering::SeqCst).to_be_bytes());
769        let obj = StoredObject {
770            etag: put_etag(&salted),
771            crc64: crc64_nvme(&bytes),
772            meta: HashMap::new(),
773            last_modified_unix: now_unix(),
774            bytes,
775            version_id: String::new(),
776            deleted: false,
777        };
778        let token = obj.etag.clone();
779        inner.push(key, obj);
780        Ok(token)
781    }
782
783    fn bump(&self, op: &'static str) {
784        *self.ops.lock().unwrap().entry(op).or_insert(0) += 1;
785    }
786    /// Call counts since construction or the last reset.
787    pub fn op_counts(&self) -> std::collections::BTreeMap<&'static str, u64> {
788        self.ops.lock().unwrap().clone()
789    }
790    pub fn reset_op_counts(&self) {
791        self.ops.lock().unwrap().clear();
792    }
793    pub fn total_ops(&self) -> u64 {
794        self.ops.lock().unwrap().values().sum()
795    }
796}
797
798#[async_trait]
799impl ObjectStore for MemoryStore {
800    fn request_counts(&self) -> Option<crate::RequestCounts> {
801        // The double counts calls by method; fold them into the kinds a
802        // real store would have sent (the cell is a GET or a PUT there).
803        let mut c = crate::RequestCounts::default();
804        for (op, n) in self.op_counts() {
805            match op {
806                "get_whole" | "get_range" | "get_version" | "epoch_read" | "presign_get" => c.get += n,
807                "head" | "head_version" => c.head += n,
808                "put_whole" | "epoch_acquire" | "epoch_renew" | "epoch_release" | "epoch_handoff"
809                | "epoch_enqueue" | "presign_put" => c.put += n,
810                "copy_object" => c.copy += n,
811                "delete" | "delete_version" => c.delete += n,
812                "list" | "list_versions" | "list_uploads" => c.list += n,
813                "compose_generation" | "compose_inner" | "abort_upload" => c.multipart += n,
814                _ => {}
815            }
816        }
817        Some(c)
818    }
819
820    async fn put_whole(
821        &self,
822        key: &str,
823        body: Bytes,
824        condition: &PutCondition,
825        stamps: &GenerationStamps,
826        crc64: u64,
827    ) -> StoreResult<ObjectMeta> {
828        self.bump("put_whole");
829        self.pace().await;
830        let held = self.hold_puts.load(Ordering::SeqCst)
831            && match self.hold_puts_matching.lock().unwrap().as_deref() {
832                None => true,
833                Some(m) => key.contains(m),
834            };
835        if held {
836            let (tx, rx) = tokio::sync::oneshot::channel();
837            self.held_puts.lock().unwrap().push(tx);
838            // A dropped sender (a release that drained the list) lets
839            // the PUT through as well.
840            let _ = rx.await;
841        }
842        let delay = self.put_whole_delay_ms.load(Ordering::SeqCst);
843        if delay > 0 {
844            tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
845        }
846        let actual = crc64_nvme(&body);
847        if actual != crc64 {
848            return Err(StoreError::ChecksumMismatch(format!(
849                "put {}: claimed {:#x}, content is {:#x}",
850                key, crc64, actual
851            )));
852        }
853        let mut inner = self.inner.lock().unwrap();
854        self.check_condition(inner.current(key), condition)?;
855        let obj = StoredObject {
856            etag: put_etag(&body),
857            crc64,
858            meta: stamps.to_meta().into_iter().collect(),
859            last_modified_unix: now_unix(),
860            bytes: body,
861            version_id: String::new(),
862            deleted: false,
863        };
864        // The version id is minted by `push`; reporting a meta built
865        // BEFORE it would hand the caller `Some("")` — and a manifest
866        // that cites version "" makes the citation GC match nothing and
867        // reap every real version of the key.
868        let vid = inner.push(key, obj);
869        let mut m = inner.current(key).expect("just pushed").to_meta();
870        m.version_id = Some(vid);
871        if self.strip_version_ids.load(Ordering::SeqCst) {
872            // The version still EXISTS — the proxy only hid its name.
873            // That asymmetry is the hazard: everything keeps working
874            // until a citation needs to name a version.
875            m.version_id = None;
876        }
877        // The object is already stored. Reporting the failure HERE is
878        // the whole point: the caller cannot tell this from a write
879        // that never happened.
880        if self.fail_put_after_land_count.load(Ordering::SeqCst) > 0 {
881            let pat = self.fail_put_after_land_key.lock().unwrap().clone();
882            if (pat.is_empty() || key.contains(pat.as_str()))
883                && self
884                    .fail_put_after_land_count
885                    .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| v.checked_sub(1))
886                    .is_ok()
887            {
888                return Err(StoreError::Conflict(format!(
889                    "injected: the PUT of {key} landed and the response did not"
890                )));
891            }
892        }
893        Ok(m)
894    }
895
896    async fn copy_object(
897        &self,
898        src_key: &str,
899        src_if_match: Option<&str>,
900        dst_key: &str,
901        condition: &PutCondition,
902        stamps: &GenerationStamps,
903    ) -> StoreResult<ObjectMeta> {
904        self.bump("copy_object");
905        self.pace().await;
906        let mut inner = self.inner.lock().unwrap();
907        let src = inner
908            .current(src_key)
909            .ok_or_else(|| StoreError::NotFound(src_key.to_string()))?;
910        // The SOURCE guard. A 412 here is not the destination's: the
911        // object the caller read has been overwritten, and copying the
912        // replacement would move bytes nobody asked for.
913        if let Some(want) = src_if_match {
914            if src.etag != want {
915                return Err(StoreError::PreconditionFailed(format!(
916                    "copy source {src_key} is at {} , not {want}",
917                    src.etag
918                )));
919            }
920        }
921        let bytes = src.bytes.clone();
922        // The CRC travels with the BYTES — they are the same bytes. The
923        // stamps do NOT: a copy is a new publish under a new key, and
924        // wearing the source's generation would file one object's
925        // history under another's name. A double that copied the source
926        // meta wholesale would pass a test the real store fails.
927        let crc64 = src.crc64;
928        self.check_condition(inner.current(dst_key), condition)?;
929        let obj = StoredObject {
930            etag: put_etag(&bytes),
931            crc64,
932            meta: stamps.to_meta().into_iter().collect(),
933            last_modified_unix: now_unix(),
934            bytes,
935            version_id: String::new(),
936            deleted: false,
937        };
938        let vid = inner.push(dst_key, obj);
939        let mut m = inner.current(dst_key).expect("just pushed").to_meta();
940        m.version_id = Some(vid);
941        if self.strip_version_ids.load(Ordering::SeqCst) {
942            m.version_id = None;
943        }
944        Ok(m)
945    }
946
947    async fn compose_generation(&self, spec: &ComposeSpec<'_>) -> StoreResult<ObjectMeta> {
948        self.bump("compose_generation");
949        self.pace().await;
950        if spec.parts.is_empty() {
951            return Err(StoreError::Other("compose: no parts".into()));
952        }
953        if spec.parts.len() > self.max_parts {
954            return Err(StoreError::Other(format!(
955                "compose: {} parts exceeds the backend maximum {}",
956                spec.parts.len(),
957                self.max_parts
958            )));
959        }
960        let upload_id = self.raw_begin_upload(spec.key);
961        // Everything below aborts the MPU on error (A9): no failure
962        // path may leave the assembly pending — except the injected
963        // crash, whose whole point is the orphan.
964        let result = self.compose_inner(spec, &upload_id).await;
965        if result.is_err() && !self.leave_orphan.swap(false, Ordering::SeqCst) {
966            let mut inner = self.inner.lock().unwrap();
967            inner.uploads.remove(&upload_id);
968        }
969        result
970    }
971
972    async fn head(&self, key: &str) -> StoreResult<ObjectMeta> {
973        self.bump("head");
974        self.pace().await;
975        if self
976            .fail_head_count
977            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
978            .is_ok()
979        {
980            return Err(StoreError::Other("injected head failure".into()));
981        }
982        self.inner
983            .lock()
984            .unwrap()
985            .current(key)
986            .map(|o| o.to_meta())
987            .ok_or_else(|| StoreError::NotFound(key.into()))
988    }
989
990    /// A stand-in URL: deterministic, unmistakably not real, and
991    /// refused for a key that does not exist. A test can assert that
992    /// the right key was signed and that a missing object is not
993    /// advertised; whether a git client can actually fetch it is a
994    /// question only a real store answers, and forge's storm leg is
995    /// where that is asked.
996    async fn presign_get(&self, key: &str, ttl_secs: u64) -> StoreResult<String> {
997        self.bump("presign_get");
998        let inner = self.inner.lock().unwrap();
999        if inner.current(key).is_none() {
1000            return Err(StoreError::NotFound(key.into()));
1001        }
1002        Ok(format!("memory://presigned/{key}?ttl={ttl_secs}"))
1003    }
1004
1005    /// A stand-in URL for a write. Unlike the read side it does NOT
1006    /// require the object to exist — that is the whole point of an
1007    /// upload. The headers it binds are written INTO the URL as `h.`
1008    /// parameters, which is how `redeem_presigned_put` plays S3's
1009    /// signature: a signed header must be presented as signed.
1010    async fn presign_put(
1011        &self,
1012        key: &str,
1013        ttl_secs: u64,
1014        sha256: &[u8; 32],
1015    ) -> StoreResult<PresignedPut> {
1016        self.bump("presign_put");
1017        let headers: BTreeMap<String, String> = [
1018            ("x-amz-checksum-sha256".to_string(), crate::base64_std(sha256)),
1019            ("if-none-match".to_string(), "*".to_string()),
1020        ]
1021        .into_iter()
1022        .collect();
1023        let mut url = format!("memory://presigned-put/{key}?ttl={ttl_secs}");
1024        for (k, v) in &headers {
1025            url.push_str(&format!("&h.{k}={v}"));
1026        }
1027        Ok(PresignedPut { url, headers })
1028    }
1029
1030    async fn get_whole(
1031        &self,
1032        key: &str,
1033        if_match: Option<&str>,
1034    ) -> StoreResult<(ObjectMeta, Bytes)> {
1035        self.bump("get_whole");
1036        self.pace().await;
1037        let inner = self.inner.lock().unwrap();
1038        let o = inner
1039            .current(key)
1040            .ok_or_else(|| StoreError::NotFound(key.into()))?;
1041        if let Some(want) = if_match {
1042            if o.etag != want {
1043                return Err(StoreError::PreconditionFailed(format!(
1044                    "get {}: etag {} != {}",
1045                    key, o.etag, want
1046                )));
1047            }
1048        }
1049        Ok((o.to_meta(), o.bytes.clone()))
1050    }
1051
1052    /// Real S3 always delivers a ranged body as MANY frames. This
1053    /// double handed back exactly one, so a caller that walks segments
1054    /// and advances a running offset was never actually exercised
1055    /// against it — a double missing a property its subject always has,
1056    /// which passes for the wrong reason. Built on `get_range` so every
1057    /// injected failure, stall and in-flight count still applies.
1058    async fn get_range_segments(
1059        &self,
1060        key: &str,
1061        offset: u64,
1062        len: u64,
1063        if_match: &str,
1064    ) -> StoreResult<Vec<Bytes>> {
1065        let whole = self.get_range(key, offset, len, if_match).await?;
1066        // Deliberately UNEVEN and never aligned to the caller's chunk
1067        // size: equal segments let an off-by-one in the running offset
1068        // cancel itself out and land the bytes correctly anyway.
1069        let mut segs: Vec<Bytes> = Vec::new();
1070        let (mut at, mut step) = (0usize, 1usize);
1071        while at < whole.len() {
1072            let n = step.min(whole.len() - at);
1073            segs.push(whole.slice(at..at + n)); // zero-copy
1074            at += n;
1075            step = (step.saturating_mul(3)).min(64 * 1024);
1076        }
1077        if segs.is_empty() {
1078            segs.push(whole);
1079        }
1080        Ok(segs)
1081    }
1082
1083    async fn get_range(
1084        &self,
1085        key: &str,
1086        offset: u64,
1087        len: u64,
1088        if_match: &str,
1089    ) -> StoreResult<Bytes> {
1090        self.bump("get_range");
1091        self.pace().await;
1092        let _in_flight = {
1093            let now = self.inflight_get_range.fetch_add(1, Ordering::SeqCst) + 1;
1094            self.peak_get_range.fetch_max(now, Ordering::SeqCst);
1095            InFlight(&self.inflight_get_range)
1096        };
1097        // Counted injections for the step-11 drills.
1098        if self
1099            .fail_get_range_count
1100            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
1101            .is_ok()
1102        {
1103            return Err(StoreError::Other("injected get_range failure".into()));
1104        }
1105        let stall = self.stall_next_get_range_ms.swap(0, Ordering::SeqCst);
1106        if stall > 0 {
1107            tokio::time::sleep(std::time::Duration::from_millis(stall)).await;
1108        }
1109        let delay = self.get_range_delay_ms.load(Ordering::SeqCst);
1110        if delay > 0 {
1111            tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
1112        }
1113        let inner = self.inner.lock().unwrap();
1114        let o = inner
1115            .current(key)
1116            .ok_or_else(|| StoreError::NotFound(key.into()))?;
1117        if o.etag != if_match {
1118            return Err(StoreError::PreconditionFailed(format!(
1119                "get_range {}: etag {} != {}",
1120                key, o.etag, if_match
1121            )));
1122        }
1123        let start = offset.min(o.bytes.len() as u64) as usize;
1124        let end = (offset + len).min(o.bytes.len() as u64) as usize;
1125        Ok(o.bytes.slice(start..end))
1126    }
1127
1128    async fn list(&self, prefix: &str) -> StoreResult<Vec<ListedObject>> {
1129        self.bump("list");
1130        self.pace().await;
1131        let denied = self.deny_list_matching.lock().unwrap().as_deref().is_some_and(|m| prefix.contains(m));
1132        if denied {
1133            return Err(StoreError::Auth(format!("injected: no listing of {prefix}")));
1134        }
1135        Ok(self
1136            .inner
1137            .lock()
1138            .unwrap()
1139            .chains
1140            .range(prefix.to_string()..)
1141            .take_while(|(k, _)| k.starts_with(prefix))
1142            // Current versions only, delete markers skipped — a LIST
1143            // must never report a key a GET would 404 on.
1144            .filter_map(|(k, c)| {
1145                c.current().map(|o| ListedObject {
1146                    key: k.clone(),
1147                    size: o.bytes.len() as u64,
1148                    etag: o.etag.clone(),
1149                    last_modified_unix: Some(o.last_modified_unix),
1150                })
1151            })
1152            .collect())
1153    }
1154
1155    async fn delete(&self, key: &str) -> StoreResult<()> {
1156        self.bump("delete");
1157        self.pace().await;
1158        self.inner.lock().unwrap().push_delete_marker(key);
1159        Ok(())
1160    }
1161
1162    async fn delete_if_match(&self, key: &str, etag: &str) -> StoreResult<()> {
1163        // Counted as the one DELETE request it is on the wire.
1164        self.bump("delete");
1165        self.pace().await;
1166        let mut inner = self.inner.lock().unwrap();
1167        let refused = match inner.current(key) {
1168            None => Some(StoreError::NotFound(key.to_string())),
1169            Some(o) if o.etag != etag => Some(StoreError::PreconditionFailed(format!(
1170                "{key}: If-Match {etag} but the current etag is {}",
1171                o.etag
1172            ))),
1173            Some(_) => None,
1174        };
1175        if let Some(e) = refused {
1176            return Err(e);
1177        }
1178        inner.push_delete_marker(key);
1179        Ok(())
1180    }
1181
1182    /// One request per thousand keys, as S3's `DeleteObjects` costs — so
1183    /// a request census over the double reads what the wire would.
1184    async fn delete_many(&self, keys: &[String]) -> StoreResult<DeleteManyReport> {
1185        let mut report = DeleteManyReport::default();
1186        for chunk in keys.chunks(1000) {
1187            self.bump("delete");
1188            self.pace().await;
1189            let mut inner = self.inner.lock().unwrap();
1190            for key in chunk {
1191                inner.push_delete_marker(key);
1192                report.deleted += 1;
1193            }
1194        }
1195        Ok(report)
1196    }
1197
1198    async fn head_version(&self, key: &str, version_id: &str) -> StoreResult<ObjectMeta> {
1199        self.bump("head_version");
1200        self.pace().await;
1201        self.inner
1202            .lock()
1203            .unwrap()
1204            .version(key, version_id)
1205            .map(|o| o.to_meta())
1206            .ok_or_else(|| StoreError::NotFound(format!("{key}?versionId={version_id}")))
1207    }
1208
1209    async fn get_version(&self, key: &str, version_id: &str) -> StoreResult<(ObjectMeta, Bytes)> {
1210        self.bump("get_version");
1211        self.pace().await;
1212        let inner = self.inner.lock().unwrap();
1213        let o = inner
1214            .version(key, version_id)
1215            .ok_or_else(|| StoreError::NotFound(format!("{key}?versionId={version_id}")))?;
1216        Ok((o.to_meta(), o.bytes.clone()))
1217    }
1218
1219    async fn delete_version(&self, key: &str, version_id: &str) -> StoreResult<()> {
1220        self.bump("delete_version");
1221        self.pace().await;
1222        // Idempotent, exactly as S3: deleting an absent version is Ok —
1223        // the GC pass and the operator sweep race each other by design.
1224        self.inner.lock().unwrap().remove_version(key, version_id);
1225        Ok(())
1226    }
1227
1228    async fn list_versions(&self, prefix: &str) -> StoreResult<Vec<ListedVersion>> {
1229        self.bump("list_versions");
1230        self.pace().await;
1231        let inner = self.inner.lock().unwrap();
1232        let mut out = vec![];
1233        for (k, chain) in inner.chains.range(prefix.to_string()..) {
1234            if !k.starts_with(prefix) {
1235                break;
1236            }
1237            let last = chain.versions.len().saturating_sub(1);
1238            for (i, o) in chain.versions.iter().enumerate() {
1239                out.push(ListedVersion {
1240                    key: k.clone(),
1241                    version_id: o.version_id.clone(),
1242                    etag: o.etag.clone(),
1243                    size: o.bytes.len() as u64,
1244                    is_current: i == last,
1245                    is_delete_marker: o.deleted,
1246                    last_modified_unix: Some(o.last_modified_unix),
1247                });
1248            }
1249        }
1250        Ok(out)
1251    }
1252
1253    async fn list_uploads(&self, prefix: &str) -> StoreResult<Vec<PendingUpload>> {
1254        self.bump("list_uploads");
1255        self.pace().await;
1256        let inner = self.inner.lock().unwrap();
1257        let mut v: Vec<PendingUpload> = inner
1258            .uploads
1259            .iter()
1260            .filter(|(_, m)| m.key.starts_with(prefix))
1261            .map(|(id, m)| PendingUpload {
1262                key: m.key.clone(),
1263                upload_id: id.clone(),
1264                initiated_unix: Some(m.initiated_unix),
1265            })
1266            .collect();
1267        v.sort_by(|a, b| a.upload_id.cmp(&b.upload_id));
1268        Ok(v)
1269    }
1270
1271    async fn abort_upload(&self, _key: &str, upload_id: &str) -> StoreResult<()> {
1272        self.bump("abort_upload");
1273        self.pace().await;
1274        // Absent is Ok: abort races the lifecycle rule by design.
1275        self.inner.lock().unwrap().uploads.remove(upload_id);
1276        Ok(())
1277    }
1278
1279    async fn bootstrap(&self, _prefix: &str) -> StoreResult<BootstrapReport> {
1280        self.bump("bootstrap");
1281        self.pace().await;
1282        Ok(BootstrapReport {
1283            notes: vec!["memory store: no bucket posture to verify".into()],
1284            ..Default::default()
1285        })
1286    }
1287
1288    async fn lifecycle_rules(&self) -> StoreResult<Vec<LifecycleView>> {
1289        self.bump("lifecycle_rules");
1290        self.pace().await;
1291        Ok(self.inner.lock().unwrap().lifecycle.clone())
1292    }
1293
1294    async fn ensure_noncurrent_retention(
1295        &self,
1296        prefix: &str,
1297        days: u64,
1298    ) -> StoreResult<RetentionOutcome> {
1299        self.bump("ensure_noncurrent_retention");
1300        self.pace().await;
1301        if self.fail_lifecycle_writes.load(Ordering::SeqCst) {
1302            return Err(StoreError::Other("lifecycle writes are denied".into()));
1303        }
1304        let rule_id = format!("flint-lean-noncurrent-{}", prefix.trim_end_matches('/').replace('/', "-"));
1305        let mut inner = self.inner.lock().unwrap();
1306        // Read-merge-append, exactly as S3 must: an existing conforming
1307        // rule is left alone and NOTHING is rewritten.
1308        if let Some(r) = inner
1309            .lifecycle
1310            .iter()
1311            .find(|r| r.enabled && r.prefix == prefix && r.noncurrent_days == Some(days))
1312        {
1313            return Ok(RetentionOutcome {
1314                rule_id: r.id.clone(),
1315                noncurrent_days: days,
1316                created: false,
1317            });
1318        }
1319        inner.lifecycle.retain(|r| r.id != rule_id);
1320        inner.lifecycle.push(LifecycleView {
1321            id: rule_id.clone(),
1322            enabled: true,
1323            prefix: prefix.to_string(),
1324            noncurrent_days: Some(days),
1325            expired_delete_marker: true,
1326        });
1327        Ok(RetentionOutcome { rule_id, noncurrent_days: days, created: true })
1328    }
1329
1330    async fn epoch_read(&self, key: &str) -> StoreResult<Option<EpochState>> {
1331        self.bump("epoch_read");
1332        self.pace().await;
1333        let inner = self.inner.lock().unwrap();
1334        let Some(o) = inner.current(key) else {
1335            return Ok(None);
1336        };
1337        let body: EpochBody = serde_json::from_slice(&o.bytes)
1338            .map_err(|e| StoreError::Other(format!("epoch body: {}", e)))?;
1339        Ok(Some(EpochState {
1340            holder_id: body.holder_id,
1341            epoch: body.epoch,
1342            token: o.etag.clone(),
1343            last_renew_unix: Some(o.last_modified_unix),
1344            released: body.released,
1345            echo: body.echo,
1346            waiters: body.waiters,
1347            handoff: body.handoff,
1348        }))
1349    }
1350
1351    async fn epoch_acquire(
1352        &self,
1353        key: &str,
1354        holder_id: &str,
1355        supersede: Option<&EpochState>,
1356    ) -> StoreResult<EpochLease> {
1357        self.bump("epoch_acquire");
1358        self.pace().await;
1359        let epoch = supersede.map_or(1, |s| s.epoch + 1);
1360        let waiters: Vec<String> = supersede
1361            .map(|s| s.waiters.iter().filter(|w| w.as_str() != holder_id).cloned().collect())
1362            .unwrap_or_default();
1363        let condition = match supersede {
1364            None => PutCondition::IfNoneMatchAny,
1365            Some(s) => PutCondition::IfMatch(s.token.clone()),
1366        };
1367        let token = self.epoch_store(
1368            key,
1369            EpochBody {
1370                holder_id: holder_id.into(),
1371                epoch,
1372                released: false,
1373                echo: None,
1374                waiters: waiters.clone(),
1375                handoff: None,
1376            },
1377            &condition,
1378        )?;
1379        Ok(EpochLease { holder_id: holder_id.into(), epoch, token, waiters })
1380    }
1381
1382    async fn epoch_renew(
1383        &self,
1384        key: &str,
1385        lease: &EpochLease,
1386        echo: Option<&str>,
1387    ) -> StoreResult<EpochLease> {
1388        self.bump("epoch_renew");
1389        self.pace().await;
1390        if self
1391            .fail_epoch_renew_count
1392            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| c.checked_sub(1))
1393            .is_ok()
1394        {
1395            return Err(StoreError::Other("injected renew failure: store unreachable".into()));
1396        }
1397        let token = self.epoch_store(
1398            key,
1399            EpochBody {
1400                holder_id: lease.holder_id.clone(),
1401                epoch: lease.epoch,
1402                released: false,
1403                echo: echo.map(|e| e.to_string()),
1404                waiters: lease.waiters.clone(),
1405                handoff: None,
1406            },
1407            &PutCondition::IfMatch(lease.token.clone()),
1408        )?;
1409        Ok(EpochLease {
1410            holder_id: lease.holder_id.clone(),
1411            epoch: lease.epoch,
1412            token,
1413            waiters: lease.waiters.clone(),
1414        })
1415    }
1416
1417    async fn epoch_release(&self, key: &str, lease: &EpochLease) -> StoreResult<()> {
1418        self.bump("epoch_release");
1419        self.pace().await;
1420        // Mark, never delete — deleting restarts epoch numbering at 1.
1421        // A released cell reports no live syncer: clearing the echo is
1422        // the point, not an omission (the tier hub's rule; lean's
1423        // per-barrier `epoch_handoff` keeps it).
1424        self.epoch_store(
1425            key,
1426            EpochBody {
1427                holder_id: lease.holder_id.clone(),
1428                epoch: lease.epoch,
1429                released: true,
1430                echo: None,
1431                waiters: lease.waiters.clone(),
1432                handoff: None,
1433            },
1434            &PutCondition::IfMatch(lease.token.clone()),
1435        )?;
1436        Ok(())
1437    }
1438
1439    async fn epoch_handoff(
1440        &self,
1441        key: &str,
1442        lease: &EpochLease,
1443        echo: Option<&str>,
1444    ) -> StoreResult<()> {
1445        self.bump("epoch_handoff");
1446        self.pace().await;
1447        let mut rest = lease.waiters.clone();
1448        let head = if rest.is_empty() { None } else { Some(rest.remove(0)) };
1449        self.epoch_store(
1450            key,
1451            EpochBody {
1452                holder_id: lease.holder_id.clone(),
1453                epoch: lease.epoch,
1454                released: true,
1455                echo: echo.map(|e| e.to_string()),
1456                waiters: rest,
1457                handoff: head,
1458            },
1459            &PutCondition::IfMatch(lease.token.clone()),
1460        )?;
1461        Ok(())
1462    }
1463
1464    async fn epoch_enqueue(
1465        &self,
1466        key: &str,
1467        observed: &EpochState,
1468        holder_id: &str,
1469    ) -> StoreResult<EpochState> {
1470        self.bump("epoch_enqueue");
1471        self.pace().await;
1472        let mut waiters = observed.waiters.clone();
1473        if !waiters.iter().any(|w| w == holder_id) {
1474            waiters.push(holder_id.to_string());
1475        }
1476        let token = self.epoch_store(
1477            key,
1478            EpochBody {
1479                holder_id: observed.holder_id.clone(),
1480                epoch: observed.epoch,
1481                released: observed.released,
1482                echo: observed.echo.clone(),
1483                waiters: waiters.clone(),
1484                handoff: observed.handoff.clone(),
1485            },
1486            &PutCondition::IfMatch(observed.token.clone()),
1487        )?;
1488        Ok(EpochState {
1489            holder_id: observed.holder_id.clone(),
1490            epoch: observed.epoch,
1491            token,
1492            last_renew_unix: Some(now_unix()),
1493            echo: observed.echo.clone(),
1494            released: observed.released,
1495            waiters,
1496            handoff: observed.handoff.clone(),
1497        })
1498    }
1499
1500    fn min_part_size(&self) -> u64 {
1501        self.min_part
1502    }
1503
1504    fn max_parts(&self) -> usize {
1505        self.max_parts
1506    }
1507
1508    fn upload_gate(&self) -> Option<std::sync::Arc<crate::gate::ByteGate>> {
1509        self.upload_gate.clone()
1510    }
1511}
1512
1513impl MemoryStore {
1514    /// One part of a compose, staged into the upload's part map: a
1515    /// local part read through the upload byte gate, a copied part
1516    /// taken under its guard. Independent of every other part, which
1517    /// is what lets `compose_inner` run these `part_parallelism` wide —
1518    /// the S3 backend's `compose_one_part`, in the double.
1519    async fn stage_one_part(
1520        &self,
1521        spec: &ComposeSpec<'_>,
1522        upload_id: &str,
1523        i: usize,
1524        p: &PartSource,
1525    ) -> StoreResult<()> {
1526        match p {
1527            PartSource::Local { offset, len } => {
1528                // Charged from BEFORE the read — the read is the
1529                // allocation — until the part has landed (the permit
1530                // lives to the end of this arm), exactly as the S3
1531                // backend holds it until `UploadPart` returns. The read
1532                // runs on the blocking pool as the backend's does, so
1533                // the parts of one object genuinely overlap and a test
1534                // reading the gate's high-water mark sees the window,
1535                // not the scheduler.
1536                let _permit = match &self.upload_gate {
1537                    Some(g) => Some(g.acquire(*len).await),
1538                    None => None,
1539                };
1540                let file = spec.local.clone().ok_or_else(|| {
1541                    StoreError::Other(format!(
1542                        "compose of {}: a Local part with no open descriptor",
1543                        spec.key
1544                    ))
1545                })?;
1546                let (offset, len) = (*offset, *len);
1547                let bytes = tokio::task::spawn_blocking(move || read_local(&file, offset, len))
1548                    .await
1549                    .map_err(|e| StoreError::Other(format!("local read join: {e}")))??;
1550                // The `UploadPart` round trip, under the permit: with a
1551                // request delay injected, the parts of one object are in
1552                // flight together for it, however fast the reads finish
1553                // on a busy machine (`tests_upload_gate`).
1554                self.pace().await;
1555                self.land_part(spec, upload_id, i, len, bytes)
1556            }
1557            PartSource::BaseCopy { offset, len } => {
1558                let want = spec.base_etag.as_deref().ok_or_else(|| {
1559                    StoreError::Other("compose: BaseCopy without base_etag".into())
1560                })?;
1561                let bytes = {
1562                    let inner = self.inner.lock().unwrap();
1563                    let base_key = spec.base_key.unwrap_or(spec.key);
1564                    let base = inner.current(base_key).ok_or_else(|| {
1565                        StoreError::PreconditionFailed("copy-source: no base object".into())
1566                    })?;
1567                    if base.etag != want {
1568                        return Err(StoreError::PreconditionFailed(
1569                            "copy-source-if-match: base etag differs".into(),
1570                        ));
1571                    }
1572                    let s = *offset as usize;
1573                    let e = (*offset + *len) as usize;
1574                    if e > base.bytes.len() {
1575                        return Err(StoreError::Other(format!(
1576                            "copy-source range [{}, {}) beyond base size {}",
1577                            s,
1578                            e,
1579                            base.bytes.len()
1580                        )));
1581                    }
1582                    base.bytes.slice(s..e)
1583                };
1584                self.land_part(spec, upload_id, i, *len, bytes)
1585            }
1586        }
1587    }
1588
1589    /// A staged part lands in the upload's part map — the double's
1590    /// "UploadPart returned".
1591    fn land_part(
1592        &self,
1593        spec: &ComposeSpec<'_>,
1594        upload_id: &str,
1595        i: usize,
1596        want_len: u64,
1597        bytes: Bytes,
1598    ) -> StoreResult<()> {
1599        if bytes.len() as u64 != want_len {
1600            return Err(StoreError::Other(format!("compose: part {} short read", i)));
1601        }
1602        spec.note_progress(bytes.len() as u64);
1603        self.inner
1604            .lock()
1605            .unwrap()
1606            .uploads
1607            .get_mut(upload_id)
1608            .ok_or_else(|| StoreError::NoSuchUpload(upload_id.into()))?
1609            .parts
1610            .insert(i, bytes);
1611        Ok(())
1612    }
1613
1614    async fn compose_inner(
1615        &self,
1616        spec: &ComposeSpec<'_>,
1617        upload_id: &str,
1618    ) -> StoreResult<ObjectMeta> {
1619        self.bump("compose_inner");
1620        // The real store cannot hash a copied part it never reads; the
1621        // double refuses the same spec so a caller learns it here.
1622        if spec.crc64.is_none()
1623            && spec.parts.iter().any(|p| matches!(p, PartSource::BaseCopy { .. }))
1624        {
1625            return Err(StoreError::Other(
1626                "compose: the checksum cannot be accumulated over a copied part; supply crc64".into(),
1627            ));
1628        }
1629        // Parts must be contiguous from 0 and respect granularity —
1630        // catching a flusher part-grid bug here beats catching it on
1631        // real S3.
1632        let mut expect = 0u64;
1633        for (i, p) in spec.parts.iter().enumerate() {
1634            let (off, len) = match p {
1635                PartSource::Local { offset, len } | PartSource::BaseCopy { offset, len } => {
1636                    (*offset, *len)
1637                }
1638            };
1639            if off != expect {
1640                return Err(StoreError::Other(format!(
1641                    "compose: part {} at offset {} but object is contiguous to {}",
1642                    i, off, expect
1643                )));
1644            }
1645            if len < self.min_part && i + 1 != spec.parts.len() {
1646                return Err(StoreError::Other(format!(
1647                    "compose: part {} len {} under the backend minimum {}",
1648                    i, len, self.min_part
1649                )));
1650            }
1651            expect = off + len;
1652        }
1653
1654        // Stage: local reads (through the upload byte gate) + guarded
1655        // base copies (the copy guard is evaluated per part against the
1656        // CURRENT object, exactly like x-amz-copy-source-if-match),
1657        // `part_parallelism` at a time — the S3 backend's window shape.
1658        // The futures are built by a loop for the reason `s3.rs` gives:
1659        // a closure here is not general enough over the borrow.
1660        {
1661            use futures::stream::StreamExt;
1662            let par = self.part_parallelism.max(1);
1663            let mut futs = Vec::with_capacity(spec.parts.len());
1664            for (i, p) in spec.parts.iter().enumerate() {
1665                futs.push(self.stage_one_part(spec, upload_id, i, p));
1666            }
1667            let staged: Vec<StoreResult<()>> =
1668                futures::stream::iter(futs).buffer_unordered(par).collect().await;
1669            staged.into_iter().collect::<StoreResult<Vec<()>>>()?;
1670        }
1671        // Assemble in PART order — the parts landed in completion order.
1672        let mut assembled = Vec::with_capacity(expect as usize);
1673        {
1674            let inner = self.inner.lock().unwrap();
1675            let up = inner
1676                .uploads
1677                .get(upload_id)
1678                .ok_or_else(|| StoreError::NoSuchUpload(upload_id.into()))?;
1679            for i in 0..spec.parts.len() {
1680                let bytes = up.parts.get(&i).ok_or_else(|| {
1681                    StoreError::Other(format!("compose: part {} was staged but did not land", i))
1682                })?;
1683                assembled.extend_from_slice(bytes);
1684            }
1685        }
1686
1687        // S3 validates the full-object CRC64-NVME at
1688        // CompleteMultipartUpload, so a composed upload whose checksum
1689        // does not match its bytes fails there and not on the next
1690        // read. Without this the double was WEAKER than the backend on
1691        // the one path that carries the largest objects: every
1692        // multipart upload in the tree — forge's packs above the
1693        // whole-put ceiling, lean's chunks — had its checksum accepted
1694        // untested, and a wrong CRC would first be seen by a real
1695        // bucket.
1696        let actual = crc64_nvme(&assembled);
1697        if let Some(claimed) = spec.crc64 {
1698            if actual != claimed {
1699                return Err(StoreError::ChecksumMismatch(format!(
1700                    "compose {}: claimed {:#x}, content is {:#x}",
1701                    spec.key, claimed, actual
1702                )));
1703            }
1704        }
1705
1706        match self.inject.swap(INJECT_NONE, Ordering::SeqCst) {
1707            INJECT_CRASH_BEFORE_COMPLETE => {
1708                // Died before Complete: the MPU stays pending — the
1709                // one error path that deliberately leaves the orphan
1710                // (the A9 sweep exists for it).
1711                self.leave_orphan.store(true, Ordering::SeqCst);
1712                return Err(StoreError::Other("injected: crash before Complete".into()));
1713            }
1714            INJECT_TORN_COMPLETE => {
1715                // Complete lands server-side; the response is lost.
1716                self.finish_complete(spec, upload_id, assembled)?;
1717                return Err(StoreError::Other("injected: Complete response lost".into()));
1718            }
1719            INJECT_SWEPT => {
1720                return Err(StoreError::NoSuchUpload(format!("upload {upload_id} (injected: swept)")));
1721            }
1722            _ => {}
1723        }
1724
1725        let meta = self.finish_complete(spec, upload_id, assembled)?;
1726        Ok(meta)
1727    }
1728
1729    fn finish_complete(
1730        &self,
1731        spec: &ComposeSpec<'_>,
1732        upload_id: &str,
1733        assembled: Vec<u8>,
1734    ) -> StoreResult<ObjectMeta> {
1735        let actual = crc64_nvme(&assembled);
1736        if let Some(claimed) = spec.crc64 {
1737            if actual != claimed {
1738                return Err(StoreError::ChecksumMismatch(format!(
1739                    "compose {}: claimed {:#x}, assembled is {:#x}",
1740                    spec.key, claimed, actual
1741                )));
1742            }
1743        }
1744        let mut inner = self.inner.lock().unwrap();
1745        if inner.uploads.remove(upload_id).is_none() {
1746            // Fenced by a takeover sweep or lifecycle abort.
1747            return Err(StoreError::NoSuchUpload(upload_id.into()));
1748        }
1749        self.check_condition(inner.current(spec.key), &spec.condition)?;
1750        let parts = spec.parts.len();
1751        let bytes = Bytes::from(assembled);
1752        let obj = StoredObject {
1753            etag: mpu_etag(&bytes, parts),
1754            crc64: actual,
1755            meta: spec.stamps.to_meta().into_iter().collect(),
1756            last_modified_unix: now_unix(),
1757            bytes,
1758                   version_id: String::new(),
1759            deleted: false,
1760        };
1761        let vid = inner.push(spec.key, obj);
1762        let mut m = inner.current(spec.key).expect("just pushed").to_meta();
1763        m.version_id = Some(vid);
1764        Ok(m)
1765    }
1766}
1767
1768/// One part, from the descriptor the caller opened — the S3 backend's
1769/// rule in the double, so a test that plants a swap in the window sees
1770/// what the real backend does (review 2026-09-18, H5).
1771fn read_local(file: &std::sync::Arc<std::fs::File>, offset: u64, len: u64) -> StoreResult<Bytes> {
1772    use std::os::unix::fs::FileExt;
1773    let mut buf = vec![0u8; len as usize];
1774    file.read_exact_at(&mut buf, offset)
1775        .map_err(|e| StoreError::Other(format!("local read: {}", e)))?;
1776    Ok(Bytes::from(buf))
1777}
1778
1779#[cfg(test)]
1780mod tests {
1781    use super::*;
1782
1783    fn stamps(generation: u64) -> GenerationStamps {
1784        GenerationStamps { generation, epoch: 1, flush_uuid: format!("u-{}", generation), boundary_source: None, posix: None }
1785    }
1786
1787    /// The handles collector's delete: every named key goes, an absent
1788    /// key is not an error, nothing else is touched, and the request
1789    /// census reads one DELETE per thousand keys — what `DeleteObjects`
1790    /// costs on the wire, and what a request-count test over this double
1791    /// must therefore see.
1792    #[tokio::test]
1793    async fn delete_many_takes_every_key_tolerates_absent_ones_and_costs_one_request_per_batch() {
1794        let s = MemoryStore::new();
1795        let body = Bytes::from_static(b"bytes");
1796        let crc = crc64_nvme(&body);
1797        for k in ["h/a@1", "h/a@2", "h/b@1"] {
1798            s.put_whole(k, body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc).await.unwrap();
1799        }
1800        s.reset_op_counts();
1801        let keys: Vec<String> = ["h/a@1", "h/b@1", "h/never-existed@9"].iter().map(|k| k.to_string()).collect();
1802        let report = s.delete_many(&keys).await.unwrap();
1803        assert_eq!(report, DeleteManyReport { deleted: 3, failed: vec![] });
1804        assert!(matches!(s.head("h/a@1").await, Err(StoreError::NotFound(_))));
1805        assert!(matches!(s.head("h/b@1").await, Err(StoreError::NotFound(_))));
1806        assert!(s.head("h/a@2").await.is_ok(), "a key the batch did not name must stay");
1807        assert_eq!(s.op_counts().get("delete").copied(), Some(1), "one request for a batch under a thousand");
1808
1809        let big: Vec<String> = (0..2001).map(|i| format!("h/x@{i}")).collect();
1810        s.reset_op_counts();
1811        let report = s.delete_many(&big).await.unwrap();
1812        assert_eq!(report.deleted, 2001);
1813        assert_eq!(s.op_counts().get("delete").copied(), Some(3), "a thousand keys per request");
1814    }
1815
1816    #[tokio::test]
1817    async fn conditional_puts_and_gets_enforce_the_contract() {
1818        let s = MemoryStore::new();
1819        let body = Bytes::from_static(b"gen-1");
1820        let crc = crc64_nvme(&body);
1821
1822        // A wrong claimed checksum must fail the publish, not warn.
1823        let err = s
1824            .put_whole("k", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc ^ 1)
1825            .await
1826            .unwrap_err();
1827        assert!(matches!(err, StoreError::ChecksumMismatch(_)));
1828
1829        let m1 = s
1830            .put_whole("k", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc)
1831            .await
1832            .unwrap();
1833        // Create-new against an existing object: 412.
1834        let err = s
1835            .put_whole("k", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc)
1836            .await
1837            .unwrap_err();
1838        assert!(matches!(err, StoreError::PreconditionFailed(_)));
1839
1840        // Guarded update with the right etag works; the stale etag 412s.
1841        let b2 = Bytes::from_static(b"gen-2");
1842        let m2 = s
1843            .put_whole("k", b2.clone(), &PutCondition::IfMatch(m1.etag.clone()), &stamps(2), crc64_nvme(&b2))
1844            .await
1845            .unwrap();
1846        let err = s
1847            .put_whole("k", b2.clone(), &PutCondition::IfMatch(m1.etag.clone()), &stamps(3), crc64_nvme(&b2))
1848            .await
1849            .unwrap_err();
1850        assert!(matches!(err, StoreError::PreconditionFailed(_)));
1851
1852        // Guarded GET: the hydration posture's input — stale etag 412s.
1853        let (gm, gb) = s.get_whole("k", Some(&m2.etag)).await.unwrap();
1854        assert_eq!(gb, b2);
1855        assert_eq!(GenerationStamps::from_meta(&gm.meta).unwrap().generation, 2);
1856        let err = s.get_whole("k", Some(&m1.etag)).await.unwrap_err();
1857        assert!(matches!(err, StoreError::PreconditionFailed(_)));
1858    }
1859
1860    #[tokio::test]
1861    async fn epoch_cas_lease_lifecycle() {
1862        let s = MemoryStore::new();
1863        const K: &str = "vol/.flint-epoch";
1864
1865        // First claim creates; a second blind claim 412s.
1866        let l1 = s.epoch_acquire(K, "hub-a", None).await.unwrap();
1867        assert_eq!(l1.epoch, 1);
1868        let err = s.epoch_acquire(K, "hub-b", None).await.unwrap_err();
1869        assert!(matches!(err, StoreError::PreconditionFailed(_)));
1870
1871        // Renew with the live token; the token rotates so the OLD one
1872        // is dead afterwards (a stale holder's heartbeat must fail).
1873        let l1b = s.epoch_renew(K, &l1, None).await.unwrap();
1874        assert_ne!(l1b.token, l1.token, "renew must rotate the CAS token");
1875        let err = s.epoch_renew(K, &l1, None).await.unwrap_err();
1876        assert!(matches!(err, StoreError::PreconditionFailed(_)), "stale token must be dead");
1877
1878        // Takeover: supersede the OBSERVED state (step 7 judges the
1879        // holder dead first); epoch increments; the deposed lease is
1880        // fully fenced.
1881        let observed = s.epoch_read(K).await.unwrap().unwrap();
1882        assert_eq!(observed.holder_id, "hub-a");
1883        let l2 = s.epoch_acquire(K, "hub-b", Some(&observed)).await.unwrap();
1884        assert_eq!(l2.epoch, 2);
1885        let err = s.epoch_renew(K, &l1b, None).await.unwrap_err();
1886        assert!(matches!(err, StoreError::PreconditionFailed(_)), "deposed renew must fail");
1887        let err = s.epoch_release(K, &l1b).await.unwrap_err();
1888        assert!(matches!(err, StoreError::PreconditionFailed(_)), "deposed release must fail");
1889
1890        // The holder releases: the cell is MARKED, not deleted. Losing
1891        // it would restart epoch numbering at 1 for the next claimant,
1892        // and every publish stamp on the volume is already past that.
1893        s.epoch_release(K, &l2).await.unwrap();
1894        let after = s.epoch_read(K).await.unwrap().expect("the cell must survive a release");
1895        assert!(after.released, "a clean shutdown marks the cell released");
1896        assert_eq!(after.epoch, 2, "and the epoch number is preserved");
1897        assert_eq!(after.holder_id, "hub-b");
1898
1899        // The successor supersedes the released cell at epoch+1 — the
1900        // fast path `claim` takes with no quiet wait.
1901        let l3 = s.epoch_acquire(K, "hub-c", Some(&after)).await.unwrap();
1902        assert_eq!(l3.epoch, 3, "numbering continues, it does not reset");
1903        assert!(
1904            !s.epoch_read(K).await.unwrap().unwrap().released,
1905            "a fresh claim clears the released mark"
1906        );
1907    }
1908
1909    /// The per-barrier lease's queue (lean, design 2026-09-13 §4.2):
1910    /// a waiter appends itself ONCE, the handoff pops the head into the
1911    /// reservation and keeps the echo, only the reserved holder's
1912    /// acquire consumes the reservation, and every transition rotates
1913    /// the token so a heartbeat issued against the pre-enqueue token
1914    /// is dead — which is what forces the holder to re-read and carry
1915    /// the longer queue forward.
1916    #[tokio::test]
1917    async fn epoch_queue_hands_the_cell_to_the_head_and_keeps_the_echo() {
1918        const K: &str = "t/.flint/lean/epoch";
1919        let s = MemoryStore::new();
1920        let a = s.epoch_acquire(K, "a", None).await.unwrap();
1921        let seen = s.epoch_read(K).await.unwrap().unwrap();
1922        let seen = s.epoch_enqueue(K, &seen, "b").await.unwrap();
1923        let seen = s.epoch_enqueue(K, &seen, "c").await.unwrap();
1924        let seen = s.epoch_enqueue(K, &seen, "b").await.unwrap();
1925        assert_eq!(seen.waiters, vec!["b", "c"], "a waiter is appended once, in order");
1926        // The holder's renew with its claim-time token is dead: the
1927        // queue moved the token.
1928        let err = s.epoch_renew(K, &a, None).await.unwrap_err();
1929        assert!(matches!(err, StoreError::PreconditionFailed(_)));
1930        // The holder adopts the observed token + queue and renews; the
1931        // renew CARRIES the queue.
1932        let adopted = EpochLease {
1933            holder_id: "a".into(),
1934            epoch: seen.epoch,
1935            token: seen.token.clone(),
1936            waiters: seen.waiters.clone(),
1937        };
1938        let a = s.epoch_renew(K, &adopted, Some("echo-a")).await.unwrap();
1939        assert_eq!(s.epoch_read(K).await.unwrap().unwrap().waiters, vec!["b", "c"]);
1940        // Handoff: released, reserved for b, c still queued, echo KEPT.
1941        s.epoch_handoff(K, &a, Some("echo-a")).await.unwrap();
1942        let cell = s.epoch_read(K).await.unwrap().unwrap();
1943        assert!(cell.released);
1944        assert_eq!(cell.handoff.as_deref(), Some("b"));
1945        assert_eq!(cell.waiters, vec!["c"]);
1946        assert_eq!(cell.echo.as_deref(), Some("echo-a"), "the last barrier's echo survives the handoff");
1947        // b acquires: reservation consumed, c stays queued, epoch+1.
1948        let b = s.epoch_acquire(K, "b", Some(&cell)).await.unwrap();
1949        assert_eq!(b.epoch, cell.epoch + 1);
1950        assert_eq!(b.waiters, vec!["c"]);
1951        let cell = s.epoch_read(K).await.unwrap().unwrap();
1952        assert!(!cell.released && cell.handoff.is_none());
1953        assert_eq!(cell.holder_id, "b");
1954        assert_eq!(cell.waiters, vec!["c"]);
1955        // b's handoff reserves c with an empty queue behind it; c
1956        // acquires with nobody left queued.
1957        s.epoch_handoff(K, &b, None).await.unwrap();
1958        let cell = s.epoch_read(K).await.unwrap().unwrap();
1959        assert_eq!(cell.handoff.as_deref(), Some("c"));
1960        assert!(cell.waiters.is_empty());
1961        let c = s.epoch_acquire(K, "c", Some(&cell)).await.unwrap();
1962        assert!(c.waiters.is_empty());
1963        s.epoch_handoff(K, &c, None).await.unwrap();
1964        let cell = s.epoch_read(K).await.unwrap().unwrap();
1965        assert!(cell.released && cell.handoff.is_none() && cell.waiters.is_empty());
1966    }
1967
1968    #[tokio::test]
1969    async fn compose_mixes_local_and_guarded_base_copy() {
1970        let s = MemoryStore::new();
1971        // Base generation: 8 bytes.
1972        let base_body = Bytes::from_static(b"AAAABBBB");
1973        let base = s
1974            .put_whole("f", base_body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(&base_body))
1975            .await
1976            .unwrap();
1977        // Local truth: first 4 bytes rewritten, tail clean.
1978        let dir = tempfile::TempDir::new().unwrap();
1979        let local = dir.path().join("f.local");
1980        std::fs::write(&local, b"XXXXBBBB").unwrap();
1981
1982        let spec = ComposeSpec {
1983            progress: None,
1984            key: "f",
1985            local: Some(std::sync::Arc::new(std::fs::File::open(&local).unwrap())),
1986            parts: vec![
1987                PartSource::Local { offset: 0, len: 4 },
1988                PartSource::BaseCopy { offset: 4, len: 4 },
1989            ],
1990            base_key: None,
1991            base_etag: Some(base.etag.clone()),
1992            condition: PutCondition::IfMatch(base.etag.clone()),
1993            stamps: stamps(2),
1994            crc64: Some(crc64_nvme(b"XXXXBBBB")),
1995        };
1996        let m2 = s.compose_generation(&spec).await.unwrap();
1997        let (_, bytes) = s.get_whole("f", Some(&m2.etag)).await.unwrap();
1998        assert_eq!(bytes, Bytes::from_static(b"XXXXBBBB"));
1999        assert!(m2.etag.ends_with("-2\""), "multipart etag must not look like a content hash");
2000
2001        // A foreign overwrite between generations fences the NEXT
2002        // compose at the copy-source guard.
2003        s.raw_put("f", Bytes::from_static(b"foreignXX"), vec![]);
2004        let err = s.compose_generation(&spec).await.unwrap_err();
2005        assert!(matches!(err, StoreError::PreconditionFailed(_)));
2006        assert!(
2007            s.list_uploads("f").await.unwrap().is_empty(),
2008            "a failed compose must abort its MPU (A9)"
2009        );
2010    }
2011    /// `crc64: None` — the checksum comes from the parts the store
2012    /// reads, and the published metadata carries the object's true
2013    /// CRC. A copied part cannot be hashed that way and is refused
2014    /// before anything moves, with no upload left pending.
2015    #[tokio::test]
2016    async fn compose_without_a_claimed_checksum_publishes_the_content_crc() {
2017        let s = MemoryStore::new();
2018        let dir = tempfile::TempDir::new().unwrap();
2019        let local = dir.path().join("g.local");
2020        let content: Vec<u8> = (0..24u8).collect();
2021        std::fs::write(&local, &content).unwrap();
2022        let spec = ComposeSpec {
2023            progress: None,
2024            key: "g",
2025            local: Some(std::sync::Arc::new(std::fs::File::open(&local).unwrap())),
2026            parts: vec![
2027                PartSource::Local { offset: 0, len: 8 },
2028                PartSource::Local { offset: 8, len: 8 },
2029                PartSource::Local { offset: 16, len: 8 },
2030            ],
2031            base_key: None,
2032            base_etag: None,
2033            condition: PutCondition::IfNoneMatchAny,
2034            stamps: stamps(1),
2035            crc64: None,
2036        };
2037        let m = s.compose_generation(&spec).await.unwrap();
2038        assert_eq!(m.crc64_b64.as_deref(), Some(crc64_to_b64(crc64_nvme(&content)).as_str()));
2039        let (h, bytes) = s.get_whole("g", None).await.unwrap();
2040        assert_eq!(&bytes[..], &content[..]);
2041        assert_eq!(h.crc64_b64, m.crc64_b64);
2042
2043        let base_body = Bytes::from_static(b"AAAABBBB");
2044        let base = s
2045            .put_whole("h", base_body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(&base_body))
2046            .await
2047            .unwrap();
2048        std::fs::write(&local, b"XXXXBBBB").unwrap();
2049        let err = s
2050            .compose_generation(&ComposeSpec {
2051                progress: None,
2052                key: "h",
2053                local: Some(std::sync::Arc::new(std::fs::File::open(&local).unwrap())),
2054                parts: vec![
2055                    PartSource::Local { offset: 0, len: 4 },
2056                    PartSource::BaseCopy { offset: 4, len: 4 },
2057                ],
2058                base_key: None,
2059                base_etag: Some(base.etag.clone()),
2060                condition: PutCondition::IfMatch(base.etag.clone()),
2061                stamps: stamps(2),
2062                crc64: None,
2063            })
2064            .await
2065            .unwrap_err();
2066        assert!(matches!(err, StoreError::Other(ref m) if m.contains("copied part")), "{err:?}");
2067        assert!(s.list_uploads("h").await.unwrap().is_empty(), "nothing pending after a refusal");
2068    }
2069}
2070
2071#[cfg(test)]
2072mod copy_tests {
2073    use super::*;
2074    use crate::{crc64_from_b64, crc64_to_b64};
2075
2076    fn stamps(g: u64) -> GenerationStamps {
2077        GenerationStamps {
2078            generation: g,
2079            epoch: 7,
2080            flush_uuid: format!("flush-{g}"),
2081            boundary_source: None,
2082            posix: None,
2083        }
2084    }
2085
2086    #[tokio::test]
2087    async fn a_copy_carries_the_bytes_and_the_checksum_but_not_the_stamps() {
2088        let s = MemoryStore::new();
2089        let body = Bytes::from_static(b"the bytes that move");
2090        let src = s
2091            .put_whole("a.txt", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(3), crc64_nvme(&body))
2092            .await
2093            .unwrap();
2094
2095        let dst = s
2096            .copy_object("a.txt", Some(&src.etag), "b.txt", &PutCondition::IfNoneMatchAny, &stamps(1))
2097            .await
2098            .unwrap();
2099
2100        let (meta, got) = s.get_whole("b.txt", None).await.unwrap();
2101        assert_eq!(got, body, "a copy is byte-identical or it is not a copy");
2102        assert_eq!(
2103            dst.crc64_b64, src.crc64_b64,
2104            "identical bytes have an identical checksum — a copy cannot change one"
2105        );
2106        // THE trap this test exists for: the destination must not wear
2107        // the source's publish history.
2108        assert_eq!(
2109            meta.meta.get("flint-gen").map(String::as_str),
2110            Some("1"),
2111            "the destination's generation is its OWN, not the source's 3: {:?}",
2112            meta.meta
2113        );
2114        assert_eq!(
2115            meta.meta.get(GenerationStamps::META_FLUSH_UUID).map(String::as_str),
2116            Some("flush-1"),
2117            "and its flush_uuid names the publish that WROTE IT, not flush-3: {:?}",
2118            meta.meta
2119        );
2120        // The source is untouched — copy, not move.
2121        assert!(s.head("a.txt").await.is_ok());
2122    }
2123
2124    #[tokio::test]
2125    async fn a_copy_refuses_when_the_source_moved_under_it() {
2126        let s = MemoryStore::new();
2127        let v1 = Bytes::from_static(b"version one");
2128        let src = s
2129            .put_whole("a.txt", v1, &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(b"version one"))
2130            .await
2131            .unwrap();
2132        let v2 = Bytes::from_static(b"version two");
2133        s.put_whole("a.txt", v2, &PutCondition::IfMatch(src.etag.clone()), &stamps(2), crc64_nvme(b"version two"))
2134            .await
2135            .unwrap();
2136
2137        let err = s
2138            .copy_object("a.txt", Some(&src.etag), "b.txt", &PutCondition::IfNoneMatchAny, &stamps(1))
2139            .await
2140            .expect_err("copying an object that moved copies something the caller never read");
2141        assert!(matches!(err, StoreError::PreconditionFailed(_)), "{err:?}");
2142        assert!(matches!(s.head("b.txt").await, Err(StoreError::NotFound(_))), "and nothing landed");
2143    }
2144
2145    #[tokio::test]
2146    async fn a_copy_honours_the_destination_precondition() {
2147        let s = MemoryStore::new();
2148        let a = Bytes::from_static(b"aaaa");
2149        s.put_whole("a.txt", a, &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(b"aaaa"))
2150            .await
2151            .unwrap();
2152        let b = Bytes::from_static(b"bbbb");
2153        let existing = s
2154            .put_whole("b.txt", b.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(b"bbbb"))
2155            .await
2156            .unwrap();
2157
2158        let err = s
2159            .copy_object("a.txt", None, "b.txt", &PutCondition::IfNoneMatchAny, &stamps(2))
2160            .await
2161            .expect_err("a rename must not silently clobber its destination");
2162        assert!(matches!(err, StoreError::PreconditionFailed(_)), "{err:?}");
2163        let (_, still) = s.get_whole("b.txt", Some(&existing.etag)).await.unwrap();
2164        assert_eq!(still, b, "the destination is untouched by a refused copy");
2165    }
2166
2167    /// `ComposeSpec::base_key` has existed for the A7 re-key flush and
2168    /// `base_key: Some(..)` appears NOWHERE in this repository — not in
2169    /// production, not in a test. `copy_object`'s MPU arm is its first
2170    /// caller, so this is the branch's first coverage.
2171    #[tokio::test]
2172    async fn a_compose_can_copy_ranges_from_a_different_key() {
2173        let s = MemoryStore::new();
2174        let body = Bytes::from_static(b"AAAABBBB");
2175        let src = s
2176            .put_whole("old/name", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(1), crc64_nvme(&body))
2177            .await
2178            .unwrap();
2179
2180        let spec = ComposeSpec {
2181            progress: None,
2182            key: "new/name",
2183            // Never read: every part is a BaseCopy.
2184            local: None,
2185            parts: vec![PartSource::BaseCopy { offset: 0, len: 8 }],
2186            base_key: Some("old/name"),
2187            base_etag: Some(src.etag.clone()),
2188            condition: PutCondition::IfNoneMatchAny,
2189            stamps: stamps(1),
2190            crc64: Some(crc64_nvme(&body)),
2191        };
2192        let m = s.compose_generation(&spec).await.unwrap();
2193        let (_, got) = s.get_whole("new/name", Some(&m.etag)).await.unwrap();
2194        assert_eq!(got, body, "the ranges came from the OTHER key");
2195        assert!(s.head("old/name").await.is_ok(), "and the source survives");
2196    }
2197
2198    #[test]
2199    fn crc64_b64_round_trips_and_refuses_what_is_not_one() {
2200        for v in [0u64, 1, u64::MAX, 0x0123_4567_89ab_cdef] {
2201            assert_eq!(crc64_from_b64(&crc64_to_b64(v)), Some(v), "round trip {v:#x}");
2202        }
2203        // A caller handed `None` has something other than a CRC-64, and
2204        // guessing a number would validate a publish against a checksum
2205        // nobody computed.
2206        // "AAAAAAAAAAA" is the discriminating case: 11 significant
2207        // characters decode to exactly 8 bytes, so the byte-count check
2208        // alone ACCEPTS it. Only the canonical-form check rejects it —
2209        // without that case the padding guard is a line no control can
2210        // move, which is how dead defensive code survives.
2211        for bad in ["", "AAAA", "not-base64!!", "AAAAAAAAAAAA", "AAAAAAAAAAA", "AAAAAAAAAAAAAAA="] {
2212            assert_eq!(crc64_from_b64(bad), None, "{bad:?} is not a canonical CRC-64");
2213        }
2214        assert_eq!(crc64_from_b64("AAAAAAAAAAA="), Some(0), "the canonical form IS accepted");
2215    }
2216}