Skip to main content

gonzalo_store_s3/
lib.rs

1//! S3-compatible object-store substrate. One JSON object per record at
2//! key `namespace/collection/id.json`.
3
4use async_trait::async_trait;
5use aws_sdk_s3::Client;
6use aws_sdk_s3::error::ProvideErrorMetadata;
7use gonzalo_core::{
8    BlobStore, ContentHash, CoreError, DeleteResult, KeyPrefix, PutResult, Record, RecordKey,
9    Result, Revision, decode_segment, object_key, store::Conflict,
10};
11
12/// Key prefix under which content-addressed blobs live (`blobs/<hash>`), kept
13/// separate from record objects (`namespace/collection/id.json`).
14const BLOB_PREFIX: &str = "blobs/";
15
16pub struct S3Store {
17    client: Client,
18    bucket: String,
19}
20
21impl S3Store {
22    /// Build a store from an explicit client and bucket. Use
23    /// [`S3Store::connect`] for the common env/endpoint path.
24    pub fn new(client: Client, bucket: impl Into<String>) -> Self {
25        Self {
26            client,
27            bucket: bucket.into(),
28        }
29    }
30
31    /// Connect using the ambient AWS config (env, profile, IRSA, etc.). If
32    /// `endpoint` is `Some`, target an S3-compatible server (MinIO/Garage, etc.)
33    /// with path-style addressing; if `region` is `Some`, override the ambient
34    /// region (else the AWS env/profile region applies).
35    pub async fn connect(
36        bucket: impl Into<String>,
37        endpoint: Option<String>,
38        region: Option<String>,
39    ) -> Self {
40        let base = aws_config::load_from_env().await;
41        let mut builder = aws_sdk_s3::config::Builder::from(&base);
42        if let Some(ep) = endpoint {
43            builder = builder.endpoint_url(ep).force_path_style(true);
44        }
45        if let Some(r) = region {
46            builder = builder.region(aws_sdk_s3::config::Region::new(r));
47        }
48        let client = Client::from_conf(builder.build());
49        Self::new(client, bucket)
50    }
51
52    async fn read(&self, key: &RecordKey) -> Result<Option<Record>> {
53        Ok(self.read_with_etag(key).await?.map(|(rec, _)| rec))
54    }
55
56    /// Like [`read`](Self::read) but also returns the object's S3 ETag, which
57    /// [`put`](gonzalo_core::Store::put) feeds back as an `If-Match` precondition
58    /// to make the compare-and-swap atomic (closing the read-then-write TOCTOU).
59    async fn read_with_etag(&self, key: &RecordKey) -> Result<Option<(Record, String)>> {
60        let obj = object_key(key);
61        match self
62            .client
63            .get_object()
64            .bucket(&self.bucket)
65            .key(&obj)
66            .send()
67            .await
68        {
69            Ok(resp) => {
70                let etag = resp.e_tag().unwrap_or_default().to_string();
71                let data = resp
72                    .body
73                    .collect()
74                    .await
75                    .map_err(|e| CoreError::Backend(e.to_string()))?
76                    .into_bytes();
77                let record =
78                    serde_json::from_slice(&data).map_err(|e| CoreError::Serde(e.to_string()))?;
79                Ok(Some((record, etag)))
80            }
81            Err(e) => {
82                let svc = e.into_service_error();
83                if svc.is_no_such_key() {
84                    Ok(None)
85                } else {
86                    Err(CoreError::Backend(svc.to_string()))
87                }
88            }
89        }
90    }
91}
92
93/// The S3 precondition that enforces OCC atomically at write time, chosen from
94/// the caller's `expected` revision and the ETag read for the object.
95#[derive(Debug, PartialEq, Eq)]
96enum Precondition {
97    /// Create only if the object is still absent (`If-None-Match: *`).
98    IfAbsent,
99    /// Replace only if the object still carries this ETag (`If-Match: <etag>`).
100    IfMatch(String),
101}
102
103/// Map `(expected, etag)` to the write precondition. A create (`expected =
104/// None`) requires the object to still be absent; an update (`expected =
105/// Some`, so the object was read with an `etag`) requires that exact ETag. The
106/// business-level OCC check runs first, so the `Some`-without-etag case can't
107/// reach here; `IfAbsent` is a safe total default for it.
108fn precondition(expected: &Option<Revision>, etag: Option<&str>) -> Precondition {
109    match (expected, etag) {
110        (Some(_), Some(tag)) => Precondition::IfMatch(tag.to_string()),
111        _ => Precondition::IfAbsent,
112    }
113}
114
115/// Whether an S3 error code denotes a failed write precondition (HTTP 412) —
116/// i.e. a concurrent writer won the race, which OCC surfaces as a `Conflict`.
117fn is_precondition_failed(code: Option<&str>) -> bool {
118    matches!(code, Some("PreconditionFailed"))
119}
120
121/// Decide the continuation token for the next `list_objects_v2` page, driving
122/// pagination off token *presence* rather than the `is_truncated` flag. A
123/// well-behaved backend only returns a token when there is more to fetch, but a
124/// misbehaving one can report `is_truncated = true` yet omit the token; keying
125/// off the flag would then re-request page 1 forever. So: if a token is present
126/// we continue with it, otherwise we terminate — regardless of `is_truncated`.
127/// This guarantees the pagination loop always makes progress or stops.
128fn next_continuation(_is_truncated: Option<bool>, token: Option<&str>) -> Option<String> {
129    token.map(str::to_string)
130}
131
132#[async_trait]
133impl gonzalo_core::Store for S3Store {
134    async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
135        self.read(key).await
136    }
137
138    async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
139        // Read the current object *and its ETag*, then make the write itself
140        // conditional on that ETag (`If-Match`) or on absence (`If-None-Match:
141        // *`). The business OCC check below is a fast pre-check; the S3
142        // precondition is what makes the compare-and-swap atomic, so a writer
143        // that slips in between our read and write loses the race with a 412
144        // rather than silently clobbering — closing the read-then-write TOCTOU.
145        let current = self.read_with_etag(&record.key).await?;
146        let current_rev = current.as_ref().map(|(r, _)| r.revision.clone());
147        if current_rev != expected {
148            if let Some((cur, _)) = current {
149                return Ok(PutResult::Conflict(Box::new(Conflict {
150                    key: record.key.clone(),
151                    expected,
152                    current: cur,
153                })));
154            }
155            return Err(CoreError::NotFound(record.key.clone()));
156        }
157
158        let bytes =
159            serde_json::to_vec_pretty(&record).map_err(|e| CoreError::Serde(e.to_string()))?;
160        let mut req = self
161            .client
162            .put_object()
163            .bucket(&self.bucket)
164            .key(object_key(&record.key))
165            .body(bytes.into());
166        req = match precondition(&expected, current.as_ref().map(|(_, tag)| tag.as_str())) {
167            Precondition::IfAbsent => req.if_none_match("*"),
168            Precondition::IfMatch(tag) => req.if_match(tag),
169        };
170
171        match req.send().await {
172            Ok(_) => Ok(PutResult::Committed(record.revision)),
173            Err(e) => {
174                let svc = e.into_service_error();
175                // A 412 means a concurrent writer changed the object between our
176                // read and conditional write: re-read for the fresh state and
177                // surface the normal, recoverable Conflict (NotFound if it was
178                // concurrently deleted).
179                if is_precondition_failed(svc.code()) {
180                    return match self.read(&record.key).await? {
181                        Some(cur) => Ok(PutResult::Conflict(Box::new(Conflict {
182                            key: record.key.clone(),
183                            expected,
184                            current: cur,
185                        }))),
186                        None => Err(CoreError::NotFound(record.key.clone())),
187                    };
188                }
189                Err(CoreError::Backend(svc.to_string()))
190            }
191        }
192    }
193
194    async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
195        let mut s3_prefix = String::new();
196        if let Some(ns) = &prefix.namespace {
197            s3_prefix.push_str(&gonzalo_core::segment(ns));
198            s3_prefix.push('/');
199            if let Some(col) = &prefix.collection {
200                s3_prefix.push_str(&gonzalo_core::segment(col));
201                s3_prefix.push('/');
202            }
203        }
204        let mut out = Vec::new();
205        let mut continuation: Option<String> = None;
206        loop {
207            let mut req = self.client.list_objects_v2().bucket(&self.bucket);
208            if !s3_prefix.is_empty() {
209                req = req.prefix(&s3_prefix);
210            }
211            if let Some(token) = &continuation {
212                req = req.continuation_token(token);
213            }
214            let resp = req
215                .send()
216                .await
217                .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
218            for obj in resp.contents() {
219                if let Some(k) = obj.key()
220                    && let Some(key) = parse_object_key(k)
221                    && prefix.matches(&key)
222                {
223                    out.push(key);
224                }
225            }
226            match next_continuation(resp.is_truncated(), resp.next_continuation_token()) {
227                Some(token) => continuation = Some(token),
228                None => break,
229            }
230        }
231        Ok(out)
232    }
233
234    async fn delete(&self, key: &RecordKey, expected: Option<Revision>) -> Result<DeleteResult> {
235        // Unconditional delete (`expected = None`): S3 delete of an absent key
236        // already succeeds, so this is an idempotent `Deleted`.
237        let Some(want) = expected.clone() else {
238            self.client
239                .delete_object()
240                .bucket(&self.bucket)
241                .key(object_key(key))
242                .send()
243                .await
244                .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
245            return Ok(DeleteResult::Deleted);
246        };
247
248        // Conditional delete: read the current object *and its ETag*, then make
249        // the removal conditional on that ETag (`If-Match`) so a writer that
250        // slips in between our read and delete loses the race with a 412 — the
251        // same TOCTOU close as `put`. An already-absent key is an idempotent
252        // `Deleted` (the revision is already gone — nothing to conflict on).
253        let Some((current, etag)) = self.read_with_etag(key).await? else {
254            return Ok(DeleteResult::Deleted);
255        };
256        if current.revision != want {
257            return Ok(DeleteResult::Conflict(Box::new(Conflict {
258                key: key.clone(),
259                expected,
260                current,
261            })));
262        }
263
264        match self
265            .client
266            .delete_object()
267            .bucket(&self.bucket)
268            .key(object_key(key))
269            .if_match(etag)
270            .send()
271            .await
272        {
273            Ok(_) => Ok(DeleteResult::Deleted),
274            Err(e) => {
275                let svc = e.into_service_error();
276                // A 412 means a concurrent writer changed the object between our
277                // read and conditional delete: re-read for the fresh state and
278                // surface the normal, recoverable Conflict (or `Deleted` if it
279                // was concurrently removed — the revision is already gone).
280                if is_precondition_failed(svc.code()) {
281                    return match self.read(key).await? {
282                        Some(cur) => Ok(DeleteResult::Conflict(Box::new(Conflict {
283                            key: key.clone(),
284                            expected,
285                            current: cur,
286                        }))),
287                        None => Ok(DeleteResult::Deleted),
288                    };
289                }
290                Err(CoreError::Backend(svc.to_string()))
291            }
292        }
293    }
294}
295
296#[async_trait]
297impl BlobStore for S3Store {
298    async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
299        let hash = ContentHash::of(content);
300        let key = format!("{BLOB_PREFIX}{}", hash.0);
301        // Content-addressed + write-if-absent: an existing blob at this key is
302        // byte-identical, so `If-None-Match: *` turns a re-upload into a no-op
303        // (a 412 just means it's already stored). Idempotent and bandwidth-cheap.
304        match self
305            .client
306            .put_object()
307            .bucket(&self.bucket)
308            .key(&key)
309            .if_none_match("*")
310            .body(content.to_vec().into())
311            .send()
312            .await
313        {
314            Ok(_) => Ok(hash),
315            Err(e) => {
316                let svc = e.into_service_error();
317                if is_precondition_failed(svc.code()) {
318                    Ok(hash) // already present — no-op
319                } else {
320                    Err(CoreError::Backend(svc.to_string()))
321                }
322            }
323        }
324    }
325
326    async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
327        let key = format!("{BLOB_PREFIX}{}", hash.0);
328        match self
329            .client
330            .get_object()
331            .bucket(&self.bucket)
332            .key(&key)
333            .send()
334            .await
335        {
336            Ok(resp) => {
337                let data = resp
338                    .body
339                    .collect()
340                    .await
341                    .map_err(|e| CoreError::Backend(e.to_string()))?
342                    .into_bytes();
343                Ok(Some(data.to_vec()))
344            }
345            Err(e) => {
346                let svc = e.into_service_error();
347                if svc.is_no_such_key() {
348                    Ok(None)
349                } else {
350                    Err(CoreError::Backend(svc.to_string()))
351                }
352            }
353        }
354    }
355
356    async fn list_blobs(&self) -> Result<Vec<ContentHash>> {
357        let mut out = Vec::new();
358        let mut continuation: Option<String> = None;
359        loop {
360            let mut req = self
361                .client
362                .list_objects_v2()
363                .bucket(&self.bucket)
364                .prefix(BLOB_PREFIX);
365            if let Some(token) = &continuation {
366                req = req.continuation_token(token);
367            }
368            let resp = req
369                .send()
370                .await
371                .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
372            for obj in resp.contents() {
373                if let Some(k) = obj.key()
374                    && let Some(hash) = blob_hash_from_key(k)
375                {
376                    out.push(hash);
377                }
378            }
379            match next_continuation(resp.is_truncated(), resp.next_continuation_token()) {
380                Some(token) => continuation = Some(token),
381                None => break,
382            }
383        }
384        Ok(out)
385    }
386
387    async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
388        let key = format!("{BLOB_PREFIX}{}", hash.0);
389        self.client
390            .delete_object()
391            .bucket(&self.bucket)
392            .key(&key)
393            .send()
394            .await
395            .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
396        Ok(()) // S3 delete of an absent key succeeds — idempotent
397    }
398}
399
400/// Parse a blob object key `blobs/<hash>` back into a [`ContentHash`]. Returns
401/// `None` for anything that isn't exactly one segment under `blobs/` — so a
402/// record object that happens to live in a `blobs` namespace
403/// (`blobs/<col>/<id>.json`, which still has a `/`) is never mistaken for a blob.
404fn blob_hash_from_key(key: &str) -> Option<ContentHash> {
405    let rest = key.strip_prefix(BLOB_PREFIX)?;
406    if rest.is_empty() || rest.contains('/') || rest.contains('.') {
407        return None;
408    }
409    Some(ContentHash(rest.to_string()))
410}
411
412/// Parse `namespace/collection/id.json` back into a `RecordKey`, decoding each
413/// component (the exact inverse of `object_key`). Returns `None` for objects
414/// that don't match the expected three-part `.json` shape. Since every literal
415/// `/` in a component is escaped, splitting on `/` always yields exactly the
416/// three separators' worth of parts.
417fn parse_object_key(s: &str) -> Option<RecordKey> {
418    let rest = s.strip_suffix(".json")?;
419    let parts: Vec<&str> = rest.split('/').collect();
420    if parts.len() == 3 {
421        Some(RecordKey::new(
422            decode_segment(parts[0]),
423            decode_segment(parts[1]),
424            decode_segment(parts[2]),
425        ))
426    } else {
427        None
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn parse_roundtrips_object_key() {
437        let k = RecordKey::new("ns", "col", "id");
438        assert_eq!(parse_object_key(&object_key(&k)), Some(k));
439    }
440
441    #[test]
442    fn parse_roundtrips_special_char_keys() {
443        // Keys with `.`, `/`, spaces, and `%` must survive the object-key
444        // round-trip and stay distinct (no collision onto one object).
445        for k in [
446            RecordKey::new("a/b", "c.d", "e/f"),
447            RecordKey::new("ns", "col", "v1.0"),
448            RecordKey::new("ns", "col", "v1_0"),
449            RecordKey::new("50% off", "café", "🚀"),
450        ] {
451            assert_eq!(parse_object_key(&object_key(&k)), Some(k));
452        }
453        assert_ne!(
454            object_key(&RecordKey::new("ns", "col", "v1.0")),
455            object_key(&RecordKey::new("ns", "col", "v1_0")),
456        );
457    }
458
459    #[test]
460    fn parse_rejects_non_json_or_wrong_depth() {
461        assert_eq!(parse_object_key("a/b/c.txt"), None);
462        assert_eq!(parse_object_key("a/b.json"), None);
463        assert_eq!(parse_object_key("a/b/c/d.json"), None);
464    }
465
466    fn rev() -> Revision {
467        Revision::initial(b"x")
468    }
469
470    #[test]
471    fn create_uses_if_absent() {
472        // expected = None → create-only, regardless of any etag.
473        assert_eq!(precondition(&None, None), Precondition::IfAbsent);
474        assert_eq!(
475            precondition(&None, Some("\"etag\"")),
476            Precondition::IfAbsent
477        );
478    }
479
480    #[test]
481    fn update_uses_if_match_on_the_read_etag() {
482        assert_eq!(
483            precondition(&Some(rev()), Some("\"abc123\"")),
484            Precondition::IfMatch("\"abc123\"".to_string())
485        );
486    }
487
488    #[test]
489    fn next_continuation_terminates_when_token_absent() {
490        // The pagination bug: a backend reports more pages but omits the token.
491        // Keying off `is_truncated` would loop forever; we must terminate.
492        assert_eq!(next_continuation(Some(true), None), None);
493        // No token, not truncated → also terminate (the normal last page).
494        assert_eq!(next_continuation(Some(false), None), None);
495        assert_eq!(next_continuation(None, None), None);
496    }
497
498    #[test]
499    fn next_continuation_advances_when_token_present() {
500        // A token means fetch the next page, regardless of the flag's value.
501        assert_eq!(
502            next_continuation(Some(true), Some("t1")),
503            Some("t1".to_string())
504        );
505        assert_eq!(
506            next_continuation(Some(false), Some("t2")),
507            Some("t2".to_string())
508        );
509        assert_eq!(next_continuation(None, Some("t3")), Some("t3".to_string()));
510    }
511
512    #[test]
513    fn precondition_failed_is_classified_by_code() {
514        assert!(is_precondition_failed(Some("PreconditionFailed")));
515        assert!(!is_precondition_failed(Some("AccessDenied")));
516        assert!(!is_precondition_failed(None));
517    }
518
519    #[test]
520    fn blob_key_roundtrips_and_rejects_records() {
521        let h = ContentHash::of(b"slice bytes");
522        let key = format!("{BLOB_PREFIX}{}", h.0);
523        assert_eq!(blob_hash_from_key(&key), Some(h));
524        // A record object under a `blobs` namespace has a nested path + `.json`
525        // and must never be read back as a blob hash.
526        assert_eq!(blob_hash_from_key("blobs/col/id.json"), None);
527        assert_eq!(blob_hash_from_key("ns/col/id.json"), None);
528        assert_eq!(blob_hash_from_key("blobs/"), None);
529    }
530}