Skip to main content

icechunk_s3/
lib.rs

1//! Native S3 client implementation of [`Storage`](icechunk_storage::Storage).
2
3// Re-export AWS SDK types needed by consumers (e.g., icechunk's virtual_chunks)
4pub use aws_sdk_s3;
5
6use std::{
7    borrow::Cow, collections::HashMap, fmt, future::ready, ops::Range, pin::Pin,
8    sync::Arc, time::Duration,
9};
10
11use async_trait::async_trait;
12use aws_config::{
13    BehaviorVersion, meta::region::RegionProviderChain, retry::RetryConfig,
14    timeout::TimeoutConfig,
15};
16use aws_credential_types::provider::error::CredentialsError;
17use aws_sdk_s3::{
18    Client,
19    config::{
20        Builder, ConfigBag, IdentityCache, Intercept, ProvideCredentials, Region,
21        RuntimeComponents, StalledStreamProtectionConfig,
22        interceptors::{
23            BeforeDeserializationInterceptorContextMut,
24            BeforeTransmitInterceptorContextMut,
25        },
26    },
27    error::{BoxError, SdkError},
28    operation::{copy_object::CopyObjectError, put_object::PutObjectError},
29    primitives::ByteStream,
30    types::{CompletedMultipartUpload, CompletedPart, Delete, Object, ObjectIdentifier},
31};
32use aws_smithy_runtime::client::retries::classifiers::HttpStatusCodeClassifier;
33use aws_smithy_types_convert::{
34    date_time::DateTimeExt as _, stream::PaginationStreamExt as _,
35};
36use bytes::Bytes;
37use chrono::{DateTime, Utc};
38use futures::{
39    Stream, StreamExt as _, TryStreamExt as _,
40    stream::{self, BoxStream, FuturesOrdered},
41};
42pub use icechunk_storage::s3_config::{
43    S3ChecksumAlgorithm, S3Credentials, S3CredentialsFetcher, S3Options,
44    S3StaticCredentials,
45};
46use icechunk_storage::{
47    DeleteObjectsResult, GetModifiedResult, ListInfo, RepositoryCreation, Settings,
48    Storage, StorageContext, StorageError, StorageErrorKind, StorageInfo, StorageResult,
49    VersionInfo, VersionedUpdateResult, obj_not_found_res, obj_store_error,
50    obj_store_error_res, other_error,
51    readback::{
52        ReadbackOutcome, WRITE_ID_METADATA_KEY, resolve_lost_response,
53        resolve_precondition, write_id_for,
54    },
55    sealed, split_in_multiple_equal_requests, strip_quotes, throttled_error,
56};
57use icechunk_types::ICResultExt as _;
58use serde::{Deserialize, Serialize};
59use tokio::sync::OnceCell;
60use tokio_util::io::StreamReader;
61use tracing::{error, instrument, trace, warn};
62use typed_path::Utf8UnixPath;
63use uuid::Uuid;
64
65/// How object keys are laid out inside the bucket for a given repository.
66///
67/// Native-S3 repositories written before the fix for
68/// <https://github.com/earth-mover/icechunk/issues/2239> stored every object
69/// under a leading slash when the prefix was empty (`"/chunks/..."`, etc.).
70/// We keep being able to read and write those repositories via [`KeyLayout::LegacyRoot`],
71/// while all new repositories use the clean [`KeyLayout::Standard`] layout.
72///
73/// A repository has exactly one layout, decided once and never mixed.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub enum KeyLayout {
76    /// `prefix == ""` -> `"chunks/x"`; `prefix == "p"` -> `"p/chunks/x"`.
77    Standard,
78    /// Only used with an empty prefix: reproduces the historical `"/chunks/x"`.
79    LegacyRoot,
80}
81
82/// Well-known object names probed by [`S3Storage::probe_layout`] to auto-detect
83/// the key layout of a pre-existing repository. These are fixed keys that exist
84/// in every repository.
85///
86/// It's awful to have to have this tacit dependency here, this is icechunk-format stuff.
87/// But... reality hits you hard. I don't want an explicit dependency between the crates
88/// but there are some tests in `icechunk/src/refs.rs` that verify these strings are "right".
89pub const DEFAULT_LAYOUT_ANCHORS: &[&str] = &["repo", "refs/branch.main/ref.json"];
90
91/// Serializes the resolved [`KeyLayout`] as `Option<KeyLayout>`.
92mod layout_cell_serde {
93    use super::{KeyLayout, OnceCell};
94    use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer};
95
96    pub(super) fn serialize<S: Serializer>(
97        cell: &OnceCell<KeyLayout>,
98        serializer: S,
99    ) -> Result<S::Ok, S::Error> {
100        cell.get().copied().serialize(serializer)
101    }
102
103    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
104        deserializer: D,
105    ) -> Result<OnceCell<KeyLayout>, D::Error> {
106        let resolved = Option::<KeyLayout>::deserialize(deserializer)?;
107        Ok(OnceCell::new_with(resolved))
108    }
109}
110
111#[derive(Debug, Serialize, Deserialize)]
112pub struct S3Storage {
113    // config and credentials are stored so we are able to serialize and deserialize the struct
114    config: S3Options,
115    credentials: S3Credentials,
116    bucket: String,
117    prefix: String,
118    can_write: bool,
119    extra_read_headers: Vec<(String, String)>,
120    extra_write_headers: Vec<(String, String)>,
121    #[serde(default, with = "layout_cell_serde")]
122    key_layout: OnceCell<KeyLayout>,
123    /// Test/internal escape hatch permitting repository creation at an empty
124    /// prefix.
125    #[serde(skip)]
126    allow_empty_prefix_creation: bool,
127    #[serde(skip)]
128    /// We need to use `OnceCell` to allow async initialization, because serde
129    /// does not support async function calls from deserialization. This gives
130    /// us a way to lazily initialize the client.
131    client: OnceCell<Arc<Client>>,
132}
133
134impl fmt::Display for S3Storage {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(
137            f,
138            "S3Storage(bucket={}, prefix={}, config={})",
139            self.bucket, self.prefix, self.config,
140        )
141    }
142}
143#[derive(Debug)]
144struct ExtraHeadersInterceptor {
145    extra_read_headers: Vec<(String, String)>,
146    extra_write_headers: Vec<(String, String)>,
147}
148
149impl Intercept for ExtraHeadersInterceptor {
150    fn name(&self) -> &'static str {
151        "ExtraHeaders"
152    }
153
154    fn modify_before_retry_loop(
155        &self,
156        context: &mut BeforeTransmitInterceptorContextMut<'_>,
157        _runtime_components: &RuntimeComponents,
158        _cfg: &mut ConfigBag,
159    ) -> Result<(), BoxError> {
160        let request = context.request_mut();
161        let headers = match request.method() {
162            "GET" | "HEAD" | "OPTIONS" | "TRACE" => &self.extra_read_headers,
163            _ => &self.extra_write_headers,
164        };
165        for (k, v) in headers.iter() {
166            request.headers_mut().try_insert(k.clone(), v.clone()).map_err(
167                |e| -> BoxError {
168                    format!("invalid extra HTTP header {k:?}: {e}").into()
169                },
170            )?;
171        }
172        Ok(())
173    }
174}
175
176/// Appends the icechunk attribution fragment to the `user-agent` the SDK
177/// built. It runs after the SDK's client-level `UserAgentInterceptor`,
178/// so the fragment lands after the SDK tokens.
179#[derive(Debug)]
180struct AttributionInterceptor {
181    fragment: String,
182}
183
184impl Intercept for AttributionInterceptor {
185    fn name(&self) -> &'static str {
186        "IcechunkAttribution"
187    }
188
189    fn modify_before_signing(
190        &self,
191        context: &mut BeforeTransmitInterceptorContextMut<'_>,
192        _runtime_components: &RuntimeComponents,
193        _cfg: &mut ConfigBag,
194    ) -> Result<(), BoxError> {
195        let headers = context.request_mut().headers_mut();
196        let ua = match headers.get("user-agent") {
197            Some(current) => format!("{current} {}", self.fragment),
198            None => self.fragment.clone(),
199        };
200        headers.try_insert("user-agent", ua)?;
201        Ok(())
202    }
203}
204
205/// Per-request config that appends `fragment` to the SDK's `user-agent`.
206pub fn attribution_override_for(fragment: String) -> Builder {
207    Builder::new().interceptor(AttributionInterceptor { fragment })
208}
209
210fn attribution_override(ctx: &StorageContext<'_>) -> Builder {
211    attribution_override_for(ctx.attribution.user_agent_fragment())
212}
213
214/// Strips `x-amz-checksum-*` headers from HTTP 304 (Not Modified) responses.
215///
216/// R2 includes checksum headers (e.g. crc32, crc64nvme) on 304 responses, but
217/// because 304 responses have no body the AWS SDK's checksum validation fails
218/// with a mismatch, triggering transient-error retries with exponential backoff.
219#[derive(Debug)]
220struct StripChecksumOn304Interceptor;
221
222impl Intercept for StripChecksumOn304Interceptor {
223    fn name(&self) -> &'static str {
224        "StripChecksumOn304"
225    }
226
227    fn modify_before_deserialization(
228        &self,
229        context: &mut BeforeDeserializationInterceptorContextMut<'_>,
230        _runtime_components: &RuntimeComponents,
231        _cfg: &mut ConfigBag,
232    ) -> Result<(), BoxError> {
233        let response = context.response_mut();
234        if response.status().as_u16() == 304 {
235            let to_remove: Vec<_> = response
236                .headers()
237                .iter()
238                .filter(|(name, _)| name.starts_with("x-amz-checksum-"))
239                .map(|(name, _)| name.to_owned())
240                .collect();
241            for name in to_remove {
242                response.headers_mut().remove(&name);
243            }
244        }
245        Ok(())
246    }
247}
248
249/// Append the `/` that separates an endpoint path from the bucket name.
250///
251/// The SDK omits it, so `https://s3.hf.co/<ns>` addresses `/<ns><bucket>/<key>`.
252fn endpoint_with_bucket_separator(endpoint_url: &str) -> Cow<'_, str> {
253    // The SDK appends the bucket name to any path past the root.
254    let has_path =
255        endpoint_url.split_once("://").is_some_and(|(_, rest)| rest.contains('/'));
256    if has_path && !endpoint_url.ends_with('/') {
257        Cow::Owned(format!("{endpoint_url}/"))
258    } else {
259        Cow::Borrowed(endpoint_url)
260    }
261}
262
263#[instrument(skip(credentials))]
264pub async fn mk_client(
265    config: &S3Options,
266    credentials: S3Credentials,
267    extra_read_headers: Vec<(String, String)>,
268    extra_write_headers: Vec<(String, String)>,
269    settings: &Settings,
270) -> Client {
271    let region = config
272        .region
273        .as_ref()
274        .map(|r| RegionProviderChain::first_try(Some(Region::new(r.clone()))))
275        .unwrap_or_else(RegionProviderChain::default_provider);
276
277    let endpoint = config.endpoint_url.as_deref().map(endpoint_with_bucket_separator);
278    let region = if endpoint.is_some() {
279        // GH793, the S3 SDK requires a region even though it may not make sense
280        // for S3-compatible object stores like Tigris or Ceph.
281        // So we set a fake region, using the `endpoint_url` as a sign that
282        // we are not talking to real S3
283        region.or_else(Region::new("region-was-not-set"))
284    } else {
285        region
286    };
287
288    let mut aws_config =
289        aws_config::defaults(BehaviorVersion::v2026_01_12()).region(region);
290
291    if let Some(endpoint) = endpoint {
292        aws_config = aws_config.endpoint_url(endpoint);
293    }
294
295    let stalled_stream = if config.network_stream_timeout_seconds == Some(0) {
296        StalledStreamProtectionConfig::disabled()
297    } else {
298        StalledStreamProtectionConfig::enabled()
299            .grace_period(Duration::from_secs(
300                config.network_stream_timeout_seconds.unwrap_or(10) as u64,
301            ))
302            .build()
303    };
304    aws_config = aws_config.stalled_stream_protection(stalled_stream);
305
306    match credentials {
307        S3Credentials::FromEnv => {}
308        S3Credentials::Anonymous => aws_config = aws_config.no_credentials(),
309        S3Credentials::Static(credentials) => {
310            aws_config =
311                aws_config.credentials_provider(aws_credential_types::Credentials::new(
312                    credentials.access_key_id,
313                    credentials.secret_access_key,
314                    credentials.session_token,
315                    credentials.expires_after.map(|e| e.into()),
316                    "user",
317                ));
318        }
319        S3Credentials::Refreshable(fetcher) => {
320            aws_config =
321                aws_config.credentials_provider(ProvideRefreshableCredentials(fetcher));
322        }
323    }
324
325    let retry_config = RetryConfig::standard()
326        .with_max_attempts(settings.retries().max_tries().get() as u32)
327        .with_initial_backoff(Duration::from_millis(
328            settings.retries().initial_backoff_ms() as u64,
329        ))
330        .with_max_backoff(Duration::from_millis(
331            settings.retries().max_backoff_ms() as u64
332        ));
333
334    if let Some(timeouts) = settings.timeouts() {
335        let mut timeout_builder = TimeoutConfig::builder();
336        if let Some(ms) = timeouts.connect_timeout_ms {
337            timeout_builder =
338                timeout_builder.connect_timeout(Duration::from_millis(ms as u64));
339        }
340        if let Some(ms) = timeouts.read_timeout_ms {
341            timeout_builder =
342                timeout_builder.read_timeout(Duration::from_millis(ms as u64));
343        }
344        if let Some(ms) = timeouts.operation_timeout_ms {
345            timeout_builder =
346                timeout_builder.operation_timeout(Duration::from_millis(ms as u64));
347        }
348        if let Some(ms) = timeouts.operation_attempt_timeout_ms {
349            timeout_builder = timeout_builder
350                .operation_attempt_timeout(Duration::from_millis(ms as u64));
351        }
352        aws_config = aws_config.timeout_config(timeout_builder.build());
353    }
354
355    let mut s3_builder = Builder::from(&aws_config.load().await)
356        .force_path_style(config.force_path_style)
357        .retry_config(retry_config);
358
359    // credentials may take a while to refresh, defaults are too strict
360    let id_cache = IdentityCache::lazy()
361        .load_timeout(Duration::from_secs(120))
362        .buffer_time(Duration::from_secs(120))
363        .build();
364
365    s3_builder = s3_builder.identity_cache(id_cache);
366
367    // Add retry classifier for HTTP 408 (Request Timeout) and 429 (Too Many Requests).
368    // The default HttpStatusCodeClassifier only retries on 500, 502, 503, 504
369    // Note R2 sends 429 for "slowdown" while S3 sends 503.
370    //   - R2: https://developers.cloudflare.com/r2/api/error-codes/
371    //   - S3: https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
372    // Tigris can occasionally respond with 499: "Client Closed Request"
373    static RETRY_CODES: &[u16] = &[408, 429, 499];
374    // This confusingly named `retry_classifier` method ends up calling
375    // `push_retry_classifier` after wrapping our custom classifier in `SharedRetryClassifier`.
376    // Ultimately, this is a push on to a `Vec<SharedRetryClassifier>`, and is thus additive
377    // to the existing default retry configuration.
378    // https://github.com/smithy-lang/smithy-rs/blob/cfcc39cf4b5bea665bba684b64bfca2b89e4bc73/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs#L755
379    // https://github.com/smithy-lang/smithy-rs/blob/cfcc39cf4b5bea665bba684b64bfca2b89e4bc73/rust-runtime/aws-smithy-runtime-api/src/client/runtime_components.rs#L370
380    s3_builder = s3_builder
381        .retry_classifier(HttpStatusCodeClassifier::new_from_codes(RETRY_CODES));
382
383    if !extra_read_headers.is_empty() || !extra_write_headers.is_empty() {
384        s3_builder = s3_builder.interceptor(ExtraHeadersInterceptor {
385            extra_read_headers,
386            extra_write_headers,
387        });
388    }
389
390    // R2 and Scaleway include x-amz-checksum-* headers on 304 Not Modified
391    // responses, but because 304 has no body the SDK's checksum validation
392    // fails and triggers expensive transient-error retries.
393    if config.endpoint_url.as_ref().is_some_and(|url| {
394        url.contains(".r2.cloudflarestorage.com") || url.contains(".scw.cloud")
395    }) {
396        s3_builder = s3_builder.interceptor(StripChecksumOn304Interceptor);
397    }
398
399    let config = s3_builder.build();
400
401    Client::from_conf(config)
402}
403
404fn to_sdk_checksum(a: S3ChecksumAlgorithm) -> aws_sdk_s3::types::ChecksumAlgorithm {
405    use aws_sdk_s3::types::ChecksumAlgorithm as Sdk;
406    match a {
407        S3ChecksumAlgorithm::Crc32 => Sdk::Crc32,
408        S3ChecksumAlgorithm::Crc32c => Sdk::Crc32C,
409        S3ChecksumAlgorithm::Crc64Nvme => Sdk::Crc64Nvme,
410        S3ChecksumAlgorithm::Sha1 => Sdk::Sha1,
411        S3ChecksumAlgorithm::Sha256 => Sdk::Sha256,
412    }
413}
414
415fn stream2stream(
416    s: ByteStream,
417) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
418    let res = stream::try_unfold(s, move |mut stream| async move {
419        let next = stream.try_next().await?;
420        Ok(next.map(|bytes| (bytes, stream)))
421    });
422    Box::pin(res)
423}
424
425/// `Uuid::new_v4` in production; tests force a value via [`test_util`].
426fn next_write_id() -> String {
427    #[cfg(feature = "test-util")]
428    if let Some(forced) = test_util::take_forced_write_id() {
429        return forced;
430    }
431    Uuid::new_v4().to_string()
432}
433
434/// Test-only write-id injection for the #2099 lost-response tests. Feature-gated
435/// `pub` (not `#[cfg(test)]`) because the consumers are in the `icechunk` crate;
436/// the forced id is thread-local and consumed by a single PUT.
437///
438/// This approach avoids exposing a new argument to pass an explicit write-id
439/// where needed, and it should be an implementation detail anyway.
440#[cfg(feature = "test-util")]
441pub mod test_util {
442    use std::cell::RefCell;
443
444    thread_local! {
445        static FORCED_WRITE_ID: RefCell<Option<String>> = const { RefCell::new(None) };
446    }
447
448    /// Make the next conditional PUT on this thread stamp `id`.
449    pub fn force_next_write_id(id: impl Into<String>) {
450        FORCED_WRITE_ID.with(|c| *c.borrow_mut() = Some(id.into()));
451    }
452
453    pub(crate) fn take_forced_write_id() -> Option<String> {
454        FORCED_WRITE_ID.with(|c| c.borrow_mut().take())
455    }
456}
457
458/// Conditional header a PUT carries; shared by single-PUT and multipart.
459enum Conditional<'a> {
460    IfNoneMatch,
461    IfMatch(&'a str),
462}
463
464fn conditional_for<'a>(
465    previous_version: Option<&'a VersionInfo>,
466    settings: &Settings,
467) -> Option<Conditional<'a>> {
468    let pv = previous_version?;
469    match (
470        pv.etag(),
471        settings.unsafe_use_conditional_create(),
472        settings.unsafe_use_conditional_update(),
473    ) {
474        (None, true, _) => Some(Conditional::IfNoneMatch),
475        (Some(etag), _, true) => Some(Conditional::IfMatch(etag)),
476        (_, _, _) => None,
477    }
478}
479
480/// Error codes meaning the conditional header tripped. `PreconditionFailed`
481/// is minio, `ConditionalRequestConflict` is S3, `ConcurrentModification` is
482/// Ceph Object Gateway.
483fn is_precondition_code(code: &str) -> bool {
484    matches!(
485        code,
486        "PreconditionFailed" | "ConditionalRequestConflict" | "ConcurrentModification"
487    )
488}
489
490/// Identify a retryable error: any 5xx, HTTP 429, or an S3 code that means "try again"
491fn is_transient_s3_error(status: Option<u16>, code: &str) -> bool {
492    matches!(status, Some(s) if s >= 500 || s == 429)
493        || matches!(
494            code,
495            "InternalError"
496                | "ServiceUnavailable"
497                | "SlowDown"
498                | "RequestLimitExceeded"
499                | "Throttling"
500                | "ThrottlingException"
501        )
502}
503
504/// The code to report when a partial `DeleteObjects` response should be
505/// retried whole: `Some` only if every per-key error is transient.
506fn transient_per_key_code(errors: &[aws_sdk_s3::types::Error]) -> Option<&str> {
507    let first = errors.first()?.code()?;
508    errors
509        .iter()
510        .all(|e| e.code().is_some_and(|c| is_transient_s3_error(None, c)))
511        .then_some(first)
512}
513
514impl S3Storage {
515    /// Build an [`S3Storage`].
516    ///
517    /// `extra_read_headers`/`extra_write_headers` are extra HTTP headers injected
518    /// by an SDK interceptor and split by HTTP method: `GET`/`HEAD`/`OPTIONS`/`TRACE`
519    /// carry the read headers, everything else the write headers.
520    ///
521    /// `legacy_rooted_keys` declares the bucket's key layout:
522    /// - `None` — unknown: the layout is auto-detected by probing storage on first
523    ///   use. The right choice when opening a repository whose layout you don't know.
524    /// - `Some(true)` — force the legacy leading-slash layout used before the fix
525    ///   for <https://github.com/earth-mover/icechunk/issues/2239>. Only valid with
526    ///   an empty prefix; errors otherwise.
527    /// - `Some(false)` — force the standard layout, skipping the probe.
528    #[expect(clippy::too_many_arguments)]
529    pub fn new(
530        config: S3Options,
531        bucket: String,
532        prefix: Option<String>,
533        credentials: S3Credentials,
534        can_write: bool,
535        extra_read_headers: Vec<(String, String)>,
536        extra_write_headers: Vec<(String, String)>,
537        legacy_rooted_keys: Option<bool>,
538    ) -> Result<S3Storage, StorageError> {
539        let client = OnceCell::new();
540        let prefix = prefix.unwrap_or_default();
541        let prefix = prefix.strip_suffix("/").unwrap_or(prefix.as_str()).to_string();
542        // A known layout pre-seeds the cell so `probe_layout` is never reached;
543        // `None` leaves it empty to be detected lazily on first use.
544        let key_layout = match legacy_rooted_keys {
545            Some(true) => {
546                if !prefix.is_empty() {
547                    return Err(other_error(
548                        "legacy_rooted_keys is only valid with an empty prefix",
549                    ));
550                }
551                OnceCell::new_with(Some(KeyLayout::LegacyRoot))
552            }
553            Some(false) => OnceCell::new_with(Some(KeyLayout::Standard)),
554            None => OnceCell::new(),
555        };
556        Ok(S3Storage {
557            client,
558            config,
559            bucket,
560            prefix,
561            credentials,
562            can_write,
563            extra_read_headers,
564            extra_write_headers,
565            key_layout,
566            allow_empty_prefix_creation: false,
567        })
568    }
569
570    /// Test/internal escape hatch: permit creating a new repository at an empty
571    /// prefix (the bucket root), which [`Storage::can_create_repository`] would
572    /// otherwise refuse.
573    pub fn unsafe_allow_empty_prefix_creation(mut self) -> Self {
574        self.allow_empty_prefix_creation = true;
575        self
576    }
577
578    /// Get the client, initializing it if it hasn't been initialized yet. This is necessary because the
579    /// client is not serializeable and must be initialized after deserialization. Under normal construction
580    /// the original client is returned immediately.
581    #[instrument(skip_all)]
582    pub async fn get_client(&self, settings: &Settings) -> &Arc<Client> {
583        self.client
584            .get_or_init(|| async {
585                Arc::new(
586                    mk_client(
587                        &self.config,
588                        self.credentials.clone(),
589                        self.extra_read_headers.clone(),
590                        self.extra_write_headers.clone(),
591                        settings,
592                    )
593                    .await,
594                )
595            })
596            .await
597    }
598
599    /// Build the object key for a repository-relative path under the given layout.
600    fn key_for(&self, layout: KeyLayout, relpath: &str) -> String {
601        match layout {
602            // Only reachable with an empty prefix (enforced at construction and
603            // by `probe_layout`), so the prefix is intentionally ignored here.
604            KeyLayout::LegacyRoot => format!("/{relpath}"),
605            KeyLayout::Standard if self.prefix.is_empty() => relpath.to_string(),
606            KeyLayout::Standard => format!("{}/{}", self.prefix, relpath),
607        }
608    }
609
610    /// Key prefix to list. Strips one leading `/` from the relpath so it
611    /// doesn't double against the join (S3 matches `//` literally).
612    /// An interior `//` is left intact.
613    fn list_prefix(&self, layout: KeyLayout, relpath: &str) -> String {
614        self.key_for(layout, relpath.strip_prefix('/').unwrap_or(relpath))
615    }
616
617    /// Lists the keys that start with `key_prefix`. Ids are relative to `id_root`.
618    async fn list_keys(
619        &self,
620        ctx: &StorageContext<'_>,
621        key_prefix: String,
622        id_root: String,
623    ) -> BoxStream<'static, StorageResult<ListInfo<String>>> {
624        let mut req = self
625            .get_client(ctx.settings)
626            .await
627            .list_objects_v2()
628            .bucket(self.bucket.clone())
629            .prefix(key_prefix);
630
631        if self.config.requester_pays {
632            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
633        }
634
635        // FIXME: The SDK paginator drops per-request config, so listings carry no
636        // attribution fragment.
637        req.into_paginator()
638            .send()
639            .into_stream_03x()
640            .map_err(obj_store_error)
641            .try_filter_map(|page| {
642                let contents = page.contents.map(|cont| stream::iter(cont).map(Ok));
643                ready(Ok(contents))
644            })
645            .try_flatten()
646            .and_then(move |object| ready(object_to_list_info(id_root.as_str(), &object)))
647            .boxed()
648    }
649
650    /// Resolve this repository's [`KeyLayout`], probing storage at most once.
651    async fn layout(&self, ctx: &StorageContext<'_>) -> StorageResult<KeyLayout> {
652        self.key_layout.get_or_try_init(|| self.probe_layout(ctx)).await.copied()
653    }
654
655    /// Detect the key layout of the repository.
656    ///
657    /// Only ever issues requests for empty-prefix repositories — a non-empty
658    /// prefix can never produce a leading slash, so the layout is unambiguously
659    /// [`KeyLayout::Standard`]. The probe HEADs a few fixed anchor files under
660    /// both layouts.
661    async fn probe_layout(&self, ctx: &StorageContext<'_>) -> StorageResult<KeyLayout> {
662        if !self.prefix.is_empty() {
663            // the legacy bug only triggered on empty prefixes
664            return Ok(KeyLayout::Standard);
665        }
666        // For each anchor, HEAD the clean and rooted keys and compare `ETag`s
667        // rather than mere existence. Some S3-compatible stores (e.g. MinIO) strip
668        // a leading slash, so HEAD("/repo") returns the *same* object as
669        // HEAD("repo"); that is key normalization, not a mixed repository, and must
670        // resolve to the clean layout.
671        let probes = DEFAULT_LAYOUT_ANCHORS.iter().map(|anchor| {
672            let clean_key = self.key_for(KeyLayout::Standard, anchor);
673            let rooted_key = self.key_for(KeyLayout::LegacyRoot, anchor);
674            async move {
675                let (clean, rooted) = futures::future::try_join(
676                    self.head_etag(ctx, &clean_key),
677                    self.head_etag(ctx, &rooted_key),
678                )
679                .await?;
680                Ok::<_, StorageError>(AnchorProbe { clean, rooted })
681            }
682        });
683        let anchors = futures::future::try_join_all(probes).await?;
684        layout_from_anchor_etags(&anchors, &self.bucket)
685    }
686
687    /// HEAD a single key, returning its `ETag` if the object exists, or `None` if
688    /// absent.
689    ///
690    /// Treats both 404 and 400 as "absent" because some S3-compatible stores
691    /// (rustfs, some `MinIO` versions) reject leading-slash keys with 400 rather
692    /// than 404. The `ETag` lets [`Self::probe_layout`] tell a genuinely-rooted
693    /// object apart from a normalizing store that maps `"/x"` to the same object
694    /// as `"x"`.
695    async fn head_etag(
696        &self,
697        ctx: &StorageContext<'_>,
698        key: &str,
699    ) -> StorageResult<Option<String>> {
700        let mut req = self
701            .get_client(ctx.settings)
702            .await
703            .head_object()
704            .bucket(self.bucket.clone())
705            .key(key);
706        if self.config.requester_pays {
707            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
708        }
709        match req.customize().config_override(attribution_override(ctx)).send().await {
710            Ok(out) => Ok(Some(out.e_tag().unwrap_or_default().to_string())),
711            Err(sdk_err) => {
712                let absent = sdk_err.as_service_error().is_some_and(|e| e.is_not_found())
713                    || sdk_err
714                        .raw_response()
715                        .is_some_and(|r| matches!(r.status().as_u16(), 404 | 400));
716                if absent { Ok(None) } else { obj_store_error_res(sdk_err) }
717            }
718        }
719    }
720
721    async fn put_object_single<
722        I: IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
723    >(
724        &self,
725        ctx: &StorageContext<'_>,
726        key: &str,
727        bytes: Bytes,
728        content_type: Option<impl Into<String>>,
729        metadata: I,
730        previous_version: Option<&VersionInfo>,
731    ) -> StorageResult<VersionedUpdateResult> {
732        let mut req = self
733            .get_client(ctx.settings)
734            .await
735            .put_object()
736            .bucket(self.bucket.clone())
737            .key(key)
738            .body(bytes.into());
739
740        if let Some(algo) = self.config.checksum_algorithm {
741            req = req.checksum_algorithm(to_sdk_checksum(algo));
742        }
743
744        if ctx.settings.unsafe_use_metadata() {
745            if let Some(ct) = content_type {
746                req = req.content_type(ct);
747            };
748
749            for (k, v) in metadata {
750                req = req.metadata(k, v);
751            }
752        }
753
754        if let Some(klass) = ctx.settings.storage_class() {
755            let klass = klass.as_str().into();
756            req = req.storage_class(klass);
757        }
758
759        let conditional_applied = match conditional_for(previous_version, ctx.settings) {
760            Some(Conditional::IfNoneMatch) => {
761                req = req.if_none_match("*");
762                true
763            }
764            Some(Conditional::IfMatch(etag)) => {
765                req = req.if_match(strip_quotes(etag));
766                true
767            }
768            None => false,
769        };
770
771        let write_id = write_id_for(ctx.settings, conditional_applied, next_write_id);
772        if let Some(id) = write_id.as_deref() {
773            req = req.metadata(WRITE_ID_METADATA_KEY, id);
774        }
775
776        match req.customize().config_override(attribution_override(ctx)).send().await {
777            Ok(out) => {
778                let new_etag = out
779                    .e_tag()
780                    .ok_or(other_error("Object should have an etag".to_string()))?
781                    .to_string();
782                let new_version = VersionInfo::from_etag_only(new_etag);
783                Ok(VersionedUpdateResult::Updated { new_version })
784            }
785            // minio returns this
786            Err(SdkError::ServiceError(err)) => {
787                let code = err.err().meta().code().unwrap_or_default();
788                if is_precondition_code(code) {
789                    self.recover_precondition(ctx, key, write_id.as_deref()).await
790                } else {
791                    obj_store_error_res(SdkError::<PutObjectError>::ServiceError(err))
792                }
793            }
794            // S3 API documents this
795            Err(SdkError::ResponseError(err)) => {
796                let status = err.raw().status().as_u16();
797                // see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax
798                if status == 409 || status == 412 {
799                    self.recover_precondition(ctx, key, write_id.as_deref()).await
800                } else {
801                    obj_store_error_res(SdkError::<PutObjectError>::ResponseError(err))
802                }
803            }
804            Err(err) => obj_store_error_res(err),
805        }
806    }
807
808    async fn put_object_multipart<
809        I: IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
810    >(
811        &self,
812        ctx: &StorageContext<'_>,
813        key: &str,
814        bytes: &Bytes,
815        content_type: Option<impl Into<String>>,
816        metadata: I,
817        previous_version: Option<&VersionInfo>,
818    ) -> StorageResult<VersionedUpdateResult> {
819        let mut multi = self
820            .get_client(ctx.settings)
821            .await
822            .create_multipart_upload()
823            // We would like this, but it fails in MinIO
824            //.checksum_type(aws_sdk_s3::types::ChecksumType::FullObject)
825            //.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Crc64Nvme)
826            .bucket(self.bucket.clone())
827            .key(key);
828
829        // Write-id rides as metadata on `create_multipart_upload`, so decide
830        // here whether the later `complete` will carry a condition.
831        let will_apply_condition =
832            conditional_for(previous_version, ctx.settings).is_some();
833        let write_id = write_id_for(ctx.settings, will_apply_condition, next_write_id);
834
835        if ctx.settings.unsafe_use_metadata() {
836            if let Some(ct) = content_type {
837                multi = multi.content_type(ct);
838            };
839            for (k, v) in metadata {
840                multi = multi.metadata(k, v);
841            }
842            if let Some(id) = write_id.as_deref() {
843                multi = multi.metadata(WRITE_ID_METADATA_KEY, id);
844            }
845        }
846
847        if let Some(klass) = ctx.settings.storage_class() {
848            let klass = klass.as_str().into();
849            multi = multi.storage_class(klass);
850        }
851
852        let create_res = multi
853            .customize()
854            .config_override(attribution_override(ctx))
855            .send()
856            .await
857            .capture_box()?;
858        let upload_id = create_res.upload_id().ok_or(other_error(
859            "No upload_id in create multipart upload result".to_string(),
860        ))?;
861
862        // We need to ensure all requests are the same size except for the last one, which can be
863        // smaller. This is a requirement for R2 compatibility
864        let parts = split_in_multiple_equal_requests(
865            &(0..bytes.len() as u64),
866            ctx.settings.concurrency().ideal_concurrent_request_size().get(),
867            ctx.settings.concurrency().max_concurrent_requests_for_object().get(),
868        )
869        .collect::<Vec<_>>();
870
871        let results = parts
872            .into_iter()
873            .enumerate()
874            .map(|(part_idx, range)| async move {
875                let body = bytes.slice(range.start as usize..range.end as usize).into();
876                let idx = part_idx as i32 + 1;
877                let mut req = self
878                    .get_client(ctx.settings)
879                    .await
880                    .upload_part()
881                    .upload_id(upload_id)
882                    .bucket(self.bucket.clone())
883                    .key(key)
884                    .part_number(idx)
885                    .body(body);
886
887                if let Some(algo) = self.config.checksum_algorithm {
888                    req = req.checksum_algorithm(to_sdk_checksum(algo));
889                }
890
891                req.customize()
892                    .config_override(attribution_override(ctx))
893                    .send()
894                    .await
895                    .map(|res| (idx, res))
896            })
897            .collect::<FuturesOrdered<_>>();
898
899        let completed_parts = results
900            .map_ok(|(idx, res)| {
901                let etag = res.e_tag().unwrap_or("");
902                CompletedPart::builder()
903                    .e_tag(strip_quotes(etag))
904                    .part_number(idx)
905                    .build()
906            })
907            .try_collect::<Vec<_>>()
908            .await
909            .capture_box()?;
910
911        let completed_parts =
912            CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build();
913
914        let mut req = self
915            .get_client(ctx.settings)
916            .await
917            .complete_multipart_upload()
918            .bucket(self.bucket.clone())
919            .key(key)
920            .upload_id(upload_id)
921            //.checksum_type(aws_sdk_s3::types::ChecksumType::FullObject)
922            .multipart_upload(completed_parts);
923
924        match conditional_for(previous_version, ctx.settings) {
925            Some(Conditional::IfNoneMatch) => req = req.if_none_match("*"),
926            Some(Conditional::IfMatch(etag)) => req = req.if_match(strip_quotes(etag)),
927            None => {}
928        }
929
930        match req.customize().config_override(attribution_override(ctx)).send().await {
931            Ok(out) => {
932                let new_etag = out
933                    .e_tag()
934                    .ok_or(other_error("Object should have an etag".to_string()))?
935                    .to_string();
936                let new_version = VersionInfo::from_etag_only(new_etag);
937                Ok(VersionedUpdateResult::Updated { new_version })
938            }
939            Err(SdkError::ServiceError(err)) => {
940                let code = err.err().meta().code().unwrap_or_default();
941                // `NoSuchUpload` = SDK-retried complete after a lost response,
942                // or a failed upload; readback tells them apart.
943                if is_precondition_code(code) {
944                    self.recover_precondition(ctx, key, write_id.as_deref()).await
945                } else if code == "NoSuchUpload" {
946                    self.recover_lost_response(
947                        ctx,
948                        key,
949                        write_id.as_deref(),
950                        obj_store_error(SdkError::ServiceError(err)),
951                    )
952                    .await
953                } else {
954                    obj_store_error_res(SdkError::ServiceError(err))
955                }
956            }
957            Err(SdkError::ResponseError(err)) => {
958                let status = err.raw().status().as_u16();
959                if status == 409 || status == 412 {
960                    self.recover_precondition(ctx, key, write_id.as_deref()).await
961                } else if status == 404 {
962                    // NoSuchUpload surfaced as a raw HTTP status.
963                    self.recover_lost_response(
964                        ctx,
965                        key,
966                        write_id.as_deref(),
967                        obj_store_error(SdkError::<PutObjectError>::ResponseError(err)),
968                    )
969                    .await
970                } else {
971                    obj_store_error_res(SdkError::<PutObjectError>::ResponseError(err))
972                }
973            }
974            Err(err) => obj_store_error_res(err),
975        }
976    }
977
978    /// Readback + resolve for a tripped conditional (precondition code or
979    /// 409/412 status).
980    async fn recover_precondition(
981        &self,
982        ctx: &StorageContext<'_>,
983        key: &str,
984        write_id: Option<&str>,
985    ) -> StorageResult<VersionedUpdateResult> {
986        let outcome = self.read_back_after_conditional_failure(ctx, key, write_id).await;
987        resolve_precondition(outcome, key)
988    }
989
990    /// Readback + resolve for a failure only our own landed write can rescue.
991    async fn recover_lost_response(
992        &self,
993        ctx: &StorageContext<'_>,
994        key: &str,
995        write_id: Option<&str>,
996        original: StorageError,
997    ) -> StorageResult<VersionedUpdateResult> {
998        let outcome = self.read_back_after_conditional_failure(ctx, key, write_id).await;
999        resolve_lost_response(outcome, key, original)
1000    }
1001
1002    /// HEAD `key` and classify it against our write-id. `Err` only on a HEAD
1003    /// failure.
1004    async fn read_back_after_conditional_failure(
1005        &self,
1006        ctx: &StorageContext<'_>,
1007        key: &str,
1008        write_id: Option<&str>,
1009    ) -> StorageResult<ReadbackOutcome> {
1010        let Some(write_id) = write_id else { return Ok(ReadbackOutcome::NotOurs) };
1011        let mut head = self
1012            .get_client(ctx.settings)
1013            .await
1014            .head_object()
1015            .bucket(self.bucket.clone())
1016            .key(key);
1017        if self.config.requester_pays {
1018            head = head.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
1019        }
1020        let (stored_write_id, version) = match head
1021            .customize()
1022            .config_override(attribution_override(ctx))
1023            .send()
1024            .await
1025        {
1026            Ok(out) => (
1027                out.metadata().and_then(|m| m.get(WRITE_ID_METADATA_KEY)).cloned(),
1028                // S3 has no generation; etag is the only version identity.
1029                out.e_tag()
1030                    .map(|e| VersionInfo::from_etag_only(e.to_string()))
1031                    .unwrap_or_else(VersionInfo::for_creation),
1032            ),
1033            // Absent is conclusive (not a failed read-back): our write didn't land.
1034            Err(sdk_err)
1035                if sdk_err.as_service_error().is_some_and(|e| e.is_not_found())
1036                    || sdk_err
1037                        .raw_response()
1038                        .is_some_and(|r| r.status().as_u16() == 404) =>
1039            {
1040                return Ok(ReadbackOutcome::NotOurs);
1041            }
1042            Err(sdk_err) => {
1043                warn!(key, error = %sdk_err, "readback HEAD failed; propagating the HEAD error");
1044                return obj_store_error_res(sdk_err);
1045            }
1046        };
1047        Ok(ReadbackOutcome::classify(write_id, stored_write_id.as_deref(), &version))
1048    }
1049}
1050
1051pub fn range_to_header(range: &Range<u64>) -> String {
1052    format!("bytes={}-{}", range.start, range.end - 1)
1053}
1054
1055impl sealed::Sealed for S3Storage {}
1056
1057#[async_trait]
1058#[typetag::serde]
1059impl Storage for S3Storage {
1060    fn storage_info(&self) -> StorageInfo {
1061        let mut fields = vec![("bucket", self.bucket.clone())];
1062        if !self.prefix.is_empty() {
1063            fields.push(("prefix", self.prefix.clone()));
1064        }
1065        fields.extend(self.config.info_fields());
1066        StorageInfo { backend_type: "S3 (native)", fields }
1067    }
1068
1069    async fn can_write(&self) -> StorageResult<bool> {
1070        Ok(self.can_write)
1071    }
1072
1073    async fn can_create_repository(&self) -> StorageResult<RepositoryCreation> {
1074        if self.prefix.is_empty() && !self.allow_empty_prefix_creation {
1075            Ok(RepositoryCreation::RefusedEmptyPrefix)
1076        } else {
1077            Ok(RepositoryCreation::Allowed)
1078        }
1079    }
1080
1081    async fn put_object(
1082        &self,
1083        ctx: &StorageContext<'_>,
1084        path: &str,
1085        bytes: Bytes,
1086        content_type: Option<&str>,
1087        metadata: Vec<(String, String)>,
1088        previous_version: Option<&VersionInfo>,
1089    ) -> StorageResult<VersionedUpdateResult> {
1090        let layout = self.layout(ctx).await?;
1091        let path = self.key_for(layout, path);
1092        if bytes.len() >= ctx.settings.minimum_size_for_multipart_upload() as usize {
1093            self.put_object_multipart(
1094                ctx,
1095                path.as_str(),
1096                &bytes,
1097                content_type,
1098                metadata,
1099                previous_version,
1100            )
1101            .await
1102        } else {
1103            self.put_object_single(
1104                ctx,
1105                path.as_str(),
1106                bytes,
1107                content_type,
1108                metadata,
1109                previous_version,
1110            )
1111            .await
1112        }
1113    }
1114
1115    async fn copy_object(
1116        &self,
1117        ctx: &StorageContext<'_>,
1118        from: &str,
1119        to: &str,
1120        content_type: Option<&str>,
1121        version: &VersionInfo,
1122    ) -> StorageResult<VersionedUpdateResult> {
1123        let layout = self.layout(ctx).await?;
1124        let from = format!("{}/{}", self.bucket, self.key_for(layout, from));
1125        let to = self.key_for(layout, to);
1126        let mut req = self
1127            .get_client(ctx.settings)
1128            .await
1129            .copy_object()
1130            .bucket(self.bucket.clone())
1131            .key(to)
1132            .copy_source(from);
1133        if ctx.settings.unsafe_use_conditional_update()
1134            && let Some(etag) = version.etag()
1135        {
1136            req = req.copy_source_if_match(strip_quotes(etag));
1137        }
1138        if let Some(klass) = ctx.settings.storage_class() {
1139            let klass = klass.as_str().into();
1140            req = req.storage_class(klass);
1141        }
1142        if let Some(ct) = content_type {
1143            req = req.content_type(ct);
1144        }
1145        if self.config.requester_pays {
1146            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
1147        }
1148        match req.customize().config_override(attribution_override(ctx)).send().await {
1149            Ok(_) => Ok(VersionedUpdateResult::Updated { new_version: version.clone() }),
1150            Err(SdkError::ServiceError(err)) => {
1151                let code = err.err().meta().code().unwrap_or_default();
1152                if code == "PreconditionFailed"
1153                    || code == "ConditionalRequestConflict"
1154                    // ConcurrentModification sent by Ceph Object Gateway
1155                    || code == "ConcurrentModification"
1156                {
1157                    Ok(VersionedUpdateResult::NotOnLatestVersion)
1158                } else {
1159                    obj_store_error_res(SdkError::<CopyObjectError>::ServiceError(err))
1160                }
1161            }
1162            // S3 API documents this
1163            Err(SdkError::ResponseError(err)) => {
1164                let status = err.raw().status().as_u16();
1165                // see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html#API_PutObject_RequestSyntax
1166                if status == 409 || status == 412 {
1167                    Ok(VersionedUpdateResult::NotOnLatestVersion)
1168                } else {
1169                    obj_store_error_res(SdkError::<PutObjectError>::ResponseError(err))
1170                }
1171            }
1172            Err(sdk_err) => match sdk_err.as_service_error() {
1173                Some(_)
1174                    if sdk_err
1175                        .raw_response()
1176                        .is_some_and(|x| x.status().as_u16() == 404) =>
1177                {
1178                    // needed for Cloudflare R2 public bucket URLs
1179                    // if object doesn't exist we get a 404 that isn't parsed by the AWS SDK
1180                    // into anything useful. So we need to parse the raw response, and match
1181                    // the status code.
1182                    obj_not_found_res()
1183                }
1184                _ => obj_store_error_res(sdk_err),
1185            },
1186        }
1187    }
1188
1189    #[instrument(skip(self, ctx))]
1190    async fn list_objects<'a>(
1191        &'a self,
1192        ctx: &StorageContext<'_>,
1193        prefix: &str,
1194    ) -> StorageResult<BoxStream<'a, StorageResult<ListInfo<String>>>> {
1195        let layout = self.layout(ctx).await?;
1196        let prefix = self.list_prefix(layout, prefix);
1197        Ok(self.list_keys(ctx, prefix.clone(), prefix).await)
1198    }
1199
1200    #[instrument(skip(self, ctx, id_prefixes))]
1201    async fn list_objects_with_id_prefixes<'a>(
1202        &'a self,
1203        ctx: &StorageContext<'_>,
1204        prefix: &str,
1205        id_prefixes: &[String],
1206    ) -> StorageResult<BoxStream<'a, StorageResult<ListInfo<String>>>> {
1207        let layout = self.layout(ctx).await?;
1208        let prefix = self.list_prefix(layout, prefix);
1209        let mut listings = Vec::with_capacity(id_prefixes.len());
1210        for id_prefix in id_prefixes {
1211            let key_prefix = if prefix.is_empty() || prefix.ends_with('/') {
1212                format!("{prefix}{id_prefix}")
1213            } else {
1214                format!("{prefix}/{id_prefix}")
1215            };
1216            listings.push(self.list_keys(ctx, key_prefix, prefix.clone()).await);
1217        }
1218        Ok(stream::select_all(listings).boxed())
1219    }
1220
1221    fn lists_id_prefixes_natively(&self) -> bool {
1222        true
1223    }
1224
1225    #[instrument(skip(self, ctx, batch))]
1226    async fn delete_batch(
1227        &self,
1228        ctx: &StorageContext<'_>,
1229        prefix: &str,
1230        batch: Vec<(String, u64)>,
1231    ) -> StorageResult<DeleteObjectsResult> {
1232        fn join_prefix_id(prefix: &str, id: &str) -> String {
1233            if prefix.is_empty() {
1234                id.to_string()
1235            } else {
1236                format!("{}/{}", prefix.trim_end_matches('/'), id)
1237            }
1238        }
1239
1240        let layout = self.layout(ctx).await?;
1241        let mut sizes = HashMap::new();
1242        let mut ids = Vec::new();
1243        for (id, size) in batch.into_iter() {
1244            let key = self.key_for(layout, &join_prefix_id(prefix, &id));
1245            if let Ok(ident) = ObjectIdentifier::builder().key(key.clone()).build() {
1246                ids.push(ident);
1247                sizes.insert(key, size);
1248            }
1249        }
1250
1251        let requested = ids.len();
1252        let delete = Delete::builder()
1253            .set_objects(Some(ids))
1254            .build()
1255            .map_err(|e| other_error(e.to_string()))?;
1256
1257        let mut req = self
1258            .get_client(ctx.settings)
1259            .await
1260            .delete_objects()
1261            .bucket(self.bucket.clone())
1262            .delete(delete);
1263
1264        if let Some(algo) = self.config.checksum_algorithm {
1265            req = req.checksum_algorithm(to_sdk_checksum(algo));
1266        }
1267
1268        if self.config.requester_pays {
1269            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
1270        }
1271
1272        // A throttle gets its own kind, so the caller can back off instead of
1273        // counting it as a failure. The SDK has already retried it `max_tries`
1274        // times with its own backoff by then.
1275        let res = match req
1276            .customize()
1277            .config_override(attribution_override(ctx))
1278            .send()
1279            .await
1280        {
1281            Ok(res) => res,
1282            Err(SdkError::ServiceError(err)) => {
1283                use aws_sdk_s3::operation::RequestId as _;
1284                let meta = err.err().meta();
1285                let status = err.raw().status().as_u16();
1286                let code = meta.code().unwrap_or("?");
1287                let message = format!(
1288                    "DeleteObjects failed: status {status}, code {code}, message {}, request id {}",
1289                    meta.message().unwrap_or("?"),
1290                    meta.request_id().unwrap_or("?"),
1291                );
1292                return Err(if is_transient_s3_error(Some(status), code) {
1293                    throttled_error(code, message)
1294                } else {
1295                    other_error(message)
1296                });
1297            }
1298            Err(SdkError::ResponseError(err)) => {
1299                return Err(other_error(format!(
1300                    "DeleteObjects failed: unparsable response, status {}",
1301                    err.raw().status().as_u16()
1302                )));
1303            }
1304            Err(err) => return Err(obj_store_error(err)),
1305        };
1306
1307        if let Some(errors) = res.errors.as_deref().filter(|e| !e.is_empty()) {
1308            if let Some(code) = transient_per_key_code(errors) {
1309                return Err(throttled_error(
1310                    code,
1311                    format!(
1312                        "DeleteObjects: {} of {requested} keys failed with {code}: {}",
1313                        errors.len(),
1314                        errors.first().and_then(|e| e.message()).unwrap_or("?"),
1315                    ),
1316                ));
1317            }
1318            tracing::error!(
1319                error = ?errors[0],
1320                failed = errors.len(),
1321                "Errors deleting objects",
1322            );
1323        }
1324
1325        let mut result = DeleteObjectsResult::default();
1326        for deleted in res.deleted() {
1327            if let Some(key) = deleted.key() {
1328                let size = sizes.get(key).unwrap_or(&0);
1329                result.deleted_bytes += *size;
1330                result.deleted_objects += 1;
1331            } else {
1332                tracing::error!("Deleted object without key");
1333            }
1334        }
1335        Ok(result)
1336    }
1337
1338    #[instrument(skip(self, ctx))]
1339    async fn get_object_last_modified(
1340        &self,
1341        ctx: &StorageContext<'_>,
1342        path: &str,
1343    ) -> StorageResult<DateTime<Utc>> {
1344        let layout = self.layout(ctx).await?;
1345        let key = self.key_for(layout, path);
1346        let mut req = self
1347            .get_client(ctx.settings)
1348            .await
1349            .head_object()
1350            .bucket(self.bucket.clone())
1351            .key(key);
1352
1353        if self.config.requester_pays {
1354            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
1355        }
1356
1357        let res = req
1358            .customize()
1359            .config_override(attribution_override(ctx))
1360            .send()
1361            .await
1362            .capture_box()?;
1363
1364        let res = res
1365            .last_modified
1366            .ok_or(other_error("Object has no last_modified field".to_string()))?;
1367        let res = res
1368            .to_chrono_utc()
1369            .map_err(|_| other_error("Invalid metadata timestamp".to_string()))?;
1370
1371        Ok(res)
1372    }
1373
1374    #[instrument(skip(self, ctx))]
1375    async fn get_object_conditional(
1376        &self,
1377        ctx: &StorageContext<'_>,
1378        path: &str,
1379        previous_version: Option<&VersionInfo>,
1380    ) -> StorageResult<GetModifiedResult> {
1381        match self.get_object_range_conditional(ctx, path, None, previous_version).await {
1382            Ok(Some((stream, new_version))) => {
1383                let reader = StreamReader::new(stream.map_err(std::io::Error::other));
1384                Ok(GetModifiedResult::Modified { data: Box::pin(reader), new_version })
1385            }
1386            Ok(None) => Ok(GetModifiedResult::OnLatestVersion),
1387            Err(e) => Err(e),
1388        }
1389    }
1390
1391    async fn get_object_range(
1392        &self,
1393        ctx: &StorageContext<'_>,
1394        path: &str,
1395        range: Option<&Range<u64>>,
1396    ) -> StorageResult<(
1397        Pin<Box<dyn Stream<Item = Result<Bytes, StorageError>> + Send>>,
1398        VersionInfo,
1399    )> {
1400        self.get_object_range_conditional(ctx, path, range, None).await.map(|v| {
1401            // If we got a result, then we can unwrap safely here:
1402            // Errors would be in the other branch, and None is only expected
1403            // if previous_version was passed in function call, but we set it to None
1404            #[expect(clippy::expect_used)]
1405            v.expect("Logic bug in get_object_range_conditional, should not get None")
1406        })
1407    }
1408}
1409
1410impl S3Storage {
1411    async fn get_object_range_conditional(
1412        &self,
1413        ctx: &StorageContext<'_>,
1414        path: &str,
1415        range: Option<&Range<u64>>,
1416        previous_version: Option<&VersionInfo>,
1417    ) -> StorageResult<
1418        Option<(
1419            Pin<Box<dyn Stream<Item = Result<Bytes, StorageError>> + Send>>,
1420            VersionInfo,
1421        )>,
1422    > {
1423        let layout = self.layout(ctx).await?;
1424        let client = self.get_client(ctx.settings).await;
1425        let bucket = self.bucket.clone();
1426        let key = self.key_for(layout, path);
1427
1428        let mut req = client.get_object().bucket(bucket).key(key);
1429
1430        if let Some(range) = range {
1431            req = req.range(range_to_header(range));
1432        }
1433
1434        if self.config.requester_pays {
1435            req = req.request_payer(aws_sdk_s3::types::RequestPayer::Requester);
1436        }
1437
1438        if let Some(previous_version) = previous_version.as_ref()
1439            && let Some(etag) = previous_version.etag()
1440        {
1441            req = req.if_none_match(strip_quotes(etag));
1442        };
1443
1444        match req.customize().config_override(attribution_override(ctx)).send().await {
1445            Ok(output) => match output.e_tag {
1446                Some(etag) => {
1447                    let stream = stream2stream(output.body)
1448                        .map_err(|e| StorageError::capture(e.into()));
1449                    Ok(Some((Box::pin(stream), VersionInfo::from_etag_only(etag))))
1450                }
1451                None => Err(other_error("Object should have an etag".to_string())),
1452            },
1453            Err(sdk_err) => {
1454                match sdk_err.as_service_error() {
1455                    Some(e) if e.is_no_such_key() => {
1456                        obj_not_found_res()
1457                    }
1458                    Some(_)
1459                        if sdk_err
1460                            .raw_response()
1461                            .is_some_and(|x| x.status().as_u16() == 404) =>
1462                    {
1463                        // needed for Cloudflare R2 public bucket URLs
1464                        // if object doesn't exist we get a 404 that isn't parsed by the AWS SDK
1465                        // into anything useful. So we need to parse the raw response, and match
1466                        // the status code.
1467                        obj_not_found_res()
1468                    }
1469                    Some(_)
1470                        // aws_sdk_s3 doesn't return an error when
1471                        // status 304 (Not Modified) happens, so
1472                        // check the http status code here and
1473                        // return None to make it easy to catch
1474                        // downstream
1475                        if sdk_err
1476                            .raw_response()
1477                            .is_some_and(|x| x.status().as_u16() == 304) =>
1478                    {
1479                        trace!("Received 304 (Not Modified). Treating requested object as up-to-date.");
1480                        Ok(None)
1481                    }
1482                    _ => obj_store_error_res(sdk_err),
1483                }
1484            }
1485        }
1486    }
1487}
1488
1489#[derive(Debug)]
1490struct AnchorProbe<T> {
1491    /// Modern layout using non-rooted keys
1492    clean: T,
1493    /// Legacy buggy layout using /chunks style keys on empty prefixes
1494    rooted: T,
1495}
1496
1497/// Resolve the layout from per-anchor HEAD results.
1498///
1499/// For each anchor, comparing `ETag`s (not mere existence) distinguishes a store
1500/// that normalizes `"/x"` to `"x"` (same object under both keys → clean layout)
1501/// from one that genuinely holds distinct objects at both keys (→ mixed).
1502fn layout_from_anchor_etags(
1503    anchors: &[AnchorProbe<Option<String>>],
1504    bucket: &str,
1505) -> StorageResult<KeyLayout> {
1506    let seen = anchors.iter().fold(
1507        AnchorProbe { clean: false, rooted: false },
1508        |AnchorProbe { clean: clean_seen, rooted: rooted_seen },
1509         AnchorProbe { clean, rooted }| {
1510            let (clean_vote, rooted_vote) = match (clean, rooted) {
1511                // Same physical object under both keys: the store normalized away
1512                // the leading slash. Clean layout, not a mixed repository.
1513                (Some(c), Some(r)) if c == r => (true, false),
1514                // Genuinely distinct objects at both keys: ambiguous; flag as mixed.
1515                (Some(_), Some(_)) => (true, true),
1516                (Some(_), None) => (true, false),
1517                (None, Some(_)) => (false, true),
1518                (None, None) => (false, false),
1519            };
1520            AnchorProbe {
1521                clean: clean_seen || clean_vote,
1522                rooted: rooted_seen || rooted_vote,
1523            }
1524        },
1525    );
1526
1527    decide_layout(&seen, bucket)
1528}
1529
1530/// `bucket` is only used to build the mixed-layout error.
1531fn decide_layout(seen: &AnchorProbe<bool>, bucket: &str) -> StorageResult<KeyLayout> {
1532    match seen {
1533        // Both layouts present: ambiguous and unsafe
1534        AnchorProbe { clean: true, rooted: true } => Err(other_error(format!(
1535            "repository in bucket {bucket} has objects under both the standard and the \
1536             legacy leading-slash key layouts; this is ambiguous and unsafe. \
1537             See https://github.com/earth-mover/icechunk/issues/2239"
1538        ))),
1539        AnchorProbe { clean: false, rooted: true } => Ok(KeyLayout::LegacyRoot),
1540        // (true, false) => existing clean repo;
1541        // (false, false) => empty/new repository. Both use the clean layout.
1542        _ => Ok(KeyLayout::Standard),
1543    }
1544}
1545
1546fn object_to_list_info(prefix: &str, object: &Object) -> StorageResult<ListInfo<String>> {
1547    let inner = || {
1548        let key = object.key()?;
1549        let last_modified = object.last_modified()?;
1550        let created_at = last_modified.to_chrono_utc().ok()?;
1551        let prefix = Utf8UnixPath::new(prefix);
1552        let id = Utf8UnixPath::new(key).strip_prefix(prefix).ok()?.to_string();
1553        let size_bytes = object.size.unwrap_or(0) as u64;
1554        Some(ListInfo { id, created_at, size_bytes })
1555    };
1556    inner()
1557        .ok_or_else(|| StorageError::capture(StorageErrorKind::BadPrefix(prefix.into())))
1558}
1559
1560#[derive(Debug)]
1561struct ProvideRefreshableCredentials(Arc<dyn S3CredentialsFetcher>);
1562
1563impl ProvideCredentials for ProvideRefreshableCredentials {
1564    fn provide_credentials<'a>(
1565        &'a self,
1566    ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
1567    where
1568        Self: 'a,
1569    {
1570        aws_credential_types::provider::future::ProvideCredentials::new(self.provide())
1571    }
1572}
1573
1574impl ProvideRefreshableCredentials {
1575    async fn provide(
1576        &self,
1577    ) -> Result<aws_credential_types::Credentials, CredentialsError> {
1578        let creds = self
1579            .0
1580            .get()
1581            .await
1582            .inspect_err(|err| error!(error = err, "Cannot load credentials"))
1583            .map_err(CredentialsError::not_loaded)?;
1584        let creds = aws_credential_types::Credentials::new(
1585            creds.access_key_id,
1586            creds.secret_access_key,
1587            creds.session_token,
1588            creds.expires_after.map(|e| e.into()),
1589            "user",
1590        );
1591        Ok(creds)
1592    }
1593}
1594
1595// Factory functions
1596
1597/// Build storage for an S3 (or S3-compatible, non-Tigris) bucket.
1598///
1599/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1600/// read/write requests respectively.
1601///
1602/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1603/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1604/// and `Some(false)` forces the standard layout.
1605pub fn new_s3_storage(
1606    config: S3Options,
1607    bucket: String,
1608    prefix: Option<String>,
1609    credentials: Option<S3Credentials>,
1610    extra_read_headers: Vec<(String, String)>,
1611    extra_write_headers: Vec<(String, String)>,
1612    legacy_rooted_keys: Option<bool>,
1613) -> StorageResult<Arc<dyn Storage + Send + Sync>> {
1614    Ok(Arc::new(s3_storage(
1615        config,
1616        bucket,
1617        prefix,
1618        credentials,
1619        extra_read_headers,
1620        extra_write_headers,
1621        legacy_rooted_keys,
1622    )?))
1623}
1624
1625/// Build storage for an S3 (or S3-compatible, non-Tigris) bucket.
1626///
1627/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1628/// read/write requests respectively.
1629///
1630/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1631/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1632/// and `Some(false)` forces the standard layout.
1633pub fn s3_storage(
1634    config: S3Options,
1635    bucket: String,
1636    prefix: Option<String>,
1637    credentials: Option<S3Credentials>,
1638    extra_read_headers: Vec<(String, String)>,
1639    extra_write_headers: Vec<(String, String)>,
1640    legacy_rooted_keys: Option<bool>,
1641) -> StorageResult<S3Storage> {
1642    if let Some(endpoint) = &config.endpoint_url
1643        && (endpoint.contains("fly.storage.tigris.dev")
1644            || endpoint.contains("t3.storage.dev"))
1645    {
1646        return Err(other_error(
1647            "Tigris Storage is not S3 compatible, use the Tigris specific constructor instead"
1648                .to_string(),
1649        ));
1650    }
1651
1652    S3Storage::new(
1653        config,
1654        bucket,
1655        prefix,
1656        credentials.unwrap_or(S3Credentials::FromEnv),
1657        true,
1658        extra_read_headers,
1659        extra_write_headers,
1660        legacy_rooted_keys,
1661    )
1662}
1663
1664/// Build storage for a Cloudflare R2 bucket.
1665///
1666/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1667/// read/write requests respectively.
1668///
1669/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1670/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1671/// and `Some(false)` forces the standard layout.
1672#[expect(clippy::too_many_arguments)]
1673pub fn new_r2_storage(
1674    config: S3Options,
1675    bucket: Option<String>,
1676    prefix: Option<String>,
1677    account_id: Option<String>,
1678    credentials: Option<S3Credentials>,
1679    extra_read_headers: Vec<(String, String)>,
1680    extra_write_headers: Vec<(String, String)>,
1681    legacy_rooted_keys: Option<bool>,
1682) -> StorageResult<Arc<dyn Storage + Send + Sync>> {
1683    Ok(Arc::new(r2_storage(
1684        config,
1685        bucket,
1686        prefix,
1687        account_id,
1688        credentials,
1689        extra_read_headers,
1690        extra_write_headers,
1691        legacy_rooted_keys,
1692    )?))
1693}
1694
1695/// Build storage for a Cloudflare R2 bucket.
1696///
1697/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1698/// read/write requests respectively.
1699///
1700/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1701/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1702/// and `Some(false)` forces the standard layout.
1703#[expect(clippy::too_many_arguments)]
1704pub fn r2_storage(
1705    config: S3Options,
1706    bucket: Option<String>,
1707    prefix: Option<String>,
1708    account_id: Option<String>,
1709    credentials: Option<S3Credentials>,
1710    extra_read_headers: Vec<(String, String)>,
1711    extra_write_headers: Vec<(String, String)>,
1712    legacy_rooted_keys: Option<bool>,
1713) -> StorageResult<S3Storage> {
1714    let (bucket, prefix) = match (bucket, prefix) {
1715        (Some(bucket), Some(prefix)) => (bucket, Some(prefix)),
1716        (None, Some(prefix)) => match prefix.split_once("/") {
1717            Some((bucket, prefix)) => (bucket.to_string(), Some(prefix.to_string())),
1718            None => (prefix, None),
1719        },
1720        (Some(bucket), None) => (bucket, None),
1721        (None, None) => {
1722            return Err(StorageErrorKind::R2ConfigurationError(
1723                "Either bucket or prefix must be provided.".to_string(),
1724            ))
1725            .capture();
1726        }
1727    };
1728
1729    if config.endpoint_url.is_none() && account_id.is_none() {
1730        return Err(StorageErrorKind::R2ConfigurationError(
1731            "Either endpoint_url or account_id must be provided.".to_string(),
1732        ))
1733        .capture();
1734    }
1735
1736    let mut config = config;
1737    if config.region.is_none() {
1738        config.region = Some("auto".to_string());
1739    }
1740    if config.endpoint_url.is_none() {
1741        config.endpoint_url =
1742            account_id.map(|x| format!("https://{x}.r2.cloudflarestorage.com"));
1743    }
1744    config.force_path_style = true;
1745    S3Storage::new(
1746        config,
1747        bucket,
1748        prefix,
1749        credentials.unwrap_or(S3Credentials::FromEnv),
1750        true,
1751        extra_read_headers,
1752        extra_write_headers,
1753        legacy_rooted_keys,
1754    )
1755}
1756
1757/// The Hugging Face S3-compatible gateway.
1758const HF_GATEWAY_ENDPOINT: &str = "https://s3.hf.co";
1759
1760/// Build storage for a Hugging Face Storage Bucket.
1761///
1762/// `namespace` owns the bucket: a Hugging Face user or organization. The gateway
1763/// scopes every operation to it through the endpoint path.
1764///
1765/// Hugging Face runs one gateway, so this overwrites any `endpoint_url` in
1766/// `config`.
1767///
1768/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1769/// read/write requests respectively.
1770///
1771/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1772/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1773/// and `Some(false)` forces the standard layout.
1774#[expect(clippy::too_many_arguments)]
1775pub fn new_hf_storage(
1776    config: S3Options,
1777    bucket: String,
1778    prefix: Option<String>,
1779    namespace: &str,
1780    credentials: Option<S3Credentials>,
1781    extra_read_headers: Vec<(String, String)>,
1782    extra_write_headers: Vec<(String, String)>,
1783    legacy_rooted_keys: Option<bool>,
1784) -> StorageResult<Arc<dyn Storage + Send + Sync>> {
1785    Ok(Arc::new(hf_storage(
1786        config,
1787        bucket,
1788        prefix,
1789        namespace,
1790        credentials,
1791        extra_read_headers,
1792        extra_write_headers,
1793        legacy_rooted_keys,
1794    )?))
1795}
1796
1797/// The concrete storage behind [`new_hf_storage`], so tests can read its config.
1798#[expect(clippy::too_many_arguments)]
1799fn hf_storage(
1800    config: S3Options,
1801    bucket: String,
1802    prefix: Option<String>,
1803    namespace: &str,
1804    credentials: Option<S3Credentials>,
1805    extra_read_headers: Vec<(String, String)>,
1806    extra_write_headers: Vec<(String, String)>,
1807    legacy_rooted_keys: Option<bool>,
1808) -> StorageResult<S3Storage> {
1809    let mut config = config;
1810    config.endpoint_url = Some(format!("{HF_GATEWAY_ENDPOINT}/{namespace}"));
1811    if config.region.is_none() {
1812        // the gateway serves one region and still requires the field
1813        config.region = Some("us-east-1".to_string());
1814    }
1815    // the gateway does not serve virtual-hosted style URLs
1816    config.force_path_style = true;
1817
1818    S3Storage::new(
1819        config,
1820        bucket,
1821        prefix,
1822        credentials.unwrap_or(S3Credentials::FromEnv),
1823        true,
1824        extra_read_headers,
1825        extra_write_headers,
1826        legacy_rooted_keys,
1827    )
1828}
1829
1830/// Build storage for a Tigris bucket.
1831///
1832/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1833/// read/write requests. The required `X-Tigris-*` consistency headers take
1834/// precedence on a name conflict.
1835///
1836/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1837/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1838/// and `Some(false)` forces the standard layout.
1839#[expect(clippy::too_many_arguments)]
1840pub fn new_tigris_storage(
1841    config: S3Options,
1842    bucket: String,
1843    prefix: Option<String>,
1844    credentials: Option<S3Credentials>,
1845    use_weak_consistency: bool,
1846    extra_read_headers: Vec<(String, String)>,
1847    extra_write_headers: Vec<(String, String)>,
1848    legacy_rooted_keys: Option<bool>,
1849) -> StorageResult<Arc<dyn Storage + Send + Sync>> {
1850    Ok(Arc::new(tigris_storage(
1851        config,
1852        bucket,
1853        prefix,
1854        credentials,
1855        use_weak_consistency,
1856        extra_read_headers,
1857        extra_write_headers,
1858        legacy_rooted_keys,
1859    )?))
1860}
1861
1862/// Merge user-supplied headers with headers Icechunk must set itself. On a
1863/// case-insensitive name conflict the required header wins (user entries with a
1864/// colliding name are dropped), since the required headers carry correctness
1865/// guarantees (e.g. Tigris consistency).
1866fn merge_required_headers(
1867    user: Vec<(String, String)>,
1868    required: Vec<(String, String)>,
1869) -> Vec<(String, String)> {
1870    let mut merged: Vec<(String, String)> = user
1871        .into_iter()
1872        .filter(|(k, _)| !required.iter().any(|(rk, _)| rk.eq_ignore_ascii_case(k)))
1873        .collect();
1874    merged.extend(required);
1875    merged
1876}
1877
1878/// Build storage for a Tigris bucket.
1879///
1880/// `extra_read_headers`/`extra_write_headers` are extra HTTP headers attached to
1881/// read/write requests. The required `X-Tigris-*` consistency headers take
1882/// precedence on a name conflict.
1883///
1884/// For `legacy_rooted_keys`, see [`S3Storage::new`]: `None` auto-detects the key
1885/// layout (the usual choice), `Some(true)` forces the legacy leading-slash layout,
1886/// and `Some(false)` forces the standard layout.
1887#[expect(clippy::too_many_arguments)]
1888pub fn tigris_storage(
1889    config: S3Options,
1890    bucket: String,
1891    prefix: Option<String>,
1892    credentials: Option<S3Credentials>,
1893    use_weak_consistency: bool,
1894    extra_read_headers: Vec<(String, String)>,
1895    extra_write_headers: Vec<(String, String)>,
1896    legacy_rooted_keys: Option<bool>,
1897) -> StorageResult<S3Storage> {
1898    let mut config = config;
1899    if config.endpoint_url.is_none() {
1900        config.endpoint_url = Some("https://t3.storage.dev".to_string());
1901    }
1902    let mut tigris_write_headers = Vec::with_capacity(2);
1903    let mut tigris_read_headers = Vec::with_capacity(3);
1904
1905    if !use_weak_consistency {
1906        // TODO: Tigris will need more than this to offer good eventually consistent behavior
1907        // For example: we should use no-cache for branches and config file
1908        if let Some(region) = config.region.as_ref() {
1909            tigris_write_headers.push(("X-Tigris-Regions".to_string(), region.clone()));
1910            tigris_write_headers
1911                .push(("X-Tigris-Consistent".to_string(), "true".to_string()));
1912
1913            tigris_read_headers.push(("X-Tigris-Regions".to_string(), region.clone()));
1914            tigris_read_headers
1915                .push(("Cache-Control".to_string(), "no-cache".to_string()));
1916            tigris_read_headers
1917                .push(("X-Tigris-Consistent".to_string(), "true".to_string()));
1918        } else {
1919            return Err(other_error("Tigris storage requires a region to provide full consistency. Either set the region for the bucket or use the read-only, eventually consistent storage by passing `use_weak_consistency=True` (experts only)".to_string()));
1920        }
1921    }
1922    S3Storage::new(
1923        config,
1924        bucket,
1925        prefix,
1926        credentials.unwrap_or(S3Credentials::FromEnv),
1927        !use_weak_consistency, // notice eventually consistent storage can't do writes
1928        merge_required_headers(extra_read_headers, tigris_read_headers),
1929        merge_required_headers(extra_write_headers, tigris_write_headers),
1930        legacy_rooted_keys,
1931    )
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936    use icechunk_macros::tokio_test;
1937
1938    use super::*;
1939
1940    // Readback classification is unit-tested in `icechunk_storage::readback`.
1941
1942    #[test]
1943    fn transient_delete_errors_are_throttles() {
1944        for (status, code) in [
1945            (Some(500), "InternalError"),
1946            (Some(503), "SlowDown"),
1947            (Some(503), "ServiceUnavailable"),
1948            (Some(429), "?"),
1949            (Some(502), "?"),
1950            (None, "InternalError"),
1951            (None, "SlowDown"),
1952        ] {
1953            assert!(is_transient_s3_error(status, code), "{status:?} {code}");
1954        }
1955        for (status, code) in [
1956            (Some(403), "AccessDenied"),
1957            (Some(404), "NoSuchKey"),
1958            (Some(400), "MalformedXML"),
1959            (None, "AccessDenied"),
1960            (None, "?"),
1961        ] {
1962            assert!(!is_transient_s3_error(status, code), "{status:?} {code}");
1963        }
1964    }
1965
1966    fn per_key_error(code: Option<&str>) -> aws_sdk_s3::types::Error {
1967        let mut b = aws_sdk_s3::types::Error::builder().key("k").message("m");
1968        if let Some(code) = code {
1969            b = b.code(code);
1970        }
1971        b.build()
1972    }
1973
1974    #[test]
1975    fn partial_delete_is_retried_whole_only_when_every_key_error_is_transient() {
1976        let errs =
1977            [per_key_error(Some("InternalError")), per_key_error(Some("SlowDown"))];
1978        assert_eq!(transient_per_key_code(&errs), Some("InternalError"));
1979        let errs =
1980            [per_key_error(Some("InternalError")), per_key_error(Some("AccessDenied"))];
1981        assert_eq!(transient_per_key_code(&errs), None);
1982        assert_eq!(transient_per_key_code(&[per_key_error(None)]), None);
1983        assert_eq!(transient_per_key_code(&[]), None);
1984    }
1985
1986    #[test]
1987    fn precondition_codes() {
1988        for code in
1989            ["PreconditionFailed", "ConditionalRequestConflict", "ConcurrentModification"]
1990        {
1991            assert!(is_precondition_code(code), "{code} must count as a precondition");
1992        }
1993        assert!(!is_precondition_code("NoSuchUpload"));
1994        assert!(!is_precondition_code(""));
1995    }
1996
1997    #[test]
1998    fn endpoint_bucket_separator() {
1999        // these endpoints need the separator the SDK omits
2000        for (input, expected) in [
2001            ("https://s3.hf.co/my-namespace", "https://s3.hf.co/my-namespace/"),
2002            ("https://s3.hf.co/my-namespace/", "https://s3.hf.co/my-namespace/"),
2003            ("http://localhost:9000/a/b", "http://localhost:9000/a/b/"),
2004            // no path, so the SDK builds the URL correctly
2005            ("https://fly.storage.tigris.dev", "https://fly.storage.tigris.dev"),
2006            ("http://localhost:9000", "http://localhost:9000"),
2007            ("http://localhost:9000/", "http://localhost:9000/"),
2008            ("localhost:9000", "localhost:9000"),
2009        ] {
2010            assert_eq!(endpoint_with_bucket_separator(input), expected, "{input}");
2011        }
2012    }
2013
2014    /// GC fans its listing out over id prefixes only when the backend lists
2015    /// them server-side; losing this override silently turns GC's listing
2016    /// into a single stream.
2017    #[test]
2018    fn s3_lists_id_prefixes_natively() {
2019        let storage = new_s3_storage(
2020            S3Options::default(),
2021            "my-bucket".to_string(),
2022            Some("some/prefix".to_string()),
2023            None,
2024            Vec::new(),
2025            Vec::new(),
2026            None,
2027        )
2028        .unwrap();
2029        assert!(storage.lists_id_prefixes_natively());
2030    }
2031
2032    #[test]
2033    fn hf_storage_sets_gateway_config() {
2034        let storage = hf_storage(
2035            S3Options::default(),
2036            "my-bucket".to_string(),
2037            Some("some/prefix".to_string()),
2038            "my-namespace",
2039            None,
2040            Vec::new(),
2041            Vec::new(),
2042            None,
2043        )
2044        .unwrap();
2045
2046        assert_eq!(
2047            storage.config.endpoint_url.as_deref(),
2048            Some("https://s3.hf.co/my-namespace")
2049        );
2050        assert_eq!(storage.config.region.as_deref(), Some("us-east-1"));
2051        assert!(storage.config.force_path_style);
2052        assert_eq!(storage.bucket, "my-bucket");
2053    }
2054
2055    #[tokio_test]
2056    async fn test_serialize_s3_storage() {
2057        let config = S3Options::default()
2058            .with_region("us-west-2")
2059            .with_endpoint_url("http://localhost:4200")
2060            .with_allow_http(true);
2061        let credentials = S3Credentials::Static(S3StaticCredentials {
2062            access_key_id: "access_key_id".to_string(),
2063            secret_access_key: "secret_access_key".to_string(),
2064            session_token: Some("session_token".to_string()),
2065            expires_after: None,
2066        });
2067        let storage = S3Storage::new(
2068            config,
2069            "bucket".to_string(),
2070            Some("prefix".to_string()),
2071            credentials,
2072            true,
2073            Vec::new(),
2074            Vec::new(),
2075            None,
2076        )
2077        .unwrap();
2078
2079        let serialized = serde_json::to_string(&storage).unwrap();
2080
2081        assert_eq!(
2082            serialized,
2083            r#"{"config":{"region":"us-west-2","endpoint_url":"http://localhost:4200","anonymous":false,"allow_http":true,"force_path_style":false,"network_stream_timeout_seconds":null,"requester_pays":false,"checksum_algorithm":null},"credentials":{"s3_credential_type":"static","access_key_id":"access_key_id","secret_access_key":"secret_access_key","session_token":"session_token","expires_after":null},"bucket":"bucket","prefix":"prefix","can_write":true,"extra_read_headers":[],"extra_write_headers":[],"key_layout":null}"#
2084        );
2085
2086        let deserialized: S3Storage = serde_json::from_str(&serialized).unwrap();
2087        assert_eq!(storage.config, deserialized.config);
2088    }
2089
2090    /// Extra headers are serialized on the struct (so they survive the pickle /
2091    /// distributed path), and round-trip intact.
2092    #[tokio_test]
2093    async fn test_serialize_s3_storage_with_headers() {
2094        let read_headers = vec![("x-amz-meta-reader".to_string(), "r".to_string())];
2095        let write_headers = vec![
2096            ("x-amz-acl".to_string(), "bucket-owner-full-control".to_string()),
2097            ("x-amz-meta-writer".to_string(), "w".to_string()),
2098        ];
2099        let storage = S3Storage::new(
2100            S3Options::default(),
2101            "bucket".to_string(),
2102            Some("prefix".to_string()),
2103            S3Credentials::FromEnv,
2104            true,
2105            read_headers.clone(),
2106            write_headers.clone(),
2107            None,
2108        )
2109        .unwrap();
2110
2111        let serialized = serde_json::to_string(&storage).unwrap();
2112        assert!(serialized.contains("x-amz-acl"), "got: {serialized}");
2113
2114        let deserialized: S3Storage = serde_json::from_str(&serialized).unwrap();
2115        assert_eq!(deserialized.extra_read_headers, read_headers);
2116        assert_eq!(deserialized.extra_write_headers, write_headers);
2117    }
2118
2119    /// Tigris consistency headers win over user-supplied headers on a
2120    /// case-insensitive name conflict; non-conflicting user headers survive.
2121    #[test]
2122    fn test_tigris_user_headers_merge() {
2123        let storage = tigris_storage(
2124            S3Options::default().with_region("iad"),
2125            "bucket".to_string(),
2126            Some("prefix".to_string()),
2127            Some(S3Credentials::FromEnv),
2128            false,
2129            vec![("x-amz-meta-reader".to_string(), "r".to_string())],
2130            vec![
2131                ("x-amz-acl".to_string(), "bucket-owner-full-control".to_string()),
2132                // collides (case-insensitively) with the required Tigris header
2133                ("x-tigris-regions".to_string(), "hijacked".to_string()),
2134            ],
2135            None,
2136        )
2137        .unwrap();
2138
2139        // user read header preserved alongside the injected Tigris read headers
2140        assert!(
2141            storage
2142                .extra_read_headers
2143                .contains(&("x-amz-meta-reader".to_string(), "r".to_string()))
2144        );
2145        assert!(
2146            storage
2147                .extra_read_headers
2148                .iter()
2149                .any(|(k, v)| k == "X-Tigris-Regions" && v == "iad")
2150        );
2151
2152        // user x-amz-acl preserved; the colliding x-tigris-regions override dropped
2153        assert!(storage.extra_write_headers.contains(&(
2154            "x-amz-acl".to_string(),
2155            "bucket-owner-full-control".to_string()
2156        )));
2157        assert!(!storage.extra_write_headers.iter().any(|(_, v)| v == "hijacked"));
2158        assert!(
2159            storage
2160                .extra_write_headers
2161                .iter()
2162                .any(|(k, v)| k == "X-Tigris-Regions" && v == "iad")
2163        );
2164    }
2165
2166    fn storage_with_prefix(prefix: Option<&str>, legacy_rooted_keys: bool) -> S3Storage {
2167        S3Storage::new(
2168            S3Options::default(),
2169            "bucket".to_string(),
2170            prefix.map(str::to_string),
2171            S3Credentials::FromEnv,
2172            true,
2173            Vec::new(),
2174            Vec::new(),
2175            // map the helper's bool: force legacy when set, else auto-detect
2176            legacy_rooted_keys.then_some(true),
2177        )
2178        .unwrap()
2179    }
2180
2181    /// empty prefix + Standard layout never produces a
2182    /// leading slash, for every kind of object.
2183    #[test]
2184    fn test_key_for_standard_empty_prefix() {
2185        let s = storage_with_prefix(Some(""), false);
2186        for relpath in
2187            ["chunks/abc123", "repo", "refs/branch.main/ref.json", "config.yaml"]
2188        {
2189            let key = s.key_for(KeyLayout::Standard, relpath);
2190            assert_eq!(key, relpath);
2191            assert!(!key.starts_with('/'), "key {key:?} must not start with a slash");
2192        }
2193    }
2194
2195    #[test]
2196    fn test_key_for_standard_nonempty_prefix() {
2197        let s = storage_with_prefix(Some("foo"), false);
2198        assert_eq!(s.key_for(KeyLayout::Standard, "chunks/abc123"), "foo/chunks/abc123");
2199        // A trailing slash on the prefix is normalized away at construction.
2200        let s = storage_with_prefix(Some("foo/"), false);
2201        assert_eq!(s.key_for(KeyLayout::Standard, "chunks/abc123"), "foo/chunks/abc123");
2202    }
2203
2204    /// `None` and `""` are the two ways #2239 was triggered; they must produce an
2205    /// identical empty prefix (they converge at `unwrap_or_default` in `new`).
2206    #[test]
2207    fn test_none_and_empty_prefix_are_equivalent() {
2208        let from_none = storage_with_prefix(None, false);
2209        let from_empty = storage_with_prefix(Some(""), false);
2210        for relpath in ["chunks/abc123", "repo"] {
2211            assert_eq!(
2212                from_none.key_for(KeyLayout::Standard, relpath),
2213                from_empty.key_for(KeyLayout::Standard, relpath),
2214            );
2215        }
2216    }
2217
2218    /// A leading slash on the relpath is collapsed at the join, but an interior
2219    /// `//` is preserved
2220    #[test]
2221    fn test_list_prefix() {
2222        // leading slash stripped for each layout
2223        let s = storage_with_prefix(Some("foo"), false);
2224        assert_eq!(s.list_prefix(KeyLayout::Standard, "/chunks"), "foo/chunks");
2225        let s = storage_with_prefix(Some(""), false);
2226        assert_eq!(s.list_prefix(KeyLayout::Standard, "/chunks"), "chunks");
2227        let s = storage_with_prefix(Some(""), true);
2228        assert_eq!(s.list_prefix(KeyLayout::LegacyRoot, "/chunks"), "/chunks");
2229
2230        // interior `//` is preserved, matching `key_for`
2231        let s = storage_with_prefix(Some("a//b"), false);
2232        assert_eq!(s.list_prefix(KeyLayout::Standard, "chunks/x"), "a//b/chunks/x");
2233        assert_eq!(
2234            s.list_prefix(KeyLayout::Standard, "chunks/x"),
2235            s.key_for(KeyLayout::Standard, "chunks/x"),
2236        );
2237    }
2238
2239    /// `LegacyRoot` reproduces the exact pre-#2239 buggy layout, so we can keep
2240    /// reading repositories written by old clients.
2241    #[test]
2242    fn test_key_for_legacy_root_reproduces_bug() {
2243        let s = storage_with_prefix(Some(""), true);
2244        assert_eq!(s.key_for(KeyLayout::LegacyRoot, "chunks/abc123"), "/chunks/abc123");
2245        assert_eq!(s.key_for(KeyLayout::LegacyRoot, "repo"), "/repo");
2246    }
2247
2248    #[test]
2249    fn test_legacy_rooted_keys_rejected_with_nonempty_prefix() {
2250        let err = S3Storage::new(
2251            S3Options::default(),
2252            "bucket".to_string(),
2253            Some("foo".to_string()),
2254            S3Credentials::FromEnv,
2255            true,
2256            Vec::new(),
2257            Vec::new(),
2258            Some(true),
2259        )
2260        .unwrap_err();
2261        assert!(err.to_string().contains("empty prefix"), "got: {err}");
2262    }
2263
2264    #[test]
2265    fn test_decide_layout_four_way() {
2266        let seen = |clean, rooted| AnchorProbe { clean, rooted };
2267        // empty/new repo -> clean
2268        assert_eq!(decide_layout(&seen(false, false), "b").unwrap(), KeyLayout::Standard);
2269        // clean repo -> clean
2270        assert_eq!(decide_layout(&seen(true, false), "b").unwrap(), KeyLayout::Standard);
2271        // legacy rooted repo -> legacy
2272        assert_eq!(
2273            decide_layout(&seen(false, true), "b").unwrap(),
2274            KeyLayout::LegacyRoot
2275        );
2276        // mixed -> hard error
2277        let err = decide_layout(&seen(true, true), "mybucket").unwrap_err();
2278        assert!(err.to_string().contains("both"), "got: {err}");
2279        assert!(err.to_string().contains("mybucket"), "got: {err}");
2280    }
2281
2282    #[test]
2283    fn test_layout_from_anchor_etags() {
2284        let et = |s: &str| Some(s.to_string());
2285        let probe =
2286            |clean: Option<String>, rooted: Option<String>| AnchorProbe { clean, rooted };
2287        let layout = |anchors: &[AnchorProbe<Option<String>>]| {
2288            layout_from_anchor_etags(anchors, "b")
2289        };
2290
2291        // empty/new repo: nothing found -> clean
2292        assert_eq!(layout(&[probe(None, None)]).unwrap(), KeyLayout::Standard);
2293        // clean repo (clean key found, rooted key absent: AWS 404 or rustfs 400)
2294        assert_eq!(layout(&[probe(et("E"), None)]).unwrap(), KeyLayout::Standard);
2295        // genuine legacy rooted repo: only the rooted key exists
2296        assert_eq!(layout(&[probe(None, et("E"))]).unwrap(), KeyLayout::LegacyRoot);
2297        // normalizing store (MinIO): "/x" and "x" are the SAME object (equal ETag)
2298        // -> clean, NOT a spurious mixed-layout error (regression for #2239 probe).
2299        assert_eq!(layout(&[probe(et("E"), et("E"))]).unwrap(), KeyLayout::Standard);
2300        // genuinely distinct objects at both keys -> mixed, hard error
2301        assert!(layout(&[probe(et("CLEAN"), et("ROOTED"))]).is_err());
2302        // multi-anchor: V2 repo on a normalizing store (repo found+normalized,
2303        // refs anchor absent) -> clean
2304        assert_eq!(
2305            layout(&[probe(et("E"), et("E")), probe(None, None)]).unwrap(),
2306            KeyLayout::Standard
2307        );
2308    }
2309
2310    /// Forcing the layout pre-seeds the cell and survives a serialize round-trip,
2311    /// so a deserialized (forked/pickled) storage inherits it without probing.
2312    #[test]
2313    fn test_forced_layout_serializes_and_is_inherited() {
2314        let s = storage_with_prefix(Some(""), true);
2315        assert_eq!(s.key_layout.get().copied(), Some(KeyLayout::LegacyRoot));
2316        let json = serde_json::to_string(&s).unwrap();
2317        assert!(json.contains(r#""key_layout":"LegacyRoot""#), "got: {json}");
2318        let restored: S3Storage = serde_json::from_str(&json).unwrap();
2319        assert_eq!(restored.key_layout.get().copied(), Some(KeyLayout::LegacyRoot));
2320    }
2321}