1use 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
40const PROBE_RUN_PREFIX: &str = "probe-runs";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct StoreProbeReport {
46 pub run_id: String,
48 pub checks: Vec<StoreProbeCheck>,
50}
51
52impl StoreProbeReport {
53 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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct StoreProbeCheck {
71 pub name: &'static str,
74 pub outcome: StoreProbeOutcome,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum StoreProbeOutcome {
81 Passed,
83 Unsupported,
88 Failed {
92 message: String,
96 },
97}
98
99pub 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 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
169struct ProbeRun {
171 prefix: String,
172}
173
174impl ProbeRun {
175 fn key(&self, name: &str) -> String {
177 format!("{}/{name}", self.prefix)
178 }
179
180 fn listing(&self, name: &str) -> String {
182 format!("{}/{name}/", self.prefix)
183 }
184}
185
186enum CheckFailure {
188 Unsupported,
190 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
205fn 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
217fn wrong(message: impl Into<String>) -> CheckFailure {
219 CheckFailure::Failed(message.into())
220}
221
222fn ok<T>(operation: &str, result: StoreResult<T>) -> std::result::Result<T, CheckFailure> {
224 result.map_err(|error| failed(operation, &error))
225}
226
227fn 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
238fn 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
244fn 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
260async 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
291async 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
344async 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
359async 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
407async 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
441async 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
463async 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
486async 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
498async 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 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 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
544async 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 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
615async fn multipart_round_trip(store: &dyn ObjectStore, run: &ProbeRun) -> CheckResult {
620 let key = run.key("multipart");
621
622 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
653async 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 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 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
724async 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 #[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 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 #[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}