Skip to main content

objstore_r/
testing.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! A conformance suite for [`ObjectStore`] implementations, and the harness
5//! every live test of the stack shares (feature `testing`).
6//!
7//! Every backend in this crate passes it, and crates wrapping other storage in
8//! the trait (for example an application's own GCS client) can run it too:
9//!
10//! ```
11//! # futures::executor::block_on(async {
12//! objstore_r::testing::conformance(&objstore_r::Mem::default(), "t").await.unwrap();
13//! # });
14//! ```
15
16use crate::{Bytes, Cond, Error, ObjectStore};
17
18/// Run the suite under `prefix` (a valid key; everything is written below it
19/// and deleted afterwards). Returns the first violated expectation.
20#[allow(clippy::too_many_lines)] // one linear script: splitting it would hide the order
21pub async fn conformance<S: ObjectStore>(s: &S, prefix: &str) -> Result<(), String> {
22    macro_rules! ensure {
23        ($c:expr, $($t:tt)*) => { if !$c { return Err(format!($($t)*)); } };
24    }
25    let k = |name: &str| format!("{prefix}/{name}");
26    let e = |r: Error| format!("unexpected error: {r}");
27
28    // Missing objects.
29    ensure!(s.get(&k("missing")).await.map_err(e)?.is_none(), "get of a missing key must be None");
30    ensure!(
31        s.head(&k("missing")).await.map_err(e)?.is_none(),
32        "head of a missing key must be None"
33    );
34    ensure!(
35        matches!(s.get_range(&k("missing"), 0..4).await, Err(Error::NotFound)),
36        "range of a missing key must be NotFound"
37    );
38    s.delete(&k("missing")).await.map_err(e)?;
39
40    // Create-only, conflict, exact bytes.
41    let body = Bytes::from_static(b"hello, object store");
42    let g1 = s.put(&k("a"), body.clone(), Cond::CreateOnly).await.map_err(e)?;
43    ensure!(g1 != 0, "generations are never zero");
44    ensure!(
45        matches!(
46            s.put(&k("a"), Bytes::from_static(b"x"), Cond::CreateOnly).await,
47            Err(Error::Conflict)
48        ),
49        "create-only over an existing key must be Conflict"
50    );
51    let got = s.get(&k("a")).await.map_err(e)?.ok_or("object vanished")?;
52    ensure!(
53        got.bytes == body && got.generation == g1,
54        "get must return the bytes and generation written"
55    );
56    let h = s.head(&k("a")).await.map_err(e)?.ok_or("head of an existing key")?;
57    ensure!(
58        h.size == body.len() as u64 && h.generation == g1 && h.key == k("a"),
59        "head must report size, generation and key"
60    );
61
62    // Generation CAS.
63    ensure!(
64        matches!(
65            s.put(&k("a"), Bytes::from_static(b"y"), Cond::IfGeneration(g1 + 1)).await,
66            Err(Error::PreconditionFailed)
67        ),
68        "a stale generation must fail the precondition"
69    );
70    let g2 = s.put(&k("a"), Bytes::from_static(b"v2"), Cond::IfGeneration(g1)).await.map_err(e)?;
71    ensure!(g2 != g1, "a write must change the generation");
72    ensure!(
73        matches!(
74            s.put(&k("a"), Bytes::from_static(b"v3"), Cond::IfGeneration(g1)).await,
75            Err(Error::PreconditionFailed)
76        ),
77        "the old generation must no longer match"
78    );
79    ensure!(
80        matches!(
81            s.put(&k("nope"), Bytes::from_static(b"v"), Cond::IfGeneration(g1)).await,
82            Err(Error::PreconditionFailed)
83        ),
84        "IfGeneration on a missing key must fail"
85    );
86    let g3 = s.put(&k("a"), Bytes::from_static(b"v3"), Cond::None).await.map_err(e)?;
87    ensure!(g3 != g2, "an unconditional write changes the generation");
88
89    // Ranges.
90    s.put(&k("r"), Bytes::from_static(b"0123456789"), Cond::None).await.map_err(e)?;
91    let cases: [(u64, u64, &[u8]); 5] =
92        [(0, 4, b"0123"), (6, 10, b"6789"), (8, 100, b"89"), (10, 12, b""), (50, 60, b"")];
93    for (a, b, want) in cases {
94        let got = s.get_range(&k("r"), a..b).await.map_err(e)?;
95        ensure!(got.as_ref() == want, "range {a}..{b} returned {:?}", got);
96    }
97
98    // Empty and large bodies.
99    s.put(&k("empty"), Bytes::new(), Cond::CreateOnly).await.map_err(e)?;
100    ensure!(
101        s.get(&k("empty")).await.map_err(e)?.is_some_and(|o| o.bytes.is_empty()),
102        "empty objects round-trip"
103    );
104    let big: Bytes = (0..(1u32 << 20))
105        .map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8)
106        .collect::<Vec<u8>>()
107        .into();
108    s.put(&k("big"), big.clone(), Cond::CreateOnly).await.map_err(e)?;
109    ensure!(
110        s.get(&k("big")).await.map_err(e)?.is_some_and(|o| o.bytes == big),
111        "a 1 MiB object round-trips exactly"
112    );
113    ensure!(
114        s.get_range(&k("big"), 1000..1010).await.map_err(e)? == big.slice(1000..1010),
115        "ranges of a large object"
116    );
117
118    // Above the resumable threshold (8 MiB by default): chunked uploads, and
119    // ranges that cross a chunk boundary.
120    let huge: Bytes = (0..(9u32 << 20))
121        .map(|i| (i.wrapping_mul(2_246_822_519) >> 25) as u8)
122        .collect::<Vec<u8>>()
123        .into();
124    s.put(&k("huge"), huge.clone(), Cond::CreateOnly).await.map_err(e)?;
125    ensure!(
126        s.head(&k("huge")).await.map_err(e)?.is_some_and(|m| m.size == huge.len() as u64),
127        "a 9 MiB object reports its size"
128    );
129    let edge = (8u64 << 20) - 5;
130    ensure!(
131        s.get_range(&k("huge"), edge..edge + 10).await.map_err(e)?
132            == huge.slice(edge as usize..edge as usize + 10),
133        "a range across the 8 MiB boundary"
134    );
135    ensure!(
136        s.get(&k("huge")).await.map_err(e)?.is_some_and(|o| o.bytes == huge),
137        "a 9 MiB object round-trips exactly"
138    );
139
140    // Listing: sorted, prefix-scoped, nested keys.
141    s.put(&k("dir/x"), Bytes::from_static(b"1"), Cond::None).await.map_err(e)?;
142    s.put(&k("dir/y/z"), Bytes::from_static(b"22"), Cond::None).await.map_err(e)?;
143    s.put(&k("dirt"), Bytes::from_static(b"3"), Cond::None).await.map_err(e)?;
144    let listed: Vec<String> =
145        s.list(&k("dir/")).await.map_err(e)?.into_iter().map(|m| m.key).collect();
146    ensure!(listed == [k("dir/x"), k("dir/y/z")], "list(\"dir/\") returned {listed:?}");
147    let listed: Vec<String> =
148        s.list(&k("dir")).await.map_err(e)?.into_iter().map(|m| m.key).collect();
149    ensure!(listed == [k("dir/x"), k("dir/y/z"), k("dirt")], "list(\"dir\") returned {listed:?}");
150    let all = s.list(&format!("{prefix}/")).await.map_err(e)?;
151    ensure!(all.windows(2).all(|w| w[0].key < w[1].key), "list output must be sorted");
152    ensure!(all.iter().any(|m| m.key == k("dir/y/z") && m.size == 2), "list reports sizes");
153
154    // Invalid keys never reach storage.
155    for bad in ["", "../x", "a//b", ".hidden", "sp ace"] {
156        ensure!(
157            matches!(s.put(bad, Bytes::new(), Cond::None).await, Err(Error::InvalidKey { .. })),
158            "key {bad:?} must be rejected"
159        );
160    }
161
162    // Delete, then recreate with a new generation.
163    s.delete(&k("a")).await.map_err(e)?;
164    ensure!(s.get(&k("a")).await.map_err(e)?.is_none(), "deleted objects are gone");
165    let g4 = s.put(&k("a"), Bytes::from_static(b"again"), Cond::CreateOnly).await.map_err(e)?;
166    ensure!(g4 != g3, "a recreated object gets a new generation");
167
168    // Clean up.
169    for m in s.list(&format!("{prefix}/")).await.map_err(e)? {
170        s.delete(&m.key).await.map_err(e)?;
171    }
172    ensure!(
173        s.list(&format!("{prefix}/")).await.map_err(e)?.is_empty(),
174        "cleanup left objects behind"
175    );
176    Ok(())
177}
178
179/// A unique key prefix for one test run: `tag-pid-nanos`.
180#[must_use]
181pub fn unique(tag: &str) -> String {
182    let nanos = std::time::SystemTime::now()
183        .duration_since(std::time::UNIX_EPOCH)
184        .map_or(0, |d| d.as_nanos());
185    format!("{tag}-{}-{nanos}", std::process::id())
186}
187
188/// Delete everything under `prefix/` and confirm nothing is left.
189pub async fn clean<S: ObjectStore>(s: &S, prefix: &str) -> Result<(), String> {
190    let dir = format!("{}/", prefix.trim_end_matches('/'));
191    for m in s.list(&dir).await.map_err(|e| e.to_string())? {
192        s.delete(&m.key).await.map_err(|e| e.to_string())?;
193    }
194    if s.list(&dir).await.map_err(|e| e.to_string())?.is_empty() {
195        Ok(())
196    } else {
197        Err(format!("{dir} not empty after cleanup"))
198    }
199}
200
201/// The live bucket named by `OBJSTORE_LIVE_BUCKET`, through Application
202/// Default Credentials; `None` (skip) when unset.
203#[cfg(feature = "gcs")]
204#[must_use]
205pub fn live_bucket() -> Option<crate::Gcs<crate::token::Adc>> {
206    let bucket = std::env::var("OBJSTORE_LIVE_BUCKET").ok()?;
207    let adc = crate::token::Adc::discover().expect("credentials for the live bucket");
208    Some(crate::Gcs::new(&bucket, adc, crate::GcsConfig::default()).expect("live bucket name"))
209}
210
211/// A fresh bucket on the fake-gcs-server named by `OBJSTORE_FAKE_GCS`, with
212/// uploads above `resumable_threshold` bytes resumable; `None` (skip) when
213/// unset.
214#[cfg(feature = "gcs")]
215pub async fn emulator_bucket(
216    resumable_threshold: usize,
217) -> Option<crate::Gcs<crate::token::Static>> {
218    let endpoint = std::env::var("OBJSTORE_FAKE_GCS").ok()?;
219    let bucket = unique("objstore-r").replace('_', "-");
220    let resp = reqwest::Client::new()
221        .post(format!("{endpoint}/storage/v1/b?project=test"))
222        .header("content-type", "application/json")
223        .body(format!(r#"{{"name":"{bucket}"}}"#))
224        .send()
225        .await
226        .expect("fake-gcs-server reachable");
227    assert!(resp.status().is_success(), "bucket creation: {}", resp.status());
228    let cfg = crate::GcsConfig { endpoint, resumable_threshold, ..crate::GcsConfig::default() };
229    Some(crate::Gcs::new(&bucket, crate::token::Static::none(), cfg).expect("emulator bucket"))
230}