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;