Skip to main content

sui_castore/storage/
s3.rs

1//! S3-compatible object storage backend.
2//!
3//! Uses `object_store` crate — works with AWS S3, CloudFlare R2, MinIO,
4//! RustFS, Backblaze B2, and any S3-compatible endpoint.
5//!
6//! Breathable by design: S3 provides infinite elasticity.
7//! Combined with redb for ephemeral local metadata index.
8
9use async_trait::async_trait;
10use bytes::Bytes;
11use futures::StreamExt;
12use object_store::aws::AmazonS3Builder;
13use object_store::path::Path;
14use object_store::{ObjectStore, WriteMultipart};
15use tracing::{debug, warn};
16
17use super::nar_refs::{referrer_of, NarRefIndex, NarRefKey, NarRefScan};
18use super::nar_stream::{self, NarSource, NarStream};
19use super::{NarResidency, StorageBackend};
20use crate::StoreError;
21
22/// How many multipart parts may be in flight at once.
23///
24/// The peak this backend can reach is
25/// `(1 + S3_MAX_INFLIGHT_PARTS) * S3_PART_BYTES` plus one source chunk — a
26/// constant, not a function of NAR size. Two is enough to keep the pipe full
27/// without turning "bounded" into "bounded by something large".
28const S3_MAX_INFLIGHT_PARTS: usize = 2;
29
30/// Multipart part size. **5 MiB is S3's minimum for a non-final part** — a
31/// smaller value makes real S3 reject the upload, so this is not a free knob and
32/// deliberately does not reuse [`NAR_CHUNK_BYTES`](super::NAR_CHUNK_BYTES) (4 MiB).
33const S3_PART_BYTES: usize = 5 * 1024 * 1024;
34
35/// S3-compatible object storage backend.
36pub struct S3Storage {
37    store: Box<dyn ObjectStore>,
38    bucket: String,
39    region: String,
40    endpoint: Option<String>,
41}
42
43impl S3Storage {
44    /// Create a new S3 storage backend.
45    ///
46    /// Uses AWS default credential chain (IRSA, env vars, instance profile).
47    /// Set `endpoint` for non-AWS S3-compatible services (MinIO, RustFS, R2).
48    pub fn new(bucket: String, region: String, endpoint: Option<String>) -> Result<Self, StoreError> {
49        let mut builder = AmazonS3Builder::new()
50            .with_bucket_name(&bucket)
51            .with_region(&region);
52
53        if let Some(ep) = &endpoint {
54            builder = builder.with_endpoint(ep).with_allow_http(true);
55        }
56
57        let store = builder
58            .build()
59            .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 init failed: {e}"))))?;
60
61        Ok(Self {
62            store: Box::new(store),
63            bucket,
64            region,
65            endpoint,
66        })
67    }
68
69    /// Back this backend by an in-process object store.
70    ///
71    /// `object_store`'s `InMemory` implements the same [`ObjectStore`] trait a
72    /// live S3 does, so the key layout, the `LIST`-driven reverse index and the
73    /// delete semantics are exercised for real rather than asserted about. What
74    /// it does **not** prove is anything S3-specific — multipart minimums,
75    /// eventual consistency, IAM — so it is a unit seam, not an S3 integration
76    /// test.
77    #[cfg(test)]
78    #[must_use]
79    fn in_memory() -> Self {
80        Self {
81            store: Box::new(object_store::memory::InMemory::new()),
82            bucket: "in-memory".to_string(),
83            region: "none".to_string(),
84            endpoint: None,
85        }
86    }
87
88    /// Return the bucket name.
89    #[must_use]
90    pub fn bucket(&self) -> &str {
91        &self.bucket
92    }
93
94    /// Return the region.
95    #[must_use]
96    pub fn region(&self) -> &str {
97        &self.region
98    }
99
100    /// Return the custom endpoint, if any.
101    #[must_use]
102    pub fn endpoint(&self) -> Option<&str> {
103        self.endpoint.as_deref()
104    }
105
106    /// Delete one object, treating "already gone" as success.
107    ///
108    /// Every delete on this backend is idempotent by contract: a GC that
109    /// re-reaps a key it already reaped must not fail the run.
110    async fn delete_object(&self, path: &Path) -> Result<(), StoreError> {
111        match self.store.delete(path).await {
112            Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
113            Err(e) => Err(StoreError::Io(std::io::Error::other(format!("S3 delete: {e}")))),
114        }
115    }
116}
117
118#[async_trait]
119impl StorageBackend for S3Storage {
120    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
121        let path = Path::from(format!("{hash}.narinfo"));
122        match self.store.get(&path).await {
123            Ok(result) => {
124                let bytes = result
125                    .bytes()
126                    .await
127                    .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 read: {e}"))))?;
128                Ok(Some(
129                    String::from_utf8(bytes.to_vec())
130                        .map_err(|e| StoreError::NarInfo(format!("Invalid UTF-8: {e}")))?,
131                ))
132            }
133            Err(object_store::Error::NotFound { .. }) => Ok(None),
134            Err(e) => Err(StoreError::Io(std::io::Error::other(format!("S3 get: {e}")))),
135        }
136    }
137
138    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
139        let path = Path::from(format!("{hash}.narinfo"));
140        self.store
141            .put(&path, Bytes::from(content.to_string()).into())
142            .await
143            .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 put: {e}"))))?;
144        debug!(hash = %hash, "Stored narinfo in S3");
145        Ok(())
146    }
147
148    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
149        self.delete_object(&Path::from(format!("{hash}.narinfo"))).await
150    }
151
152    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
153        self.delete_object(&Path::from(nar_path)).await
154    }
155
156    fn nar_ref_index(&self) -> &dyn NarRefIndex {
157        self
158    }
159
160    async fn get_nar(&self, nar_path: &str) -> Result<Option<Vec<u8>>, StoreError> {
161        // ONE code path: the whole-value verb is the streaming verb drained.
162        match self.get_nar_stream(nar_path).await? {
163            Some(s) => Ok(Some(nar_stream::collect_nar(s, None).await?)),
164            None => Ok(None),
165        }
166    }
167
168    async fn put_nar(&self, nar_path: &str, data: &[u8]) -> Result<(), StoreError> {
169        self.put_nar_stream(nar_path, &nar_stream::BytesNarSource::from(data)).await
170    }
171
172    /// **O(constant).** Reads come off the object stream; writes go up as
173    /// bounded multipart parts with at most [`S3_MAX_INFLIGHT_PARTS`] in flight.
174    fn nar_residency(&self) -> NarResidency {
175        NarResidency::Streaming
176    }
177
178    async fn get_nar_stream(&self, nar_path: &str) -> Result<Option<NarStream>, StoreError> {
179        let path = Path::from(nar_path);
180        match self.store.get(&path).await {
181            Ok(result) => Ok(Some(
182                result
183                    .into_stream()
184                    .map(|r| {
185                        r.map_err(|e| {
186                            StoreError::Io(std::io::Error::other(format!("S3 read: {e}")))
187                        })
188                    })
189                    .boxed(),
190            )),
191            Err(object_store::Error::NotFound { .. }) => Ok(None),
192            Err(e) => Err(StoreError::Io(std::io::Error::other(format!("S3 get: {e}")))),
193        }
194    }
195
196    /// Upload as a bounded multipart, aborting on any fault.
197    ///
198    /// The abort matters for the same reason the local tier renames: a
199    /// half-finished multipart is not a truncated object (S3 only publishes on
200    /// `complete`), but it *is* billable storage that lingers until a lifecycle
201    /// rule reaps it. Aborting turns a failed push into nothing at all.
202    async fn put_nar_stream(&self, nar_path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
203        let path = Path::from(nar_path);
204        let upload = self
205            .store
206            .put_multipart(&path)
207            .await
208            .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 multipart init: {e}"))))?;
209        let mut writer = WriteMultipart::new_with_chunk_size(upload, S3_PART_BYTES);
210
211        let mut written: u64 = 0;
212        let pump = async {
213            let mut stream = src.open().await?;
214            while let Some(chunk) = stream.next().await {
215                let chunk: Bytes = chunk?;
216                // Back-pressure BEFORE buffering the next part, so the number of
217                // parts resident is capped rather than "however fast the source
218                // produces". Without this, `write`/`put` spawn uploads eagerly
219                // and a fast local source would queue the whole NAR in flight.
220                writer.wait_for_capacity(S3_MAX_INFLIGHT_PARTS).await.map_err(|e| {
221                    StoreError::Io(std::io::Error::other(format!("S3 multipart backpressure: {e}")))
222                })?;
223                written += chunk.len() as u64;
224                writer.put(chunk);
225            }
226            Ok::<(), StoreError>(())
227        }
228        .await;
229
230        match pump {
231            Ok(()) => {
232                writer.finish().await.map_err(|e| {
233                    StoreError::Io(std::io::Error::other(format!("S3 multipart complete: {e}")))
234                })?;
235                debug!(path = %nar_path, size = written, "Stored NAR in S3 (multipart)");
236                Ok(())
237            }
238            Err(e) => {
239                if let Err(abort_err) = writer.abort().await {
240                    warn!(
241                        path = %nar_path, error = %abort_err,
242                        "S3 multipart abort failed — orphaned parts may linger until a \
243                         lifecycle rule reaps them",
244                    );
245                }
246                Err(e)
247            }
248        }
249    }
250
251    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
252        use futures::TryStreamExt;
253
254        let prefix = Path::from("");
255        let mut hashes = Vec::new();
256
257        let mut list_stream = self.store.list(Some(&prefix));
258
259        while let Some(meta) = list_stream
260            .try_next()
261            .await
262            .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 list: {e}"))))?
263        {
264            let key = meta.location.to_string();
265            if let Some(hash) = key.strip_suffix(".narinfo") {
266                hashes.push(hash.to_string());
267            }
268        }
269
270        debug!(count = hashes.len(), "Listed narinfos from S3");
271        Ok(hashes)
272    }
273}
274
275/// The reverse index as one zero-byte object per edge, under `nar-refs/`.
276///
277/// A `LIST` bounded by the edge prefix *is* the referrer set. Recording is a
278/// blind `PUT` of a key that names its own content, so it is idempotent and two
279/// concurrent pushes cannot lose an edge — which a read-modify-write of a
280/// set-valued object could, and S3 gives no compare-and-swap to prevent it with.
281#[async_trait]
282impl NarRefIndex for S3Storage {
283    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
284        let path = Path::from(NarRefKey { nar_path, hash }.to_string());
285        self.store
286            .put(&path, Bytes::new().into())
287            .await
288            .map_err(|e| StoreError::Io(std::io::Error::other(format!("S3 put nar-ref: {e}"))))?;
289        Ok(())
290    }
291
292    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
293        self.delete_object(&Path::from(NarRefKey { nar_path, hash }.to_string())).await
294    }
295
296    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
297        use futures::TryStreamExt;
298
299        let scan = NarRefScan { nar_path };
300        let prefix = Path::from(scan.to_string());
301        let mut list = self.store.list(Some(&prefix));
302        let mut hashes = Vec::new();
303        while let Some(meta) = list.try_next().await.map_err(|e| {
304            StoreError::Io(std::io::Error::other(format!("S3 list nar-refs: {e}")))
305        })? {
306            let key = meta.location.to_string();
307            if let Some(hash) = referrer_of(&scan, &key) {
308                hashes.push(hash.to_string());
309            }
310        }
311        hashes.sort();
312        hashes.dedup();
313        Ok(hashes)
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn s3_storage_accessors() {
323        let storage = S3Storage::new(
324            "my-bucket".to_string(),
325            "us-east-1".to_string(),
326            Some("http://localhost:9000".to_string()),
327        )
328        .unwrap();
329        assert_eq!(storage.bucket(), "my-bucket");
330        assert_eq!(storage.region(), "us-east-1");
331        assert_eq!(storage.endpoint(), Some("http://localhost:9000"));
332    }
333
334    #[test]
335    fn s3_storage_no_endpoint() {
336        // This may fail without valid AWS creds — skip in CI
337        let result = S3Storage::new("bucket".to_string(), "eu-west-1".to_string(), None);
338        // Just verify construction doesn't panic
339        assert!(result.is_ok());
340    }
341
342    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/narhash.nar.xz\n\
343                           Compression: xz\nFileHash: sha256:aaa\nFileSize: 100\n\
344                           NarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
345    const ADVERTISED: &str = "nar/narhash.nar.xz";
346
347    /// `delete` takes the object the narinfo names, and leaves a
348    /// store-hash-shaped key it never named.
349    #[tokio::test]
350    async fn delete_resolves_the_nar_from_the_narinfo_instead_of_guessing() {
351        let s3 = S3Storage::in_memory();
352        s3.put_narinfo("storehash", NARINFO).await.unwrap();
353        s3.put_nar(ADVERTISED, b"the real nar").await.unwrap();
354        s3.put_nar("nar/storehash.nar.zst", b"someone else's nar").await.unwrap();
355
356        s3.delete("storehash").await.unwrap();
357
358        assert!(s3.get_narinfo("storehash").await.unwrap().is_none());
359        assert!(s3.get_nar(ADVERTISED).await.unwrap().is_none());
360        assert_eq!(
361            s3.get_nar("nar/storehash.nar.zst").await.unwrap().unwrap(),
362            b"someone else's nar",
363        );
364    }
365
366    /// The `LIST`-driven reverse index round-trips, and a co-referenced NAR
367    /// survives the first delete.
368    #[tokio::test]
369    async fn the_object_index_holds_every_referrer() {
370        let s3 = S3Storage::in_memory();
371        s3.put_narinfo("pathA", NARINFO).await.unwrap();
372        s3.put_narinfo("pathB", NARINFO).await.unwrap();
373        s3.put_nar(ADVERTISED, b"shared").await.unwrap();
374        assert_eq!(
375            s3.nar_ref_index().referrers(ADVERTISED).await.unwrap(),
376            vec!["pathA".to_string(), "pathB".to_string()],
377        );
378
379        s3.delete("pathA").await.unwrap();
380        assert_eq!(
381            s3.nar_ref_index().referrers(ADVERTISED).await.unwrap(),
382            vec!["pathB".to_string()],
383        );
384        assert!(s3.get_nar(ADVERTISED).await.unwrap().is_some(), "pathB still advertises it");
385
386        s3.delete("pathB").await.unwrap();
387        assert!(s3.nar_ref_index().referrers(ADVERTISED).await.unwrap().is_empty());
388        assert!(s3.get_nar(ADVERTISED).await.unwrap().is_none());
389    }
390
391    /// Edge objects live under `nar-refs/`, which must not be mistaken for a
392    /// narinfo by the bucket-wide listing.
393    #[tokio::test]
394    async fn edge_objects_do_not_pollute_the_narinfo_listing() {
395        let s3 = S3Storage::in_memory();
396        s3.put_narinfo("storehash", NARINFO).await.unwrap();
397        assert_eq!(s3.list_narinfos().await.unwrap(), vec!["storehash".to_string()]);
398    }
399}