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