1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub enum KeyLayout {
76 Standard,
78 LegacyRoot,
80}
81
82pub const DEFAULT_LAYOUT_ANCHORS: &[&str] = &["repo", "refs/branch.main/ref.json"];
90
91mod 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: 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 #[serde(skip)]
126 allow_empty_prefix_creation: bool,
127 #[serde(skip)]
128 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#[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
205pub 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#[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
249fn endpoint_with_bucket_separator(endpoint_url: &str) -> Cow<'_, str> {
253 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 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 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 static RETRY_CODES: &[u16] = &[408, 429, 499];
374 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 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
425fn 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#[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 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
458enum 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
480fn is_precondition_code(code: &str) -> bool {
484 matches!(
485 code,
486 "PreconditionFailed" | "ConditionalRequestConflict" | "ConcurrentModification"
487 )
488}
489
490fn 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
504fn 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 #[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 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 pub fn unsafe_allow_empty_prefix_creation(mut self) -> Self {
574 self.allow_empty_prefix_creation = true;
575 self
576 }
577
578 #[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 fn key_for(&self, layout: KeyLayout, relpath: &str) -> String {
601 match layout {
602 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 fn list_prefix(&self, layout: KeyLayout, relpath: &str) -> String {
614 self.key_for(layout, relpath.strip_prefix('/').unwrap_or(relpath))
615 }
616
617 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 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 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 async fn probe_layout(&self, ctx: &StorageContext<'_>) -> StorageResult<KeyLayout> {
662 if !self.prefix.is_empty() {
663 return Ok(KeyLayout::Standard);
665 }
666 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 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 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 Err(SdkError::ResponseError(err)) => {
796 let status = err.raw().status().as_u16();
797 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 .bucket(self.bucket.clone())
827 .key(key);
828
829 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 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 .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 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 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 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 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 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 out.e_tag()
1030 .map(|e| VersionInfo::from_etag_only(e.to_string()))
1031 .unwrap_or_else(VersionInfo::for_creation),
1032 ),
1033 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 || code == "ConcurrentModification"
1156 {
1157 Ok(VersionedUpdateResult::NotOnLatestVersion)
1158 } else {
1159 obj_store_error_res(SdkError::<CopyObjectError>::ServiceError(err))
1160 }
1161 }
1162 Err(SdkError::ResponseError(err)) => {
1164 let status = err.raw().status().as_u16();
1165 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 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 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 #[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 obj_not_found_res()
1468 }
1469 Some(_)
1470 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 clean: T,
1493 rooted: T,
1495}
1496
1497fn 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 (Some(c), Some(r)) if c == r => (true, false),
1514 (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
1530fn decide_layout(seen: &AnchorProbe<bool>, bucket: &str) -> StorageResult<KeyLayout> {
1532 match seen {
1533 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 _ => 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
1595pub 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
1625pub 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#[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#[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
1757const HF_GATEWAY_ENDPOINT: &str = "https://s3.hf.co";
1759
1760#[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#[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 config.region = Some("us-east-1".to_string());
1814 }
1815 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#[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
1862fn 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#[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 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, 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 #[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 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 ("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 #[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 #[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 #[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 ("x-tigris-regions".to_string(), "hijacked".to_string()),
2134 ],
2135 None,
2136 )
2137 .unwrap();
2138
2139 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 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 legacy_rooted_keys.then_some(true),
2177 )
2178 .unwrap()
2179 }
2180
2181 #[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 let s = storage_with_prefix(Some("foo/"), false);
2201 assert_eq!(s.key_for(KeyLayout::Standard, "chunks/abc123"), "foo/chunks/abc123");
2202 }
2203
2204 #[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 #[test]
2221 fn test_list_prefix() {
2222 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 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 #[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 assert_eq!(decide_layout(&seen(false, false), "b").unwrap(), KeyLayout::Standard);
2269 assert_eq!(decide_layout(&seen(true, false), "b").unwrap(), KeyLayout::Standard);
2271 assert_eq!(
2273 decide_layout(&seen(false, true), "b").unwrap(),
2274 KeyLayout::LegacyRoot
2275 );
2276 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 assert_eq!(layout(&[probe(None, None)]).unwrap(), KeyLayout::Standard);
2293 assert_eq!(layout(&[probe(et("E"), None)]).unwrap(), KeyLayout::Standard);
2295 assert_eq!(layout(&[probe(None, et("E"))]).unwrap(), KeyLayout::LegacyRoot);
2297 assert_eq!(layout(&[probe(et("E"), et("E"))]).unwrap(), KeyLayout::Standard);
2300 assert!(layout(&[probe(et("CLEAN"), et("ROOTED"))]).is_err());
2302 assert_eq!(
2305 layout(&[probe(et("E"), et("E")), probe(None, None)]).unwrap(),
2306 KeyLayout::Standard
2307 );
2308 }
2309
2310 #[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}