1use 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
12const BLOB_PREFIX: &str = "blobs/";
15
16pub struct S3Store {
17 client: Client,
18 bucket: String,
19}
20
21impl S3Store {
22 pub fn new(client: Client, bucket: impl Into<String>) -> Self {
25 Self {
26 client,
27 bucket: bucket.into(),
28 }
29 }
30
31 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 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#[derive(Debug, PartialEq, Eq)]
96enum Precondition {
97 IfAbsent,
99 IfMatch(String),
101}
102
103fn 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
115fn is_precondition_failed(code: Option<&str>) -> bool {
118 matches!(code, Some("PreconditionFailed"))
119}
120
121fn 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 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 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 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 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 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 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) } 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(()) }
398}
399
400fn 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
412fn 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 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 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 assert_eq!(next_continuation(Some(true), None), None);
493 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 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 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}