Skip to main content

autumn_storage_s3/
lib.rs

1//! S3-compatible [`BlobStore`] plugin for autumn-web.
2//!
3//! # Quick start
4//!
5//! ```toml
6//! [dependencies]
7//! autumn-web     = { version = "0.3", features = ["storage", "multipart"] }
8//! autumn-storage-s3 = "0.3"
9//! ```
10//!
11//! ```rust,ignore
12//! use autumn_storage_s3::S3BlobStore;
13//!
14//! #[tokio::main]
15//! async fn main() {
16//!     let config = autumn_web::config::TomlEnvConfigLoader::new()
17//!         .load()
18//!         .await
19//!         .expect("failed to load config");
20//!
21//!     let store = S3BlobStore::from_config(&config.storage.s3)
22//!         .await
23//!         .expect("failed to build S3 store");
24//!
25//!     autumn_web::app()
26//!         .with_blob_store(store)
27//!         .run()
28//!         .await;
29//! }
30//! ```
31
32use std::sync::Arc;
33use std::time::Duration;
34
35use autumn_web::storage::{
36    Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream, PresignPutResult,
37    StorageS3Config, validate_key,
38};
39use aws_credential_types::Credentials;
40use aws_sdk_s3::Client;
41use aws_sdk_s3::config::{BehaviorVersion, Region};
42use aws_sdk_s3::presigning::PresigningConfig;
43use bytes::Bytes;
44use futures::StreamExt as _;
45use thiserror::Error;
46
47/// Errors returned during [`S3BlobStore`] construction.
48#[derive(Debug, Error)]
49#[non_exhaustive]
50pub enum S3BlobStoreError {
51    /// `[storage.s3].bucket` is not set.
52    #[error("storage.s3.bucket is required but not set")]
53    MissingBucket,
54    /// `[storage.s3].region` is not set.
55    #[error("storage.s3.region is required but not set")]
56    MissingRegion,
57    /// Exactly one of `access_key_id_env` / `secret_access_key_env` is set.
58    /// Both must be provided together, or neither (to use the AWS default
59    /// credential chain).
60    #[error(
61        "partial credential configuration: access_key_id_env and \
62         secret_access_key_env must both be set, or neither"
63    )]
64    PartialCredentialConfig,
65    /// A credential env var listed in config is not present in the process
66    /// environment.
67    #[error("credential env var '{var}' is not set in the environment")]
68    MissingCredentialEnvVar {
69        /// Name of the missing environment variable.
70        var: String,
71    },
72}
73
74#[derive(Debug, Clone)]
75struct S3Options {
76    provider_id: String,
77    bucket: String,
78}
79
80/// S3-compatible [`BlobStore`] backed by
81/// `aws-sdk-s3`.
82///
83/// Supports AWS S3, Cloudflare R2, `MinIO`, `DigitalOcean` Spaces, and Wasabi.
84/// Construct with [`S3BlobStore::from_config`].
85#[derive(Debug, Clone)]
86pub struct S3BlobStore {
87    client: Client,
88    presign_client: Client,
89    options: Arc<S3Options>,
90}
91
92impl S3BlobStore {
93    /// Build an `S3BlobStore` from the `[storage.s3]` config section.
94    ///
95    /// Credentials resolve from `access_key_id_env` /
96    /// `secret_access_key_env` when **both** are set; if neither is set the
97    /// AWS default credential chain (environment variables, instance metadata,
98    /// IAM roles) is used. Providing exactly one is a configuration error —
99    /// [`S3BlobStoreError::PartialCredentialConfig`] is returned immediately.
100    ///
101    /// The [`provider_id`](BlobStore::provider_id) recorded on every produced
102    /// [`Blob`] defaults to `"s3"`. Override it with
103    /// [`with_provider_id`](Self::with_provider_id) if you want blobs to carry
104    /// the same [`StorageConfig::default_provider`](autumn_web::storage::StorageConfig)
105    /// label used by other backends in your deployment.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`S3BlobStoreError`] when required config fields are absent,
110    /// credential env vars are partially configured, or a listed credential
111    /// env var is missing from the environment.
112    pub async fn from_config(cfg: &StorageS3Config) -> Result<Self, S3BlobStoreError> {
113        let bucket = cfg
114            .bucket
115            .as_deref()
116            .filter(|b| !b.trim().is_empty())
117            .ok_or(S3BlobStoreError::MissingBucket)?
118            .to_owned();
119        let region = cfg
120            .region
121            .as_deref()
122            .filter(|r| !r.trim().is_empty())
123            .ok_or(S3BlobStoreError::MissingRegion)?
124            .to_owned();
125
126        if cfg.access_key_id_env.is_some() != cfg.secret_access_key_env.is_some() {
127            return Err(S3BlobStoreError::PartialCredentialConfig);
128        }
129
130        let presign_endpoint = cfg.public_base_url.as_deref().or(cfg.endpoint.as_deref());
131        let (client, presign_client) = if let (Some(key_env), Some(secret_env)) =
132            (&cfg.access_key_id_env, &cfg.secret_access_key_env)
133        {
134            let key =
135                std::env::var(key_env).map_err(|_| S3BlobStoreError::MissingCredentialEnvVar {
136                    var: key_env.clone(),
137                })?;
138            let secret = std::env::var(secret_env).map_err(|_| {
139                S3BlobStoreError::MissingCredentialEnvVar {
140                    var: secret_env.clone(),
141                }
142            })?;
143            let creds = Credentials::new(key, secret, None, None, "autumn-storage-s3");
144            let mut builder = aws_sdk_s3::Config::builder()
145                .behavior_version(BehaviorVersion::latest())
146                .region(Region::new(region.clone()))
147                .credentials_provider(creds.clone())
148                .force_path_style(cfg.force_path_style);
149            if let Some(endpoint) = &cfg.endpoint {
150                builder = builder.endpoint_url(endpoint);
151            }
152            let client = Client::from_conf(builder.build());
153
154            let mut presign_builder = aws_sdk_s3::Config::builder()
155                .behavior_version(BehaviorVersion::latest())
156                .region(Region::new(region))
157                .credentials_provider(creds)
158                .force_path_style(cfg.force_path_style);
159            if let Some(endpoint) = presign_endpoint {
160                presign_builder = presign_builder.endpoint_url(endpoint);
161            }
162            (client, Client::from_conf(presign_builder.build()))
163        } else {
164            let shared = aws_config::defaults(BehaviorVersion::latest())
165                .region(Region::new(region))
166                .load()
167                .await;
168            let mut builder =
169                aws_sdk_s3::config::Builder::from(&shared).force_path_style(cfg.force_path_style);
170            if let Some(endpoint) = &cfg.endpoint {
171                builder = builder.endpoint_url(endpoint);
172            }
173            let client = Client::from_conf(builder.build());
174
175            let mut presign_builder =
176                aws_sdk_s3::config::Builder::from(&shared).force_path_style(cfg.force_path_style);
177            if let Some(endpoint) = presign_endpoint {
178                presign_builder = presign_builder.endpoint_url(endpoint);
179            }
180            (client, Client::from_conf(presign_builder.build()))
181        };
182
183        Ok(Self {
184            client,
185            presign_client,
186            options: Arc::new(S3Options {
187                provider_id: "s3".to_owned(),
188                bucket,
189            }),
190        })
191    }
192
193    /// Override the `provider_id` recorded on every [`Blob`] produced by
194    /// this store.
195    ///
196    /// Defaults to `"s3"`. Call this if you want blobs to carry the same
197    /// [`StorageConfig::default_provider`](autumn_web::storage::StorageConfig)
198    /// label used by other backends, so backend migrations appear as
199    /// provider mismatches in existing blob records.
200    ///
201    /// ```rust,ignore
202    /// let store = S3BlobStore::from_config(&config.storage.s3)
203    ///     .await?
204    ///     .with_provider_id(&config.storage.default_provider);
205    /// ```
206    #[must_use]
207    pub fn with_provider_id(mut self, provider_id: impl Into<String>) -> Self {
208        Arc::make_mut(&mut self.options).provider_id = provider_id.into();
209        self
210    }
211}
212
213/// Minimum part size for S3 multipart uploads (5 MiB — the S3 minimum).
214///
215/// Streams smaller than this threshold are uploaded as a single `PutObject`
216/// call; larger streams are chunked into multipart uploads so the payload is
217/// never fully buffered in memory.
218const MULTIPART_PART_SIZE: usize = 5 * 1024 * 1024;
219
220/// Best-effort abort of a multipart upload; errors are silently ignored
221/// because we're already on the failure path.
222async fn abort_multipart(client: &Client, bucket: &str, key: &str, upload_id: &str) {
223    let _ = client
224        .abort_multipart_upload()
225        .bucket(bucket)
226        .key(key)
227        .upload_id(upload_id)
228        .send()
229        .await;
230}
231
232/// Check S3 egress against a failure capsule.
233///
234/// This client is not autumn-web's recorded outbound seam. A capsule replay
235/// refuses the call, and capture marks the capsule incomplete.
236fn guard_egress(method: &str) -> Result<(), BlobStoreError> {
237    // The target does not name the object: a key can hold personal data.
238    autumn_web::capsule::guard_egress("s3 blob store", method, "s3 object")
239        .map_err(|refused| BlobStoreError::backend(refused.to_string()))
240}
241
242impl BlobStore for S3BlobStore {
243    fn provider_id(&self) -> &str {
244        &self.options.provider_id
245    }
246
247    fn put<'a>(
248        &'a self,
249        key: &'a str,
250        content_type: &'a str,
251        bytes: Bytes,
252    ) -> BlobFuture<'a, Blob> {
253        let byte_size = bytes.len() as u64;
254        Box::pin(async move {
255            validate_key(key)?;
256            guard_egress("PUT")?;
257            let result = self
258                .client
259                .put_object()
260                .bucket(&self.options.bucket)
261                .key(key)
262                .content_type(content_type)
263                .body(bytes.into())
264                .send()
265                .await
266                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
267            let etag = result.e_tag().map(str::to_owned);
268            let mut blob = Blob::new(&self.options.provider_id, key, content_type, byte_size);
269            if let Some(e) = etag {
270                blob = blob.with_etag(e);
271            }
272            Ok(blob)
273        })
274    }
275
276    fn put_if_absent<'a>(
277        &'a self,
278        key: &'a str,
279        content_type: &'a str,
280        bytes: Bytes,
281    ) -> BlobFuture<'a, Option<Blob>> {
282        let byte_size = bytes.len() as u64;
283        Box::pin(async move {
284            validate_key(key)?;
285            guard_egress("PUT")?;
286            // `If-None-Match: *` makes S3 refuse the write (412) when the key
287            // exists, so a blob of another writer is never replaced. A 409
288            // means a parallel conditional write: the key is taken too.
289            let result = self
290                .client
291                .put_object()
292                .bucket(&self.options.bucket)
293                .key(key)
294                .content_type(content_type)
295                .if_none_match("*")
296                .body(bytes.into())
297                .send()
298                .await;
299            match result {
300                Ok(output) => {
301                    let mut blob =
302                        Blob::new(&self.options.provider_id, key, content_type, byte_size);
303                    if let Some(etag) = output.e_tag() {
304                        blob = blob.with_etag(etag.to_owned());
305                    }
306                    Ok(Some(blob))
307                }
308                Err(e)
309                    if e.raw_response()
310                        .is_some_and(|r| matches!(r.status().as_u16(), 409 | 412)) =>
311                {
312                    Ok(None)
313                }
314                Err(e) => Err(BlobStoreError::backend(e.to_string())),
315            }
316        })
317    }
318
319    #[allow(clippy::too_many_lines)]
320    fn put_stream<'a>(
321        &'a self,
322        key: &'a str,
323        content_type: &'a str,
324        data: ByteStream<'a>,
325    ) -> BlobFuture<'a, Blob> {
326        Box::pin(async move {
327            validate_key(key)?;
328            let mut stream = data;
329            let mut current_part: Vec<u8> = Vec::with_capacity(MULTIPART_PART_SIZE);
330
331            // Buffer until we have one full part or the stream ends.
332            loop {
333                match stream.next().await {
334                    Some(Ok(chunk)) => {
335                        current_part.extend_from_slice(&chunk);
336                        if current_part.len() >= MULTIPART_PART_SIZE {
337                            break;
338                        }
339                    }
340                    Some(Err(e)) => return Err(e),
341                    None => {
342                        // Stream ended before reaching the multipart threshold:
343                        // use a single-object PUT instead.
344                        return self.put(key, content_type, Bytes::from(current_part)).await;
345                    }
346                }
347            }
348
349            // Not before the stream is read: a stream that fails first sends
350            // nothing. A short stream is guarded in `put`.
351            guard_egress("PUT")?;
352
353            // The stream exceeded MULTIPART_PART_SIZE — use S3 multipart upload
354            // so the payload is never fully buffered in memory.
355            let create = self
356                .client
357                .create_multipart_upload()
358                .bucket(&self.options.bucket)
359                .key(key)
360                .content_type(content_type)
361                .send()
362                .await
363                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
364
365            let upload_id = create
366                .upload_id()
367                .ok_or_else(|| {
368                    BlobStoreError::backend("CreateMultipartUpload returned no upload_id")
369                })?
370                .to_owned();
371
372            let mut completed_parts: Vec<aws_sdk_s3::types::CompletedPart> = Vec::new();
373            let mut part_number = 1_i32;
374            let mut total_bytes: u64 = 0;
375
376            // Upload loop. `current_part` is always non-empty at the top.
377            loop {
378                if part_number > 10_000 {
379                    abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
380                    return Err(BlobStoreError::PayloadTooLarge(
381                        "S3 multipart upload part limit (10,000) exceeded; \
382                         object is too large to upload in 5 MiB chunks"
383                            .into(),
384                    ));
385                }
386
387                let part_bytes = Bytes::from(std::mem::take(&mut current_part));
388                total_bytes += part_bytes.len() as u64;
389
390                let upload_result = self
391                    .client
392                    .upload_part()
393                    .bucket(&self.options.bucket)
394                    .key(key)
395                    .upload_id(&upload_id)
396                    .part_number(part_number)
397                    .body(part_bytes.into())
398                    .send()
399                    .await;
400
401                let upload_resp = match upload_result {
402                    Ok(r) => r,
403                    Err(e) => {
404                        abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
405                        return Err(BlobStoreError::backend(e.to_string()));
406                    }
407                };
408
409                completed_parts.push(
410                    aws_sdk_s3::types::CompletedPart::builder()
411                        .part_number(part_number)
412                        .set_e_tag(upload_resp.e_tag().map(str::to_owned))
413                        .build(),
414                );
415                part_number += 1;
416
417                // Refill the part buffer from the remaining stream.
418                loop {
419                    match stream.next().await {
420                        Some(Ok(chunk)) => {
421                            current_part.extend_from_slice(&chunk);
422                            if current_part.len() >= MULTIPART_PART_SIZE {
423                                break;
424                            }
425                        }
426                        Some(Err(e)) => {
427                            abort_multipart(&self.client, &self.options.bucket, key, &upload_id)
428                                .await;
429                            return Err(e);
430                        }
431                        None => break,
432                    }
433                }
434
435                if current_part.is_empty() {
436                    break;
437                }
438            }
439
440            let complete_result = self
441                .client
442                .complete_multipart_upload()
443                .bucket(&self.options.bucket)
444                .key(key)
445                .upload_id(&upload_id)
446                .multipart_upload(
447                    aws_sdk_s3::types::CompletedMultipartUpload::builder()
448                        .set_parts(Some(completed_parts))
449                        .build(),
450                )
451                .send()
452                .await;
453
454            match complete_result {
455                Ok(resp) => {
456                    let mut blob =
457                        Blob::new(&self.options.provider_id, key, content_type, total_bytes);
458                    if let Some(etag) = resp.e_tag() {
459                        blob = blob.with_etag(etag.to_owned());
460                    }
461                    Ok(blob)
462                }
463                Err(e) => {
464                    abort_multipart(&self.client, &self.options.bucket, key, &upload_id).await;
465                    Err(BlobStoreError::backend(e.to_string()))
466                }
467            }
468        })
469    }
470
471    fn get<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Bytes> {
472        Box::pin(async move {
473            validate_key(key)?;
474            guard_egress("GET")?;
475            let result = self
476                .client
477                .get_object()
478                .bucket(&self.options.bucket)
479                .key(key)
480                .send()
481                .await
482                .map_err(|e| {
483                    if let aws_sdk_s3::error::SdkError::ServiceError(ref svc) = e
484                        && (svc.err().is_no_such_key() || svc.raw().status().as_u16() == 404)
485                    {
486                        return BlobStoreError::NotFound(key.to_owned());
487                    }
488                    BlobStoreError::backend(e.to_string())
489                })?;
490            let body = result
491                .body
492                .collect()
493                .await
494                .map_err(|e| BlobStoreError::io(e.to_string()))?;
495            Ok(body.into_bytes())
496        })
497    }
498
499    fn delete<'a>(&'a self, key: &'a str) -> BlobFuture<'a, ()> {
500        Box::pin(async move {
501            validate_key(key)?;
502            guard_egress("DELETE")?;
503            self.client
504                .delete_object()
505                .bucket(&self.options.bucket)
506                .key(key)
507                .send()
508                .await
509                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
510            Ok(())
511        })
512    }
513
514    fn head<'a>(&'a self, key: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
515        Box::pin(async move {
516            validate_key(key)?;
517            guard_egress("HEAD")?;
518            let result = self
519                .client
520                .head_object()
521                .bucket(&self.options.bucket)
522                .key(key)
523                .send()
524                .await;
525            match result {
526                Ok(resp) => {
527                    let content_type = resp
528                        .content_type()
529                        .unwrap_or("application/octet-stream")
530                        .to_owned();
531                    let byte_size = resp
532                        .content_length()
533                        .and_then(|n| u64::try_from(n).ok())
534                        .unwrap_or(0);
535                    let etag = resp.e_tag().map(str::to_owned);
536                    Ok(Some(BlobMeta {
537                        key: key.to_owned(),
538                        content_type,
539                        byte_size,
540                        etag,
541                    }))
542                }
543                Err(e) => {
544                    if let aws_sdk_s3::error::SdkError::ServiceError(ref svc) = e
545                        && (svc.err().is_not_found() || svc.raw().status().as_u16() == 404)
546                    {
547                        return Ok(None);
548                    }
549                    Err(BlobStoreError::backend(e.to_string()))
550                }
551            }
552        })
553    }
554
555    fn presigned_url<'a>(&'a self, key: &'a str, expires_in: Duration) -> BlobFuture<'a, String> {
556        Box::pin(async move {
557            validate_key(key)?;
558            let presigning = PresigningConfig::expires_in(expires_in)
559                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
560            let req = self
561                .presign_client
562                .get_object()
563                .bucket(&self.options.bucket)
564                .key(key)
565                .presigned(presigning)
566                .await
567                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
568            Ok(req.uri().to_string())
569        })
570    }
571
572    fn presign_put<'a>(
573        &'a self,
574        key: &'a str,
575        content_type: &'a str,
576        expires_in: Duration,
577    ) -> BlobFuture<'a, PresignPutResult> {
578        Box::pin(async move {
579            validate_key(key)?;
580            let presigning = PresigningConfig::expires_in(expires_in)
581                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
582            let req = self
583                .presign_client
584                .put_object()
585                .bucket(&self.options.bucket)
586                .key(key)
587                .content_type(content_type)
588                .presigned(presigning)
589                .await
590                .map_err(|e| BlobStoreError::backend(e.to_string()))?;
591
592            let headers: std::collections::HashMap<String, String> = req
593                .headers()
594                .map(|(k, v)| (k.to_owned(), v.to_owned()))
595                .collect();
596
597            Ok(PresignPutResult {
598                url: req.uri().to_string(),
599                method: "PUT".to_owned(),
600                headers,
601                expires_in,
602            })
603        })
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use std::time::SystemTime;
611
612    fn s3_config(bucket: Option<&str>, region: Option<&str>) -> StorageS3Config {
613        StorageS3Config {
614            bucket: bucket.map(str::to_owned),
615            region: region.map(str::to_owned),
616            ..StorageS3Config::default()
617        }
618    }
619
620    fn query_param<'a>(url: &'a str, name: &str) -> Option<&'a str> {
621        let query = url.split_once('?')?.1;
622        query.split('&').find_map(|part| {
623            let (key, value) = part.split_once('=')?;
624            (key == name).then_some(value)
625        })
626    }
627
628    fn authority(url: &str) -> Option<&str> {
629        let rest = url.split_once("://")?.1;
630        Some(rest.split('/').next().unwrap_or(rest))
631    }
632
633    fn parse_amz_date(value: &str) -> SystemTime {
634        assert_eq!(value.len(), 16, "unexpected X-Amz-Date format");
635        assert_eq!(&value[8..9], "T", "unexpected X-Amz-Date format");
636        assert_eq!(&value[15..16], "Z", "unexpected X-Amz-Date format");
637        let year = value[0..4].parse::<i32>().expect("year");
638        let month = value[4..6].parse::<u32>().expect("month");
639        let day = value[6..8].parse::<u32>().expect("day");
640        let hour = value[9..11].parse::<u64>().expect("hour");
641        let minute = value[11..13].parse::<u64>().expect("minute");
642        let second = value[13..15].parse::<u64>().expect("second");
643
644        let days = days_since_unix_epoch(year, month, day);
645        assert!(days >= 0, "X-Amz-Date before unix epoch");
646        let days = u64::try_from(days).expect("nonnegative day count");
647        SystemTime::UNIX_EPOCH
648            + Duration::from_secs(days * 86_400 + hour * 3_600 + minute * 60 + second)
649    }
650
651    fn days_since_unix_epoch(year: i32, month: u32, day: u32) -> i64 {
652        let year = year - i32::from(month <= 2);
653        let era = if year >= 0 { year } else { year - 399 } / 400;
654        let year_of_era = year - era * 400;
655        let month = i32::try_from(month).expect("month fits i32");
656        let day = i32::try_from(day).expect("day fits i32");
657        let month_prime = month + if month > 2 { -3 } else { 9 };
658        let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
659        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
660        i64::from(era) * 146_097 + i64::from(day_of_era) - 719_468
661    }
662
663    fn test_client(endpoint: &str) -> Client {
664        Client::from_conf(
665            aws_sdk_s3::Config::builder()
666                .behavior_version(BehaviorVersion::latest())
667                .region(Region::new("us-east-1"))
668                .credentials_provider(Credentials::new(
669                    "AKIAIOSFODNN7EXAMPLE",
670                    "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
671                    None,
672                    None,
673                    "test",
674                ))
675                .force_path_style(true)
676                .endpoint_url(endpoint)
677                .build(),
678        )
679    }
680
681    fn test_store() -> S3BlobStore {
682        let client = test_client("http://127.0.0.1:9");
683        S3BlobStore {
684            client: client.clone(),
685            presign_client: client,
686            options: Arc::new(S3Options {
687                provider_id: "s3".to_owned(),
688                bucket: "test-bucket".to_owned(),
689            }),
690        }
691    }
692
693    /// Serve one HTTP request on a local port and answer with `status`.
694    /// The task gives back the request head, in lower case.
695    async fn serve_one(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
696        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
697
698        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
699        let endpoint = format!("http://{}", listener.local_addr().unwrap());
700        let task = tokio::spawn(async move {
701            let (mut socket, _) = listener.accept().await.unwrap();
702            let (mut buf, mut chunk) = (Vec::new(), [0_u8; 4096]);
703            let head = loop {
704                let n = socket.read(&mut chunk).await.unwrap();
705                assert!(n > 0, "the request ended early");
706                buf.extend_from_slice(&chunk[..n]);
707                let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
708                    continue;
709                };
710                let head = String::from_utf8_lossy(&buf[..end]).to_ascii_lowercase();
711                let body_len = head
712                    .lines()
713                    .find_map(|l| l.strip_prefix("content-length:"))
714                    .map_or(0, |v| v.trim().parse::<usize>().unwrap());
715                if buf.len() >= end + 4 + body_len {
716                    break head;
717                }
718            };
719            let body = if status.starts_with("412") {
720                "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Error><Code>PreconditionFailed</Code>\
721                 <Message>At least one of the pre-conditions you specified did not hold</Message>\
722                 </Error>"
723            } else {
724                ""
725            };
726            let response = format!(
727                "HTTP/1.1 {status}\r\ncontent-length: {}\r\netag: \"e1\"\r\n\
728                 connection: close\r\n\r\n{body}",
729                body.len()
730            );
731            socket.write_all(response.as_bytes()).await.unwrap();
732            head
733        });
734        (endpoint, task)
735    }
736
737    fn store_at(endpoint: &str) -> S3BlobStore {
738        let client = test_client(endpoint);
739        S3BlobStore {
740            client: client.clone(),
741            presign_client: client,
742            options: Arc::new(S3Options {
743                provider_id: "s3".to_owned(),
744                bucket: "test-bucket".to_owned(),
745            }),
746        }
747    }
748
749    #[tokio::test]
750    async fn put_if_absent_asks_s3_to_refuse_a_taken_key() {
751        let (endpoint, server) = serve_one("412 Precondition Failed").await;
752        let result = store_at(&endpoint)
753            .put_if_absent("a/b.png", "image/png", Bytes::from_static(b"abc"))
754            .await
755            .expect("412 means the key is taken");
756        assert!(result.is_none(), "{result:?}");
757        let head = server.await.unwrap();
758        assert!(head.starts_with("put /test-bucket/a/b.png"), "{head}");
759        assert!(head.contains("\r\nif-none-match: *"), "{head}");
760    }
761
762    #[tokio::test]
763    async fn put_if_absent_creates_a_free_key() {
764        let (endpoint, server) = serve_one("200 OK").await;
765        let blob = store_at(&endpoint)
766            .put_if_absent("a/b.png", "image/png", Bytes::from_static(b"abc"))
767            .await
768            .expect("created")
769            .expect("the key was free");
770        assert_eq!(blob.byte_size, 3);
771        assert!(server.await.unwrap().contains("\r\nif-none-match: *"));
772    }
773
774    fn assert_invalid_input<T: std::fmt::Debug>(result: Result<T, BlobStoreError>) {
775        match result {
776            Err(BlobStoreError::InvalidInput(_)) => {}
777            other => panic!("expected InvalidInput, got {other:?}"),
778        }
779    }
780
781    #[tokio::test]
782    async fn from_config_requires_bucket() {
783        let cfg = s3_config(None, Some("us-east-1"));
784        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
785        assert!(matches!(err, S3BlobStoreError::MissingBucket));
786    }
787
788    #[tokio::test]
789    async fn from_config_requires_region() {
790        let cfg = s3_config(Some("my-bucket"), None);
791        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
792        assert!(matches!(err, S3BlobStoreError::MissingRegion));
793    }
794
795    #[tokio::test]
796    async fn from_config_empty_bucket_is_rejected() {
797        let cfg = s3_config(Some("   "), Some("us-east-1"));
798        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
799        assert!(matches!(err, S3BlobStoreError::MissingBucket));
800    }
801
802    #[tokio::test]
803    async fn from_config_empty_region_is_rejected() {
804        let cfg = s3_config(Some("my-bucket"), Some(""));
805        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
806        assert!(matches!(err, S3BlobStoreError::MissingRegion));
807    }
808
809    #[tokio::test]
810    async fn from_config_missing_credential_env_var() {
811        let cfg = StorageS3Config {
812            bucket: Some("b".into()),
813            region: Some("us-east-1".into()),
814            access_key_id_env: Some("__AUTUMN_TEST_KEY_ID_DEFINITELY_NOT_SET__".into()),
815            secret_access_key_env: Some("__AUTUMN_TEST_SECRET_DEFINITELY_NOT_SET__".into()),
816            ..StorageS3Config::default()
817        };
818        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
819        assert!(matches!(
820            err,
821            S3BlobStoreError::MissingCredentialEnvVar { .. }
822        ));
823    }
824
825    #[tokio::test]
826    async fn from_config_with_static_creds_sets_provider_id() {
827        let cfg = StorageS3Config {
828            bucket: Some("test-bucket".into()),
829            region: Some("us-east-1".into()),
830            access_key_id_env: Some("__AUTUMN_S3_TEST_KEY_ID__".into()),
831            secret_access_key_env: Some("__AUTUMN_S3_TEST_SECRET__".into()),
832            ..StorageS3Config::default()
833        };
834        Box::pin(temp_env::async_with_vars(
835            [
836                ("__AUTUMN_S3_TEST_KEY_ID__", Some("AKIAIOSFODNN7EXAMPLE")),
837                (
838                    "__AUTUMN_S3_TEST_SECRET__",
839                    Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
840                ),
841            ],
842            async {
843                let store = S3BlobStore::from_config(&cfg)
844                    .await
845                    .expect("should build with static creds");
846                assert_eq!(store.provider_id(), "s3");
847            },
848        ))
849        .await;
850    }
851
852    #[tokio::test]
853    async fn from_config_with_custom_endpoint() {
854        let cfg = StorageS3Config {
855            bucket: Some("my-bucket".into()),
856            region: Some("auto".into()),
857            endpoint: Some("https://example.r2.cloudflarestorage.com".into()),
858            force_path_style: true,
859            access_key_id_env: Some("__AUTUMN_S3_TEST_KEY_ID2__".into()),
860            secret_access_key_env: Some("__AUTUMN_S3_TEST_SECRET2__".into()),
861            ..StorageS3Config::default()
862        };
863        Box::pin(temp_env::async_with_vars(
864            [
865                ("__AUTUMN_S3_TEST_KEY_ID2__", Some("key")),
866                ("__AUTUMN_S3_TEST_SECRET2__", Some("secret")),
867            ],
868            async {
869                let store = S3BlobStore::from_config(&cfg).await.expect("should build");
870                assert_eq!(store.provider_id(), "s3");
871            },
872        ))
873        .await;
874    }
875
876    #[tokio::test]
877    async fn from_config_partial_key_env_is_rejected() {
878        let cfg = StorageS3Config {
879            bucket: Some("b".into()),
880            region: Some("us-east-1".into()),
881            access_key_id_env: Some("SOME_KEY_ENV".into()),
882            secret_access_key_env: None,
883            ..StorageS3Config::default()
884        };
885        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
886        assert!(matches!(err, S3BlobStoreError::PartialCredentialConfig));
887    }
888
889    #[tokio::test]
890    async fn from_config_partial_secret_env_is_rejected() {
891        let cfg = StorageS3Config {
892            bucket: Some("b".into()),
893            region: Some("us-east-1".into()),
894            access_key_id_env: None,
895            secret_access_key_env: Some("SOME_SECRET_ENV".into()),
896            ..StorageS3Config::default()
897        };
898        let err = S3BlobStore::from_config(&cfg).await.unwrap_err();
899        assert!(matches!(err, S3BlobStoreError::PartialCredentialConfig));
900    }
901
902    #[tokio::test]
903    async fn with_provider_id_overrides_default() {
904        let cfg = StorageS3Config {
905            bucket: Some("b".into()),
906            region: Some("us-east-1".into()),
907            access_key_id_env: Some("__AUTUMN_S3_PROVID_KEY__".into()),
908            secret_access_key_env: Some("__AUTUMN_S3_PROVID_SECRET__".into()),
909            ..StorageS3Config::default()
910        };
911        Box::pin(temp_env::async_with_vars(
912            [
913                ("__AUTUMN_S3_PROVID_KEY__", Some("key")),
914                ("__AUTUMN_S3_PROVID_SECRET__", Some("secret")),
915            ],
916            async {
917                let store = S3BlobStore::from_config(&cfg)
918                    .await
919                    .expect("build store")
920                    .with_provider_id("myapp-prod");
921                assert_eq!(store.provider_id(), "myapp-prod");
922            },
923        ))
924        .await;
925    }
926
927    #[tokio::test]
928    async fn public_base_url_is_used_for_presigning_host() {
929        let cfg = StorageS3Config {
930            bucket: Some("test-bucket".into()),
931            region: Some("us-east-1".into()),
932            endpoint: Some("https://internal.example.local".into()),
933            public_base_url: Some("https://public.example.com".into()),
934            force_path_style: true,
935            access_key_id_env: Some("__AUTUMN_S3_PRESIGN_KEY__".into()),
936            secret_access_key_env: Some("__AUTUMN_S3_PRESIGN_SECRET__".into()),
937            ..StorageS3Config::default()
938        };
939
940        Box::pin(temp_env::async_with_vars(
941            [
942                ("__AUTUMN_S3_PRESIGN_KEY__", Some("AKIAIOSFODNN7EXAMPLE")),
943                (
944                    "__AUTUMN_S3_PRESIGN_SECRET__",
945                    Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"),
946                ),
947            ],
948            async {
949                let store = S3BlobStore::from_config(&cfg).await.expect("build store");
950                let actual = store
951                    .presigned_url("avatars/42.bin", Duration::from_mins(15))
952                    .await
953                    .expect("presign URL");
954
955                let amz_date = query_param(&actual, "X-Amz-Date").expect("X-Amz-Date");
956                let expected_presigning = PresigningConfig::builder()
957                    .start_time(parse_amz_date(amz_date))
958                    .expires_in(Duration::from_mins(15))
959                    .build()
960                    .expect("fixed presigning config");
961                let expected = test_client("https://public.example.com")
962                    .get_object()
963                    .bucket("test-bucket")
964                    .key("avatars/42.bin")
965                    .presigned(expected_presigning)
966                    .await
967                    .expect("expected presigned URL")
968                    .uri()
969                    .to_string();
970
971                assert_eq!(authority(&actual), Some("public.example.com"));
972                assert_eq!(
973                    query_param(&actual, "X-Amz-Signature"),
974                    query_param(&expected, "X-Amz-Signature")
975                );
976            },
977        ))
978        .await;
979    }
980
981    #[tokio::test]
982    async fn operations_reject_invalid_keys_before_calling_s3() {
983        let store = test_store();
984
985        assert_invalid_input(store.put("", "text/plain", Bytes::from_static(b"x")).await);
986
987        let stream: ByteStream<'_> = Box::pin(futures::stream::once(async {
988            Ok(Bytes::from_static(b"x"))
989        }));
990        assert_invalid_input(store.put_stream("../x", "text/plain", stream).await);
991
992        assert_invalid_input(store.get("foo.meta").await);
993        assert_invalid_input(store.delete("con/file.txt").await);
994        assert_invalid_input(store.head("avatars/User.png").await);
995        assert_invalid_input(
996            store
997                .presigned_url("avatars//user.png", Duration::from_secs(60))
998                .await,
999        );
1000    }
1001
1002    /// autumn-web #2351 item 1: under a capsule replay the store refuses to
1003    /// call S3, and logs a divergence.
1004    #[tokio::test]
1005    async fn a_capsule_replay_refuses_s3_egress() {
1006        let store = test_store();
1007        let tape = std::sync::Arc::new(autumn_web::capsule::ReplayEffects::new(
1008            autumn_web::capsule::CapsuleEffects::default(),
1009        ));
1010        let result =
1011            autumn_web::capsule::with_effect_tape(std::sync::Arc::clone(&tape), store.get("a.txt"))
1012                .await;
1013        let error = result.expect_err("a replay refuses S3 egress");
1014        assert!(
1015            error.to_string().contains("blocked during replay"),
1016            "{error}"
1017        );
1018        assert_eq!(tape.divergences().len(), 1);
1019    }
1020
1021    /// autumn-web #2351, Codex review on #3222: a stream that fails before
1022    /// the multipart threshold sends nothing, so a replay does not refuse it.
1023    #[tokio::test]
1024    async fn a_stream_error_before_any_upload_is_not_egress() {
1025        let store = test_store();
1026        let tape = std::sync::Arc::new(autumn_web::capsule::ReplayEffects::new(
1027            autumn_web::capsule::CapsuleEffects::default(),
1028        ));
1029        let stream: autumn_web::storage::ByteStream<'_> =
1030            Box::pin(futures::stream::iter(vec![Err(BlobStoreError::backend(
1031                "the upload body failed",
1032            ))]));
1033        let result = autumn_web::capsule::with_effect_tape(
1034            std::sync::Arc::clone(&tape),
1035            store.put_stream("a.txt", "text/plain", stream),
1036        )
1037        .await;
1038        let error = result.expect_err("the stream failed");
1039        assert!(
1040            error.to_string().contains("the upload body failed"),
1041            "{error}"
1042        );
1043        assert!(tape.divergences().is_empty(), "{:?}", tape.divergences());
1044    }
1045
1046    #[test]
1047    fn implements_blob_store_trait() {
1048        fn assert_impl<T: BlobStore>() {}
1049        assert_impl::<S3BlobStore>();
1050    }
1051
1052    #[test]
1053    fn multipart_part_size_is_exactly_5_mib() {
1054        assert_eq!(
1055            MULTIPART_PART_SIZE, 5_242_880,
1056            "MULTIPART_PART_SIZE must be exactly 5 MiB to satisfy S3 minimums"
1057        );
1058    }
1059
1060    #[test]
1061    fn implements_send_sync_clone() {
1062        fn assert_impl<T: Send + Sync + Clone>() {}
1063        assert_impl::<S3BlobStore>();
1064    }
1065
1066    // ── RED: presign_put for S3 ─────────────────────────────────────────────
1067
1068    #[tokio::test]
1069    async fn presign_put_returns_put_method() {
1070        let store = test_store();
1071        let result = store
1072            .presign_put(
1073                "uploads/report.pdf",
1074                "application/pdf",
1075                Duration::from_secs(300),
1076            )
1077            .await
1078            .unwrap();
1079        assert_eq!(result.method, "PUT");
1080    }
1081
1082    #[tokio::test]
1083    async fn presign_put_url_uses_expected_bucket_and_key() {
1084        let store = test_store();
1085        let result = store
1086            .presign_put("docs/file.txt", "text/plain", Duration::from_secs(60))
1087            .await
1088            .unwrap();
1089        assert!(
1090            result.url.contains("docs/file.txt"),
1091            "URL must contain the key: {}",
1092            result.url
1093        );
1094        assert!(
1095            result.url.contains("test-bucket"),
1096            "URL must contain the bucket: {}",
1097            result.url
1098        );
1099    }
1100
1101    #[tokio::test]
1102    async fn presign_put_url_contains_sigv4_parameters() {
1103        let store = test_store();
1104        let result = store
1105            .presign_put("img/photo.jpg", "image/jpeg", Duration::from_mins(15))
1106            .await
1107            .unwrap();
1108        assert!(
1109            result.url.contains("X-Amz-Signature") || result.url.contains("x-amz-signature"),
1110            "S3 presigned PUT URL must contain SigV4 signature: {}",
1111            result.url
1112        );
1113    }
1114
1115    #[tokio::test]
1116    async fn presign_put_rejects_invalid_key() {
1117        let store = test_store();
1118        let err = store
1119            .presign_put("../escape", "text/plain", Duration::from_secs(60))
1120            .await
1121            .unwrap_err();
1122        assert!(matches!(err, BlobStoreError::InvalidInput(_)));
1123    }
1124
1125    #[tokio::test]
1126    async fn presign_put_and_presigned_url_produce_different_tokens() {
1127        // A presigned GET URL and a presigned PUT URL for the same key must
1128        // produce structurally different tokens — they cannot be substituted
1129        // for each other.
1130        let store = test_store();
1131        let get_url = store
1132            .presigned_url("shared/key.txt", Duration::from_secs(60))
1133            .await
1134            .unwrap();
1135        let put_result = store
1136            .presign_put("shared/key.txt", "text/plain", Duration::from_secs(60))
1137            .await
1138            .unwrap();
1139        // Different operation, different signature.
1140        assert_ne!(
1141            get_url, put_result.url,
1142            "GET and PUT presigned URLs for the same key must differ"
1143        );
1144    }
1145}