Skip to main content

decdn_cache/origin/
s3.rs

1//! S3-compatible origin backend (#437).
2//!
3//! Fetches blobs from `{prefix?}{hex[0..2]}/{hex}` keys in an S3 bucket via
4//! the official AWS SDK. The sharded key layout mirrors
5//! [`super::FilesystemOrigin`] so operators can `aws s3 sync` between a
6//! filesystem origin and an S3 origin without rewriting object names.
7//!
8//! Supports plain AWS S3, Cloudflare R2, Backblaze B2, `MinIO`, and any other
9//! S3-compatible service via the `endpoint_url` + `path_style` knobs on
10//! [`S3OriginConfig`]. Credentials come from either explicit static keys or
11//! the AWS default credential chain (env, `~/.aws/credentials` profile,
12//! container/instance role).
13//!
14//! ## TLS / HTTP layer
15//!
16//! The SDK is configured to bring its own HTTP client built from
17//! [`aws_smithy_http_client`] over **hyper-1 + rustls 0.23 + aws-lc-rs**,
18//! matching the rest of the workspace. The SDK's stock HTTP stack
19//! (hyper-0.14 + rustls 0.21 + ring) is suppressed via `default-features =
20//! false` on `aws-sdk-s3` and `aws-config` — bringing it in would force a
21//! third parallel TLS implementation into the dep tree.
22
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26
27use aws_config::BehaviorVersion;
28use aws_credential_types::Credentials;
29use aws_credential_types::provider::SharedCredentialsProvider;
30use aws_sdk_s3::Client;
31use aws_sdk_s3::config::Region;
32use aws_sdk_s3::error::SdkError;
33use aws_sdk_s3::operation::get_object::GetObjectError;
34use aws_sdk_s3::operation::head_object::HeadObjectError;
35use aws_smithy_http_client::{Builder as HttpBuilder, tls};
36use aws_smithy_types::error::metadata::ProvideErrorMetadata;
37use aws_smithy_types::retry::RetryConfig;
38use bytes::{Bytes, BytesMut};
39use futures_util::StreamExt;
40use iroh_blobs::Hash;
41use tokio_util::io::ReaderStream;
42
43use super::fs::OBAO4_SUFFIX;
44use super::io_runtime::IoRuntime;
45use super::{
46    BlobTooLargeMarker, DecompressMode, Origin, OriginByteStream, OriginFetch, OriginKind,
47    OriginRangeFetch, OriginRangeRequest, OriginUrl, OutboardFetch, decompress,
48};
49use crate::error::{OriginError, OriginPullError};
50
51/// Validated, runtime-ready configuration for an [`S3Origin`].
52///
53/// This is the cache-crate's parallel of `decdn_common::config::ResolvedS3Config`
54/// — the wiring layer in `decdn-node` does the conversion when constructing
55/// the engine. Keeping the type here means `decdn-cache` doesn't depend on
56/// `decdn-common` (which would be circular: `common -> cache` is the
57/// established direction so the resolved-config types can carry parsed
58/// `OriginUrl`s).
59///
60/// Construction is field-init at the call site; the wiring layer enforces
61/// that the values come from `decdn_common::config::resolve_origin`, the
62/// only producer that runs the bucket-name / region / endpoint-URL
63/// validators.
64///
65/// The `Debug` impl is **manual** rather than derived: it forwards every
66/// field through the [`S3Credentials`] redaction wrapper so an incidental
67/// `tracing::debug!(?cfg)` or panic-backtrace formatter cannot leak the
68/// access-key / secret-key bytes. The `#[derive(Debug)]` would print them
69/// in cleartext (the wrapped `String`s lost their `SecretString` discipline
70/// at the resolve→runtime boundary in `decdn-node`). Mirrors the pattern
71/// established by `decdn_common::config::secret::SecretString` (same crate
72/// can't be intra-doc-linked because `decdn-cache` deliberately does not
73/// depend on `decdn-common`).
74#[derive(Clone)]
75pub struct S3OriginConfig {
76    /// Bucket name. Validated DNS-safe at config-resolve time.
77    pub bucket: String,
78    /// AWS region.
79    pub region: String,
80    /// Custom endpoint URL for non-AWS S3-compatible services
81    /// (R2, B2, `MinIO`). Parsed and trailing-slash-normalized.
82    pub endpoint_url: Option<OriginUrl>,
83    /// `true` selects path-style addressing (`https://endpoint/{bucket}/{key}`),
84    /// required by `MinIO` and some on-prem providers. The SDK default
85    /// (`false`) is virtual-hosted-style (`https://{bucket}.s3.amazonaws.com/{key}`).
86    pub path_style: bool,
87    /// Trailing-slash-normalized object key prefix prepended to every
88    /// fetched object. Empty string means no prefix.
89    pub prefix: String,
90    /// Credential source. `None` is treated identically to
91    /// `Some(S3Credentials::DefaultChain { profile: None })` — the SDK's
92    /// stock chain (env, profile, container/instance role).
93    pub credentials: Option<S3Credentials>,
94}
95
96/// How `S3Origin` obtains AWS credentials.
97///
98/// `Static` carries the cleartext bytes — the wiring layer in `decdn-node`
99/// is responsible for unwrapping `decdn_common::config::secret::SecretString`
100/// before passing them here. By the time credentials reach the SDK they
101/// are plain bytes either way (the SDK's `Credentials::new` takes
102/// `&str`/`String`).
103///
104/// The `Debug` impl is **manual** to preserve the redaction discipline
105/// across the resolve→runtime boundary: `Static`'s key fields print as
106/// `"***"` so a stray `tracing::debug!(?creds)` or panic-backtrace cannot
107/// leak the credential bytes. `DefaultChain`'s `profile` is operator-set
108/// configuration and prints in cleartext (no secret content).
109#[derive(Clone)]
110pub enum S3Credentials {
111    /// Explicit IAM access key + secret + optional STS session token.
112    Static {
113        /// AWS access key ID.
114        access_key_id: String,
115        /// AWS secret access key.
116        secret_access_key: String,
117        /// Optional STS session token (for temporary credentials).
118        session_token: Option<String>,
119    },
120    /// Use the AWS default credential chain. Identical to `aws-cli`'s
121    /// discovery: `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` env vars,
122    /// then `~/.aws/credentials` (selecting `profile` if set), then
123    /// container / instance role via IMDS.
124    DefaultChain {
125        /// Profile name override for `~/.aws/credentials`.
126        profile: Option<String>,
127    },
128}
129
130impl std::fmt::Debug for S3Credentials {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        // Mirrors `decdn_common::config::secret::SecretString::Debug`: never
133        // print the cleartext key, secret, or session token, regardless of
134        // the surrounding formatter (`{:?}`, `{:#?}`, panic backtrace).
135        // `session_token` discriminates `Some`/`None` so an operator can
136        // tell whether STS temporary creds are in use without seeing the
137        // token bytes.
138        match self {
139            Self::Static { session_token, .. } => f
140                .debug_struct("Static")
141                .field("access_key_id", &"***")
142                .field("secret_access_key", &"***")
143                .field("session_token", &session_token.as_ref().map(|_| "***"))
144                .finish(),
145            Self::DefaultChain { profile } => f
146                .debug_struct("DefaultChain")
147                .field("profile", profile)
148                .finish(),
149        }
150    }
151}
152
153impl std::fmt::Debug for S3OriginConfig {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        // Manual impl rather than derive so `credentials` flows through
156        // [`S3Credentials`]'s redacting `Debug`. Bucket / region /
157        // endpoint / prefix are operator-set configuration with no
158        // secret content; print in the clear.
159        f.debug_struct("S3OriginConfig")
160            .field("bucket", &self.bucket)
161            .field("region", &self.region)
162            .field("endpoint_url", &self.endpoint_url)
163            .field("path_style", &self.path_style)
164            .field("prefix", &self.prefix)
165            .field("credentials", &self.credentials)
166            .finish()
167    }
168}
169
170/// Compute the S3 object key for `hash`: `{prefix}{hex[0..2]}/{hex}`.
171///
172/// The two-char shard mirrors `FilesystemOrigin::path_for` so operators
173/// can copy blobs between filesystem and S3 origins without renaming.
174/// Free function (not a method) so tests don't need a real `Client` to
175/// exercise the layout — the integration suite at `tests/s3_origin.rs`
176/// covers the SDK end-to-end separately.
177fn key_for(prefix: &str, hash: Hash) -> String {
178    let hex = hash.to_hex();
179    // Defensive against an unexpected `iroh_blobs::Hash` format change;
180    // the workspace `indexing_slicing = "deny"` lint forbids `&hex[..2]`.
181    let shard = hex.get(..2).unwrap_or("");
182    format!("{prefix}{shard}/{}", hex.as_str())
183}
184
185/// Origin backed by an S3-compatible object store.
186///
187/// `Clone` is cheap: the inner `Client` is `Clone` and uses an `Arc`
188/// internally; the `bucket` and `prefix` fields use `Arc<str>` so adding
189/// labels to diagnostic logs doesn't allocate.
190#[derive(Debug, Clone)]
191pub struct S3Origin {
192    client: Client,
193    /// The runtime every SDK request send runs on, so the HTTP client's
194    /// pooled keep-alive connections live there and not on a caller's
195    /// pull-leg runtime. See [`super::io_runtime`].
196    io: IoRuntime,
197    /// Operator-configured bucket. `Arc<str>` so cheap clone preserves
198    /// shared ownership for log fields without re-allocating.
199    bucket: Arc<str>,
200    /// Operator-configured key prefix (trailing slash already applied by
201    /// the resolver). Same rationale as `bucket`.
202    prefix: Arc<str>,
203    /// How to handle `Content-Encoding` on the S3 response. Defaults to
204    /// [`DecompressMode::Auto`] — see that type for the full semantics.
205    /// Override with [`Self::with_decompress_mode`]. Mirrors
206    /// [`super::HttpOrigin`] so both first-class origin backends decode
207    /// gzip/zstd objects to canonical bytes before the engine's BLAKE3
208    /// verify (#804).
209    decompress: DecompressMode,
210}
211
212impl S3Origin {
213    /// Build an `S3Origin` from validated config. Returns an error if a
214    /// `Static` credential variant carries empty bytes (defense in depth
215    /// against a config-resolver gap or a hand-built `S3OriginConfig`).
216    ///
217    /// **I/O posture:** construction itself is non-blocking — `aws_config`
218    /// builds lazy provider chains and resolves credentials on the first
219    /// signed request, not at `load()` time. The one operator-visible
220    /// caveat is the AWS SSO provider (enabled via the `sso` feature on
221    /// `aws-config`): if the `~/.aws/config` profile in use is configured
222    /// for SSO and the cached SSO token has expired, the *first* `fetch`
223    /// call may fire an HTTP request to the SSO endpoint to refresh the
224    /// token. That's a property of the AWS credential chain, not this
225    /// constructor; the construction itself stays I/O-free.
226    ///
227    /// `BehaviorVersion::latest()` is set explicitly even though
228    /// `aws-config`'s `behavior-version-latest` feature would imply the
229    /// same. Belt-and-braces against a future feature-flag edit dropping
230    /// the implicit path — older SDK versions panicked when no
231    /// `BehaviorVersion` was set; current versions surface a runtime error.
232    /// Either way, the explicit call site cannot regress.
233    ///
234    /// **Runtime:** the origin's pooled connections live on the tokio
235    /// runtime this future runs on, and every later request send runs there
236    /// (see `origin::io_runtime`). Build it on a runtime that outlives every
237    /// fetch — the node's main runtime — never on a short-lived one such as a
238    /// pull leg's.
239    ///
240    /// # Errors
241    ///
242    /// Fails when a `Static` credential carries an empty key or secret, or
243    /// when this future does not run inside a tokio runtime.
244    pub async fn new(cfg: &S3OriginConfig) -> anyhow::Result<Self> {
245        // Defense in depth against a config-resolver gap or a downstream
246        // caller that bypasses `decdn_common::config::resolve_origin` and
247        // hand-builds an `S3OriginConfig`. Empty static credentials would
248        // otherwise produce a confusing 403 from the service at first
249        // fetch — turn the failure into a clear startup-time error
250        // pointing at the [cache.origin.credentials] block.
251        if let Some(S3Credentials::Static {
252            access_key_id,
253            secret_access_key,
254            ..
255        }) = cfg.credentials.as_ref()
256            && (access_key_id.is_empty() || secret_access_key.is_empty())
257        {
258            anyhow::bail!(
259                "S3 static credentials have empty access_key_id or secret_access_key; \
260                 check [cache.origin.credentials] in config or the secret-resolution layer"
261            );
262        }
263        // Build the hyper-1 + rustls 0.23 + aws-lc-rs HTTP client.
264        // Constructed once per S3Origin and shared across every fetch via
265        // `Client::clone` (the SDK Client is cheaply cloneable). A single
266        // shared connection pool keeps idle TLS connections warm across
267        // back-to-back cache misses. Every send runs on `io`, so each pooled
268        // connection lives on the runtime that built this origin, never on a
269        // caller's pull-leg runtime (`super::io_runtime`).
270        let http_client = HttpBuilder::new()
271            .tls_provider(tls::Provider::Rustls(
272                tls::rustls_provider::CryptoMode::AwsLc,
273            ))
274            .build_https();
275
276        // Disable the SDK's internal retry layer. Without this, transient
277        // failures get retried *twice*: once by the SDK (default 3 attempts)
278        // and again by the cache engine's outer retry loop (default 4
279        // attempts), producing up to 12 dispatches per logical fetch on
280        // sustained 5xx — not what an operator reading
281        // `cache.origin_retry.max_retries = 3` expects. By disabling the
282        // SDK budget, `cache.origin_retry` becomes the single source of
283        // truth for retry policy across all three origin backends (HTTP,
284        // FS, S3), matching the operator-facing contract from #285.
285        let mut loader = aws_config::defaults(BehaviorVersion::latest())
286            .http_client(http_client)
287            .retry_config(RetryConfig::disabled())
288            .region(Region::new(cfg.region.clone()));
289
290        if let Some(endpoint) = cfg.endpoint_url.as_ref() {
291            // `OriginUrl` already enforces http/https scheme + trailing-
292            // slash normalization at config-resolve time; pass the
293            // canonical string form to the SDK.
294            loader = loader.endpoint_url(endpoint.as_url().as_str());
295        }
296
297        match cfg.credentials.as_ref() {
298            Some(S3Credentials::Static {
299                access_key_id,
300                secret_access_key,
301                session_token,
302            }) => {
303                let creds = Credentials::new(
304                    access_key_id.clone(),
305                    secret_access_key.clone(),
306                    session_token.clone(),
307                    None, // expires_after — None = treat as long-lived
308                    "decdn-static-config",
309                );
310                loader = loader.credentials_provider(SharedCredentialsProvider::new(creds));
311            }
312            Some(S3Credentials::DefaultChain { profile }) => {
313                if let Some(name) = profile.as_ref() {
314                    loader = loader.profile_name(name.clone());
315                }
316            }
317            // `None` falls through to whatever `aws_config::defaults`
318            // resolves — same as `DefaultChain { profile: None }`.
319            None => {}
320        }
321
322        let sdk_config = loader.load().await;
323        let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config)
324            .force_path_style(cfg.path_style)
325            .build();
326        let client = Client::from_conf(s3_config);
327
328        Self::from_parts(client, cfg.bucket.as_str(), cfg.prefix.as_str())
329    }
330
331    /// Assemble an origin from a pre-built `Client`, skipping
332    /// `aws_config::defaults`. [`Self::new`] builds its client and then calls
333    /// this. `tests/s3_origin.rs` calls it to inject
334    /// `aws_smithy_mocks::mock_client!`-produced clients, which can't be
335    /// obtained via `aws_config::defaults`.
336    ///
337    /// `pub` rather than `pub(crate)` because the integration tests live
338    /// outside the crate. `#[doc(hidden)]` excludes the function from
339    /// generated rustdoc only — it is technically callable by downstream
340    /// crates and is part of the semver surface by convention. Other
341    /// callers use [`Self::new`].
342    ///
343    /// The origin's connections live on the tokio runtime this is called
344    /// on.
345    ///
346    /// # Errors
347    ///
348    /// Fails outside a tokio runtime.
349    #[doc(hidden)]
350    pub fn from_parts(client: Client, bucket: &str, prefix: &str) -> anyhow::Result<Self> {
351        Ok(Self {
352            client,
353            io: IoRuntime::current()?,
354            bucket: Arc::from(bucket),
355            prefix: Arc::from(prefix),
356            decompress: DecompressMode::Auto,
357        })
358    }
359
360    /// Set the [`DecompressMode`]. See that type for the semantics of
361    /// `Auto` vs `Strict`. Defaults to `Auto`. Mirrors
362    /// [`super::HttpOrigin::with_decompress_mode`] so the wiring layer can
363    /// honour an operator-configured `decompress` knob uniformly across
364    /// backends.
365    #[must_use]
366    pub const fn with_decompress_mode(mut self, mode: DecompressMode) -> Self {
367        self.decompress = mode;
368        self
369    }
370}
371
372/// Classify an HTTP status code returned via an S3 `ServiceError`.
373/// 5xx and the two retry-flagged 4xx codes (408 Request Timeout, 429
374/// Too Many Requests) are transient; everything else is permanent.
375const fn is_transient_status(status: u16) -> bool {
376    status >= 500 || status == 408 || status == 429
377}
378
379/// Classify a `SdkError<GetObjectError>` into the cache engine's
380/// `OriginFetch` / `OriginPullError` taxonomy.
381///
382/// Returns `Ok(OriginFetch::NotFound)` only when the response is
383/// genuinely "object does not exist": either the modeled `NoSuchKey`
384/// variant, or an HTTP 404 whose AWS error code is `NoSuchKey`/empty.
385/// A 404 with any other code (`NoSuchBucket`, `AccessDenied`, ...) is
386/// **not** treated as `NotFound` — those are config / permission
387/// failures that AWS sometimes dresses up as 404 (e.g. when the
388/// principal lacks `s3:ListBucket` it returns 404 for missing objects;
389/// a typo'd bucket also surfaces as a non-modeled 404). Misclassifying
390/// those as `NotFound` would silently mask the operator's real problem.
391///
392/// Errors that may be cured by retry (timeouts, dispatch failures,
393/// 5xx/408/429) become `Transient`; everything else (other 4xx,
394/// construction failures, response-parse failures, Glacier-cold objects)
395/// becomes `Permanent`. The cache engine's retry loop
396/// (`crate::retry::run_with_retry_classified`) drives the retry budget off
397/// this distinction.
398///
399/// `SdkError` is `#[non_exhaustive]`. The catch-all arm classifies
400/// future variants as `Permanent` (fail fast over retry-storm) and
401/// emits a `tracing::warn!` so an SDK upgrade that adds a transient-
402/// shaped variant we don't yet recognize is operator-visible rather
403/// than silently misclassified.
404fn classify_get_object_error(
405    err: SdkError<GetObjectError>,
406    log_target: &str,
407) -> Result<OriginFetch, OriginPullError> {
408    match err {
409        SdkError::ServiceError(service_err) => {
410            let status = service_err.raw().status().as_u16();
411            let inner = service_err.into_err();
412            // Modeled NoSuchKey is the canonical not-found signal.
413            if matches!(inner, GetObjectError::NoSuchKey(_)) {
414                return Ok(OriginFetch::NotFound);
415            }
416            // Bare HTTP 404 fallback: treat as NotFound when the AWS
417            // error code is empty, explicitly `NoSuchKey`, or the SDK's
418            // synthetic `NotFound` code (assigned when a 404 has no
419            // parseable error body — e.g. some MinIO configurations).
420            // Any *other* code on a 404 is a permission-disguise or
421            // wrong-bucket failure (`NoSuchBucket`, `AccessDenied`) —
422            // those bypass this branch and surface as `Permanent` so
423            // the operator sees the real cause.
424            if status == 404 {
425                let code = inner.code().unwrap_or("");
426                if code.is_empty()
427                    || code.eq_ignore_ascii_case("NoSuchKey")
428                    || code.eq_ignore_ascii_case("NotFound")
429                {
430                    return Ok(OriginFetch::NotFound);
431                }
432            }
433            let context = format!(
434                "{log_target}: S3 GetObject returned {status} ({})",
435                inner.code().unwrap_or("<no error code>")
436            );
437            // `anyhow::Error::from(inner).context(...)` preserves the
438            // SDK error in the source chain — `anyhow::Error::chain()`
439            // walks through it for structured tracing, and downstream
440            // downcasts (analogous to `CacheError::origin_error_kind`
441            // for HttpOrigin) can recover the typed `GetObjectError`.
442            // Using `anyhow::anyhow!("…{inner}")` would flatten the
443            // chain to a single string and lose that.
444            if is_transient_status(status) {
445                Err(OriginPullError::Transient(
446                    anyhow::Error::from(inner).context(context),
447                ))
448            } else {
449                Err(OriginPullError::Permanent(
450                    anyhow::Error::from(inner).context(context),
451                ))
452            }
453        }
454        // Network-layer transients: TCP/TLS connect failures, idle
455        // timeouts, mid-stream disconnects. With the SDK's internal
456        // retry layer disabled (see `S3Origin::new`), these surface
457        // immediately on the first attempt; the cache engine's
458        // outer `RetryPolicy` (config: `cache.origin_retry`) is the
459        // single source of retry budget.
460        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) => {
461            Err(OriginPullError::Transient(
462                anyhow::Error::from(err)
463                    .context(format!("{log_target}: S3 GetObject transport failure")),
464            ))
465        }
466        // ConstructionFailure = the SDK could not even build the
467        // request (e.g. impossible URL, bad credentials shape). Not
468        // curable by retry. ResponseError = the SDK got a response
469        // but failed to parse it; retrying against the same response
470        // shape will keep failing.
471        SdkError::ConstructionFailure(_) | SdkError::ResponseError(_) => {
472            Err(OriginPullError::Permanent(
473                anyhow::Error::from(err)
474                    .context(format!("{log_target}: S3 GetObject permanent failure")),
475            ))
476        }
477        // `SdkError` is `#[non_exhaustive]`. Conservatively classify
478        // any future variant as Permanent so we don't retry forever
479        // against a new failure mode the workspace doesn't yet
480        // understand. Emit a `warn!` so an SDK upgrade that introduces
481        // a *transient*-shaped variant we should be retrying is
482        // operator-visible (file a ticket → adjust the classifier)
483        // rather than silently treated as fail-fast.
484        _ => {
485            tracing::warn!(
486                target: "decdn_cache::origin::s3",
487                log_target = %log_target,
488                sdk_error = %err,
489                "S3 GetObject returned an SdkError variant this build does not classify; \
490                 treating as Permanent. File a ticket if this fires after an aws-sdk-s3 bump."
491            );
492            Err(OriginPullError::Permanent(
493                anyhow::Error::from(err)
494                    .context(format!("{log_target}: S3 GetObject unrecognized failure")),
495            ))
496        }
497    }
498}
499
500/// Classify a `HeadObject` error for the best-effort [`Origin::size`] probe.
501/// A 404 / `NotFound` is a missing object, not a fault — return `Ok(None)` so
502/// the engine degrades the range pull to a whole-blob fetch. Transient
503/// transport / 5xx faults surface as [`OriginPullError::Transient`]; anything
504/// else is [`OriginPullError::Permanent`]. Mirrors
505/// [`classify_get_object_error`] but collapses the not-found arm into the
506/// `None` size signal.
507fn classify_head_object_error(
508    err: SdkError<HeadObjectError>,
509    log_target: &str,
510) -> Result<Option<u64>, OriginPullError> {
511    match err {
512        SdkError::ServiceError(service_err) => {
513            let status = service_err.raw().status().as_u16();
514            let inner = service_err.into_err();
515            // The modeled `NotFound`, or a bare 404, means the object is absent
516            // → unknown size, degrade. HeadObject carries no response body, so
517            // the AWS error code is frequently empty on a 404; treat either
518            // signal as not-found.
519            if matches!(inner, HeadObjectError::NotFound(_)) || status == 404 {
520                return Ok(None);
521            }
522            let context = format!(
523                "{log_target}: S3 HeadObject returned {status} ({})",
524                inner.code().unwrap_or("<no error code>")
525            );
526            if is_transient_status(status) {
527                Err(OriginPullError::Transient(
528                    anyhow::Error::from(inner).context(context),
529                ))
530            } else {
531                Err(OriginPullError::Permanent(
532                    anyhow::Error::from(inner).context(context),
533                ))
534            }
535        }
536        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) => {
537            Err(OriginPullError::Transient(
538                anyhow::Error::from(err)
539                    .context(format!("{log_target}: S3 HeadObject transport failure")),
540            ))
541        }
542        // Conservative: classify construction/parse and any future
543        // `#[non_exhaustive]` variant as Permanent (mirrors the GetObject path).
544        other => Err(OriginPullError::Permanent(
545            anyhow::Error::from(other).context(format!("{log_target}: S3 HeadObject failed")),
546        )),
547    }
548}
549
550/// Prepend the `s3://bucket/key` request context to a body-phase stream
551/// `io::Error`, so a mid-stream failure carries the same identifying prefix
552/// that header-phase errors get from [`classify_get_object_error`]. `#271`
553/// deferred this: the body error surfaces from `ReaderStream` after the
554/// `GetObject` future has already returned, so `log_target` has to be
555/// threaded in through a per-chunk `map` adapter rather than a single
556/// `.context(...)` call.
557///
558/// The wrapper is deliberately narrow so it does not disturb
559/// [`crate::retry::classify_io_error`], which the engine's side-channel
560/// reader runs over every body-phase `io::Error`. That classifier recovers
561/// typed markers — [`BlobTooLargeMarker`] and [`OriginError`] (e.g.
562/// `DecompressionFailed`) — by downcasting the error's inner, and routes
563/// every other error on its [`std::io::ErrorKind`]. To keep both signals
564/// intact this helper:
565///
566///   * passes a typed-marker error through **untouched** — re-wrapping it in
567///     a `String` would hide the marker and silently reclassify a
568///     deterministic permanent error as a transient `Other`; and
569///   * preserves the original `ErrorKind` on the prefixed error, so the
570///     transient/permanent routing of network faults (`UnexpectedEof`,
571///     `ConnectionReset`, …) is unchanged; and
572///   * keeps the original error's message in `Display` and its own causes on
573///     the `source()` chain (via [`PrefixedBodyError`]), so a chain formatter
574///     names each cause once and nothing under the original error is lost.
575fn prefix_body_stream_error(log_target: &str, e: std::io::Error) -> std::io::Error {
576    if e.get_ref()
577        .is_some_and(|inner| inner.is::<BlobTooLargeMarker>() || inner.is::<OriginError>())
578    {
579        return e;
580    }
581    let kind = e.kind();
582    std::io::Error::new(
583        kind,
584        PrefixedBodyError {
585            prefix: log_target.to_string(),
586            inner: e,
587        },
588    )
589}
590
591/// Error wrapper produced by [`prefix_body_stream_error`]. `Display` prepends
592/// the `s3://bucket/key` request context to the original `io::Error`'s message.
593/// `source()` continues from the original error's own source, because its
594/// message is already in `Display`: a chain formatter then names it once.
595#[derive(Debug)]
596struct PrefixedBodyError {
597    prefix: String,
598    inner: std::io::Error,
599}
600
601impl std::fmt::Display for PrefixedBodyError {
602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603        write!(f, "{}: {}", self.prefix, self.inner)
604    }
605}
606
607impl std::error::Error for PrefixedBodyError {
608    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
609        self.inner.source()
610    }
611}
612
613impl Origin for S3Origin {
614    fn kind(&self) -> OriginKind {
615        OriginKind::S3
616    }
617
618    fn fetch(
619        &self,
620        hash: Hash,
621        max_bytes: u64,
622    ) -> Pin<Box<dyn Future<Output = Result<OriginFetch, OriginPullError>> + Send + '_>> {
623        Box::pin(async move {
624            let key = key_for(&self.prefix, hash);
625            // `s3://bucket/key` is the conventional log shape. Safe to
626            // print: the bucket comes from operator-set config and the
627            // key is `{prefix}{hex[..2]}/{hex}` over a content-addressed
628            // BLAKE3 hash — no caller-controlled bytes, no credentials.
629            // (Contrast with HttpOrigin's URL, which can carry userinfo
630            // and is redacted via `redact_for_log` on every log line.)
631            let log_target = format!("s3://{}/{}", self.bucket, key);
632
633            let resp = match self
634                .io
635                .run(
636                    self.client
637                        .get_object()
638                        .bucket(self.bucket.as_ref())
639                        .key(&key)
640                        .send(),
641                )
642                .await
643                .map_err(|e| e.map_inner(|e| e.context(format!("{log_target}: S3 GetObject"))))?
644            {
645                Ok(r) => r,
646                Err(e) => return classify_get_object_error(e, &log_target),
647            };
648
649            // Classify `Content-Encoding` and apply the strict/auto policy
650            // using the same helper as the HTTP backend (#804). The BLAKE3
651            // verify in `CacheEngine` runs over canonical bytes, so a
652            // compressed body must be decoded back to canonical form before
653            // it reaches the engine. `resolve_encoding` returns `Err` for
654            // unknown encodings (e.g. `br`) and for known encodings under
655            // `DecompressMode::Strict` — both are permanent and fire before
656            // any body bytes are read.
657            let supported_encoding = match resp.content_encoding() {
658                None => None,
659                Some(enc) => match decompress::resolve_encoding(enc.trim(), self.decompress) {
660                    Ok(encoding) => encoding,
661                    Err(err) => {
662                        // Keep the operator runbook hint on the rejection: the
663                        // remediation is to store canonical bytes at the origin,
664                        // or (for gzip/zstd under `Strict`) flip to `auto`. The
665                        // `decode gzip/zstd` scoping keeps the hint honest for
666                        // unknown encodings like `br`, which `auto` cannot help.
667                        return Err(OriginPullError::Permanent(
668                            anyhow::Error::from(err).context(format!(
669                                "{log_target}: S3 origin rejected Content-Encoding — store \
670                                 canonical bytes at the origin, or set `decompress = \"auto\"` \
671                                 to decode gzip/zstd"
672                            )),
673                        ));
674                    }
675                },
676            };
677
678            // Fast-path size check from the SDK-parsed Content-Length.
679            // `content_length()` returns `Option<i64>` — negative is
680            // a deterministic protocol violation; `> max_bytes` saves
681            // us streaming bytes we'll throw away. The engine still
682            // re-checks the running total via `count_and_cap_stream`
683            // as the body arrives, so an origin that lies in the
684            // header is caught mid-flight.
685            let advertised_size: Option<u64> = match resp.content_length() {
686                Some(len) if len < 0 => {
687                    return Err(OriginPullError::Permanent(anyhow::anyhow!(
688                        "{log_target}: origin reported negative Content-Length={len}"
689                    )));
690                }
691                Some(len) => {
692                    #[allow(clippy::cast_sign_loss)] // checked >= 0 above.
693                    let len_u64 = len as u64;
694                    if len_u64 > max_bytes {
695                        return Err(OriginPullError::Permanent(anyhow::anyhow!(
696                            "{log_target}: Content-Length={len} exceeds max {max_bytes}"
697                        )));
698                    }
699                    Some(len_u64)
700                }
701                None => None,
702            };
703
704            // Stream the body chunk-by-chunk into the engine
705            // (issue #271). `ByteStream::into_async_read` yields a
706            // `tokio::io::AsyncBufRead` over the wire body —
707            // `aws_smithy_types`'s `Stream` impl is
708            // crate-private so we go through the AsyncRead seam,
709            // then back to `Stream<io::Result<Bytes>>` via
710            // `ReaderStream`. Chunks reach iroh-blobs'
711            // `add_stream` without the body ever sitting in
712            // process memory in full — a 10 GB S3 object never
713            // pins 10 GB of RSS.
714            //
715            // **Operator-visible diagnostics for body errors:**
716            // `aws_smithy_types::byte_stream::error::Error` is
717            // wrapped into the `io::Error` that `ReaderStream`
718            // emits and captured by the engine's
719            // `count_and_cap_stream` side channel. The `s3://`
720            // bucket/key prefix is attached to that error below via
721            // `prefix_body_stream_error`, so a mid-stream failure
722            // carries the same identifying context as the
723            // header-phase `classify_get_object_error` errors
724            // (issue #1617). The per-chunk `map` closure runs for
725            // every item, but it is a cheap passthrough on `Ok`
726            // (no allocation or formatting) and only builds the
727            // prefixed error on `Err`; it preserves the
728            // `io::ErrorKind` and any typed marker so the engine's
729            // `classify_io_error` still routes the error correctly.
730            let async_read = resp.body.into_async_read();
731            let raw_stream = ReaderStream::new(async_read);
732            // Layer the decoder (if any) onto the raw chunk stream. For an
733            // identity body this boxes the stream through unchanged; for
734            // gzip/zstd it decodes to canonical bytes. The decompressed-side
735            // `max_bytes` cap is still enforced by the engine's
736            // `count_and_cap_stream`, so a small compressed payload that
737            // decodes to a huge blob fails fast at the engine seam.
738            let stream = decompress::decode_stream(raw_stream, supported_encoding);
739            // For a decoded (compressed) body the advertised `Content-Length`
740            // is the *encoded* size, which understates the canonical length.
741            // Reporting it as `size_hint` would let the engine route a body
742            // whose encoded length fits under `buffered_max_bytes` into the
743            // buffer/drain path, where the drain cap (`buffered_max_bytes`)
744            // is applied to the *decoded* stream and falsely rejects an
745            // in-bounds blob as `BlobTooLarge` (#804). Hand `None` so the
746            // body always takes the streaming path, where
747            // `count_and_cap_stream` checks the running decoded total
748            // against the correct `max_blob_bytes`. The pre-stream
749            // `advertised_size > max_bytes` short-circuit above still runs
750            // first, so a compressed body advertising > `max_bytes` is
751            // rejected early (compression ratios < 1 make that a sound
752            // bound). Identity bodies keep the canonical-length hint.
753            let size_hint = if supported_encoding.is_some() {
754                None
755            } else {
756                advertised_size
757            };
758            // Attach the `s3://bucket/key` request context to any body-phase
759            // stream error (issue #1617). `log_target` is moved into the
760            // per-chunk `map`; it is not read again after this point.
761            let stream: OriginByteStream = Box::pin(
762                stream.map(move |item| item.map_err(|e| prefix_body_stream_error(&log_target, e))),
763            );
764            Ok(OriginFetch::Found { stream, size_hint })
765        })
766    }
767
768    fn fetch_range_data(
769        &self,
770        hash: Hash,
771        req: OriginRangeRequest,
772    ) -> Pin<Box<dyn Future<Output = Result<OriginRangeFetch, OriginPullError>> + Send + '_>> {
773        Box::pin(async move {
774            let data_key = key_for(&self.prefix, hash);
775            // Empty span only for a zero-length blob.
776            if req.is_empty() {
777                return Ok(OriginRangeFetch::Ranged { data: Bytes::new() });
778            }
779
780            // Ranged data read. S3 `Range` is inclusive-end (`bytes=a-b`),
781            // matching HTTP. S3 answers `206` for an honored range; the SDK
782            // surfaces that transparently, so we validate by exact returned
783            // length instead of inspecting the status (a server that ignored
784            // the range returns the whole object and trips the length check).
785            let range_val = format!("bytes={}-{}", req.fetch_start, req.fetch_end - 1);
786            let want = req.len();
787            let log_target = format!("s3://{}/{} (range {range_val})", self.bucket, data_key);
788            let resp = match self
789                .io
790                .run(
791                    self.client
792                        .get_object()
793                        .bucket(self.bucket.as_ref())
794                        .key(&data_key)
795                        .range(range_val)
796                        .send(),
797                )
798                .await
799                .map_err(|e| e.map_inner(|e| e.context(format!("{log_target}: S3 GetObject"))))?
800            {
801                Ok(r) => r,
802                // Reuse the headers-phase classifier. A `NotFound` here is the
803                // data object missing or deleted mid-pull — degrade rather than
804                // error (whole-blob pull surfaces the real `NotFound`).
805                Err(e) => match classify_get_object_error(e, &log_target)? {
806                    // `classify_get_object_error` only ever produces
807                    // `NotFound` for an `Err` input; `Found` and
808                    // `AlreadyAdmitted` are present only to satisfy
809                    // exhaustiveness.
810                    OriginFetch::NotFound
811                    | OriginFetch::Found { .. }
812                    | OriginFetch::AlreadyAdmitted => {
813                        return Ok(OriginRangeFetch::Unsupported);
814                    }
815                },
816            };
817            // A ranged GET that decoded its body would break the offset→byte
818            // mapping the bao proof anchors on; refuse a compressed ranged
819            // object and degrade to the whole-blob path (which decodes safely).
820            if resp
821                .content_encoding()
822                .is_some_and(|e| !e.trim().is_empty())
823            {
824                return Ok(OriginRangeFetch::Unsupported);
825            }
826            // Fast-fail on a `Content-Length` that doesn't match the requested
827            // span BEFORE collecting the body: an origin that ignored `Range`
828            // (and is about to stream the whole object) advertises the full
829            // length here, so we degrade without buffering. The exact-length
830            // gate on the collected bytes below is the load-bearing check;
831            // this only avoids reading a body we already know is wrong-sized.
832            if let Some(len) = resp.content_length()
833                && (len < 0 || u64::try_from(len).unwrap_or(u64::MAX) != want)
834            {
835                return Ok(OriginRangeFetch::Unsupported);
836            }
837            let want_usize = usize::try_from(want).unwrap_or(usize::MAX);
838            let Some(data) = collect_bounded(resp.body, want_usize).await? else {
839                return Ok(OriginRangeFetch::Unsupported);
840            };
841            // Exact-length gate: a server that ignored `Range` (returned the
842            // whole object) or returned multipart bytes is rejected here.
843            if u64::try_from(data.len()).unwrap_or(u64::MAX) != want {
844                return Ok(OriginRangeFetch::Unsupported);
845            }
846            Ok(OriginRangeFetch::Ranged { data })
847        })
848    }
849
850    fn fetch_outboard(
851        &self,
852        hash: Hash,
853        outboard_max_bytes: u64,
854    ) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, OriginPullError>> + Send + '_>> {
855        Box::pin(async move {
856            let data_key = key_for(&self.prefix, hash);
857            let obao4_key = format!("{data_key}{OBAO4_SUFFIX}");
858            let log_target = format!("s3://{}/{}", self.bucket, obao4_key);
859            let resp = match self
860                .io
861                .run(
862                    self.client
863                        .get_object()
864                        .bucket(self.bucket.as_ref())
865                        .key(&obao4_key)
866                        .send(),
867                )
868                .await
869                .map_err(|e| e.map_inner(|e| e.context(format!("{log_target}: S3 GetObject"))))?
870            {
871                Ok(r) => r,
872                Err(e) => match classify_get_object_error(e, &log_target)? {
873                    // Missing outboard is the expected path for an origin
874                    // that doesn't publish `{H}.obao4`.
875                    OriginFetch::NotFound => return Ok(OutboardFetch::NotFound),
876                    // `classify_get_object_error` never actually produces
877                    // these arms for an `Err` input; only present to satisfy
878                    // exhaustiveness.
879                    OriginFetch::Found { .. } | OriginFetch::AlreadyAdmitted => {
880                        return Ok(OutboardFetch::Unsupported);
881                    }
882                },
883            };
884            if let Some(len) = resp.content_length()
885                && (len < 0 || u64::try_from(len).unwrap_or(u64::MAX) > outboard_max_bytes)
886            {
887                return Ok(OutboardFetch::Unsupported);
888            }
889            let cap = usize::try_from(outboard_max_bytes).unwrap_or(usize::MAX);
890            match collect_bounded(resp.body, cap).await? {
891                Some(bytes) => Ok(OutboardFetch::Found(bytes)),
892                None => Ok(OutboardFetch::Unsupported),
893            }
894        })
895    }
896
897    fn size(
898        &self,
899        hash: Hash,
900    ) -> Pin<Box<dyn Future<Output = Result<Option<u64>, OriginPullError>> + Send + '_>> {
901        Box::pin(async move {
902            let key = key_for(&self.prefix, hash);
903            let log_target = format!("s3://{}/{key}", self.bucket);
904            // `HeadObject` returns the object metadata (including
905            // `Content-Length`) without transferring the body — the cheapest
906            // way to learn the canonical blob size before a range pull.
907            let resp = match self
908                .io
909                .run(
910                    self.client
911                        .head_object()
912                        .bucket(self.bucket.as_ref())
913                        .key(&key)
914                        .send(),
915                )
916                .await
917                .map_err(|e| e.map_inner(|e| e.context(format!("{log_target}: S3 HeadObject"))))?
918            {
919                Ok(r) => r,
920                Err(e) => return classify_head_object_error(e, &log_target),
921            };
922            // A `Content-Encoding` object advertises the *encoded* length here,
923            // not the canonical blob size — degrade to unknown, consistent with
924            // `fetch_range_data` refusing compressed ranges.
925            if resp
926                .content_encoding()
927                .is_some_and(|e| !e.trim().is_empty())
928            {
929                return Ok(None);
930            }
931            // `content_length()` is `Option<i64>`; a negative or absent value is
932            // unusable → unknown size.
933            Ok(resp
934                .content_length()
935                .and_then(|len| u64::try_from(len).ok()))
936        })
937    }
938}
939
940/// Drain an S3 `ByteStream` into `Bytes`, returning `Ok(None)` the moment the
941/// cumulative body exceeds `cap`. Streams chunk-by-chunk (via
942/// `ByteStream::try_next`) and aborts on the first over-cap chunk WITHOUT
943/// buffering the rest — a misbehaving origin that ignores `Range` and streams a
944/// huge object (or serves an oversized `.obao4`) can't force a whole-body
945/// allocation. This mirrors the bounded streaming reader in
946/// [`crate::origin::http`] (`collect_capped`); `ByteStream::collect()` is
947/// deliberately NOT used because it buffers the entire body before any cap
948/// check. Transport errors mid-body surface as `Transient`.
949async fn collect_bounded(
950    mut body: aws_sdk_s3::primitives::ByteStream,
951    cap: usize,
952) -> Result<Option<Bytes>, OriginPullError> {
953    let mut buf = BytesMut::new();
954    loop {
955        match body.try_next().await {
956            Ok(None) => break,
957            Ok(Some(chunk)) => {
958                if buf.len().saturating_add(chunk.len()) > cap {
959                    // Over the bound → degrade. The optimization is best-effort;
960                    // an oversized span/outboard is treated as "not
961                    // range-pullable", never a hard error — and we abort here
962                    // rather than keep draining the body.
963                    return Ok(None);
964                }
965                buf.extend_from_slice(&chunk);
966            }
967            Err(e) => {
968                return Err(OriginPullError::Transient(
969                    anyhow::Error::from(e).context("S3 range body read failed"),
970                ));
971            }
972        }
973    }
974    Ok(Some(buf.freeze()))
975}
976
977#[cfg(test)]
978#[allow(clippy::unwrap_used, clippy::expect_used)]
979mod tests;