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;