Skip to main content

loonfs_objectstore/
probe.rs

1//! On-demand proof that a configured store honours the object-store
2//! contract LoonFS depends on.
3//!
4//! Fencing, publication, and upload completion are all decided by provider
5//! preconditions: a gateway that accepts a create-if-absent write over an
6//! existing object, or a compare-and-swap against a stale token, corrupts
7//! data rather than failing. Nothing about a store's configuration proves
8//! it honours those preconditions, so an operator asks — and this module is
9//! the question. It is never asked implicitly: a probe writes and deletes
10//! objects, so only an explicit operator decision runs one.
11//!
12//! Every check reports its own outcome and no check can end the run, so one
13//! probe answers the whole question rather than the first thing that went
14//! wrong. A check that fails names what it expected; a store that lacks an
15//! optional capability answers [`StoreProbeOutcome::Unsupported`], which is
16//! an answer and not a failure.
17//!
18//! Every object a probe writes lives under `probe-runs/{run_id}/`, which is
19//! not a durable object family: garbage collection enumerates the durable
20//! families by name and never sees this prefix, and nothing else reads it.
21//! The final check deletes the run's objects and proves the prefix empty, so
22//! a probe that completes leaves nothing behind. A probe that dies partway
23//! leaves orphans under a prefix nothing consults — harmless, and removable
24//! by prefix.
25//!
26//! This module does not decide whether a store may serve presigned direct
27//! uploads. That trust comes from [`crate::StoreConfig::direct_put_is_proven`],
28//! because a probe exercises the store's own request path and never a
29//! presigned capability handed to a client.
30
31use crate::object_store::Result as StoreResult;
32use crate::{
33    ByteRange, ObjectStore, ObjectStoreError, PROVIDER_MULTIPART_PART_BYTES,
34    PROVIDER_MULTIPART_THRESHOLD_BYTES,
35};
36use bytes::Bytes;
37use futures::StreamExt;
38use loonfs_api::{ChecksumAlgorithm, StorageChecksum};
39
40/// Prefix owning every object a probe run writes.
41const PROBE_RUN_PREFIX: &str = "probe-runs";
42
43/// What one probe run observed, check by check.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct StoreProbeReport {
46    /// Caller-minted label scoping this run's objects and naming it in logs.
47    pub run_id: String,
48    /// Every check the run performed, in the order it performed them.
49    pub checks: Vec<StoreProbeCheck>,
50}
51
52impl StoreProbeReport {
53    /// Whether the store answered every check acceptably.
54    ///
55    /// An [`StoreProbeOutcome::Unsupported`] answer counts as acceptable:
56    /// the optional capabilities are declared missing rather than found
57    /// broken, and a deployment that does not need them is unaffected.
58    pub fn all_passed(&self) -> bool {
59        self.checks.iter().all(|check| {
60            matches!(
61                check.outcome,
62                StoreProbeOutcome::Passed | StoreProbeOutcome::Unsupported
63            )
64        })
65    }
66}
67
68/// One named contract check and what the store did with it.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct StoreProbeCheck {
71    /// Stable check name. Names are part of the report's contract: callers
72    /// and operators match on them, so they are renamed deliberately.
73    pub name: &'static str,
74    /// What the store did.
75    pub outcome: StoreProbeOutcome,
76}
77
78/// What one check concluded about the store.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum StoreProbeOutcome {
81    /// The store behaved as the contract requires.
82    Passed,
83    /// The store declares it cannot do this at all. Only the optional
84    /// capabilities — client-driven multipart and stored-checksum readback
85    /// — can answer this way, and a deployment that offers neither
86    /// `direct_put` nor multipart uploads is unaffected by it.
87    Unsupported,
88    /// The store did something the contract forbids, or the operation
89    /// failed outright. Either way the store is not trustworthy for the
90    /// behaviour this check names.
91    Failed {
92        /// What was expected and what happened instead. Provider text
93        /// arrives already sanitized, so no credential material reaches
94        /// this message.
95        message: String,
96    },
97}
98
99/// Runs every contract check against `store`, scoping the run's objects
100/// under `run_id`.
101///
102/// This never fails as a whole: a check that cannot complete records its
103/// own failure and the remaining checks still run, because an operator
104/// asking "is this store trustworthy?" is owed the full answer rather than
105/// the first symptom. `run_id` scopes the run's keys, so concurrent probes
106/// against one store do not collide; callers mint it.
107pub async fn run_store_contract_probe(store: &dyn ObjectStore, run_id: &str) -> StoreProbeReport {
108    let run = ProbeRun {
109        prefix: format!("{PROBE_RUN_PREFIX}/{run_id}"),
110    };
111    let checks = vec![
112        check(
113            "create_if_absent_enforced",
114            create_if_absent_enforced(store, &run).await,
115        ),
116        check(
117            "compare_and_swap_rejects_stale",
118            compare_and_swap_rejects_stale(store, &run).await,
119        ),
120        check(
121            "compare_and_swap_missing_object_rejected",
122            compare_and_swap_missing_object_rejected(store, &run).await,
123        ),
124        check(
125            "overwrite_updates_head_and_body",
126            overwrite_updates_head_and_body(store, &run).await,
127        ),
128        check(
129            "get_with_metadata_round_trip",
130            get_with_metadata_round_trip(store, &run).await,
131        ),
132        check(
133            "visibility_after_write",
134            visibility_after_write(store, &run).await,
135        ),
136        check(
137            "visibility_after_delete",
138            visibility_after_delete(store, &run).await,
139        ),
140        check(
141            "delete_missing_idempotent",
142            delete_missing_idempotent(store, &run).await,
143        ),
144        check("sorted_listing", sorted_listing(store, &run).await),
145        check("range_reads", range_reads(store, &run).await),
146        check(
147            "multipart_round_trip",
148            multipart_round_trip(store, &run).await,
149        ),
150        check(
151            "stored_checksum_readback",
152            stored_checksum_readback(store, &run).await,
153        ),
154        // Last, and last for a reason: it deletes what every check above
155        // wrote and then proves the prefix empty, so cleanup is itself
156        // under test rather than a hope.
157        check(
158            "cleanup_leaves_prefix_empty",
159            cleanup_leaves_prefix_empty(store, &run).await,
160        ),
161    ];
162
163    StoreProbeReport {
164        run_id: run_id.to_owned(),
165        checks,
166    }
167}
168
169/// One run's key scope.
170struct ProbeRun {
171    prefix: String,
172}
173
174impl ProbeRun {
175    /// A key inside this run's scope.
176    fn key(&self, name: &str) -> String {
177        format!("{}/{name}", self.prefix)
178    }
179
180    /// A listing prefix inside this run's scope.
181    fn listing(&self, name: &str) -> String {
182        format!("{}/{name}/", self.prefix)
183    }
184}
185
186/// What a check concluded, before it becomes a reportable outcome.
187enum CheckFailure {
188    /// The store declares the capability absent.
189    Unsupported,
190    /// The store is wrong, or the operation could not complete.
191    Failed(String),
192}
193
194type CheckResult = std::result::Result<(), CheckFailure>;
195
196fn check(name: &'static str, result: CheckResult) -> StoreProbeCheck {
197    let outcome = match result {
198        Ok(()) => StoreProbeOutcome::Passed,
199        Err(CheckFailure::Unsupported) => StoreProbeOutcome::Unsupported,
200        Err(CheckFailure::Failed(message)) => StoreProbeOutcome::Failed { message },
201    };
202    StoreProbeCheck { name, outcome }
203}
204
205/// Turns a store failure into a reportable one, naming the operation that
206/// failed and the object it was about.
207fn failed(operation: &str, error: &ObjectStoreError) -> CheckFailure {
208    match error.object_key() {
209        Some(object_key) => CheckFailure::Failed(format!(
210            "{operation} failed for `{object_key}`: {}",
211            error.message()
212        )),
213        None => CheckFailure::Failed(format!("{operation} failed: {}", error.message())),
214    }
215}
216
217/// Reports what the store did instead of what the contract requires.
218fn wrong(message: impl Into<String>) -> CheckFailure {
219    CheckFailure::Failed(message.into())
220}
221
222/// Unwraps a store call, attributing any failure to `operation`.
223fn ok<T>(operation: &str, result: StoreResult<T>) -> std::result::Result<T, CheckFailure> {
224    result.map_err(|error| failed(operation, &error))
225}
226
227/// Unwraps a store call for an optional capability: a store that declares
228/// the capability absent ends the check as [`StoreProbeOutcome::Unsupported`]
229/// rather than failing it.
230fn ok_optional<T>(operation: &str, result: StoreResult<T>) -> std::result::Result<T, CheckFailure> {
231    match result {
232        Ok(value) => Ok(value),
233        Err(ObjectStoreError::Unsupported(_)) => Err(CheckFailure::Unsupported),
234        Err(error) => Err(failed(operation, &error)),
235    }
236}
237
238/// Requires a present object, so an absent one reads as the contract
239/// violation it is rather than a `None` the check silently tolerates.
240fn present<T>(what: &str, value: Option<T>) -> std::result::Result<T, CheckFailure> {
241    value.ok_or_else(|| wrong(format!("{what} read back as absent")))
242}
243
244/// Requires the store to have refused a write whose precondition did not
245/// hold. Anything else — acceptance, or a different failure — means the
246/// precondition is not being enforced as fencing assumes.
247fn refused<T>(what: &str, result: StoreResult<T>) -> CheckResult {
248    match result {
249        Err(ObjectStoreError::PreconditionFailed { .. }) => Ok(()),
250        Err(error) => Err(wrong(format!(
251            "{what} should have been refused as a failed precondition, but failed differently: {}",
252            error.message()
253        ))),
254        Ok(_) => Err(wrong(format!(
255            "{what} was accepted; the store does not enforce this precondition"
256        ))),
257    }
258}
259
260/// Create-if-absent is how a namespace's first writer claims a mutable key
261/// nobody else may claim. A store that lets the second create through hands
262/// two writers the same claim.
263async fn create_if_absent_enforced(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
264    let key = run.key("create-if-absent");
265
266    ok(
267        "create-if-absent write",
268        store
269            .put_if_absent(&key, Bytes::from_static(br#"{"seq":41}"#))
270            .await,
271    )?;
272    refused(
273        "a create-if-absent write over an existing object",
274        store
275            .put_if_absent(&key, Bytes::from_static(br#"{"seq":42}"#))
276            .await,
277    )?;
278
279    let body = present(
280        "the created object",
281        ok("read", store.get(&key, None).await)?,
282    )?;
283    if body.as_ref() != br#"{"seq":41}"# {
284        return Err(wrong(
285            "a refused create-if-absent write still changed the stored bytes",
286        ));
287    }
288    Ok(())
289}
290
291/// Compare-and-swap is the fence itself: an evicted writer's stale token
292/// must be refused, or two writers publish over each other.
293async fn compare_and_swap_rejects_stale(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
294    let key = run.key("compare-and-swap");
295
296    ok(
297        "seed write",
298        store
299            .put_if_absent(&key, Bytes::from_static(br#"{"seq":41,"writer_epoch":8}"#))
300            .await,
301    )?;
302    let first_token = present(
303        "the seeded object's metadata",
304        ok("head", store.head(&key).await)?,
305    )?
306    .etag
307    .ok_or_else(|| {
308        wrong("the store reports no compare token, so compare-and-swap cannot fence anything")
309    })?;
310
311    ok(
312        "compare-and-swap on a current token",
313        store
314            .compare_and_swap(
315                &key,
316                &first_token,
317                Bytes::from_static(br#"{"seq":42,"writer_epoch":8}"#),
318            )
319            .await,
320    )?;
321    refused(
322        "a compare-and-swap on a stale token",
323        store
324            .compare_and_swap(
325                &key,
326                &first_token,
327                Bytes::from_static(br#"{"seq":43,"writer_epoch":9}"#),
328            )
329            .await,
330    )?;
331
332    let body = present(
333        "the compare-and-swap object",
334        ok("read", store.get(&key, None).await)?,
335    )?;
336    if body.as_ref() != br#"{"seq":42,"writer_epoch":8}"# {
337        return Err(wrong(
338            "a refused compare-and-swap still changed the stored bytes",
339        ));
340    }
341    Ok(())
342}
343
344/// A compare-and-swap against a key that does not exist has no version to
345/// match, so it is a failed precondition — not a create.
346async fn compare_and_swap_missing_object_rejected(
347    store: &dyn ObjectStore,
348    run: &ProbeRun,
349) -> CheckResult {
350    let key = run.key("compare-and-swap-missing");
351    refused(
352        "a compare-and-swap against a missing object",
353        store
354            .compare_and_swap(&key, "missing-etag", Bytes::from_static(br#"{"seq":1}"#))
355            .await,
356    )
357}
358
359/// An overwrite must be immediately authoritative in both what the object
360/// says and what its metadata says, because readers decide from the head
361/// and writers decide from the body.
362async fn overwrite_updates_head_and_body(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
363    let key = run.key("overwrite");
364
365    let first = ok(
366        "first overwrite",
367        store
368            .put_overwrite(&key, Bytes::from_static(br#"{"seq":41}"#))
369            .await,
370    )?;
371    let second = ok(
372        "second overwrite",
373        store
374            .put_overwrite(&key, Bytes::from_static(br#"{"seq":42}"#))
375            .await,
376    )?;
377
378    let body = present(
379        "the overwritten object",
380        ok("read", store.get(&key, None).await)?,
381    )?;
382    if body.as_ref() != br#"{"seq":42}"# {
383        return Err(wrong("a read after overwrite returned the previous bytes"));
384    }
385    let head = present(
386        "the overwritten object's metadata",
387        ok("head", store.head(&key).await)?,
388    )?;
389    if head.etag != second.etag || head.size_bytes != second.size_bytes {
390        return Err(wrong(
391            "the object's metadata disagrees with the overwrite that just wrote it",
392        ));
393    }
394    if first == second {
395        return Err(wrong(
396            "an overwrite left the object's visible metadata unchanged",
397        ));
398    }
399
400    ok("delete", store.delete(&key).await)?;
401    if ok("head after delete", store.head(&key).await)?.is_some() {
402        return Err(wrong("a deleted object is still visible to head"));
403    }
404    Ok(())
405}
406
407/// Reading bytes and identity from one observation is what lets a caller
408/// trust that the metadata describes the bytes it just read, rather than a
409/// version that replaced them between two requests.
410async fn get_with_metadata_round_trip(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
411    let key = run.key("get-with-metadata");
412    let bytes = br#"{"seq":41,"source":"get-with-metadata"}"#;
413
414    let written = ok(
415        "write",
416        store
417            .put_overwrite(&key, Bytes::copy_from_slice(bytes))
418            .await,
419    )?;
420    let loaded = present(
421        "the written object",
422        ok("full-object read", store.get_with_metadata(&key).await)?,
423    )?;
424
425    if loaded.bytes != bytes {
426        return Err(wrong("a full-object read returned unexpected bytes"));
427    }
428    if loaded.metadata.size_bytes != bytes.len() as u64 {
429        return Err(wrong(
430            "a full-object read reports a size that disagrees with its own bytes",
431        ));
432    }
433    if loaded.metadata.etag != written.etag {
434        return Err(wrong(
435            "a full-object read reports an identity that disagrees with the write that produced it",
436        ));
437    }
438    Ok(())
439}
440
441/// A write must be listable immediately. Recovery and garbage collection
442/// both enumerate prefixes to find what exists, so a listing that lags a
443/// write hides objects from the code that owns them.
444async fn visibility_after_write(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
445    let prefix = run.listing("visibility-after-write");
446    let key = format!("{prefix}object");
447
448    ok(
449        "write",
450        store
451            .put_if_absent(&key, Bytes::from_static(br#"{"created":true}"#))
452            .await,
453    )?;
454    let listed = ok("list", store.list_prefix(&prefix).await)?;
455    if listed != vec![key.clone()] {
456        return Err(wrong(format!(
457            "listing a prefix straight after a write into it answered {listed:?}"
458        )));
459    }
460    Ok(())
461}
462
463/// A delete must be listable-absent immediately, for the same reason: a
464/// listing that still reports a deleted object makes reclamation re-read
465/// objects that are already gone.
466async fn visibility_after_delete(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
467    let prefix = run.listing("visibility-after-delete");
468    let key = format!("{prefix}object");
469
470    ok(
471        "write",
472        store
473            .put_if_absent(&key, Bytes::from_static(br#"{"created":true}"#))
474            .await,
475    )?;
476    ok("delete", store.delete(&key).await)?;
477    let listed = ok("list", store.list_prefix(&prefix).await)?;
478    if !listed.is_empty() {
479        return Err(wrong(format!(
480            "listing a prefix straight after deleting its only object answered {listed:?}"
481        )));
482    }
483    Ok(())
484}
485
486/// Deleting what is not there succeeds. Every cleanup path deletes without
487/// first proving what it is cleaning up, so a store that errors here turns
488/// ordinary cleanup into a failure.
489async fn delete_missing_idempotent(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
490    let key = run.key("delete-missing");
491    ok("delete of a missing object", store.delete(&key).await)?;
492    if ok("head", store.head(&key).await)?.is_some() {
493        return Err(wrong("an object that was never written reads as present"));
494    }
495    Ok(())
496}
497
498/// A prefix listing must answer with exactly the objects under it, in
499/// ascending key order.
500async fn sorted_listing(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
501    let prefix = run.listing("sorted");
502    let keys = vec![
503        format!("{prefix}a"),
504        format!("{prefix}b"),
505        format!("{prefix}c"),
506    ];
507
508    // Written out of order, so a store that echoes write order rather than
509    // key order is caught.
510    for index in [1usize, 2, 0] {
511        ok(
512            "write",
513            store
514                .put_if_absent(&keys[index], Bytes::from_static(br#"{"seq":1}"#))
515                .await,
516        )?;
517    }
518
519    // The streamed keys are the provider's own answer; `list_prefix` sorts
520    // client-side, so it is the documented convenience rather than evidence
521    // about the provider.
522    let mut streamed = Vec::new();
523    let mut stream = store.list_prefix_stream(&prefix);
524    while let Some(item) = stream.next().await {
525        streamed.push(ok("list", item)?);
526    }
527    streamed.sort();
528    let listed = ok("list", store.list_prefix(&prefix).await)?;
529    if streamed != keys {
530        return Err(wrong(format!(
531            "streaming a prefix answered {streamed:?}, not the {} objects written under it",
532            keys.len()
533        )));
534    }
535    if listed != keys {
536        return Err(wrong(format!(
537            "listing a prefix answered {listed:?}, not the {} objects written under it in key order",
538            keys.len()
539        )));
540    }
541    Ok(())
542}
543
544/// Bounded reads are how metadata tables are read at all: a wrong range
545/// answer is a wrong block, which decodes as corruption rather than as an
546/// error.
547async fn range_reads(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
548    let key = run.key("range");
549    ok(
550        "write",
551        store
552            .put_if_absent(&key, Bytes::from_static(b"abcdef"))
553            .await,
554    )?;
555
556    let bounded = |start_inclusive, end_exclusive| {
557        Some(ByteRange {
558            start_inclusive,
559            end_exclusive,
560        })
561    };
562
563    let read = ok("bounded read", store.get(&key, bounded(1, 4)).await)?;
564    if read != Some(Bytes::from_static(b"bcd")) {
565        return Err(wrong(format!(
566            "a bounded read of bytes 1..4 answered {read:?}"
567        )));
568    }
569    // The bounded-read contract, uniform across providers: an end past the
570    // object clamps, reading at the exact end is empty, and a start past
571    // the end is an invalid range.
572    let clamped = ok("clamped read", store.get(&key, bounded(4, 99)).await)?;
573    if clamped != Some(Bytes::from_static(b"ef")) {
574        return Err(wrong(format!(
575            "a read whose end runs past the object should clamp, but answered {clamped:?}"
576        )));
577    }
578    let at_end = ok(
579        "read at the exact end",
580        store.get(&key, bounded(6, 8)).await,
581    )?;
582    if at_end != Some(Bytes::new()) {
583        return Err(wrong(format!(
584            "a read starting at the object's exact end should be empty, but answered {at_end:?}"
585        )));
586    }
587    match store.get(&key, bounded(7, 8)).await {
588        Err(ObjectStoreError::InvalidRange { .. }) => {}
589        Err(error) => {
590            return Err(wrong(format!(
591                "a read starting past the object's end should be an invalid range, but failed differently: {}",
592                error.message()
593            )))
594        }
595        Ok(answer) => {
596            return Err(wrong(format!(
597                "a read starting past the object's end should be an invalid range, but answered {answer:?}"
598            )))
599        }
600    }
601
602    ok("delete", store.delete(&key).await)?;
603    let missing = ok(
604        "bounded read of a missing object",
605        store.get(&key, bounded(0, 4)).await,
606    )?;
607    if missing.is_some() {
608        return Err(wrong(
609            "a bounded read of a deleted object answered bytes instead of absence",
610        ));
611    }
612    Ok(())
613}
614
615/// A write past the multipart threshold goes through the provider's own
616/// multipart rules — non-final part sizes, completion, assembly — and must
617/// read back byte-identical. Cloudflare R2's fixed non-final part size is
618/// the rule this geometry exists to satisfy.
619async fn multipart_round_trip(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
620    let key = run.key("multipart");
621
622    // The smallest payload with a middle part, which is where a provider's
623    // own rules about non-final part sizes bite.
624    let payload_len =
625        PROVIDER_MULTIPART_THRESHOLD_BYTES as usize + PROVIDER_MULTIPART_PART_BYTES as usize + 4096;
626    let payload: Vec<u8> = (0..payload_len).map(|index| (index % 251) as u8).collect();
627
628    let metadata = ok_optional(
629        "multipart overwrite",
630        store
631            .put_overwrite(&key, Bytes::from(payload.clone()))
632            .await,
633    )?;
634    if metadata.size_bytes != payload_len as u64 {
635        return Err(wrong(format!(
636            "a multipart write of {payload_len} bytes reports {} stored",
637            metadata.size_bytes
638        )));
639    }
640
641    let read_back = present(
642        "the assembled object",
643        ok_optional("read", store.get(&key, None).await)?,
644    )?;
645    if read_back.as_ref() != payload.as_slice() {
646        return Err(wrong(
647            "an object assembled from parts does not read back as the bytes written",
648        ));
649    }
650    Ok(())
651}
652
653/// Direct-put completion decides whether to publish an object from one
654/// checksum-bearing metadata request, so a store that reports a checksum
655/// must report an honest one.
656///
657/// Which algorithm comes back is the provider's business, and the providers
658/// disagree: AWS S3 reports the SHA-256 this adapter attaches to uploads,
659/// while Cloudflare R2 reports a CRC-64/NVME of its own. So this pins what
660/// must be true everywhere — the size, the algorithm's own encoding rules,
661/// and a SHA-256 that actually matches when SHA-256 is what is reported.
662async fn stored_checksum_readback(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
663    let key = run.key("stored-checksum");
664    let payload = Bytes::from_static(b"stored checksum readback payload");
665
666    // A store that cannot ask the question at all says so plainly. Those
667    // are exactly the providers that cannot offer `direct_put`, so no
668    // completion path depends on them.
669    let absent = ok_optional(
670        "stored-checksum read of a missing object",
671        store.head_stored_checksum(&key).await,
672    )?;
673    if absent.is_some() {
674        return Err(wrong("an object that does not exist reports a checksum"));
675    }
676
677    ok("write", store.put_if_absent(&key, payload.clone()).await)?;
678    let stored = match store.head_stored_checksum(&key).await {
679        Ok(stored) => present("a present object's checksum", stored)?,
680        // A provider that stores no checksum for this object must say so
681        // rather than invent an answer.
682        Err(error) => {
683            let message = error.message();
684            return if message.contains("no full-object checksum") {
685                Ok(())
686            } else {
687                Err(failed("stored-checksum read", &error))
688            };
689        }
690    };
691
692    if stored.size_bytes != payload.len() as u64 {
693        return Err(wrong(format!(
694            "a stored-checksum read of a {}-byte object reports {} bytes",
695            payload.len(),
696            stored.size_bytes
697        )));
698    }
699    let checksum = stored.storage_checksum;
700    if checksum.value.len() != checksum.algorithm.value_bytes() * 2 {
701        return Err(wrong(format!(
702            "a checksum value must be its algorithm's width in hex: {checksum:?}"
703        )));
704    }
705    if !checksum
706        .value
707        .bytes()
708        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
709    {
710        return Err(wrong(format!(
711            "a checksum value must be lowercase hex: {checksum:?}"
712        )));
713    }
714    if checksum.algorithm == ChecksumAlgorithm::Sha256
715        && checksum != StorageChecksum::sha256(&payload)
716    {
717        return Err(wrong(
718            "a reported sha256 does not describe the bytes actually stored",
719        ));
720    }
721    Ok(())
722}
723
724/// The run's own cleanup, and a check in its own right: after deleting
725/// everything the run wrote, listing the run's prefix must answer empty.
726async fn cleanup_leaves_prefix_empty(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
727    let prefix = format!("{}/", run.prefix);
728    for key in ok("list", store.list_prefix(&prefix).await)? {
729        ok("cleanup delete", store.delete(&key).await)?;
730    }
731    let remaining = ok("list after cleanup", store.list_prefix(&prefix).await)?;
732    if !remaining.is_empty() {
733        return Err(wrong(format!(
734            "the probe's own prefix still holds {remaining:?} after cleanup"
735        )));
736    }
737    Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::local_fs_store::LocalFsStore;
744    use crate::{ObjectBody, ObjectMetadata, PutMode};
745    use async_trait::async_trait;
746    use futures::stream::BoxStream;
747    use std::sync::atomic::{AtomicBool, Ordering};
748    use std::sync::Arc;
749    use tempfile::TempDir;
750
751    fn outcome<'a>(report: &'a StoreProbeReport, name: &str) -> &'a StoreProbeOutcome {
752        &report
753            .checks
754            .iter()
755            .find(|check| check.name == name)
756            .expect("report should carry the named check")
757            .outcome
758    }
759
760    #[tokio::test]
761    async fn a_conforming_store_passes_every_check() {
762        let temp_dir = TempDir::new().expect("tempdir");
763        let store = LocalFsStore::new(temp_dir.path()).expect("create local object store");
764
765        let report = run_store_contract_probe(&store, "probe_test_conforming").await;
766
767        assert_eq!(report.run_id, "probe_test_conforming");
768        let failures: Vec<_> = report
769            .checks
770            .iter()
771            .filter(|check| matches!(check.outcome, StoreProbeOutcome::Failed { .. }))
772            .collect();
773        assert!(failures.is_empty(), "unexpected failures: {failures:?}");
774        assert!(report.all_passed());
775    }
776
777    #[tokio::test]
778    async fn the_report_names_every_check_once_and_in_run_order() {
779        let temp_dir = TempDir::new().expect("tempdir");
780        let store = LocalFsStore::new(temp_dir.path()).expect("create local object store");
781
782        let report = run_store_contract_probe(&store, "probe_test_shape").await;
783
784        let names: Vec<_> = report.checks.iter().map(|check| check.name).collect();
785        assert_eq!(
786            names,
787            vec![
788                "create_if_absent_enforced",
789                "compare_and_swap_rejects_stale",
790                "compare_and_swap_missing_object_rejected",
791                "overwrite_updates_head_and_body",
792                "get_with_metadata_round_trip",
793                "visibility_after_write",
794                "visibility_after_delete",
795                "delete_missing_idempotent",
796                "sorted_listing",
797                "range_reads",
798                "multipart_round_trip",
799                "stored_checksum_readback",
800                "cleanup_leaves_prefix_empty",
801            ]
802        );
803    }
804
805    #[tokio::test]
806    async fn a_probe_run_leaves_its_prefix_empty() {
807        let temp_dir = TempDir::new().expect("tempdir");
808        let store = LocalFsStore::new(temp_dir.path()).expect("create local object store");
809
810        let report = run_store_contract_probe(&store, "probe_test_cleanup").await;
811
812        assert_eq!(
813            outcome(&report, "cleanup_leaves_prefix_empty"),
814            &StoreProbeOutcome::Passed
815        );
816        assert!(store
817            .list_prefix("probe-runs/")
818            .await
819            .expect("list the probe prefix")
820            .is_empty());
821    }
822
823    /// A store whose local filesystem honours everything except
824    /// compare-and-swap: the one shape a preconditions-ignoring S3 gateway
825    /// presents, and the one that silently corrupts fenced writes.
826    #[derive(Debug)]
827    struct StaleCompareAndSwapAcceptingStore {
828        inner: LocalFsStore,
829        accepted_a_stale_swap: Arc<AtomicBool>,
830    }
831
832    #[async_trait]
833    impl ObjectStore for StaleCompareAndSwapAcceptingStore {
834        async fn head(&self, key: &str) -> StoreResult<Option<ObjectMetadata>> {
835            self.inner.head(key).await
836        }
837
838        async fn get_with_metadata(&self, key: &str) -> StoreResult<Option<ObjectBody>> {
839            self.inner.get_with_metadata(key).await
840        }
841
842        async fn get(&self, key: &str, range: Option<ByteRange>) -> StoreResult<Option<Bytes>> {
843            self.inner.get(key, range).await
844        }
845
846        async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> StoreResult<ObjectMetadata> {
847            let mode = match mode {
848                PutMode::CompareAndSwap { .. } => {
849                    self.accepted_a_stale_swap.store(true, Ordering::SeqCst);
850                    PutMode::Overwrite
851                }
852                mode => mode,
853            };
854            self.inner.put(key, bytes, mode).await
855        }
856
857        async fn delete(&self, key: &str) -> StoreResult<()> {
858            self.inner.delete(key).await
859        }
860
861        fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, StoreResult<String>> {
862            self.inner.list_prefix_stream(prefix)
863        }
864    }
865
866    #[tokio::test]
867    async fn a_store_that_ignores_compare_and_swap_fails_only_the_checks_about_it() {
868        let temp_dir = TempDir::new().expect("tempdir");
869        let accepted_a_stale_swap = Arc::new(AtomicBool::new(false));
870        let store = StaleCompareAndSwapAcceptingStore {
871            inner: LocalFsStore::new(temp_dir.path()).expect("create local object store"),
872            accepted_a_stale_swap: Arc::clone(&accepted_a_stale_swap),
873        };
874
875        let report = run_store_contract_probe(&store, "probe_test_broken_cas").await;
876
877        assert!(accepted_a_stale_swap.load(Ordering::SeqCst));
878        assert!(!report.all_passed());
879        assert!(matches!(
880            outcome(&report, "compare_and_swap_rejects_stale"),
881            StoreProbeOutcome::Failed { message } if message.contains("does not enforce")
882        ));
883        assert!(matches!(
884            outcome(&report, "compare_and_swap_missing_object_rejected"),
885            StoreProbeOutcome::Failed { .. }
886        ));
887        // A failed check ends that check, not the run: everything after it
888        // still reports, and the run still cleans up after itself.
889        assert_eq!(
890            outcome(&report, "create_if_absent_enforced"),
891            &StoreProbeOutcome::Passed
892        );
893        assert_eq!(outcome(&report, "range_reads"), &StoreProbeOutcome::Passed);
894        assert_eq!(
895            outcome(&report, "cleanup_leaves_prefix_empty"),
896            &StoreProbeOutcome::Passed
897        );
898        assert!(store
899            .list_prefix("probe-runs/")
900            .await
901            .expect("list the probe prefix")
902            .is_empty());
903    }
904
905    /// A store that cannot report stored checksums — GCS and Azure Blob
906    /// Storage are both this case, and it is an answer rather than a fault.
907    #[derive(Debug)]
908    struct NoStoredChecksumStore {
909        inner: LocalFsStore,
910    }
911
912    #[async_trait]
913    impl ObjectStore for NoStoredChecksumStore {
914        async fn head(&self, key: &str) -> StoreResult<Option<ObjectMetadata>> {
915            self.inner.head(key).await
916        }
917
918        async fn head_stored_checksum(
919            &self,
920            _key: &str,
921        ) -> StoreResult<Option<crate::StoredObjectChecksum>> {
922            Err(ObjectStoreError::Unsupported(
923                "stored full-object checksum readback",
924            ))
925        }
926
927        async fn get_with_metadata(&self, key: &str) -> StoreResult<Option<ObjectBody>> {
928            self.inner.get_with_metadata(key).await
929        }
930
931        async fn get(&self, key: &str, range: Option<ByteRange>) -> StoreResult<Option<Bytes>> {
932            self.inner.get(key, range).await
933        }
934
935        async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> StoreResult<ObjectMetadata> {
936            self.inner.put(key, bytes, mode).await
937        }
938
939        async fn delete(&self, key: &str) -> StoreResult<()> {
940            self.inner.delete(key).await
941        }
942
943        fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, StoreResult<String>> {
944            self.inner.list_prefix_stream(prefix)
945        }
946    }
947
948    #[tokio::test]
949    async fn a_missing_optional_capability_is_an_answer_not_a_failure() {
950        let temp_dir = TempDir::new().expect("tempdir");
951        let store = NoStoredChecksumStore {
952            inner: LocalFsStore::new(temp_dir.path()).expect("create local object store"),
953        };
954
955        let report = run_store_contract_probe(&store, "probe_test_unsupported").await;
956
957        assert_eq!(
958            outcome(&report, "stored_checksum_readback"),
959            &StoreProbeOutcome::Unsupported
960        );
961        assert!(report.all_passed());
962    }
963}