Skip to main content

acyclic_objects/
conformance.rs

1//! Black-box conformance for every Objects provider implementation.
2
3use bytes::Bytes;
4
5use crate::{Condition, GetRequest, ObjectsError, ObjectsProvider, PutRequest, ReadTarget, wire};
6
7/// Exercises the complete provider contract under one caller-owned unique namespace.
8///
9/// The provider must be fresh: the suite retains global idempotency keys as well as resources.
10/// The namespace must satisfy the public bucket-name grammar and must never have been used by
11/// this provider. The suite verifies permanent identity, snapshots, forks, versions, delete
12/// markers, and idempotent recovery.
13///
14/// # Errors
15///
16/// Returns the provider's typed failure or a conformance invariant failure.
17#[allow(
18    clippy::cognitive_complexity,
19    clippy::too_many_lines,
20    reason = "linear conformance walkthrough; each ensure() is a distinct provider-contract \
21              assertion, and splitting it would only move the same sequential checks behind \
22              indirection"
23)]
24pub async fn verify<P: ObjectsProvider + ?Sized>(
25    provider: &P,
26    namespace: &str,
27) -> Result<(), ObjectsError> {
28    let version_bucket = create(provider, &format!("{namespace}-versions"), "create-v").await?;
29    ensure(
30        provider
31            .create_bucket(format!("{namespace}-other"), Some("create-v".into()))
32            .await
33            == Err(ObjectsError::IdempotencyMismatch),
34        "bucket idempotency-key rebinding was accepted",
35    )?;
36    let first = put(
37        provider,
38        &version_bucket,
39        "dir/a",
40        b"one",
41        Some(Condition::IfAbsent),
42        Some("put-one"),
43    )
44    .await?;
45    ensure(
46        provider
47            .put(PutRequest {
48                bucket: version_bucket.clone(),
49                object_key: "dir/a".into(),
50                body: Bytes::from_static(b"wrong"),
51                metadata: metadata(),
52                condition: Some(Condition::IfAbsent),
53                idempotency_key: None,
54            })
55            .await
56            == Err(ObjectsError::PreconditionFailed),
57        "IfAbsent did not reject an existing current version",
58    )?;
59    let replay = put(
60        provider,
61        &version_bucket,
62        "dir/a",
63        b"one",
64        Some(Condition::IfAbsent),
65        Some("put-one"),
66    )
67    .await?;
68    ensure(replay == first, "idempotent put did not replay exactly")?;
69    ensure(
70        provider
71            .put(PutRequest {
72                bucket: version_bucket.clone(),
73                object_key: "dir/a".into(),
74                body: Bytes::from_static(b"different"),
75                metadata: metadata(),
76                condition: Some(Condition::IfAbsent),
77                idempotency_key: Some("put-one".into()),
78            })
79            .await
80            == Err(ObjectsError::IdempotencyMismatch),
81        "idempotency-key rebinding was accepted",
82    )?;
83    let second = put(provider, &version_bucket, "dir/a", b"two", None, None).await?;
84    ensure(
85        first.version_id != second.version_id,
86        "versions were not permanent",
87    )?;
88    ensure(
89        first.etag == format!("\"{}\"", blake3::hash(b"one").to_hex()),
90        "object validator did not bind exact bytes",
91    )?;
92    let ranged = provider
93        .get(GetRequest {
94            target: ReadTarget::Bucket(version_bucket.clone()),
95            object_key: "dir/a".into(),
96            version_id: Some(first.version_id.clone()),
97            range: Some((1, Some(2))),
98            if_match: Some(first.etag.clone()),
99            if_none_match: None,
100            maximum_bytes: 2,
101        })
102        .await?;
103    ensure(
104        ranged.body.as_ref() == b"ne",
105        "exact-version range read differed",
106    )?;
107    ensure(
108        provider
109            .get(GetRequest {
110                target: ReadTarget::Bucket(version_bucket.clone()),
111                object_key: "dir/a".into(),
112                version_id: Some(first.version_id.clone()),
113                range: None,
114                if_match: None,
115                if_none_match: None,
116                maximum_bytes: 2,
117            })
118            .await
119            == Err(ObjectsError::Capacity),
120        "read allocation bound was not enforced",
121    )?;
122    let grouped = provider
123        .list(
124            ReadTarget::Bucket(version_bucket.clone()),
125            String::new(),
126            Some("/".into()),
127            false,
128            10,
129            None,
130        )
131        .await?;
132    ensure(
133        grouped.common_prefixes == vec!["dir/"],
134        "delimiter listing did not group the exact prefix",
135    )?;
136    ensure(
137        !provider
138            .delete(
139                version_bucket.clone(),
140                "dir/a".into(),
141                Some("missing-version".into()),
142                None,
143                Some("delete-missing-version".into()),
144            )
145            .await?
146            .existed,
147        "unknown exact version was reported as deleted",
148    )?;
149    ensure(
150        provider
151            .delete_bucket(&version_bucket, Some("delete-nonempty".into()))
152            .await
153            == Err(ObjectsError::PreconditionFailed),
154        "nonempty bucket deletion was accepted",
155    )?;
156
157    let listing_bucket = create(provider, &format!("{namespace}-listing"), "create-l").await?;
158    put(provider, &listing_bucket, "a", b"a", None, None).await?;
159    put(provider, &listing_bucket, "b", b"b", None, None).await?;
160    let first_page = provider
161        .list(
162            ReadTarget::Bucket(listing_bucket.clone()),
163            String::new(),
164            None,
165            false,
166            1,
167            None,
168        )
169        .await?;
170    put(provider, &listing_bucket, "aa", b"later", None, None).await?;
171    let second_page = provider
172        .list(
173            ReadTarget::Bucket(listing_bucket),
174            String::new(),
175            None,
176            false,
177            1,
178            first_page.continuation,
179        )
180        .await?;
181    ensure(
182        first_page
183            .entries
184            .first()
185            .map(|entry| entry.object_key.as_str())
186            == Some("a")
187            && second_page
188                .entries
189                .first()
190                .map(|entry| entry.object_key.as_str())
191                == Some("b"),
192        "listing continuation did not retain its immutable view",
193    )?;
194
195    let source = create(provider, &format!("{namespace}-source"), "create-s").await?;
196    put(provider, &source, "a", b"before", None, None).await?;
197    let snapshot = provider
198        .snapshot(source.clone(), Some("snapshot".into()))
199        .await?
200        .snapshot
201        .ok_or(ObjectsError::Unavailable)?;
202    put(provider, &source, "a", b"after", None, None).await?;
203    let fork = provider
204        .fork(
205            ReadTarget::Snapshot(snapshot.clone()),
206            format!("{namespace}-fork"),
207            Some("fork".into()),
208        )
209        .await?
210        .bucket
211        .ok_or(ObjectsError::Unavailable)?;
212    let forked = get(provider, ReadTarget::Bucket(fork), "a").await?;
213    ensure(
214        forked.body.as_ref() == b"before",
215        "fork did not retain snapshot state",
216    )?;
217    ensure(
218        provider
219            .destroy_snapshot(snapshot, Some("destroy-snapshot".into()))
220            .await?,
221        "existing snapshot was not destroyed",
222    )?;
223
224    let multipart_bucket = create(provider, &format!("{namespace}-multipart"), "create-m").await?;
225    let upload = provider
226        .create_multipart(
227            multipart_bucket.clone(),
228            "large".into(),
229            metadata(),
230            None,
231            Some("create-upload".into()),
232        )
233        .await?;
234    ensure(
235        get(
236            provider,
237            ReadTarget::Bucket(multipart_bucket.clone()),
238            "large",
239        )
240        .await
241            == Err(ObjectsError::NotFound),
242        "multipart staging became visible before completion",
243    )?;
244    let part = provider
245        .upload_part(
246            multipart_bucket.clone(),
247            "large".into(),
248            upload.upload_id.clone(),
249            1,
250            Bytes::from_static(b"body"),
251            Some("upload-part".into()),
252        )
253        .await?;
254    ensure(
255        provider
256            .list_parts(
257                multipart_bucket.clone(),
258                "large".into(),
259                upload.upload_id.clone(),
260            )
261            .await?
262            == vec![part.clone()],
263        "multipart part listing differed",
264    )?;
265    let completed = provider
266        .complete_multipart(
267            multipart_bucket.clone(),
268            "large".into(),
269            upload.upload_id,
270            vec![part],
271            Some("complete-upload".into()),
272        )
273        .await?;
274    ensure(completed.size == 4, "multipart size differed")?;
275    ensure(
276        get(
277            provider,
278            ReadTarget::Bucket(multipart_bucket.clone()),
279            "large",
280        )
281        .await?
282        .body
283        .as_ref()
284            == b"body",
285        "multipart body differed",
286    )?;
287    let aborted = provider
288        .create_multipart(
289            multipart_bucket.clone(),
290            "aborted".into(),
291            metadata(),
292            None,
293            Some("create-aborted".into()),
294        )
295        .await?;
296    ensure(
297        provider
298            .abort_multipart(
299                multipart_bucket.clone(),
300                "aborted".into(),
301                aborted.upload_id.clone(),
302                Some("abort".into()),
303            )
304            .await?,
305        "existing multipart upload was not aborted",
306    )?;
307    ensure(
308        !provider
309            .abort_multipart(
310                multipart_bucket.clone(),
311                "aborted".into(),
312                aborted.upload_id,
313                Some("abort-again".into()),
314            )
315            .await?,
316        "absent multipart upload was reported as aborted",
317    )?;
318
319    let marker = provider
320        .delete(
321            multipart_bucket.clone(),
322            "large".into(),
323            None,
324            None,
325            Some("delete-marker".into()),
326        )
327        .await?;
328    ensure(
329        marker.existed && marker.marker.is_some_and(|version| version.delete_marker),
330        "delete marker was not published",
331    )?;
332    ensure(
333        provider
334            .delete(
335                multipart_bucket.clone(),
336                "large".into(),
337                Some(completed.version_id),
338                None,
339                Some("delete-version".into()),
340            )
341            .await?
342            .existed,
343        "exact retained version was not deleted",
344    )?;
345    let empty = create(provider, &format!("{namespace}-empty"), "create-e").await?;
346    ensure(
347        provider
348            .delete_bucket(&empty, Some("delete-empty".into()))
349            .await?,
350        "empty bucket was not deleted",
351    )?;
352    ensure(
353        provider
354            .delete_bucket(&empty, Some("delete-empty".into()))
355            .await?,
356        "bucket deletion did not replay exactly",
357    )?;
358    Ok(())
359}
360
361async fn create<P: ObjectsProvider + ?Sized>(
362    provider: &P,
363    name: &str,
364    idempotency_key: &str,
365) -> Result<wire::BucketRef, ObjectsError> {
366    let first = provider
367        .create_bucket(name.into(), Some(idempotency_key.into()))
368        .await?;
369    let replay = provider
370        .create_bucket(name.into(), Some(idempotency_key.into()))
371        .await?;
372    ensure(first == replay, "bucket creation did not replay exactly")?;
373    let reference = first.bucket.ok_or(ObjectsError::Unavailable)?;
374    ensure(
375        provider.head_bucket(&reference).await?.bucket == Some(reference.clone()),
376        "bucket head did not preserve exact identity",
377    )?;
378    Ok(reference)
379}
380
381async fn put<P: ObjectsProvider + ?Sized>(
382    provider: &P,
383    bucket: &wire::BucketRef,
384    object_key: &str,
385    body: &'static [u8],
386    condition: Option<Condition>,
387    idempotency_key: Option<&str>,
388) -> Result<wire::ObjectVersion, ObjectsError> {
389    provider
390        .put(PutRequest {
391            bucket: bucket.clone(),
392            object_key: object_key.into(),
393            body: Bytes::from_static(body),
394            metadata: metadata(),
395            condition,
396            idempotency_key: idempotency_key.map(str::to_owned),
397        })
398        .await
399}
400
401async fn get<P: ObjectsProvider + ?Sized>(
402    provider: &P,
403    target: ReadTarget,
404    object_key: &str,
405) -> Result<crate::BufferedObject, ObjectsError> {
406    provider
407        .get(GetRequest {
408            target,
409            object_key: object_key.into(),
410            version_id: None,
411            range: None,
412            if_match: None,
413            if_none_match: None,
414            maximum_bytes: 1024,
415        })
416        .await
417}
418
419fn metadata() -> wire::ObjectMetadata {
420    wire::ObjectMetadata {
421        content_type: "application/octet-stream".into(),
422        ..wire::ObjectMetadata::default()
423    }
424}
425
426fn ensure(condition: bool, message: &'static str) -> Result<(), ObjectsError> {
427    if condition {
428        Ok(())
429    } else {
430        Err(ObjectsError::Invalid(message))
431    }
432}