Skip to main content

objstore_r/
testing.rs

1// Author: spicyzhug@gmail.com
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/// The content-encoding contract ([`ObjectStore::put_encoded`]) under
180/// `prefix`, for stores that support `gzip` (every backend here, with feature
181/// `gzip`): encoded and plain objects round-trip, `stat`/`list_stat` report
182/// the encoding, ranged reads of encoded objects refuse, conditions behave as
183/// for `put`, and plain ⇄ encoded rewrites of one key read correctly.
184#[allow(clippy::too_many_lines)] // one linear script, like `conformance`
185pub async fn conformance_encoded<S: ObjectStore>(s: &S, prefix: &str) -> Result<(), String> {
186    use crate::ContentEncoding::Gzip;
187    macro_rules! ensure {
188        ($c:expr, $($t:tt)*) => { if !$c { return Err(format!($($t)*)); } };
189    }
190    let k = |name: &str| format!("{prefix}/{name}");
191    let e = |r: Error| format!("unexpected error: {r}");
192    let text: Bytes = b"a line of text that compresses, ".repeat(4096).into();
193
194    // Round trip, and what is stored is smaller.
195    let g1 = s.put_encoded(&k("enc"), text.clone(), Cond::CreateOnly, Gzip).await.map_err(e)?;
196    let got = s.get(&k("enc")).await.map_err(e)?.ok_or("encoded object vanished")?;
197    ensure!(got.bytes == text && got.generation == g1, "get must decode to the bytes written");
198    let st = s.stat(&k("enc")).await.map_err(e)?.ok_or("stat of an encoded object")?;
199    ensure!(st.content_encoding == Some(Gzip), "stat must report gzip, got {st:?}");
200    ensure!(
201        st.meta.generation == g1 && st.meta.size < text.len() as u64 / 4,
202        "stat reports the generation and the stored (compressed) size: {st:?}"
203    );
204    let head = s.head(&k("enc")).await.map_err(e)?.ok_or("head of an encoded object")?;
205    ensure!(head == st.meta, "head and stat agree: {head:?} vs {st:?}");
206
207    // Ranges refuse, whatever the range; a missing key is still NotFound.
208    for r in [0..4, 0..u64::MAX, 3..3, 1_000_000..1_000_010] {
209        ensure!(
210            matches!(s.get_range(&k("enc"), r.clone()).await, Err(Error::EncodedRange { .. })),
211            "a range {r:?} of an encoded object must be EncodedRange"
212        );
213    }
214
215    // Plain objects beside it are unchanged.
216    s.put(&k("plain"), Bytes::from_static(b"0123456789"), Cond::CreateOnly).await.map_err(e)?;
217    ensure!(
218        s.get_range(&k("plain"), 2..5).await.map_err(e)? == b"234"[..],
219        "plain ranges are unchanged"
220    );
221    let pst = s.stat(&k("plain")).await.map_err(e)?.ok_or("stat of a plain object")?;
222    ensure!(pst.content_encoding.is_none() && pst.meta.size == 10, "plain stat: {pst:?}");
223
224    // Conditions are exactly put's. (Different bytes: a store may recognise
225    // its own earlier write, identical bytes, as a lost response.)
226    let other: Bytes = b"other text that also compresses, ".repeat(512).into();
227    ensure!(
228        matches!(
229            s.put_encoded(&k("enc"), other.clone(), Cond::CreateOnly, Gzip).await,
230            Err(Error::Conflict)
231        ),
232        "create-only over an encoded object must be Conflict"
233    );
234    ensure!(
235        matches!(
236            s.put_encoded(&k("enc"), other.clone(), Cond::IfGeneration(g1 + 1), Gzip).await,
237            Err(Error::PreconditionFailed)
238        ),
239        "a stale generation must fail the precondition"
240    );
241    ensure!(
242        matches!(
243            s.put_encoded(&k("none"), other.clone(), Cond::IfGeneration(g1), Gzip).await,
244            Err(Error::PreconditionFailed)
245        ),
246        "IfGeneration on a missing key must fail"
247    );
248
249    // Encoded -> plain -> encoded on one key, each read as written.
250    let g2 = s.put(&k("enc"), Bytes::from_static(b"now plain"), Cond::IfGeneration(g1)).await;
251    let g2 = g2.map_err(e)?;
252    ensure!(g2 != g1, "a rewrite changes the generation");
253    ensure!(
254        s.get(&k("enc")).await.map_err(e)?.is_some_and(|o| o.bytes == b"now plain"[..]),
255        "a plain rewrite of an encoded key reads plain"
256    );
257    ensure!(
258        s.get_range(&k("enc"), 0..3).await.map_err(e)? == b"now"[..],
259        "and its ranges work again"
260    );
261    let small: Bytes = Bytes::from_static(b"tiny");
262    let g3 = s.put_encoded(&k("enc"), small.clone(), Cond::IfGeneration(g2), Gzip).await;
263    let g3 = g3.map_err(e)?;
264    ensure!(
265        s.get(&k("enc")).await.map_err(e)?.is_some_and(|o| o.bytes == small && o.generation == g3),
266        "an encoded rewrite of a plain key decodes"
267    );
268    let empty = s.put_encoded(&k("empty"), Bytes::new(), Cond::None, Gzip).await.map_err(e)?;
269    ensure!(
270        s.get(&k("empty"))
271            .await
272            .map_err(e)?
273            .is_some_and(|o| o.bytes.is_empty() && o.generation == empty),
274        "an encoded empty body round-trips"
275    );
276
277    // Listing reports each object's encoding, sorted.
278    let listed = s.list_stat(&format!("{prefix}/")).await.map_err(e)?;
279    let seen: Vec<(String, Option<crate::ContentEncoding>)> =
280        listed.iter().map(|st| (st.meta.key.clone(), st.content_encoding)).collect();
281    ensure!(
282        seen == [(k("empty"), Some(Gzip)), (k("enc"), Some(Gzip)), (k("plain"), None)],
283        "list_stat returned {seen:?}"
284    );
285    let plain_list: Vec<_> = s.list(&format!("{prefix}/")).await.map_err(e)?;
286    ensure!(
287        plain_list == listed.iter().map(|st| st.meta.clone()).collect::<Vec<_>>(),
288        "list and list_stat agree"
289    );
290
291    // Invalid keys never reach storage.
292    ensure!(
293        matches!(
294            s.put_encoded("../x", Bytes::new(), Cond::None, Gzip).await,
295            Err(Error::InvalidKey { .. })
296        ),
297        "an invalid key is refused before encoding"
298    );
299
300    // Delete removes the encoding with the object.
301    s.delete(&k("enc")).await.map_err(e)?;
302    ensure!(s.stat(&k("enc")).await.map_err(e)?.is_none(), "deleted objects have no stat");
303    s.put(&k("enc"), Bytes::from_static(b"fresh"), Cond::CreateOnly).await.map_err(e)?;
304    ensure!(
305        s.stat(&k("enc")).await.map_err(e)?.is_some_and(|st| st.content_encoding.is_none()),
306        "a key recreated plain carries no stale encoding"
307    );
308    clean(s, prefix).await
309}
310
311/// A unique key prefix for one test run: `tag-pid-nanos`.
312#[must_use]
313pub fn unique(tag: &str) -> String {
314    let nanos = std::time::SystemTime::now()
315        .duration_since(std::time::UNIX_EPOCH)
316        .map_or(0, |d| d.as_nanos());
317    format!("{tag}-{}-{nanos}", std::process::id())
318}
319
320/// Delete everything under `prefix/` and confirm nothing is left.
321pub async fn clean<S: ObjectStore>(s: &S, prefix: &str) -> Result<(), String> {
322    let dir = format!("{}/", prefix.trim_end_matches('/'));
323    for m in s.list(&dir).await.map_err(|e| e.to_string())? {
324        s.delete(&m.key).await.map_err(|e| e.to_string())?;
325    }
326    if s.list(&dir).await.map_err(|e| e.to_string())?.is_empty() {
327        Ok(())
328    } else {
329        Err(format!("{dir} not empty after cleanup"))
330    }
331}
332
333/// The live bucket named by `OBJSTORE_LIVE_BUCKET`, through Application
334/// Default Credentials; `None` (skip) when unset.
335#[cfg(feature = "gcs")]
336#[must_use]
337pub fn live_bucket() -> Option<crate::Gcs<crate::token::Adc>> {
338    let bucket = std::env::var("OBJSTORE_LIVE_BUCKET").ok()?;
339    let adc = crate::token::Adc::discover().expect("credentials for the live bucket");
340    Some(crate::Gcs::new(&bucket, adc, crate::GcsConfig::default()).expect("live bucket name"))
341}
342
343/// A fresh bucket on the fake-gcs-server named by `OBJSTORE_FAKE_GCS`, with
344/// uploads above `resumable_threshold` bytes resumable; `None` (skip) when
345/// unset.
346#[cfg(feature = "gcs")]
347pub async fn emulator_bucket(
348    resumable_threshold: usize,
349) -> Option<crate::Gcs<crate::token::Static>> {
350    let endpoint = std::env::var("OBJSTORE_FAKE_GCS").ok()?;
351    let bucket = unique("objstore-r").replace('_', "-");
352    let resp = reqwest::Client::new()
353        .post(format!("{endpoint}/storage/v1/b?project=test"))
354        .header("content-type", "application/json")
355        .body(format!(r#"{{"name":"{bucket}"}}"#))
356        .send()
357        .await
358        .expect("fake-gcs-server reachable");
359    assert!(resp.status().is_success(), "bucket creation: {}", resp.status());
360    let cfg = crate::GcsConfig { endpoint, resumable_threshold, ..crate::GcsConfig::default() };
361    Some(crate::Gcs::new(&bucket, crate::token::Static::none(), cfg).expect("emulator bucket"))
362}