Skip to main content

flint_store/
readonly.rs

1//! A store that reads and cannot write: [`ReadOnly`].
2//!
3//! The consumer is the lean gateway's `Workspace::read_only` (per-user
4//! access design §4.6, phase E): a backend that serves a UI to people with
5//! read access builds that user's workspace on this wrapper, and the
6//! wrapper, not the verbs, is what keeps a write from reaching the bucket.
7//! The verbs refuse first, with an honest typed error; this is the layer
8//! that still refuses when a caller goes around them (the workspace hands
9//! out its store) or when a verb forgets.
10//!
11//! It is a backstop, not the enforcement. The enforcement is a credential
12//! that cannot write (design D1): a backend should build a read-only
13//! workspace on a read-only client, and this wrapper then turns what
14//! would have been a 403 from the bucket into the same refusal without
15//! the round trip.
16//!
17//! Two rules the implementation keeps, and the tests pin:
18//!
19//! - **A write never reaches the inner store.** Every write method answers
20//!   [`StoreError::Auth`] — the variant for "well-formed, not allowed" —
21//!   naming the verb and the key, and sends nothing. A presigned PUT is a
22//!   write: it is a credential to write, handed to someone else.
23//! - **Every read is forwarded EXPLICITLY, the trait's defaulted ones
24//!   included.** A read left to its default would run the trait's
25//!   fallback on this wrapper instead of the inner store's override: the
26//!   S3 backend's `get_range_segments` would silently go through
27//!   `get_range` and its full-size copy, and `get_version` or
28//!   `lifecycle_rules` would answer "this backend has no …" for a
29//!   backend that has one. `upload_gate` and `request_counts` are
30//!   forwarded too: they describe the inner store and send nothing.
31
32use std::sync::Arc;
33
34use async_trait::async_trait;
35use bytes::Bytes;
36
37use crate::{
38    gate, BootstrapReport, ComposeSpec, DeleteManyReport, EpochLease, EpochState,
39    GenerationStamps, LifecycleView,
40    ListedObject, ListedVersion, ObjectMeta, ObjectStore, PendingUpload, PresignedPut, PutCondition,
41    RequestCounts, RetentionOutcome, StoreError, StoreResult,
42};
43
44/// An [`ObjectStore`] whose writes are refused and whose reads go to the
45/// store it wraps. See the module note.
46pub struct ReadOnly<S: ?Sized> {
47    inner: Arc<S>,
48}
49
50impl<S: ObjectStore + ?Sized> ReadOnly<S> {
51    /// Wrap `inner`. There is deliberately no accessor back to it: a
52    /// holder of the wrapper that could reach the inner store could write.
53    pub fn new(inner: Arc<S>) -> Self {
54        ReadOnly { inner }
55    }
56}
57
58fn refuse<T>(verb: &str, key: &str) -> StoreResult<T> {
59    Err(StoreError::Auth(format!("read-only store: {verb} {key}")))
60}
61
62#[async_trait]
63impl<S: ObjectStore + ?Sized> ObjectStore for ReadOnly<S> {
64    // ── writes: refused, nothing sent ────────────────────────────────
65
66    async fn put_whole(
67        &self,
68        key: &str,
69        _body: Bytes,
70        _condition: &PutCondition,
71        _stamps: &GenerationStamps,
72        _crc64: u64,
73    ) -> StoreResult<ObjectMeta> {
74        refuse("put_whole", key)
75    }
76
77    async fn copy_object(
78        &self,
79        _src_key: &str,
80        _src_if_match: Option<&str>,
81        dst_key: &str,
82        _condition: &PutCondition,
83        _stamps: &GenerationStamps,
84    ) -> StoreResult<ObjectMeta> {
85        refuse("copy_object", dst_key)
86    }
87
88    async fn compose_generation(&self, spec: &ComposeSpec<'_>) -> StoreResult<ObjectMeta> {
89        refuse("compose_generation", spec.key)
90    }
91
92    async fn delete(&self, key: &str) -> StoreResult<()> {
93        refuse("delete", key)
94    }
95
96    async fn delete_if_match(&self, key: &str, _etag: &str) -> StoreResult<()> {
97        refuse("delete_if_match", key)
98    }
99
100    async fn delete_many(&self, keys: &[String]) -> StoreResult<DeleteManyReport> {
101        // Refused whole: a partial batch is a write either way, and the
102        // report's `failed` list is for a backend's refusal, not ours.
103        refuse("delete_many", keys.first().map(String::as_str).unwrap_or("(no keys)"))
104    }
105
106    async fn delete_version(&self, key: &str, _version_id: &str) -> StoreResult<()> {
107        refuse("delete_version", key)
108    }
109
110    async fn presign_put(
111        &self,
112        key: &str,
113        _ttl_secs: u64,
114        _sha256: &[u8; 32],
115    ) -> StoreResult<PresignedPut> {
116        refuse("presign_put", key)
117    }
118
119    async fn ensure_noncurrent_retention(
120        &self,
121        prefix: &str,
122        _days: u64,
123    ) -> StoreResult<RetentionOutcome> {
124        refuse("ensure_noncurrent_retention", prefix)
125    }
126
127    async fn abort_upload(&self, key: &str, _upload_id: &str) -> StoreResult<()> {
128        refuse("abort_upload", key)
129    }
130
131    /// Refused: the S3 bootstrap writes lifecycle rules and a probe
132    /// object, and a read-only holder has no bucket posture to set up.
133    async fn bootstrap(&self, prefix: &str) -> StoreResult<BootstrapReport> {
134        refuse("bootstrap", prefix)
135    }
136
137    async fn epoch_acquire(
138        &self,
139        key: &str,
140        _holder_id: &str,
141        _supersede: Option<&EpochState>,
142    ) -> StoreResult<EpochLease> {
143        refuse("epoch_acquire", key)
144    }
145
146    async fn epoch_renew(
147        &self,
148        key: &str,
149        _lease: &EpochLease,
150        _echo: Option<&str>,
151    ) -> StoreResult<EpochLease> {
152        refuse("epoch_renew", key)
153    }
154
155    async fn epoch_release(&self, key: &str, _lease: &EpochLease) -> StoreResult<()> {
156        refuse("epoch_release", key)
157    }
158
159    async fn epoch_enqueue(
160        &self,
161        key: &str,
162        _observed: &EpochState,
163        _holder_id: &str,
164    ) -> StoreResult<EpochState> {
165        refuse("epoch_enqueue", key)
166    }
167
168    async fn epoch_handoff(
169        &self,
170        key: &str,
171        _lease: &EpochLease,
172        _echo: Option<&str>,
173    ) -> StoreResult<()> {
174        refuse("epoch_handoff", key)
175    }
176
177    // ── reads: forwarded, defaulted ones included ────────────────────
178
179    async fn head(&self, key: &str) -> StoreResult<ObjectMeta> {
180        self.inner.head(key).await
181    }
182
183    async fn get_whole(&self, key: &str, if_match: Option<&str>) -> StoreResult<(ObjectMeta, Bytes)> {
184        self.inner.get_whole(key, if_match).await
185    }
186
187    async fn get_range(
188        &self,
189        key: &str,
190        offset: u64,
191        len: u64,
192        if_match: &str,
193    ) -> StoreResult<Bytes> {
194        self.inner.get_range(key, offset, len, if_match).await
195    }
196
197    async fn get_range_segments(
198        &self,
199        key: &str,
200        offset: u64,
201        len: u64,
202        if_match: &str,
203    ) -> StoreResult<Vec<Bytes>> {
204        self.inner.get_range_segments(key, offset, len, if_match).await
205    }
206
207    async fn list(&self, prefix: &str) -> StoreResult<Vec<ListedObject>> {
208        self.inner.list(prefix).await
209    }
210
211    async fn head_version(&self, key: &str, version_id: &str) -> StoreResult<ObjectMeta> {
212        self.inner.head_version(key, version_id).await
213    }
214
215    async fn get_version(&self, key: &str, version_id: &str) -> StoreResult<(ObjectMeta, Bytes)> {
216        self.inner.get_version(key, version_id).await
217    }
218
219    async fn list_versions(&self, prefix: &str) -> StoreResult<Vec<ListedVersion>> {
220        self.inner.list_versions(prefix).await
221    }
222
223    /// Forwarded: a presigned GET grants a read, which this holder has.
224    async fn presign_get(&self, key: &str, ttl_secs: u64) -> StoreResult<String> {
225        self.inner.presign_get(key, ttl_secs).await
226    }
227
228    async fn lifecycle_rules(&self) -> StoreResult<Vec<LifecycleView>> {
229        self.inner.lifecycle_rules().await
230    }
231
232    async fn list_uploads(&self, prefix: &str) -> StoreResult<Vec<PendingUpload>> {
233        self.inner.list_uploads(prefix).await
234    }
235
236    async fn epoch_read(&self, key: &str) -> StoreResult<Option<EpochState>> {
237        self.inner.epoch_read(key).await
238    }
239
240    fn min_part_size(&self) -> u64 {
241        self.inner.min_part_size()
242    }
243
244    fn max_parts(&self) -> usize {
245        self.inner.max_parts()
246    }
247
248    fn upload_gate(&self) -> Option<Arc<gate::ByteGate>> {
249        self.inner.upload_gate()
250    }
251
252    fn request_counts(&self) -> Option<RequestCounts> {
253        self.inner.request_counts()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::memory::MemoryStore;
261    use crate::PartSource;
262    use std::sync::Mutex;
263
264    /// The trait's writes. Each must be refused without a request.
265    const WRITES: &[&str] = &[
266        "put_whole", "copy_object", "compose_generation", "delete", "delete_if_match",
267        "delete_many", "delete_version", "presign_put", "ensure_noncurrent_retention",
268        "abort_upload",
269        "bootstrap", "epoch_acquire", "epoch_renew", "epoch_release", "epoch_enqueue",
270        "epoch_handoff",
271    ];
272
273    /// The trait's reads, and the four methods that describe the store
274    /// without sending anything. Each must reach the inner store.
275    const READS: &[&str] = &[
276        "head", "get_whole", "get_range", "get_range_segments", "list", "head_version",
277        "get_version", "list_versions", "presign_get", "lifecycle_rules", "list_uploads",
278        "epoch_read", "min_part_size", "max_parts", "upload_gate", "request_counts",
279    ];
280
281    /// The `fn` names declared in the block whose opening LINE is exactly
282    /// `opener` (so a string literal quoting it does not count), up to the
283    /// `}` at the opener's own indentation.
284    fn declared(src: &str, opener: &str) -> Vec<String> {
285        let lines: Vec<&str> = src.lines().collect();
286        let start = lines
287            .iter()
288            .position(|l| l.trim() == opener)
289            .unwrap_or_else(|| panic!("no line `{opener}` in the source"));
290        let indent = &lines[start][..lines[start].len() - lines[start].trim_start().len()];
291        let close = format!("{indent}}}");
292        let end = start + lines[start..].iter().position(|l| *l == close).expect("the block closes");
293        let mut names: Vec<String> = lines[start + 1..end]
294            .iter()
295            .filter_map(|l| {
296                let l = l.trim_start();
297                let rest = l.strip_prefix("async fn ").or_else(|| l.strip_prefix("fn "))?;
298                Some(rest[..rest.find(|c: char| !(c.is_alphanumeric() || c == '_'))?].to_string())
299            })
300            .collect();
301        names.sort();
302        names
303    }
304
305    /// The census, and its control. Every trait method is filed as a read
306    /// or a write, and BOTH this wrapper and the recording double below
307    /// name every one of them. A method the trait gains with a default
308    /// fails here: left to its default on the wrapper, a read runs the
309    /// trait's fallback instead of the inner store's override.
310    #[test]
311    fn every_trait_method_is_filed_and_every_one_is_implemented_by_name() {
312        let mut filed: Vec<String> = WRITES.iter().chain(READS).map(|s| s.to_string()).collect();
313        filed.sort();
314        let mut dedup = filed.clone();
315        dedup.dedup();
316        assert_eq!(filed, dedup, "a method is filed twice");
317
318        let trait_fns = declared(include_str!("lib.rs"), "pub trait ObjectStore: Send + Sync {");
319        // The control: the parser finds the trait's first and last
320        // methods, so an empty or truncated parse cannot agree by accident.
321        assert!(trait_fns.contains(&"put_whole".to_string()) && trait_fns.contains(&"request_counts".to_string()));
322        assert_eq!(trait_fns, filed, "the trait's methods and the read/write filing disagree");
323
324        let src = include_str!("readonly.rs");
325        assert_eq!(
326            declared(src, "impl<S: ObjectStore + ?Sized> ObjectStore for ReadOnly<S> {"),
327            filed,
328            "ReadOnly must name every trait method, defaulted ones included"
329        );
330        assert_eq!(
331            declared(src, "impl ObjectStore for Recorder {"),
332            filed,
333            "the recording double must name every trait method, or it cannot see the wrapper call one"
334        );
335    }
336
337    /// A `MemoryStore` that records every trait method called on it, by
338    /// the method's OWN name. Op counts alone cannot see a defaulted read
339    /// left unforwarded: `get_range_segments`'s default calls `get_range`,
340    /// which the double counts the same either way.
341    struct Recorder {
342        inner: MemoryStore,
343        calls: Mutex<Vec<&'static str>>,
344    }
345
346    impl Recorder {
347        fn saw(&self, m: &'static str) {
348            self.calls.lock().unwrap().push(m);
349        }
350        fn take(&self) -> Vec<&'static str> {
351            std::mem::take(&mut *self.calls.lock().unwrap())
352        }
353    }
354
355    #[async_trait]
356    impl ObjectStore for Recorder {
357        async fn put_whole(
358            &self,
359            key: &str,
360            body: Bytes,
361            condition: &PutCondition,
362            stamps: &GenerationStamps,
363            crc64: u64,
364        ) -> StoreResult<ObjectMeta> {
365            self.saw("put_whole");
366            self.inner.put_whole(key, body, condition, stamps, crc64).await
367        }
368        async fn copy_object(
369            &self,
370            src_key: &str,
371            src_if_match: Option<&str>,
372            dst_key: &str,
373            condition: &PutCondition,
374            stamps: &GenerationStamps,
375        ) -> StoreResult<ObjectMeta> {
376            self.saw("copy_object");
377            self.inner.copy_object(src_key, src_if_match, dst_key, condition, stamps).await
378        }
379        async fn compose_generation(&self, spec: &ComposeSpec<'_>) -> StoreResult<ObjectMeta> {
380            self.saw("compose_generation");
381            self.inner.compose_generation(spec).await
382        }
383        async fn head(&self, key: &str) -> StoreResult<ObjectMeta> {
384            self.saw("head");
385            self.inner.head(key).await
386        }
387        async fn get_whole(&self, key: &str, if_match: Option<&str>) -> StoreResult<(ObjectMeta, Bytes)> {
388            self.saw("get_whole");
389            self.inner.get_whole(key, if_match).await
390        }
391        async fn get_range(&self, key: &str, offset: u64, len: u64, if_match: &str) -> StoreResult<Bytes> {
392            self.saw("get_range");
393            self.inner.get_range(key, offset, len, if_match).await
394        }
395        async fn get_range_segments(
396            &self,
397            key: &str,
398            offset: u64,
399            len: u64,
400            if_match: &str,
401        ) -> StoreResult<Vec<Bytes>> {
402            self.saw("get_range_segments");
403            self.inner.get_range_segments(key, offset, len, if_match).await
404        }
405        async fn list(&self, prefix: &str) -> StoreResult<Vec<ListedObject>> {
406            self.saw("list");
407            self.inner.list(prefix).await
408        }
409        async fn delete(&self, key: &str) -> StoreResult<()> {
410            self.saw("delete");
411            self.inner.delete(key).await
412        }
413        async fn delete_if_match(&self, key: &str, etag: &str) -> StoreResult<()> {
414            self.saw("delete_if_match");
415            self.inner.delete_if_match(key, etag).await
416        }
417        async fn delete_many(&self, keys: &[String]) -> StoreResult<DeleteManyReport> {
418            self.saw("delete_many");
419            self.inner.delete_many(keys).await
420        }
421        async fn head_version(&self, key: &str, version_id: &str) -> StoreResult<ObjectMeta> {
422            self.saw("head_version");
423            self.inner.head_version(key, version_id).await
424        }
425        async fn get_version(&self, key: &str, version_id: &str) -> StoreResult<(ObjectMeta, Bytes)> {
426            self.saw("get_version");
427            self.inner.get_version(key, version_id).await
428        }
429        async fn delete_version(&self, key: &str, version_id: &str) -> StoreResult<()> {
430            self.saw("delete_version");
431            self.inner.delete_version(key, version_id).await
432        }
433        async fn list_versions(&self, prefix: &str) -> StoreResult<Vec<ListedVersion>> {
434            self.saw("list_versions");
435            self.inner.list_versions(prefix).await
436        }
437        async fn presign_get(&self, key: &str, ttl_secs: u64) -> StoreResult<String> {
438            self.saw("presign_get");
439            self.inner.presign_get(key, ttl_secs).await
440        }
441        async fn presign_put(
442            &self,
443            key: &str,
444            ttl_secs: u64,
445            sha256: &[u8; 32],
446        ) -> StoreResult<PresignedPut> {
447            self.saw("presign_put");
448            self.inner.presign_put(key, ttl_secs, sha256).await
449        }
450        async fn lifecycle_rules(&self) -> StoreResult<Vec<LifecycleView>> {
451            self.saw("lifecycle_rules");
452            self.inner.lifecycle_rules().await
453        }
454        async fn ensure_noncurrent_retention(&self, prefix: &str, days: u64) -> StoreResult<RetentionOutcome> {
455            self.saw("ensure_noncurrent_retention");
456            self.inner.ensure_noncurrent_retention(prefix, days).await
457        }
458        async fn list_uploads(&self, prefix: &str) -> StoreResult<Vec<PendingUpload>> {
459            self.saw("list_uploads");
460            self.inner.list_uploads(prefix).await
461        }
462        async fn abort_upload(&self, key: &str, upload_id: &str) -> StoreResult<()> {
463            self.saw("abort_upload");
464            self.inner.abort_upload(key, upload_id).await
465        }
466        async fn bootstrap(&self, prefix: &str) -> StoreResult<BootstrapReport> {
467            self.saw("bootstrap");
468            self.inner.bootstrap(prefix).await
469        }
470        async fn epoch_read(&self, key: &str) -> StoreResult<Option<EpochState>> {
471            self.saw("epoch_read");
472            self.inner.epoch_read(key).await
473        }
474        async fn epoch_acquire(
475            &self,
476            key: &str,
477            holder_id: &str,
478            supersede: Option<&EpochState>,
479        ) -> StoreResult<EpochLease> {
480            self.saw("epoch_acquire");
481            self.inner.epoch_acquire(key, holder_id, supersede).await
482        }
483        async fn epoch_renew(&self, key: &str, lease: &EpochLease, echo: Option<&str>) -> StoreResult<EpochLease> {
484            self.saw("epoch_renew");
485            self.inner.epoch_renew(key, lease, echo).await
486        }
487        async fn epoch_release(&self, key: &str, lease: &EpochLease) -> StoreResult<()> {
488            self.saw("epoch_release");
489            self.inner.epoch_release(key, lease).await
490        }
491        async fn epoch_enqueue(&self, key: &str, observed: &EpochState, holder_id: &str) -> StoreResult<EpochState> {
492            self.saw("epoch_enqueue");
493            self.inner.epoch_enqueue(key, observed, holder_id).await
494        }
495        async fn epoch_handoff(&self, key: &str, lease: &EpochLease, echo: Option<&str>) -> StoreResult<()> {
496            self.saw("epoch_handoff");
497            self.inner.epoch_handoff(key, lease, echo).await
498        }
499        fn min_part_size(&self) -> u64 {
500            self.saw("min_part_size");
501            self.inner.min_part_size()
502        }
503        fn max_parts(&self) -> usize {
504            self.saw("max_parts");
505            self.inner.max_parts()
506        }
507        fn upload_gate(&self) -> Option<Arc<gate::ByteGate>> {
508            self.saw("upload_gate");
509            self.inner.upload_gate()
510        }
511        fn request_counts(&self) -> Option<RequestCounts> {
512            self.saw("request_counts");
513            self.inner.request_counts()
514        }
515    }
516
517    fn stamps() -> GenerationStamps {
518        GenerationStamps { generation: 1, epoch: 0, flush_uuid: "ro-test".into(), boundary_source: None, posix: None }
519    }
520
521    /// F10's store half. Every write on `ReadOnly` answers `Auth` and the
522    /// inner store sees NOTHING — not the call, not a request; every read
523    /// reaches the inner store under its own name and answers what the
524    /// inner store answers. A write wrongly forwarded, a read wrongly
525    /// refused, or a defaulted read left to the trait's fallback fails it.
526    #[tokio::test]
527    async fn a_read_only_store_refuses_every_write_unsent_and_forwards_every_read() {
528        // Non-default shapes wherever a wrapper's own answer could pass
529        // for the inner store's: part limits, a byte gate, lifecycle rules.
530        let mut mem = MemoryStore::new().with_upload_inflight_max_bytes(1 << 20);
531        mem.min_part = 7;
532        mem.max_parts = 9;
533        mem.plant_lifecycle_rule(LifecycleView {
534            id: "customer".into(),
535            enabled: true,
536            prefix: "p/".into(),
537            noncurrent_days: Some(3),
538            expired_delete_marker: false,
539        });
540        let rec = Arc::new(Recorder { inner: mem, calls: Mutex::new(vec![]) });
541        let body = Bytes::from_static(b"hello, read-only world");
542        let crc = crate::crc64_nvme(&body);
543        let v1 = rec.inner.put_whole("p/k", body.clone(), &PutCondition::IfNoneMatchAny, &stamps(), crc).await.unwrap();
544        let v2 = rec
545            .inner
546            .put_whole("p/k", body.clone(), &PutCondition::IfMatch(v1.etag.clone()), &stamps(), crc)
547            .await
548            .unwrap();
549        let head_before = rec.inner.head("p/k").await.unwrap();
550        let lease = rec.inner.epoch_acquire("p/epoch", "holder", None).await.unwrap();
551        let cell = rec.inner.epoch_read("p/epoch").await.unwrap().unwrap();
552        let upload_id = rec.inner.raw_begin_upload("p/mpu");
553        rec.inner.reset_op_counts();
554        rec.take();
555
556        let ro = ReadOnly::new(rec.clone());
557
558        // ── writes ──
559        let mut refused: Vec<&str> = vec![];
560        let mut expect_refused = |verb: &'static str, r: StoreResult<()>| {
561            match r {
562                Err(StoreError::Auth(m)) if m.starts_with(&format!("read-only store: {verb} ")) => {}
563                other => panic!("{verb} on a read-only store answered {other:?}, not Auth"),
564            }
565            assert_eq!(rec.take(), Vec::<&str>::new(), "{verb} reached the inner store");
566            assert_eq!(rec.inner.total_ops(), 0, "{verb} sent a request: {:?}", rec.inner.op_counts());
567            refused.push(verb);
568        };
569        let spec = ComposeSpec {
570            key: "p/composed",
571            // Refused before any part moves: nothing is ever read.
572            local: None,
573            parts: vec![PartSource::Local { offset: 0, len: 1 }],
574            base_key: None,
575            base_etag: None,
576            condition: PutCondition::Unconditional,
577            stamps: stamps(),
578            crc64: Some(0),
579            progress: None,
580        };
581        let cond = PutCondition::Unconditional;
582        expect_refused("put_whole", ro.put_whole("p/new", body.clone(), &cond, &stamps(), crc).await.map(drop));
583        expect_refused("copy_object", ro.copy_object("p/k", None, "p/copy", &cond, &stamps()).await.map(drop));
584        expect_refused("compose_generation", ro.compose_generation(&spec).await.map(drop));
585        expect_refused("delete", ro.delete("p/k").await);
586        expect_refused("delete_if_match", ro.delete_if_match("p/k", &v2.etag).await);
587        expect_refused("delete_many", ro.delete_many(&["p/k".to_string()]).await.map(drop));
588        expect_refused("delete_version", ro.delete_version("p/k", v1.version_id.as_deref().unwrap()).await);
589        expect_refused("presign_put", ro.presign_put("p/k", 60, &[0; 32]).await.map(drop));
590        expect_refused("ensure_noncurrent_retention", ro.ensure_noncurrent_retention("p/", 30).await.map(drop));
591        expect_refused("abort_upload", ro.abort_upload("p/mpu", &upload_id).await);
592        expect_refused("bootstrap", ro.bootstrap("p/").await.map(drop));
593        expect_refused("epoch_acquire", ro.epoch_acquire("p/epoch2", "h2", None).await.map(drop));
594        expect_refused("epoch_renew", ro.epoch_renew("p/epoch", &lease, None).await.map(drop));
595        expect_refused("epoch_release", ro.epoch_release("p/epoch", &lease).await);
596        expect_refused("epoch_enqueue", ro.epoch_enqueue("p/epoch", &cell, "h3").await.map(drop));
597        expect_refused("epoch_handoff", ro.epoch_handoff("p/epoch", &lease, None).await);
598        assert_eq!(refused, WRITES, "every write, and only the writes, were exercised");
599        // And the bucket is as it was: the object, its versions, the
600        // cell, the pending upload.
601        assert_eq!(rec.inner.head("p/k").await.unwrap(), head_before);
602        assert_eq!(rec.inner.version_count("p/k"), 2);
603        assert_eq!(rec.inner.epoch_read("p/epoch").await.unwrap().unwrap(), cell);
604        assert_eq!(rec.inner.list_uploads("p/").await.unwrap().len(), 1);
605        rec.take();
606
607        // ── reads ──
608        let mut forwarded: Vec<&str> = vec![];
609        let mut reached = |verb: &'static str| {
610            assert_eq!(rec.take(), vec![verb], "{verb} did not reach the inner store as itself");
611            forwarded.push(verb);
612        };
613        let vid1 = v1.version_id.clone().unwrap();
614        assert_eq!(ro.head("p/k").await.unwrap(), head_before);
615        reached("head");
616        let whole = ro.get_whole("p/k", Some(&v2.etag)).await.unwrap();
617        assert_eq!(whole, rec.inner.get_whole("p/k", Some(&v2.etag)).await.unwrap());
618        assert_eq!(whole.1, body);
619        reached("get_whole");
620        assert_eq!(ro.get_range("p/k", 7, 9, &v2.etag).await.unwrap(), body.slice(7..16));
621        reached("get_range");
622        assert_eq!(ro.get_range_segments("p/k", 7, 9, &v2.etag).await.unwrap().concat(), body.slice(7..16));
623        reached("get_range_segments");
624        assert_eq!(ro.list("p/").await.unwrap(), rec.inner.list("p/").await.unwrap());
625        reached("list");
626        assert_eq!(ro.head_version("p/k", &vid1).await.unwrap(), rec.inner.head_version("p/k", &vid1).await.unwrap());
627        reached("head_version");
628        assert_eq!(ro.get_version("p/k", &vid1).await.unwrap(), rec.inner.get_version("p/k", &vid1).await.unwrap());
629        reached("get_version");
630        let versions = ro.list_versions("p/").await.unwrap();
631        assert_eq!(versions, rec.inner.list_versions("p/").await.unwrap());
632        reached("list_versions");
633        assert!(versions.iter().filter(|v| v.key == "p/k").count() == 2, "{versions:?}");
634        assert_eq!(ro.presign_get("p/k", 60).await.unwrap(), rec.inner.presign_get("p/k", 60).await.unwrap());
635        reached("presign_get");
636        let rules = ro.lifecycle_rules().await.unwrap();
637        assert_eq!(rules, rec.inner.lifecycle_rules().await.unwrap());
638        assert_eq!(rules.len(), 1);
639        reached("lifecycle_rules");
640        assert_eq!(ro.list_uploads("p/").await.unwrap(), rec.inner.list_uploads("p/").await.unwrap());
641        reached("list_uploads");
642        assert_eq!(ro.epoch_read("p/epoch").await.unwrap(), Some(cell.clone()));
643        reached("epoch_read");
644        assert_eq!(ro.min_part_size(), 7);
645        reached("min_part_size");
646        assert_eq!(ro.max_parts(), 9);
647        reached("max_parts");
648        let gate = ro.upload_gate().expect("the inner store has a byte gate");
649        assert!(Arc::ptr_eq(&gate, &rec.inner.upload_gate().unwrap()));
650        reached("upload_gate");
651        let counts = ro.request_counts().expect("the inner store counts");
652        assert_eq!(counts, rec.inner.request_counts().unwrap());
653        assert!(counts.get > 0 && counts.put == 0, "{counts:?}");
654        reached("request_counts");
655        assert_eq!(forwarded, READS, "every read, and only the reads, were exercised");
656
657        // Reads never write either.
658        let writes: Vec<_> = rec
659            .inner
660            .op_counts()
661            .into_iter()
662            .filter(|(op, _)| !READS.contains(op))
663            .collect();
664        assert_eq!(writes, vec![], "a read sent a write");
665    }
666}