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