1use 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
22const S3_MAX_INFLIGHT_PARTS: usize = 2;
29
30const S3_PART_BYTES: usize = 5 * 1024 * 1024;
34
35pub struct S3Storage {
37 store: Box<dyn ObjectStore>,
38 bucket: String,
39 region: String,
40 endpoint: Option<String>,
41}
42
43impl S3Storage {
44 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(®ion);
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 #[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 #[must_use]
90 pub fn bucket(&self) -> &str {
91 &self.bucket
92 }
93
94 #[must_use]
96 pub fn region(&self) -> &str {
97 &self.region
98 }
99
100 #[must_use]
102 pub fn endpoint(&self) -> Option<&str> {
103 self.endpoint.as_deref()
104 }
105
106 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 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 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 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 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#[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 let result = S3Storage::new("bucket".to_string(), "eu-west-1".to_string(), None);
338 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 #[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 #[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 #[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}