laskoviymishka commented on code in PR #3165:
URL: https://github.com/apache/iceberg-rust/pull/3165#discussion_r4045350071


##########
crates/storage/object_store/src/s3.rs:
##########
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iceberg::io::S3Config;
+use iceberg::{Error, ErrorKind, Result};
+use object_store::ObjectStore;
+use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
+use url::Url;
+
+/// Parsed components of an S3 URL.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ParsedS3Url {
+    pub(crate) scheme: String,
+    pub(crate) bucket: String,
+    pub(crate) relative: String,
+}
+
+/// Parse an absolute S3 URL into [`ParsedS3Url`].
+///
+/// Accepts `s3://`, `s3a://`, and `s3n://` schemes.
+pub(crate) fn parse_s3_url(path: &str) -> Result<ParsedS3Url> {
+    let url = Url::parse(path).map_err(|e| {
+        Error::new(ErrorKind::DataInvalid, format!("Invalid URL: 
{path}")).with_source(e)
+    })?;
+
+    let scheme = url.scheme();
+    match scheme {
+        "s3" | "s3a" | "s3n" => {}
+        _ => {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("Unsupported S3 scheme: {scheme} in url: {path}"),
+            ));
+        }
+    }
+
+    let bucket = url.host_str().ok_or_else(|| {

Review Comment:
   The `Url` rework fixed the uppercase-scheme case from last round, nice. The 
percent-encoded bucket half is still open though: `host_str()` returns the host 
still percent-encoded, so `s3://my%2Dbucket/...` yields bucket `my%2Dbucket`, 
which we hand straight to `with_bucket_name` and also use as the cache key — no 
real bucket has that name, and `s3://my-bucket` vs `s3://my%2Dbucket` split 
into two cache entries for the same bucket.
   
   I'd decode the host, or reject any host containing `%` with `DataInvalid`. 
The current test asserts the encoded form, so it's pinning the bug rather than 
the fix.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,410 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    Error::new(ErrorKind::Unexpected, "Failure in doing io 
operation").with_source(e)
+}
+
+/// Convert `object_store::ObjectMeta` into `iceberg::io::FileMetadata`.
+fn to_file_metadata(meta: object_store::ObjectMeta) -> FileMetadata {
+    FileMetadata { size: meta.size }
+}
+
+/// `object_store`-based storage factory.
+///
+/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO 
instances
+/// backed by the `object_store` crate.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorageFactory {
+    /// S3 storage factory.
+    #[cfg(feature = "object_store-s3")]
+    S3,
+}
+
+#[typetag::serde(name = "ObjectStoreStorageFactory")]
+impl StorageFactory for ObjectStoreStorageFactory {
+    #[allow(unused_variables)]
+    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorageFactory::S3 => {
+                let s3_config = S3Config::try_from(config)?;
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    store_cache: Arc::new(DashMap::new()),
+                })))
+            }
+        }
+    }
+}
+
+type StoreCache = Arc<DashMap<String, Arc<dyn ObjectStore>>>;
+
+/// `object_store` S3 storage state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct S3Storage {
+    config: Arc<S3Config>,
+    #[serde(skip, default)]
+    store_cache: StoreCache,
+}
+
+/// `object_store`-based storage implementation.
+///
+/// Stores are cached per bucket to avoid rebuilding the client on every 
operation.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorage {
+    /// S3 storage variant.
+    #[cfg(feature = "object_store-s3")]
+    S3(S3Storage),
+}
+
+struct StoreAndPath {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+impl ObjectStoreStorage {
+    /// Get or create a cached store and extract the relative 
`ObjectStorePath`.
+    fn get_store_and_path(&self, path: &str) -> Result<StoreAndPath> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => {
+                let parsed = parse_s3_url(path)?;
+
+                let store = s3
+                    .store_cache
+                    .entry(parsed.bucket.clone())
+                    .or_try_insert_with(|| build_s3_store(&s3.config, 
&parsed.bucket))?
+                    .value()
+                    .clone();
+
+                let object_path =
+                    
ObjectStorePath::from_url_path(&parsed.relative).map_err(|e| {
+                        Error::new(
+                            ErrorKind::DataInvalid,
+                            format!("Invalid URL path: {}", parsed.relative),
+                        )
+                        .with_source(e)
+                    })?;
+
+                Ok(StoreAndPath {
+                    store,
+                    path: object_path,
+                })
+            }
+        }
+    }
+}
+
+#[typetag::serde(name = "ObjectStoreStorage")]
+#[async_trait]
+impl Storage for ObjectStoreStorage {
+    async fn exists(&self, path: &str) -> Result<bool> {
+        let target = self.get_store_and_path(path)?;
+        match target.store.head(&target.path).await {
+            Ok(_) => Ok(true),
+            Err(object_store::Error::NotFound { .. }) => Ok(false),
+            Err(e) => Err(from_object_store_error(e)),
+        }
+    }
+
+    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+        let target = self.get_store_and_path(path)?;
+        let meta = target
+            .store
+            .head(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(to_file_metadata(meta))
+    }
+
+    async fn read(&self, path: &str) -> Result<Bytes> {
+        let target = self.get_store_and_path(path)?;
+        let result = target
+            .store
+            .get(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+
+    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+        let target = self.get_store_and_path(path)?;
+        Ok(Box::new(ObjectStoreReader {
+            store: target.store,
+            path: target.path,
+        }))
+    }
+
+    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .put(&target.path, PutPayload::from_bytes(bs))
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+        let target = self.get_store_and_path(path)?;
+        let upload = target
+            .store
+            .put_multipart(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        let writer = WriteMultipart::new(upload);
+        Ok(Box::new(ObjectStoreWriter {
+            writer: Some(writer),
+        }))
+    }
+
+    async fn delete(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .delete(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_prefix(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        let prefix = if target.path.as_ref().ends_with('/') {
+            target.path
+        } else {
+            ObjectStorePath::from(format!("{}/", target.path.as_ref()))
+        };
+
+        let mut list_stream = target.store.list(Some(&prefix));
+        while let Some(entry) = list_stream.next().await {
+            let entry = entry.map_err(from_object_store_error)?;
+            target
+                .store
+                .delete(&entry.location)
+                .await
+                .map_err(from_object_store_error)?;
+        }
+        Ok(())
+    }
+
+    async fn delete_stream(&self, paths: BoxStream<'static, String>) -> 
Result<()> {
+        paths
+            .map(Ok)
+            .try_for_each_concurrent(16, |path| async move {
+                let target = self.get_store_and_path(&path)?;
+                target
+                    .store
+                    .delete(&target.path)
+                    .await
+                    .map_err(from_object_store_error)?;
+                Ok(())
+            })
+            .await
+    }
+
+    fn new_input(&self, path: &str) -> Result<InputFile> {
+        Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+
+    fn new_output(&self, path: &str) -> Result<OutputFile> {
+        Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+}
+
+/// Reader that implements `FileRead` using `object_store`.
+struct ObjectStoreReader {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+#[async_trait]
+impl FileRead for ObjectStoreReader {
+    async fn read(&self, range: Range<u64>) -> Result<Bytes> {
+        let opts = object_store::GetOptions {
+            range: Some((range.start..range.end).into()),
+            ..Default::default()
+        };
+        let result = self
+            .store
+            .get_opts(&self.path, opts)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+}
+
+/// Writer that implements `FileWrite` using `object_store` multipart upload.
+struct ObjectStoreWriter {
+    writer: Option<WriteMultipart>,
+}
+
+impl Drop for ObjectStoreWriter {
+    fn drop(&mut self) {
+        if let Some(writer) = self.writer.take()
+            && let Ok(handle) = tokio::runtime::Handle::try_current()
+        {
+            handle.spawn(async move {
+                let _ = writer.abort().await;
+            });
+        }
+    }
+}
+
+#[async_trait]
+impl FileWrite for ObjectStoreWriter {
+    async fn write(&mut self, bs: Bytes) -> Result<()> {
+        let writer = self
+            .writer
+            .as_mut()
+            .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has 
already been closed"))?;
+        writer.put(bs);
+        Ok(())
+    }
+
+    async fn close(&mut self) -> Result<()> {
+        let writer = self
+            .writer
+            .take()
+            .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has 
already been closed"))?;
+        writer.finish().await.map_err(from_object_store_error)?;

Review Comment:
   The Drop-abort from last round is in, but it can't fire in the case it's for.
   
   `close()` takes the writer out before `finish().await`, so if `finish()` 
errors the `WriteMultipart` is already consumed by value — by the time Drop 
runs, `self.writer` is `None` and the guard skips the abort. That's exactly the 
transient-error path where parts have already been flushed to S3 and now leak 
until a lifecycle rule expires them.
   
   Holding the lower-level `Box<dyn MultipartUpload>` instead of 
`WriteMultipart` lets us `abort()` on a failed `complete()` and again in Drop. 
wdyt?



##########
crates/storage/object_store/src/s3.rs:
##########
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iceberg::io::S3Config;
+use iceberg::{Error, ErrorKind, Result};
+use object_store::ObjectStore;
+use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
+use url::Url;
+
+/// Parsed components of an S3 URL.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ParsedS3Url {
+    pub(crate) scheme: String,
+    pub(crate) bucket: String,
+    pub(crate) relative: String,
+}
+
+/// Parse an absolute S3 URL into [`ParsedS3Url`].
+///
+/// Accepts `s3://`, `s3a://`, and `s3n://` schemes.
+pub(crate) fn parse_s3_url(path: &str) -> Result<ParsedS3Url> {
+    let url = Url::parse(path).map_err(|e| {
+        Error::new(ErrorKind::DataInvalid, format!("Invalid URL: 
{path}")).with_source(e)
+    })?;
+
+    let scheme = url.scheme();
+    match scheme {
+        "s3" | "s3a" | "s3n" => {}
+        _ => {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("Unsupported S3 scheme: {scheme} in url: {path}"),
+            ));
+        }
+    }
+
+    let bucket = url.host_str().ok_or_else(|| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid s3 url: {path}, missing bucket"),
+        )
+    })?;
+
+    if bucket.is_empty() {
+        return Err(Error::new(
+            ErrorKind::DataInvalid,
+            format!("Empty s3 url: {path}, missing bucket"),
+        ));
+    }
+
+    let relative = url.path().trim_start_matches('/');
+
+    Ok(ParsedS3Url {
+        scheme: scheme.to_string(),
+        bucket: bucket.to_string(),
+        relative: relative.to_string(),
+    })
+}
+
+/// Build an `AmazonS3` store from iceberg's `S3Config` for a given bucket.
+pub(crate) fn build_s3_store(config: &S3Config, bucket: &str) -> 
Result<Arc<dyn ObjectStore>> {
+    if config.role_arn.is_some() {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 assume-role (role_arn) is not supported by object_store 
backend",
+        ));
+    }
+    if config.disable_ec2_metadata {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_ec2_metadata is not supported by object_store backend",
+        ));
+    }
+    if config.disable_config_load {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_config_load is not supported by object_store backend",
+        ));
+    }
+
+    let mut builder = AmazonS3Builder::new().with_bucket_name(bucket);
+
+    if let Some(ref endpoint) = config.endpoint {
+        builder = builder.with_endpoint(endpoint);
+        if endpoint.starts_with("http://";) {
+            builder = builder.with_allow_http(true);
+        }
+    }
+    if let Some(ref access_key_id) = config.access_key_id {
+        builder = builder.with_access_key_id(access_key_id);
+    }
+    if let Some(ref secret_access_key) = config.secret_access_key {
+        builder = builder.with_secret_access_key(secret_access_key);
+    }
+    if let Some(ref session_token) = config.session_token {
+        builder = builder.with_token(session_token);
+    }
+    if let Some(ref region) = config.region {
+        builder = builder.with_region(region);
+    }
+    if config.enable_virtual_host_style {
+        builder = builder.with_virtual_hosted_style_request(true);
+    }
+    if config.allow_anonymous {
+        builder = builder.with_skip_signature(true);
+    }
+
+    if let Some(ref sse) = config.server_side_encryption {
+        match sse.as_str() {
+            "aws:kms" => {
+                let key = config
+                    .server_side_encryption_aws_kms_key_id
+                    .as_deref()
+                    .unwrap_or_default();
+                builder = builder.with_sse_kms_encryption(key);

Review Comment:
   The KMS forward landed, thanks — but the default-key path breaks every write 
here.
   
   When `s3.sse.type=kms` is set without an explicit key, 
`server_side_encryption_aws_kms_key_id` is `None`, so we pass `""` into 
`with_sse_kms_encryption`. object_store's `impl<T> From<T> for Option<T>` turns 
that back into `Some("")`, so `encryption_kms_key_id` becomes `Some("")` and 
every PUT carries an empty `x-amz-server-side-encryption-aws-kms-key-id:` 
header — S3 rejects those with `InvalidArgument`. The store builds fine, so it 
fails silently at write time, and "use the bucket's default CMK" is the most 
common KMS setup.
   
   Java and opendal send `aws:kms` with no key-id header in this case. I'd 
guard it: forward the key when we have one, otherwise set 
`ServerSideEncryption` to `aws:kms` via `with_config` and leave the key-id 
unset. A unit test for the no-key path would catch this.



##########
crates/storage/object_store/tests/file_io_s3_test.rs:
##########
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Integration tests for FileIO S3 using object_store backend.
+//!
+//! These tests assume Docker containers are started externally via `make 
docker-up`.
+//! Each test uses unique file paths based on module path to avoid conflicts.
+
+#[cfg(feature = "object_store-s3")]
+mod tests {
+    use std::sync::Arc;
+
+    use bytes::Bytes;
+    use futures::StreamExt;
+    use iceberg::io::{
+        FileIO, FileIOBuilder, S3_ACCESS_KEY_ID, S3_ENDPOINT, 
S3_PATH_STYLE_ACCESS, S3_REGION,
+        S3_SECRET_ACCESS_KEY,
+    };
+    use iceberg_storage_object_store::ObjectStoreStorageFactory;
+    use iceberg_test_utils::{get_minio_endpoint, 
normalize_test_name_with_parts, set_up};
+
+    async fn get_file_io() -> FileIO {
+        set_up();
+
+        let minio_endpoint = get_minio_endpoint();
+
+        FileIOBuilder::new(Arc::new(ObjectStoreStorageFactory::S3))
+            .with_props(vec![
+                (S3_ENDPOINT, minio_endpoint),
+                (S3_ACCESS_KEY_ID, "admin".to_string()),
+                (S3_SECRET_ACCESS_KEY, "password".to_string()),
+                (S3_REGION, "us-east-1".to_string()),
+                (S3_PATH_STYLE_ACCESS, "true".to_string()),
+            ])
+            .build()
+    }
+
+    fn roundtrip_file_io(file_io: &FileIO) -> FileIO {
+        let serialized = file_io.serialize_all().unwrap();
+        FileIO::deserialize_all(&serialized).unwrap()
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_serialization_roundtrip() {
+        let file_io = roundtrip_file_io(&get_file_io().await);
+        let path = format!(
+            "s3://bucket1/{}",
+            
normalize_test_name_with_parts!("test_file_io_s3_serialization_roundtrip")
+        );
+
+        let _ = file_io.delete(&path).await;
+        file_io
+            .new_output(&path)
+            .unwrap()
+            .write(Bytes::from_static(b"roundtrip"))
+            .await
+            .unwrap();
+        assert_eq!(
+            file_io.new_input(&path).unwrap().read().await.unwrap(),
+            Bytes::from_static(b"roundtrip")
+        );
+        file_io.delete(&path).await.unwrap();
+        assert!(!file_io.exists(&path).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_exists() {
+        let file_io = get_file_io().await;
+        assert!(!file_io.exists("s3://bucket2/any").await.unwrap());
+        assert!(file_io.exists("s3://bucket1/").await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_file_io_s3_output() {

Review Comment:
   The MinIO tests landed — that closes the "nothing exercises a real 
read/write" ask. They all go through `write(bytes)` → `put()` though; the 
`writer()` → `WriteMultipart` path has no coverage, and that's the primary path 
for streaming Parquet/Avro data files (and where the Drop/abort issues above 
live).
   
   A single test that writes past the multipart threshold, closes, and reads 
back — plus one that drops a writer without closing — would cover both the 
multipart lifecycle and ask #4 end-to-end.



##########
crates/storage/object_store/src/s3.rs:
##########
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iceberg::io::S3Config;
+use iceberg::{Error, ErrorKind, Result};
+use object_store::ObjectStore;
+use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
+use url::Url;
+
+/// Parsed components of an S3 URL.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ParsedS3Url {
+    pub(crate) scheme: String,
+    pub(crate) bucket: String,
+    pub(crate) relative: String,
+}
+
+/// Parse an absolute S3 URL into [`ParsedS3Url`].
+///
+/// Accepts `s3://`, `s3a://`, and `s3n://` schemes.
+pub(crate) fn parse_s3_url(path: &str) -> Result<ParsedS3Url> {
+    let url = Url::parse(path).map_err(|e| {
+        Error::new(ErrorKind::DataInvalid, format!("Invalid URL: 
{path}")).with_source(e)
+    })?;
+
+    let scheme = url.scheme();
+    match scheme {
+        "s3" | "s3a" | "s3n" => {}
+        _ => {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("Unsupported S3 scheme: {scheme} in url: {path}"),
+            ));
+        }
+    }
+
+    let bucket = url.host_str().ok_or_else(|| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid s3 url: {path}, missing bucket"),
+        )
+    })?;
+
+    if bucket.is_empty() {
+        return Err(Error::new(
+            ErrorKind::DataInvalid,
+            format!("Empty s3 url: {path}, missing bucket"),
+        ));
+    }
+
+    let relative = url.path().trim_start_matches('/');
+
+    Ok(ParsedS3Url {
+        scheme: scheme.to_string(),
+        bucket: bucket.to_string(),
+        relative: relative.to_string(),
+    })
+}
+
+/// Build an `AmazonS3` store from iceberg's `S3Config` for a given bucket.
+pub(crate) fn build_s3_store(config: &S3Config, bucket: &str) -> 
Result<Arc<dyn ObjectStore>> {
+    if config.role_arn.is_some() {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 assume-role (role_arn) is not supported by object_store 
backend",
+        ));
+    }
+    if config.disable_ec2_metadata {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_ec2_metadata is not supported by object_store backend",
+        ));
+    }
+    if config.disable_config_load {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_config_load is not supported by object_store backend",
+        ));
+    }
+
+    let mut builder = AmazonS3Builder::new().with_bucket_name(bucket);
+
+    if let Some(ref endpoint) = config.endpoint {
+        builder = builder.with_endpoint(endpoint);
+        if endpoint.starts_with("http://";) {
+            builder = builder.with_allow_http(true);
+        }
+    }
+    if let Some(ref access_key_id) = config.access_key_id {
+        builder = builder.with_access_key_id(access_key_id);
+    }
+    if let Some(ref secret_access_key) = config.secret_access_key {
+        builder = builder.with_secret_access_key(secret_access_key);
+    }
+    if let Some(ref session_token) = config.session_token {
+        builder = builder.with_token(session_token);
+    }
+    if let Some(ref region) = config.region {
+        builder = builder.with_region(region);
+    }
+    if config.enable_virtual_host_style {
+        builder = builder.with_virtual_hosted_style_request(true);
+    }
+    if config.allow_anonymous {
+        builder = builder.with_skip_signature(true);
+    }
+
+    if let Some(ref sse) = config.server_side_encryption {
+        match sse.as_str() {
+            "aws:kms" => {
+                let key = config
+                    .server_side_encryption_aws_kms_key_id
+                    .as_deref()
+                    .unwrap_or_default();
+                builder = builder.with_sse_kms_encryption(key);
+            }
+            "AES256" => {
+                builder = builder.with_config(
+                    
AmazonS3ConfigKey::from_str("aws_server_side_encryption").map_err(|e| {

Review Comment:
   This branch reaches into object_store internals by string, which gives us an 
`Unexpected` error path that can never fire and a silent dependency on a 
private constant across a crate boundary. There's a typed form:
   
   ```rust
   builder = builder.with_config(
       
AmazonS3ConfigKey::Encryption(S3EncryptionConfigKey::ServerSideEncryption),
       "AES256",
   );
   ```
   
   Worth a `server_side_encryption = "AES256"` unit test too, since nothing 
exercises this branch today.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,410 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    Error::new(ErrorKind::Unexpected, "Failure in doing io 
operation").with_source(e)
+}
+
+/// Convert `object_store::ObjectMeta` into `iceberg::io::FileMetadata`.
+fn to_file_metadata(meta: object_store::ObjectMeta) -> FileMetadata {
+    FileMetadata { size: meta.size }
+}
+
+/// `object_store`-based storage factory.
+///
+/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO 
instances
+/// backed by the `object_store` crate.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorageFactory {
+    /// S3 storage factory.
+    #[cfg(feature = "object_store-s3")]
+    S3,
+}
+
+#[typetag::serde(name = "ObjectStoreStorageFactory")]
+impl StorageFactory for ObjectStoreStorageFactory {
+    #[allow(unused_variables)]
+    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorageFactory::S3 => {
+                let s3_config = S3Config::try_from(config)?;
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    store_cache: Arc::new(DashMap::new()),
+                })))
+            }
+        }
+    }
+}
+
+type StoreCache = Arc<DashMap<String, Arc<dyn ObjectStore>>>;
+
+/// `object_store` S3 storage state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct S3Storage {
+    config: Arc<S3Config>,
+    #[serde(skip, default)]
+    store_cache: StoreCache,
+}
+
+/// `object_store`-based storage implementation.
+///
+/// Stores are cached per bucket to avoid rebuilding the client on every 
operation.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorage {
+    /// S3 storage variant.
+    #[cfg(feature = "object_store-s3")]
+    S3(S3Storage),
+}
+
+struct StoreAndPath {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+impl ObjectStoreStorage {
+    /// Get or create a cached store and extract the relative 
`ObjectStorePath`.
+    fn get_store_and_path(&self, path: &str) -> Result<StoreAndPath> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => {
+                let parsed = parse_s3_url(path)?;
+
+                let store = s3
+                    .store_cache
+                    .entry(parsed.bucket.clone())
+                    .or_try_insert_with(|| build_s3_store(&s3.config, 
&parsed.bucket))?
+                    .value()
+                    .clone();
+
+                let object_path =
+                    
ObjectStorePath::from_url_path(&parsed.relative).map_err(|e| {
+                        Error::new(
+                            ErrorKind::DataInvalid,
+                            format!("Invalid URL path: {}", parsed.relative),
+                        )
+                        .with_source(e)
+                    })?;
+
+                Ok(StoreAndPath {
+                    store,
+                    path: object_path,
+                })
+            }
+        }
+    }
+}
+
+#[typetag::serde(name = "ObjectStoreStorage")]
+#[async_trait]
+impl Storage for ObjectStoreStorage {
+    async fn exists(&self, path: &str) -> Result<bool> {
+        let target = self.get_store_and_path(path)?;
+        match target.store.head(&target.path).await {
+            Ok(_) => Ok(true),
+            Err(object_store::Error::NotFound { .. }) => Ok(false),
+            Err(e) => Err(from_object_store_error(e)),
+        }
+    }
+
+    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+        let target = self.get_store_and_path(path)?;
+        let meta = target
+            .store
+            .head(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(to_file_metadata(meta))
+    }
+
+    async fn read(&self, path: &str) -> Result<Bytes> {
+        let target = self.get_store_and_path(path)?;
+        let result = target
+            .store
+            .get(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+
+    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+        let target = self.get_store_and_path(path)?;
+        Ok(Box::new(ObjectStoreReader {
+            store: target.store,
+            path: target.path,
+        }))
+    }
+
+    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .put(&target.path, PutPayload::from_bytes(bs))
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+        let target = self.get_store_and_path(path)?;
+        let upload = target
+            .store
+            .put_multipart(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        let writer = WriteMultipart::new(upload);
+        Ok(Box::new(ObjectStoreWriter {
+            writer: Some(writer),
+        }))
+    }
+
+    async fn delete(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .delete(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_prefix(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        let prefix = if target.path.as_ref().ends_with('/') {
+            target.path
+        } else {
+            ObjectStorePath::from(format!("{}/", target.path.as_ref()))
+        };
+
+        let mut list_stream = target.store.list(Some(&prefix));
+        while let Some(entry) = list_stream.next().await {

Review Comment:
   This lists the prefix then deletes one object at a time, each awaited before 
the next — 10k objects is 10k sequential round-trips, and snapshot expiry hits 
this with large numbers of manifests. `ObjectStoreExt::delete_stream` (already 
imported) maps to S3 `DeleteObjects` at up to 1000 keys per request.
   
   Piping the list stream into it also lets us drop the trailing-slash branch 
just above, which is a no-op anyway since `ObjectStorePath` normalizes trailing 
slashes. Something like `store.list(Some(&prefix)).map_ok(|m| m.location)...` 
fed into `delete_stream(...)`.



##########
crates/storage/object_store/src/lib.rs:
##########
@@ -0,0 +1,410 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `object_store`-based storage implementation for Apache Iceberg.
+//!
+//! This crate provides [`ObjectStoreStorage`] and 
[`ObjectStoreStorageFactory`],
+//! which implement the [`Storage`] and
+//! [`StorageFactory`] traits from the `iceberg` crate
+//! using the [`object_store`](https://docs.rs/object_store) crate as the 
backend.
+//!
+//! Currently only S3 storage is supported (via the `object_store-s3` feature 
flag,
+//! enabled by default).
+
+#[cfg(feature = "object_store-s3")]
+mod s3;
+
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use dashmap::DashMap;
+use futures::stream::BoxStream;
+use futures::{StreamExt, TryStreamExt};
+#[cfg(feature = "object_store-s3")]
+use iceberg::io::S3Config;
+use iceberg::io::{
+    FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, 
StorageConfig,
+    StorageFactory,
+};
+use iceberg::{Error, ErrorKind, Result};
+use object_store::path::Path as ObjectStorePath;
+use object_store::{ObjectStore, ObjectStoreExt, PutPayload, WriteMultipart};
+#[cfg(feature = "object_store-s3")]
+use s3::{build_s3_store, parse_s3_url};
+use serde::{Deserialize, Serialize};
+
+/// Convert an `object_store::Error` into an `iceberg::Error`.
+fn from_object_store_error(e: object_store::Error) -> Error {
+    Error::new(ErrorKind::Unexpected, "Failure in doing io 
operation").with_source(e)
+}
+
+/// Convert `object_store::ObjectMeta` into `iceberg::io::FileMetadata`.
+fn to_file_metadata(meta: object_store::ObjectMeta) -> FileMetadata {
+    FileMetadata { size: meta.size }
+}
+
+/// `object_store`-based storage factory.
+///
+/// Use this factory with `FileIOBuilder::new(factory)` to create FileIO 
instances
+/// backed by the `object_store` crate.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorageFactory {
+    /// S3 storage factory.
+    #[cfg(feature = "object_store-s3")]
+    S3,
+}
+
+#[typetag::serde(name = "ObjectStoreStorageFactory")]
+impl StorageFactory for ObjectStoreStorageFactory {
+    #[allow(unused_variables)]
+    fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorageFactory::S3 => {
+                let s3_config = S3Config::try_from(config)?;
+                Ok(Arc::new(ObjectStoreStorage::S3(S3Storage {
+                    config: Arc::new(s3_config),
+                    store_cache: Arc::new(DashMap::new()),
+                })))
+            }
+        }
+    }
+}
+
+type StoreCache = Arc<DashMap<String, Arc<dyn ObjectStore>>>;
+
+/// `object_store` S3 storage state.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct S3Storage {
+    config: Arc<S3Config>,
+    #[serde(skip, default)]
+    store_cache: StoreCache,
+}
+
+/// `object_store`-based storage implementation.
+///
+/// Stores are cached per bucket to avoid rebuilding the client on every 
operation.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum ObjectStoreStorage {
+    /// S3 storage variant.
+    #[cfg(feature = "object_store-s3")]
+    S3(S3Storage),
+}
+
+struct StoreAndPath {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+impl ObjectStoreStorage {
+    /// Get or create a cached store and extract the relative 
`ObjectStorePath`.
+    fn get_store_and_path(&self, path: &str) -> Result<StoreAndPath> {
+        match self {
+            #[cfg(feature = "object_store-s3")]
+            ObjectStoreStorage::S3(s3) => {
+                let parsed = parse_s3_url(path)?;
+
+                let store = s3
+                    .store_cache
+                    .entry(parsed.bucket.clone())
+                    .or_try_insert_with(|| build_s3_store(&s3.config, 
&parsed.bucket))?
+                    .value()
+                    .clone();
+
+                let object_path =
+                    
ObjectStorePath::from_url_path(&parsed.relative).map_err(|e| {
+                        Error::new(
+                            ErrorKind::DataInvalid,
+                            format!("Invalid URL path: {}", parsed.relative),
+                        )
+                        .with_source(e)
+                    })?;
+
+                Ok(StoreAndPath {
+                    store,
+                    path: object_path,
+                })
+            }
+        }
+    }
+}
+
+#[typetag::serde(name = "ObjectStoreStorage")]
+#[async_trait]
+impl Storage for ObjectStoreStorage {
+    async fn exists(&self, path: &str) -> Result<bool> {
+        let target = self.get_store_and_path(path)?;
+        match target.store.head(&target.path).await {
+            Ok(_) => Ok(true),
+            Err(object_store::Error::NotFound { .. }) => Ok(false),
+            Err(e) => Err(from_object_store_error(e)),
+        }
+    }
+
+    async fn metadata(&self, path: &str) -> Result<FileMetadata> {
+        let target = self.get_store_and_path(path)?;
+        let meta = target
+            .store
+            .head(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(to_file_metadata(meta))
+    }
+
+    async fn read(&self, path: &str) -> Result<Bytes> {
+        let target = self.get_store_and_path(path)?;
+        let result = target
+            .store
+            .get(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+
+    async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
+        let target = self.get_store_and_path(path)?;
+        Ok(Box::new(ObjectStoreReader {
+            store: target.store,
+            path: target.path,
+        }))
+    }
+
+    async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .put(&target.path, PutPayload::from_bytes(bs))
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
+        let target = self.get_store_and_path(path)?;
+        let upload = target
+            .store
+            .put_multipart(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        let writer = WriteMultipart::new(upload);
+        Ok(Box::new(ObjectStoreWriter {
+            writer: Some(writer),
+        }))
+    }
+
+    async fn delete(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        target
+            .store
+            .delete(&target.path)
+            .await
+            .map_err(from_object_store_error)?;
+        Ok(())
+    }
+
+    async fn delete_prefix(&self, path: &str) -> Result<()> {
+        let target = self.get_store_and_path(path)?;
+        let prefix = if target.path.as_ref().ends_with('/') {
+            target.path
+        } else {
+            ObjectStorePath::from(format!("{}/", target.path.as_ref()))
+        };
+
+        let mut list_stream = target.store.list(Some(&prefix));
+        while let Some(entry) = list_stream.next().await {
+            let entry = entry.map_err(from_object_store_error)?;
+            target
+                .store
+                .delete(&entry.location)
+                .await
+                .map_err(from_object_store_error)?;
+        }
+        Ok(())
+    }
+
+    async fn delete_stream(&self, paths: BoxStream<'static, String>) -> 
Result<()> {
+        paths
+            .map(Ok)
+            .try_for_each_concurrent(16, |path| async move {
+                let target = self.get_store_and_path(&path)?;
+                target
+                    .store
+                    .delete(&target.path)
+                    .await
+                    .map_err(from_object_store_error)?;
+                Ok(())
+            })
+            .await
+    }
+
+    fn new_input(&self, path: &str) -> Result<InputFile> {
+        Ok(InputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+
+    fn new_output(&self, path: &str) -> Result<OutputFile> {
+        Ok(OutputFile::new(Arc::new(self.clone()), path.to_string()))
+    }
+}
+
+/// Reader that implements `FileRead` using `object_store`.
+struct ObjectStoreReader {
+    store: Arc<dyn ObjectStore>,
+    path: ObjectStorePath,
+}
+
+#[async_trait]
+impl FileRead for ObjectStoreReader {
+    async fn read(&self, range: Range<u64>) -> Result<Bytes> {
+        let opts = object_store::GetOptions {
+            range: Some((range.start..range.end).into()),
+            ..Default::default()
+        };
+        let result = self
+            .store
+            .get_opts(&self.path, opts)
+            .await
+            .map_err(from_object_store_error)?;
+        result.bytes().await.map_err(from_object_store_error)
+    }
+}
+
+/// Writer that implements `FileWrite` using `object_store` multipart upload.
+struct ObjectStoreWriter {
+    writer: Option<WriteMultipart>,
+}
+
+impl Drop for ObjectStoreWriter {
+    fn drop(&mut self) {
+        if let Some(writer) = self.writer.take()
+            && let Ok(handle) = tokio::runtime::Handle::try_current()

Review Comment:
   One more thing on this Drop: if it runs outside a Tokio context 
(`try_current()` fails on a sync drop or during shutdown) the abort is silently 
skipped. I'd at minimum `tracing::warn!` in that branch so an orphaned upload 
isn't invisible, or document that abort-on-drop only holds from an async 
context.



##########
crates/storage/object_store/src/s3.rs:
##########
@@ -0,0 +1,281 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iceberg::io::S3Config;
+use iceberg::{Error, ErrorKind, Result};
+use object_store::ObjectStore;
+use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
+use url::Url;
+
+/// Parsed components of an S3 URL.
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ParsedS3Url {
+    pub(crate) scheme: String,
+    pub(crate) bucket: String,
+    pub(crate) relative: String,
+}
+
+/// Parse an absolute S3 URL into [`ParsedS3Url`].
+///
+/// Accepts `s3://`, `s3a://`, and `s3n://` schemes.
+pub(crate) fn parse_s3_url(path: &str) -> Result<ParsedS3Url> {
+    let url = Url::parse(path).map_err(|e| {
+        Error::new(ErrorKind::DataInvalid, format!("Invalid URL: 
{path}")).with_source(e)
+    })?;
+
+    let scheme = url.scheme();
+    match scheme {
+        "s3" | "s3a" | "s3n" => {}
+        _ => {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("Unsupported S3 scheme: {scheme} in url: {path}"),
+            ));
+        }
+    }
+
+    let bucket = url.host_str().ok_or_else(|| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!("Invalid s3 url: {path}, missing bucket"),
+        )
+    })?;
+
+    if bucket.is_empty() {
+        return Err(Error::new(
+            ErrorKind::DataInvalid,
+            format!("Empty s3 url: {path}, missing bucket"),
+        ));
+    }
+
+    let relative = url.path().trim_start_matches('/');
+
+    Ok(ParsedS3Url {
+        scheme: scheme.to_string(),
+        bucket: bucket.to_string(),
+        relative: relative.to_string(),
+    })
+}
+
+/// Build an `AmazonS3` store from iceberg's `S3Config` for a given bucket.
+pub(crate) fn build_s3_store(config: &S3Config, bucket: &str) -> 
Result<Arc<dyn ObjectStore>> {
+    if config.role_arn.is_some() {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 assume-role (role_arn) is not supported by object_store 
backend",
+        ));
+    }
+    if config.disable_ec2_metadata {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_ec2_metadata is not supported by object_store backend",
+        ));
+    }
+    if config.disable_config_load {
+        return Err(Error::new(
+            ErrorKind::FeatureUnsupported,
+            "S3 disable_config_load is not supported by object_store backend",
+        ));
+    }
+
+    let mut builder = AmazonS3Builder::new().with_bucket_name(bucket);
+
+    if let Some(ref endpoint) = config.endpoint {
+        builder = builder.with_endpoint(endpoint);
+        if endpoint.starts_with("http://";) {
+            builder = builder.with_allow_http(true);
+        }
+    }
+    if let Some(ref access_key_id) = config.access_key_id {
+        builder = builder.with_access_key_id(access_key_id);
+    }
+    if let Some(ref secret_access_key) = config.secret_access_key {
+        builder = builder.with_secret_access_key(secret_access_key);
+    }
+    if let Some(ref session_token) = config.session_token {
+        builder = builder.with_token(session_token);
+    }
+    if let Some(ref region) = config.region {
+        builder = builder.with_region(region);
+    }
+    if config.enable_virtual_host_style {
+        builder = builder.with_virtual_hosted_style_request(true);
+    }
+    if config.allow_anonymous {
+        builder = builder.with_skip_signature(true);
+    }
+
+    if let Some(ref sse) = config.server_side_encryption {
+        match sse.as_str() {
+            "aws:kms" => {
+                let key = config
+                    .server_side_encryption_aws_kms_key_id
+                    .as_deref()
+                    .unwrap_or_default();
+                builder = builder.with_sse_kms_encryption(key);
+            }
+            "AES256" => {
+                builder = builder.with_config(
+                    
AmazonS3ConfigKey::from_str("aws_server_side_encryption").map_err(|e| {
+                        Error::new(ErrorKind::Unexpected, "Failed to parse S3 
config key")
+                            .with_source(e)
+                    })?,
+                    "AES256",
+                );
+            }
+            other => {
+                return Err(Error::new(
+                    ErrorKind::FeatureUnsupported,
+                    format!("Unsupported server side encryption type: 
{other}"),
+                ));
+            }
+        }
+    }
+
+    if let Some(ref custom_key) = config.server_side_encryption_customer_key {
+        builder = builder.with_ssec_encryption(custom_key);

Review Comment:
   `server_side_encryption_customer_key_md5` gets populated by `TryFrom` when 
`s3.sse.md5` is set, but we only forward `custom_key` here — the MD5 is 
dropped. Some S3-compatible stores validate the supplied MD5 and reject SSE-C 
ops without it. Can we check whether `with_ssec_encryption` computes it for us, 
and forward it (or log) if not?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to