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