This is an automated email from the ASF dual-hosted git repository.

gaborgsomogyi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new b0c5254ce36 [FLINK-39110][s3] Add CRT support in native-s3-fs
b0c5254ce36 is described below

commit b0c5254ce36d3d61e55600a9d9356fe0b861cc6c
Author: Samrat <[email protected]>
AuthorDate: Thu Jul 30 17:09:21 2026 +0530

    [FLINK-39110][s3] Add CRT support in native-s3-fs
---
 flink-filesystems/flink-s3-fs-native/README.md     |  83 ++++-
 flink-filesystems/flink-s3-fs-native/pom.xml       |  37 +++
 .../flink/fs/s3native/NativeS3BulkCopyHelper.java  | 369 +++++++++++++++++----
 .../flink/fs/s3native/NativeS3FileIoUtils.java     |  86 +++++
 .../flink/fs/s3native/NativeS3FileSystem.java      |   8 +-
 .../fs/s3native/NativeS3FileSystemFactory.java     | 166 ++++++++-
 .../flink/fs/s3native/NativeS3OutputStream.java    |   4 +-
 .../apache/flink/fs/s3native/S3ClientProvider.java | 353 +++++++++++++++++---
 .../s3native/writer/NativeS3ObjectOperations.java  |  58 +++-
 .../NativeS3RecoverableFsDataOutputStream.java     |  30 +-
 .../src/main/resources/META-INF/NOTICE             |   1 +
 .../fs/s3native/NativeS3BulkCopyHelperTest.java    | 326 +++++++++++++++++-
 .../flink/fs/s3native/NativeS3FileIoUtilsTest.java |  90 +++++
 .../fs/s3native/NativeS3FileSystemFactoryTest.java | 169 +++++++++-
 .../flink/fs/s3native/S3ClientProviderTest.java    |  89 +++++
 .../flink-s3-fs-native/tools/download-crt-jars.sh  | 134 ++++++++
 16 files changed, 1847 insertions(+), 156 deletions(-)

diff --git a/flink-filesystems/flink-s3-fs-native/README.md 
b/flink-filesystems/flink-s3-fs-native/README.md
index 292f87cd25a..d8be409626b 100644
--- a/flink-filesystems/flink-s3-fs-native/README.md
+++ b/flink-filesystems/flink-s3-fs-native/README.md
@@ -71,9 +71,10 @@ input.sinkTo(FileSink.forRowFormat(new 
Path("s3://my-bucket/output"),
 | s3.upload.max.concurrent.uploads | CPU cores | Maximum concurrent part 
uploads per stream |
 | s3.entropy.key | (none) | Key for entropy injection in paths |
 | s3.entropy.length | 4 | Length of entropy string |
-| s3.bulk-copy.enabled | true | Enable bulk copy operations using 
S3TransferManager |
+| s3.bulk-copy.enabled | true | Enable bulk copy operations for S3-to-local 
downloads |
 | s3.bulk-copy.max-concurrent | 16 | Maximum number of concurrent copy 
operations |
-| s3.connection.max | 50 | Maximum HTTP connections in the S3 client 
connection pool. Applies to both sync (Apache HTTP) and async (Netty) clients. 
Must be ≥ `s3.bulk-copy.max-concurrent` |
+| s3.bulk-copy.download-buffer-size | 262144 (256KB) | Buffer size for writing 
bulk-copy downloads to local disk. Bounds the JDK's cached temporary direct 
buffers, preventing direct-memory OutOfMemoryError during large RocksDB state 
restores |
+| s3.connection.max | 50 | Maximum HTTP connections in the S3 client 
connection pool. Applies to sync and async clients, including CRT when enabled. 
Must be ≥ `s3.bulk-copy.max-concurrent` |
 | s3.async.enabled | true | Enable async read/write with TransferManager |
 | s3.read.buffer.size | 262144 (256KB) | Read buffer size per stream (64KB - 
4MB) |
 
@@ -357,6 +358,84 @@ When enabled, file uploads automatically use 
TransferManager for:
 - Better utilization of available bandwidth
 - Lower heap requirements for write operations
 
+## AWS Common Runtime (CRT) Support
+
+The filesystem optionally supports the [AWS Common Runtime 
(CRT)](https://github.com/awslabs/aws-crt-java) HTTP transport
+for higher throughput on large S3 workloads.
+
+When enabled, the CRT transport replaces:
+- **Sync client**: Apache HTTP Client → `AwsCrtHttpClient`
+- **Async client**: Netty NIO → `S3AsyncClient.crtBuilder()` (with built-in 
multipart acceleration)
+
+### Prerequisites
+
+The `aws-crt` artifact contains JNI-linked native libraries whose C-side 
`FindClass` paths are
+hardcoded, making Maven shade relocation incompatible. Therefore **the 
`aws-crt` JAR is not
+bundled** in the fat JAR and must be placed manually.
+
+### Setup
+
+1. From the module directory, run the helper script to download the `aws-crt`
+   JAR (auto-resolves the compatible version for the AWS SDK version this
+   module was built against):
+
+   ```bash
+   ./tools/download-crt-jars.sh
+   ```
+
+   This places the JAR in `./crt-jars/`. Pass a different directory as the
+   first argument if needed. Requires `mvn` on `PATH`.
+
+2. Copy the JAR into the Flink plugin directory alongside 
`flink-s3-fs-native.jar`:
+
+   ```bash
+   cp crt-jars/aws-crt-*.jar $FLINK_HOME/plugins/s3-fs-native/
+   ```
+
+   > **Note:** `aws-crt-client` does **not** need to be downloaded or placed
+   > separately — it is bundled (shaded) directly inside 
`flink-s3-fs-native.jar`
+   > at build time. Only `aws-crt` (the JNI native lib) must be placed manually
+   > because its C-side `FindClass` paths are hardcoded and incompatible with
+   > Maven shade relocation.
+
+3. Enable CRT in your Flink configuration (`conf/config.yaml`):
+
+   ```yaml
+   s3.crt.enabled: true
+   ```
+
+If the `aws-crt` JAR is missing when `s3.crt.enabled: true`, the filesystem
+fails fast at startup with an `IllegalStateException` pointing back to this
+setup procedure.
+
+#### Manual download (alternative)
+
+If `mvn` is unavailable, fetch the JAR by hand from Maven Central:
+
+`aws-crt-<version>.jar` (groupId: `software.amazon.awssdk.crt`) — the version 
is
+declared as the `<dependency>` on `software.amazon.awssdk.crt:aws-crt` inside
+`aws-crt-client-<version>.pom` on Maven Central. The `aws-crt` artifact uses an
+independent versioning scheme (e.g. `0.45.x`) that does **not** track the AWS 
SDK
+version.
+
+`aws-crt-client` does **not** need to be downloaded — it is bundled inside the 
fat JAR.
+
+### CRT Configuration Options
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| s3.crt.enabled | false | Enable CRT HTTP transport for both sync and async 
S3 clients |
+| s3.crt.target-throughput-gbps | (none) | Soft target throughput in Gbps for 
the CRT async client. Hint, not a hard cap — actual throughput may exceed the 
configured value. When unset, the AWS CRT runtime applies its own internal 
default; set this only to override it (e.g. to match the network bandwidth 
available to a single TaskManager). |
+| s3.crt.max-native-memory-limit | (none) | Maximum native memory the CRT 
async client may use. When unset, the AWS CRT runtime applies its own internal 
limit. |
+| s3.crt.read-buffer-size | (none) | Read buffer size for the CRT sync and 
async clients. Decoupled from `s3.read.buffer.size` so lowering the streaming 
read buffer does not shrink CRT's native transfer buffers. When unset, the AWS 
CRT runtime applies its own (larger) default. |
+| s3.crt.max-concurrency | 256 | Max concurrent in-flight requests for the CRT 
sync and async clients. Decoupled from `s3.connection.max` because CRT fans one 
logical transfer into many part-sized requests; reusing the smaller 
connection-pool size causes "failed to acquire a connection" timeouts under 
parallel checkpoint upload/restore. |
+
+The CRT read buffer is controlled independently via `s3.crt.read-buffer-size`; 
when unset, the CRT runtime keeps its own default rather than inheriting 
`s3.read.buffer.size`.
+
+> **Note on options silently ignored under CRT:**
+> - `s3.socket.timeout` — `AwsCrtHttpClient` has no socket-level read timeout; 
the CRT runtime uses `ConnectionHealthConfiguration` for stalled-read detection 
instead.
+> - `s3.chunked-encoding.enabled` — `S3CrtAsyncClientBuilder` manages wire 
encoding internally and exposes no equivalent setter.
+
 ## Checkpointing
 
 Configure checkpoint storage in `conf/config.yaml`:
diff --git a/flink-filesystems/flink-s3-fs-native/pom.xml 
b/flink-filesystems/flink-s3-fs-native/pom.xml
index cdc644d58af..b0403112df0 100644
--- a/flink-filesystems/flink-s3-fs-native/pom.xml
+++ b/flink-filesystems/flink-s3-fs-native/pom.xml
@@ -32,6 +32,10 @@ under the License.
 
        <properties>
                <fs.s3.aws.sdk.version>2.44.4</fs.s3.aws.sdk.version>
+               <!-- aws-crt uses a separate versioning scheme from the AWS 
SDK; this version was
+                    validated against aws-crt-client ${fs.s3.aws.sdk.version}. 
Update when bumping
+                    the SDK version (check aws-crt-client-<version>.pom → 
awscrt.version). -->
+               <fs.s3.aws.crt.version>0.45.1</fs.s3.aws.crt.version>
                <japicmp.skip>true</japicmp.skip>
                
<surefire.module.config>--add-opens=java.base/java.util=ALL-UNNAMED</surefire.module.config>
        </properties>
@@ -89,6 +93,22 @@ under the License.
                        <optional>${flink.markBundledAsOptional}</optional>
                </dependency>
 
+               <dependency>
+                       <groupId>software.amazon.awssdk</groupId>
+                       <artifactId>aws-crt-client</artifactId>
+                       <version>${fs.s3.aws.sdk.version}</version>
+                       <optional>${flink.markBundledAsOptional}</optional>
+                       <exclusions>
+                               <!-- aws-crt contains JNI native libs with 
hardcoded FindClass paths;
+                                    it cannot be bundled or shaded. It must be 
placed alongside the
+                                    fat JAR in the plugin directory with its 
original class names. -->
+                               <exclusion>
+                                       
<groupId>software.amazon.awssdk.crt</groupId>
+                                       <artifactId>aws-crt</artifactId>
+                               </exclusion>
+                       </exclusions>
+               </dependency>
+
                <dependency>
                        <groupId>org.apache.flink</groupId>
                        <artifactId>flink-runtime</artifactId>
@@ -135,6 +155,15 @@ under the License.
                        <artifactId>testcontainers-junit-jupiter</artifactId>
                        <scope>test</scope>
                </dependency>
+
+               <!-- aws-crt is excluded from production fat-JAR (JNI shading 
incompatibility) but
+                    must be on the test classpath for CRT-enabled unit tests 
to instantiate clients. -->
+               <dependency>
+                       <groupId>software.amazon.awssdk.crt</groupId>
+                       <artifactId>aws-crt</artifactId>
+                       <version>${fs.s3.aws.crt.version}</version>
+                       <scope>test</scope>
+               </dependency>
        </dependencies>
 
        <build>
@@ -164,6 +193,14 @@ under the License.
                                                                <relocation>
                                                                        
<pattern>software.amazon.awssdk</pattern>
                                                                        
<shadedPattern>org.apache.flink.fs.s3native.shaded.software.amazon.awssdk</shadedPattern>
+                                                                       
<excludes>
+                                                                               
<!-- aws-crt JNI bridge classes must keep their original names.
+                                                                               
     The C-side FindClass paths are hardcoded in native code and
+                                                                               
     cannot be relocated. aws-crt-client (pure Java) is bundled
+                                                                               
     and shaded, but its calls into aws-crt must resolve against
+                                                                               
     the external aws-crt JAR using the original package names. -->
+                                                                               
<exclude>software.amazon.awssdk.crt.**</exclude>
+                                                                       
</excludes>
                                                                </relocation>
                                                                <relocation>
                                                                        
<pattern>org.apache.http</pattern>
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelper.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelper.java
index 5fde18815d3..e077cb0635f 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelper.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelper.java
@@ -22,26 +22,40 @@ import org.apache.flink.annotation.Internal;
 import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.core.fs.ICloseableRegistry;
 import org.apache.flink.core.fs.PathsCopyingFileSystem;
+import org.apache.flink.util.ExceptionUtils;
+import org.apache.flink.util.IOUtils;
+import org.apache.flink.util.concurrent.ExecutorThreadFactory;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import software.amazon.awssdk.transfer.s3.S3TransferManager;
-import software.amazon.awssdk.transfer.s3.model.CompletedCopy;
-import software.amazon.awssdk.transfer.s3.model.DownloadFileRequest;
-import software.amazon.awssdk.transfer.s3.model.FileDownload;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
 
+import java.io.Closeable;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.apache.flink.util.Preconditions.checkArgument;
 
 /**
- * Helper class for performing bulk S3 to local file system copies using 
S3TransferManager.
+ * Helper class for performing bulk S3 to local file system copies using the 
S3 async client.
  *
  * <p><b>Concurrency Model:</b> Uses batch-based concurrency control with 
{@code
  * maxConcurrentCopies} to limit parallel downloads. The effective concurrency 
is clamped to the
@@ -51,18 +65,19 @@ import static 
org.apache.flink.util.Preconditions.checkArgument;
  * bounded executor) to allow continuous submission of new downloads as slots 
become available,
  * which would provide better throughput by avoiding the "slowest task in 
batch" bottleneck.
  *
- * <p><b>Retry Handling:</b> Relies on the S3TransferManager's built-in retry 
mechanism for
- * transient failures. If a download fails after retries:
+ * <p><b>Retry Handling:</b> Relies on the S3 async client's built-in retry 
mechanism for transient
+ * failures. If a download fails after retries:
  *
  * <ul>
  *   <li>The entire bulk copy operation fails with an IOException
  *   <li>Successfully downloaded files are NOT cleaned up (they remain on disk)
- *   <li>Partial downloads may leave incomplete files that should be cleaned 
up by the caller
+ *   <li>The failed file's temporary download is deleted before the method 
returns
  * </ul>
  *
- * <p><b>Cleanup:</b> No automatic cleanup is performed on failure. Callers 
are responsible for
- * cleaning up destination files if the bulk copy fails. Consider wrapping in 
a try-finally or using
- * a temp directory that can be deleted on failure.
+ * <p><b>Cleanup:</b> Each file is downloaded into a temporary file in the 
destination directory and
+ * atomically moved into place after the stream has been fully copied. 
Cancellation through the
+ * provided {@link ICloseableRegistry} aborts active S3 response streams, 
cancels pending futures,
+ * stops the worker pool, and deletes incomplete temporary files.
  *
  * <p><b>TODO:</b> Consider extracting URI parsing logic to a shared 
S3UriUtils utility class to
  * consolidate S3 URI handling across the codebase.
@@ -72,24 +87,32 @@ class NativeS3BulkCopyHelper {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(NativeS3BulkCopyHelper.class);
 
-    private final S3TransferManager transferManager;
+    private final S3AsyncClient asyncClient;
     private final int maxConcurrentCopies;
     private final int maxConnections;
+    private final int downloadBufferSize;
 
     /**
      * Creates a new bulk copy helper.
      *
-     * @param transferManager the S3 transfer manager for async downloads
+     * @param asyncClient the S3 async client used for downloads
      * @param maxConcurrentCopies the requested maximum number of concurrent 
copy operations
      * @param maxConnections the HTTP connection pool size; if {@code 
maxConcurrentCopies} exceeds
      *     this value, it is clamped down to prevent connection pool exhaustion
+     * @param downloadBufferSize the buffer size in bytes used to write each 
downloaded file to the
+     *     local filesystem; bounds the temporary direct buffers the JDK 
caches for channel writes
      */
     NativeS3BulkCopyHelper(
-            S3TransferManager transferManager, int maxConcurrentCopies, int 
maxConnections) {
+            S3AsyncClient asyncClient,
+            int maxConcurrentCopies,
+            int maxConnections,
+            int downloadBufferSize) {
         checkArgument(maxConcurrentCopies > 0, "maxConcurrentCopies must be 
positive");
         checkArgument(maxConnections > 0, "maxConnections must be positive");
-        this.transferManager = transferManager;
+        checkArgument(downloadBufferSize > 0, "downloadBufferSize must be 
positive");
+        this.asyncClient = asyncClient;
         this.maxConnections = maxConnections;
+        this.downloadBufferSize = downloadBufferSize;
         if (maxConcurrentCopies > maxConnections) {
             LOG.warn(
                     "{} ({}) exceeds {} ({}). "
@@ -109,16 +132,20 @@ class NativeS3BulkCopyHelper {
         return maxConcurrentCopies;
     }
 
+    @VisibleForTesting
+    int getDownloadBufferSize() {
+        return downloadBufferSize;
+    }
+
     /**
      * Copies files from S3 to local filesystem in batches.
      *
-     * <p><b>Error Handling:</b> If an unsupported URI scheme is encountered, 
all already-started
-     * copy operations are awaited to completion before throwing the 
exception. This ensures that no
-     * background copy tasks are left running when the method returns, 
allowing the caller to safely
-     * manage cleanup and resource lifecycle.
+     * <p><b>Error Handling:</b> If an unsupported URI scheme or copy failure 
is encountered,
+     * already-started copy operations are cancelled before throwing the 
exception. Successfully
+     * completed destination files are left in place; incomplete temporary 
files are deleted.
      *
      * @param requests List of copy requests (source S3 path to destination 
local path)
-     * @param closeableRegistry Registry for cleanup (currently unused, 
reserved for future use)
+     * @param closeableRegistry Registry for cancelling in-flight copies 
during task cancellation
      * @throws IOException if any copy operation fails or if an unsupported 
URI scheme is
      *     encountered
      */
@@ -133,24 +160,40 @@ class NativeS3BulkCopyHelper {
         int totalFiles = requests.size();
         int totalBatches = (totalFiles + maxConcurrentCopies - 1) / 
maxConcurrentCopies;
         LOG.info(
-                "Starting bulk copy of {} files using S3TransferManager "
-                        + "(batch size: {}, total batches: {})",
+                "Starting bulk copy of {} files (batch size: {}, total 
batches: {})",
                 totalFiles,
                 maxConcurrentCopies,
                 totalBatches);
 
-        List<CompletableFuture<CompletedCopy>> copyFutures = new ArrayList<>();
+        ExecutorService downloadPool =
+                Executors.newFixedThreadPool(
+                        maxConcurrentCopies,
+                        new ExecutorThreadFactory(
+                                "s3-native-bulk-copy",
+                                (thread, error) ->
+                                        LOG.error(
+                                                "Uncaught exception in S3 
bulk-copy worker {}",
+                                                thread.getName(),
+                                                error)));
+        BulkCopyCancellation cancellation = new 
BulkCopyCancellation(downloadPool);
+        ICloseableRegistry registry =
+                closeableRegistry == null ? ICloseableRegistry.NO_OP : 
closeableRegistry;
+        List<CompletableFuture<Void>> copyFutures = new ArrayList<>();
         int batchNumber = 0;
 
-        try {
+        try (Closeable ignored = 
registry.registerCloseableTemporarily(cancellation)) {
             for (int i = 0; i < requests.size(); i++) {
                 PathsCopyingFileSystem.CopyRequest request = requests.get(i);
                 String sourceUri = request.getSource().toUri().toString();
-                if (sourceUri.startsWith("s3://") || 
sourceUri.startsWith("s3a://")) {
-                    copyFutures.add(copyS3ToLocal(request));
+                if (isSupportedS3Scheme(request.getSource())
+                        && isSupportedLocalScheme(request.getDestination())) {
+                    copyFutures.add(copyS3ToLocal(request, downloadPool, 
cancellation));
                 } else {
                     throw new UnsupportedOperationException(
-                            "Only S3 to local copies are currently supported: 
" + sourceUri);
+                            "Only S3 to local copies are currently supported: "
+                                    + sourceUri
+                                    + " -> "
+                                    + request.getDestination());
                 }
 
                 if (copyFutures.size() >= maxConcurrentCopies || i == 
requests.size() - 1) {
@@ -166,69 +209,168 @@ class NativeS3BulkCopyHelper {
             }
 
             LOG.info("Completed bulk copy of {} files", totalFiles);
-        } catch (Exception e) {
-            if (!copyFutures.isEmpty()) {
-                LOG.warn(
-                        "Error during bulk copy, waiting for {} in-flight 
operations to complete",
-                        copyFutures.size());
-                try {
-                    waitForCopies(copyFutures);
-                } catch (IOException waitError) {
-                    LOG.warn(
-                            "Error waiting for in-flight copy operations: {}",
-                            waitError.getMessage());
-                    e.addSuppressed(waitError);
-                }
-            }
-            if (e instanceof IOException) {
-                throw (IOException) e;
-            } else {
-                throw new IOException(e);
-            }
+        } catch (Throwable e) {
+            cancellation.close();
+            ExceptionUtils.rethrowIOException(e);
+        } finally {
+            downloadPool.shutdownNow();
         }
     }
 
     /**
      * Initiates an async S3 to local file copy.
      *
-     * @param request The copy request containing source S3 path and 
destination local path
-     * @return A CompletableFuture that completes when the download finishes
+     * <p>The object body is streamed to disk through a bounded {@code byte[]} 
buffer (see {@code
+     * s3.bulk-copy.download-buffer-size}) rather than the SDK's {@code 
AsynchronousFileChannel}
+     * based {@code toFile} transformer, which caches an unbounded per-thread 
temporary direct
+     * buffer sized to each write and can exhaust direct memory during large 
restores.
+     *
+     * @param request the copy request containing source S3 path and 
destination local path
+     * @param downloadPool the executor on which the blocking stream copy runs
+     * @return a CompletableFuture that completes when the download finishes
      * @throws IOException if the destination directory cannot be created
      */
-    private CompletableFuture<CompletedCopy> copyS3ToLocal(
-            PathsCopyingFileSystem.CopyRequest request) throws IOException {
+    private CompletableFuture<Void> copyS3ToLocal(
+            PathsCopyingFileSystem.CopyRequest request,
+            ExecutorService downloadPool,
+            BulkCopyCancellation cancellation)
+            throws IOException {
 
         String sourceUri = request.getSource().toUri().toString();
         String bucket = extractBucket(sourceUri);
         String key = extractKey(sourceUri);
-        File destFile = new File(request.getDestination().getPath());
+        Path destination = new 
File(request.getDestination().getPath()).toPath().toAbsolutePath();
 
-        Files.createDirectories(destFile.getParentFile().toPath());
+        Path parent = destination.getParent();
+        if (parent != null) {
+            Files.createDirectories(parent);
+        }
+        Path tempDestination = 
NativeS3FileIoUtils.createTemporaryDownloadFile(parent, destination);
 
-        DownloadFileRequest downloadRequest =
-                DownloadFileRequest.builder()
-                        .getObjectRequest(req -> req.bucket(bucket).key(key))
-                        .destination(destFile.toPath())
-                        .build();
+        GetObjectRequest getObjectRequest =
+                GetObjectRequest.builder().bucket(bucket).key(key).build();
 
-        FileDownload download = transferManager.downloadFile(downloadRequest);
+        CompletableFuture<ResponseInputStream<GetObjectResponse>> 
responseFuture;
+        try {
+            responseFuture =
+                    asyncClient.getObject(
+                            getObjectRequest, 
AsyncResponseTransformer.toBlockingInputStream());
+        } catch (RuntimeException | Error e) {
+            IOUtils.deleteFileQuietly(tempDestination);
+            throw e;
+        }
+        cancellation.registerFuture(responseFuture);
+        CompletableFuture<Void> copyFuture = new CompletableFuture<>();
+        cancellation.registerFuture(copyFuture);
+        responseFuture.whenComplete(
+                (responseStream, error) -> {
+                    cancellation.unregisterFuture(responseFuture);
+                    if (error != null) {
+                        IOUtils.deleteFileQuietly(tempDestination);
+                        copyFuture.completeExceptionally(error);
+                        return;
+                    }
+                    if (responseStream == null) {
+                        IOUtils.deleteFileQuietly(tempDestination);
+                        copyFuture.completeExceptionally(
+                                new IOException(
+                                        "S3 getObject completed without a 
response stream"));
+                        return;
+                    }
+                    submitDownload(
+                            responseStream,
+                            tempDestination,
+                            destination,
+                            sourceUri,
+                            downloadPool,
+                            cancellation,
+                            copyFuture);
+                });
+        copyFuture.whenComplete(
+                (ignored, error) -> {
+                    cancellation.unregisterFuture(copyFuture);
+                    if (copyFuture.isCancelled()) {
+                        responseFuture.cancel(true);
+                    }
+                    if (error != null) {
+                        IOUtils.deleteFileQuietly(tempDestination);
+                    }
+                });
+        return copyFuture;
+    }
 
-        return download.completionFuture()
-                .thenApply(
-                        completed -> {
-                            LOG.debug("Successfully copied {} to {}", 
sourceUri, destFile);
-                            return null;
-                        });
+    private void submitDownload(
+            ResponseInputStream<GetObjectResponse> responseStream,
+            Path tempDestination,
+            Path destination,
+            String sourceUri,
+            ExecutorService downloadPool,
+            BulkCopyCancellation cancellation,
+            CompletableFuture<Void> copyFuture) {
+        if (!cancellation.registerStream(responseStream)) {
+            abortAndClose(responseStream);
+            IOUtils.deleteFileQuietly(tempDestination);
+            copyFuture.completeExceptionally(new CancellationException("Bulk 
copy was cancelled"));
+            return;
+        }
+        try {
+            downloadPool.execute(
+                    () -> {
+                        boolean success = false;
+                        try {
+                            NativeS3FileIoUtils.copyStream(
+                                    responseStream, tempDestination, 
downloadBufferSize);
+                            NativeS3FileIoUtils.moveFile(tempDestination, 
destination);
+                            success = true;
+                            LOG.debug("Successfully copied {} to {}", 
sourceUri, destination);
+                            copyFuture.complete(null);
+                        } catch (Throwable t) {
+                            copyFuture.completeExceptionally(t);
+                        } finally {
+                            cancellation.unregisterStream(responseStream);
+                            // Close on success (stream fully read, connection 
reusable); abort on
+                            // failure to drop the connection immediately 
instead of draining a
+                            // large partially-read body.
+                            if (success) {
+                                closeQuietly(responseStream);
+                            } else {
+                                abortAndClose(responseStream);
+                            }
+                            IOUtils.deleteFileQuietly(tempDestination);
+                        }
+                    });
+        } catch (RejectedExecutionException e) {
+            cancellation.unregisterStream(responseStream);
+            abortAndClose(responseStream);
+            IOUtils.deleteFileQuietly(tempDestination);
+            copyFuture.completeExceptionally(e);
+        }
     }
 
-    private void waitForCopies(List<CompletableFuture<CompletedCopy>> futures) 
throws IOException {
+    private void waitForCopies(List<CompletableFuture<Void>> futures) throws 
IOException {
         try {
-            CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0])).get();
+            // Fail fast: complete as soon as either all downloads finish 
successfully or the first
+            // one fails, rather than waiting for every in-flight download to 
run to completion. The
+            // outer copyFiles handler aborts the remaining streams and shuts 
the pool down on the
+            // resulting exception.
+            CompletableFuture<Void> allDone =
+                    CompletableFuture.allOf(futures.toArray(new 
CompletableFuture[0]));
+            CompletableFuture<Void> firstFailure = new CompletableFuture<>();
+            for (CompletableFuture<Void> future : futures) {
+                future.whenComplete(
+                        (ignored, error) -> {
+                            if (error != null) {
+                                firstFailure.completeExceptionally(error);
+                            }
+                        });
+            }
+            CompletableFuture.anyOf(allDone, firstFailure).get();
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             throw new IOException("Bulk copy interrupted", e);
         } catch (ExecutionException e) {
             Throwable cause = e.getCause();
+            ExceptionUtils.rethrowIfFatalError(cause);
             if (isConnectionPoolExhausted(cause)) {
                 throw new IOException(
                         String.format(
@@ -246,6 +388,101 @@ class NativeS3BulkCopyHelper {
         }
     }
 
+    static boolean isSupportedS3Scheme(org.apache.flink.core.fs.Path path) {
+        String scheme = path.toUri().getScheme();
+        return "s3".equalsIgnoreCase(scheme) || "s3a".equalsIgnoreCase(scheme);
+    }
+
+    static boolean isSupportedLocalScheme(org.apache.flink.core.fs.Path path) {
+        String scheme = path.toUri().getScheme();
+        return scheme == null || "file".equalsIgnoreCase(scheme);
+    }
+
+    private static void abortAndClose(ResponseInputStream<GetObjectResponse> 
stream) {
+        try {
+            stream.abort();
+        } catch (RuntimeException e) {
+            LOG.debug("Error aborting S3 response stream during bulk-copy 
cancellation", e);
+        }
+        try {
+            stream.close();
+        } catch (IOException e) {
+            LOG.debug("Error closing S3 response stream during bulk-copy 
cancellation", e);
+        }
+    }
+
+    private static void closeQuietly(ResponseInputStream<GetObjectResponse> 
stream) {
+        try {
+            stream.close();
+        } catch (IOException e) {
+            LOG.debug("Error closing S3 response stream after successful 
bulk-copy download", e);
+        }
+    }
+
+    private static final class BulkCopyCancellation implements Closeable {
+        private static final long TERMINATION_TIMEOUT_SECONDS = 5L;
+
+        private final ExecutorService downloadPool;
+        private final Set<CompletableFuture<?>> futures =
+                Collections.newSetFromMap(new 
java.util.concurrent.ConcurrentHashMap<>());
+        private final Set<ResponseInputStream<GetObjectResponse>> 
activeStreams =
+                Collections.newSetFromMap(new 
java.util.concurrent.ConcurrentHashMap<>());
+        private final AtomicBoolean closed = new AtomicBoolean(false);
+
+        private BulkCopyCancellation(ExecutorService downloadPool) {
+            this.downloadPool = downloadPool;
+        }
+
+        private void registerFuture(CompletableFuture<?> future) {
+            if (closed.get()) {
+                future.cancel(true);
+                return;
+            }
+            futures.add(future);
+            if (closed.get() && futures.remove(future)) {
+                future.cancel(true);
+            }
+        }
+
+        private void unregisterFuture(CompletableFuture<?> future) {
+            futures.remove(future);
+        }
+
+        private boolean registerStream(ResponseInputStream<GetObjectResponse> 
stream) {
+            if (closed.get()) {
+                return false;
+            }
+            activeStreams.add(stream);
+            if (closed.get() && activeStreams.remove(stream)) {
+                return false;
+            }
+            return true;
+        }
+
+        private void unregisterStream(ResponseInputStream<GetObjectResponse> 
stream) {
+            activeStreams.remove(stream);
+        }
+
+        @Override
+        public void close() {
+            if (!closed.compareAndSet(false, true)) {
+                return;
+            }
+            futures.forEach(future -> future.cancel(true));
+            activeStreams.forEach(NativeS3BulkCopyHelper::abortAndClose);
+            downloadPool.shutdownNow();
+            try {
+                if 
(!downloadPool.awaitTermination(TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) 
{
+                    LOG.warn(
+                            "S3 bulk-copy worker pool did not terminate within 
{} seconds",
+                            TERMINATION_TIMEOUT_SECONDS);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
     /**
      * Checks whether a failure was caused by HTTP connection pool exhaustion.
      *
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileIoUtils.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileIoUtils.java
new file mode 100644
index 00000000000..34f35ebc469
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileIoUtils.java
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.fs.s3native;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.util.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+
+/**
+ * Shared local-filesystem helpers for the S3 download paths (bulk copy and 
single-object get).
+ *
+ * <p>Downloads stream the S3 object body to disk through a bounded heap 
{@code byte[]} rather than
+ * the SDK's {@code AsynchronousFileChannel}-based {@code toFile} transformer, 
which caches an
+ * unbounded per-thread temporary direct buffer sized to each write and can 
exhaust direct memory
+ * during large restores. Keeping this logic in one place ensures both 
download paths stay bounded.
+ */
+@Internal
+public final class NativeS3FileIoUtils {
+
+    private NativeS3FileIoUtils() {}
+
+    /**
+     * Copies all bytes from {@code in} to {@code destination}, overwriting 
any existing file, using
+     * a fixed-size heap buffer. The caller retains ownership of {@code in} 
and is responsible for
+     * closing (or aborting) it — this method only closes the destination 
stream so the correct
+     * close-vs-abort decision can be made based on success or failure.
+     */
+    public static void copyStream(InputStream in, Path destination, int 
bufferSize)
+            throws IOException {
+        try (OutputStream out = Files.newOutputStream(destination)) {
+            IOUtils.copyBytes(in, out, bufferSize, false);
+        }
+    }
+
+    /** Moves {@code source} onto {@code destination}, preferring an atomic 
move when supported. */
+    public static void moveFile(Path source, Path destination) throws 
IOException {
+        try {
+            Files.move(
+                    source,
+                    destination,
+                    StandardCopyOption.REPLACE_EXISTING,
+                    StandardCopyOption.ATOMIC_MOVE);
+        } catch (AtomicMoveNotSupportedException ignored) {
+            Files.move(source, destination, 
StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    /**
+     * Creates a temporary download file in {@code parent} whose name is 
derived from {@code
+     * destination}. The prefix is padded to satisfy {@link 
Files#createTempFile}'s minimum length.
+     */
+    public static Path createTemporaryDownloadFile(Path parent, Path 
destination)
+            throws IOException {
+        String prefix =
+                destination.getFileName() == null
+                        ? "s3-download"
+                        : destination.getFileName().toString();
+        if (prefix.length() < 3) {
+            prefix = "s3-" + prefix;
+        }
+        return Files.createTempFile(parent, prefix, ".tmp");
+    }
+}
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
index 0ec9fb22624..efec0dcc185 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
@@ -512,7 +512,9 @@ class NativeS3FileSystem extends FileSystem
 
     @Override
     public boolean canCopyPaths(Path source, Path destination) {
-        return bulkCopyHelper != null;
+        return bulkCopyHelper != null
+                && NativeS3BulkCopyHelper.isSupportedS3Scheme(source)
+                && NativeS3BulkCopyHelper.isSupportedLocalScheme(destination);
     }
 
     @Override
@@ -566,12 +568,12 @@ class NativeS3FileSystem extends FileSystem
                                                                         "S3 
client provider closed");
                                                             }
                                                         }))
-                        .orTimeout(fsCloseTimeout.toSeconds(), 
TimeUnit.SECONDS)
+                        .orTimeout(fsCloseTimeout.toMillis(), 
TimeUnit.MILLISECONDS)
                         .whenComplete(
                                 (result, error) -> {
                                     if (error != null) {
                                         LOG.error(
-                                                "FileSystem close timed out 
after {} for bucket: {}",
+                                                "FileSystem close did not 
complete cleanly within {} for bucket: {}",
                                                 fsCloseTimeout,
                                                 bucketName,
                                                 error);
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
index 2d7fea8532e..cb4fd4788f5 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
@@ -24,6 +24,7 @@ import org.apache.flink.configuration.ConfigOptions;
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.configuration.ConfigurationUtils;
 import org.apache.flink.configuration.IllegalConfigurationException;
+import org.apache.flink.configuration.MemorySize;
 import org.apache.flink.core.fs.FileSystem;
 import org.apache.flink.core.fs.FileSystemFactory;
 import org.apache.flink.util.Preconditions;
@@ -144,7 +145,7 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
             ConfigOptions.key("s3.bulk-copy.enabled")
                     .booleanType()
                     .defaultValue(true)
-                    .withDescription("Enable bulk copy operations using 
S3TransferManager");
+                    .withDescription("Enable bulk copy operations for 
S3-to-local downloads");
 
     public static final ConfigOption<Integer> MAX_CONNECTIONS =
             ConfigOptions.key("s3.connection.max")
@@ -152,7 +153,9 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
                     .defaultValue(50)
                     .withDescription(
                             "Maximum number of HTTP connections in the S3 
client connection pool. "
-                                    + "Applies to both the sync client (Apache 
HTTP) and the async client (Netty). "
+                                    + "Applies to the sync (Apache) and async 
(Netty) clients. "
+                                    + "When s3.crt.enabled is true, the CRT 
clients use "
+                                    + "'s3.crt.max-concurrency' instead of 
this option. "
                                     + "Must be at least as large as 
's3.bulk-copy.max-concurrent'.");
 
     public static final ConfigOption<Integer> BULK_COPY_MAX_CONCURRENT =
@@ -161,6 +164,15 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
                     .defaultValue(16)
                     .withDescription("Maximum number of concurrent copy 
operations");
 
+    public static final ConfigOption<Integer> BULK_COPY_DOWNLOAD_BUFFER_SIZE =
+            ConfigOptions.key("s3.bulk-copy.download-buffer-size")
+                    .intType()
+                    .defaultValue(256 * 1024) // 256KB default
+                    .withDescription(
+                            "Buffer size in bytes used when writing files 
downloaded via bulk copy "
+                                    + "to the local filesystem. Bounds the 
per-thread temporary "
+                                    + "direct buffers the JDK caches for 
channel writes");
+
     public static final ConfigOption<Boolean> USE_ASYNC_OPERATIONS =
             ConfigOptions.key("s3.async.enabled")
                     .booleanType()
@@ -321,6 +333,69 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
                                     + "When not set, the default chain is 
used: delegation tokens -> "
                                     + "static credentials (if configured) -> 
DefaultCredentialsProvider.");
 
+    public static final ConfigOption<Boolean> CRT_ENABLED =
+            ConfigOptions.key("s3.crt.enabled")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Enable AWS Common Runtime (CRT) HTTP transport. "
+                                    + "When true, uses AwsCrtHttpClient for 
sync S3 operations and "
+                                    + "S3AsyncClient.crtBuilder() for 
async/transfer operations, "
+                                    + "providing higher throughput for large 
S3 transfers. "
+                                    + "Requires the aws-crt JAR in the plugin 
directory. "
+                                    + "The pure-Java aws-crt-client classes 
are bundled in the fat JAR; "
+                                    + "only aws-crt is external because its 
JNI classes cannot be shaded.");
+
+    public static final ConfigOption<Double> CRT_TARGET_THROUGHPUT_GBPS =
+            ConfigOptions.key("s3.crt.target-throughput-gbps")
+                    .doubleType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Soft target throughput in Gbps for the CRT-based 
S3 async client. "
+                                    + "Only used when s3.crt.enabled is true. "
+                                    + "This is a hint to the CRT runtime, not 
a hard cap: actual throughput "
+                                    + "may exceed this value, and the runtime 
uses it to size its internal "
+                                    + "worker pool and tune parallelism, so 
there is no separate Flink-level "
+                                    + "knob for concurrent CRT transfer 
threads. "
+                                    + "When unset, the AWS CRT runtime applies 
its own internal default. "
+                                    + "Set this only when you want to override 
that — e.g. pick a value "
+                                    + "matching the network bandwidth 
available to a single TaskManager.");
+
+    public static final ConfigOption<MemorySize> CRT_MAX_NATIVE_MEMORY_LIMIT =
+            ConfigOptions.key("s3.crt.max-native-memory-limit")
+                    .memoryType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Maximum native memory the CRT-based S3 async 
client may use. "
+                                    + "Only used when s3.crt.enabled is true. "
+                                    + "When unset, the AWS CRT runtime applies 
its own internal limit.");
+
+    public static final ConfigOption<MemorySize> CRT_READ_BUFFER_SIZE =
+            ConfigOptions.key("s3.crt.read-buffer-size")
+                    .memoryType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Read buffer size for the CRT HTTP transport 
(applied to both the sync "
+                                    + "AwsCrtHttpClient and the async CRT 
client's initial read buffer). "
+                                    + "Only used when s3.crt.enabled is true. 
This is decoupled from "
+                                    + "'s3.read.buffer.size' so that lowering 
the streaming read buffer "
+                                    + "does not shrink CRT's native transfer 
buffers and regress throughput. "
+                                    + "When unset, the AWS CRT runtime applies 
its own (larger) default.");
+
+    public static final ConfigOption<Integer> CRT_MAX_CONCURRENCY =
+            ConfigOptions.key("s3.crt.max-concurrency")
+                    .intType()
+                    .defaultValue(256)
+                    .withDescription(
+                            "Maximum number of concurrent requests the CRT 
HTTP transport may have "
+                                    + "in flight (applied to both the sync 
AwsCrtHttpClient and the "
+                                    + "async CRT client). Only used when 
s3.crt.enabled is true. This "
+                                    + "is decoupled from 's3.connection.max' 
because the CRT client "
+                                    + "fans a single logical transfer out into 
many concurrent "
+                                    + "part-sized requests; reusing the 
smaller sync connection-pool "
+                                    + "size here causes 'failed to acquire a 
connection' timeouts "
+                                    + "under parallel checkpoint 
upload/restore. Defaults to 256.");
+
     @Nullable private Configuration flinkConfig;
     @Nullable private BucketConfigProvider bucketConfigProvider;
 
@@ -462,7 +537,63 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
                 MAX_CONNECTIONS.key(),
                 maxConnections);
 
-        S3ClientProvider clientProvider =
+        final boolean crtEnabled = config.get(CRT_ENABLED);
+
+        // CRT-only options. Validated only when CRT is enabled (mirroring the 
bulk-copy gating
+        // below); when CRT is disabled these options are ignored. Unset 
optional values fall back
+        // to the CRT runtime's own defaults.
+        final Double crtTargetThroughputGbps =
+                config.getOptional(CRT_TARGET_THROUGHPUT_GBPS).orElse(null);
+        final MemorySize crtMaxNativeMemoryLimit =
+                config.getOptional(CRT_MAX_NATIVE_MEMORY_LIMIT).orElse(null);
+        final MemorySize crtReadBufferSize = 
config.getOptional(CRT_READ_BUFFER_SIZE).orElse(null);
+        final int crtMaxConcurrency = config.get(CRT_MAX_CONCURRENCY);
+        if (crtEnabled) {
+            if (crtTargetThroughputGbps != null) {
+                Preconditions.checkArgument(
+                        crtTargetThroughputGbps > 0,
+                        "'%s' must be positive, but was %s",
+                        CRT_TARGET_THROUGHPUT_GBPS.key(),
+                        crtTargetThroughputGbps);
+            }
+            if (crtMaxNativeMemoryLimit != null) {
+                Preconditions.checkArgument(
+                        crtMaxNativeMemoryLimit.getBytes() > 0,
+                        "'%s' must be positive, but was %s",
+                        CRT_MAX_NATIVE_MEMORY_LIMIT.key(),
+                        crtMaxNativeMemoryLimit);
+            }
+            if (crtReadBufferSize != null) {
+                Preconditions.checkArgument(
+                        crtReadBufferSize.getBytes() > 0,
+                        "'%s' must be positive, but was %s",
+                        CRT_READ_BUFFER_SIZE.key(),
+                        crtReadBufferSize);
+            }
+            Preconditions.checkArgument(
+                    crtMaxConcurrency > 0,
+                    "'%s' must be a positive integer, but was %s",
+                    CRT_MAX_CONCURRENCY.key(),
+                    crtMaxConcurrency);
+        }
+
+        final boolean bulkCopyEnabled = config.get(BULK_COPY_ENABLED);
+        final int bulkCopyMaxConcurrent = config.get(BULK_COPY_MAX_CONCURRENT);
+        final int bulkCopyDownloadBufferSize = 
config.get(BULK_COPY_DOWNLOAD_BUFFER_SIZE);
+        if (bulkCopyEnabled) {
+            Preconditions.checkArgument(
+                    bulkCopyMaxConcurrent > 0,
+                    "'%s' must be a positive integer, but was %s",
+                    BULK_COPY_MAX_CONCURRENT.key(),
+                    bulkCopyMaxConcurrent);
+            Preconditions.checkArgument(
+                    bulkCopyDownloadBufferSize > 0,
+                    "'%s' must be a positive integer, but was %s",
+                    BULK_COPY_DOWNLOAD_BUFFER_SIZE.key(),
+                    bulkCopyDownloadBufferSize);
+        }
+
+        S3ClientProvider.Builder clientProviderBuilder =
                 S3ClientProvider.builder()
                         .accessKey(accessKey)
                         .secretKey(secretKey)
@@ -486,21 +617,30 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory {
                         .retryMaxBackoff(config.get(RETRY_MAX_BACKOFF))
                         .credentialsProviderClasses(credentialsProviderClasses)
                         .encryptionConfig(encryptionConfig)
-                        .build();
+                        .useCrt(crtEnabled);
+
+        if (crtEnabled) {
+            clientProviderBuilder
+                    .crtTargetThroughputGbps(crtTargetThroughputGbps)
+                    .crtReadBufferSizeInBytes(
+                            crtReadBufferSize == null ? null : 
crtReadBufferSize.getBytes())
+                    .crtMaxConcurrency(crtMaxConcurrency)
+                    .crtMaxNativeMemoryLimitInBytes(
+                            crtMaxNativeMemoryLimit == null
+                                    ? null
+                                    : crtMaxNativeMemoryLimit.getBytes())
+                    .crtMinPartSizeInBytes(config.get(PART_UPLOAD_MIN_SIZE));
+        }
+        S3ClientProvider clientProvider = clientProviderBuilder.build();
 
         NativeS3BulkCopyHelper bulkCopyHelper = null;
-        if (config.get(BULK_COPY_ENABLED)) {
-            final int bulkCopyMaxConcurrent = 
config.get(BULK_COPY_MAX_CONCURRENT);
-            Preconditions.checkArgument(
-                    bulkCopyMaxConcurrent > 0,
-                    "'%s' must be a positive integer, but was %s",
-                    BULK_COPY_MAX_CONCURRENT.key(),
-                    bulkCopyMaxConcurrent);
+        if (bulkCopyEnabled) {
             bulkCopyHelper =
                     new NativeS3BulkCopyHelper(
-                            clientProvider.getTransferManager(),
+                            clientProvider.getAsyncClient(),
                             bulkCopyMaxConcurrent,
-                            maxConnections);
+                            maxConnections,
+                            bulkCopyDownloadBufferSize);
         }
 
         return new NativeS3FileSystem(
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3OutputStream.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3OutputStream.java
index d9a123d1b53..94a26b87f84 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3OutputStream.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3OutputStream.java
@@ -79,9 +79,7 @@ class NativeS3OutputStream extends FSDataOutputStream {
                 Preconditions.checkNotNull(encryptionConfig, "encryptionConfig 
must not be null");
 
         File tmpDir = new File(localTmpDir);
-        if (!tmpDir.exists()) {
-            tmpDir.mkdirs();
-        }
+        Files.createDirectories(tmpDir.toPath());
 
         this.tmpFile = new File(tmpDir, "s3-upload-" + UUID.randomUUID());
         this.bufferedStream = new BufferedOutputStream(new 
FileOutputStream(tmpFile), BUFFER_SIZE);
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
index e3c7a1f2380..a12ef1da405 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
@@ -34,6 +34,7 @@ import 
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
 import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
 import software.amazon.awssdk.http.apache.ApacheHttpClient;
+import software.amazon.awssdk.http.crt.AwsCrtHttpClient;
 import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain;
@@ -44,6 +45,9 @@ import 
software.amazon.awssdk.services.s3.S3AsyncClientBuilder;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.S3ClientBuilder;
 import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.S3CrtAsyncClientBuilder;
+import software.amazon.awssdk.services.s3.crt.S3CrtHttpConfiguration;
+import software.amazon.awssdk.services.s3.crt.S3CrtRetryConfiguration;
 import software.amazon.awssdk.services.sts.StsClient;
 import 
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
 import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
@@ -76,6 +80,7 @@ class S3ClientProvider implements AutoCloseableAsync {
     private static final Logger LOG = 
LoggerFactory.getLogger(S3ClientProvider.class);
 
     private final S3Client s3Client;
+    private final S3AsyncClient asyncClient;
     private final S3TransferManager transferManager;
     private final S3EncryptionConfig encryptionConfig;
     private final AwsCredentialsProvider credentialsProvider;
@@ -98,10 +103,16 @@ class S3ClientProvider implements AutoCloseableAsync {
     @Nullable private final String assumeRoleExternalId;
     @Nullable private final String assumeRoleSessionName;
     private final int assumeRoleSessionDurationSeconds;
+    private final boolean useCrt;
+    @Nullable private final Double crtTargetThroughputGbps;
+    @Nullable private final Long crtReadBufferSizeInBytes;
+    private final int crtMaxConcurrency;
+    @Nullable private final Long crtMaxNativeMemoryLimitInBytes;
     private final AtomicBoolean closed = new AtomicBoolean(false);
 
     private S3ClientProvider(
             S3Client s3Client,
+            S3AsyncClient asyncClient,
             S3TransferManager transferManager,
             S3EncryptionConfig encryptionConfig,
             AwsCredentialsProvider credentialsProvider,
@@ -123,8 +134,14 @@ class S3ClientProvider implements AutoCloseableAsync {
             @Nullable String assumeRoleArn,
             @Nullable String assumeRoleExternalId,
             @Nullable String assumeRoleSessionName,
-            int assumeRoleSessionDurationSeconds) {
+            int assumeRoleSessionDurationSeconds,
+            boolean useCrt,
+            @Nullable Double crtTargetThroughputGbps,
+            @Nullable Long crtReadBufferSizeInBytes,
+            int crtMaxConcurrency,
+            @Nullable Long crtMaxNativeMemoryLimitInBytes) {
         this.s3Client = Preconditions.checkNotNull(s3Client, "s3Client must 
not be null");
+        this.asyncClient = Preconditions.checkNotNull(asyncClient, 
"asyncClient must not be null");
         this.transferManager =
                 Preconditions.checkNotNull(transferManager, "transferManager 
must not be null");
         this.encryptionConfig =
@@ -161,6 +178,11 @@ class S3ClientProvider implements AutoCloseableAsync {
         this.assumeRoleExternalId = assumeRoleExternalId;
         this.assumeRoleSessionName = assumeRoleSessionName;
         this.assumeRoleSessionDurationSeconds = 
assumeRoleSessionDurationSeconds;
+        this.useCrt = useCrt;
+        this.crtTargetThroughputGbps = crtTargetThroughputGbps;
+        this.crtReadBufferSizeInBytes = crtReadBufferSizeInBytes;
+        this.crtMaxConcurrency = crtMaxConcurrency;
+        this.crtMaxNativeMemoryLimitInBytes = crtMaxNativeMemoryLimitInBytes;
     }
 
     public S3Client getS3Client() {
@@ -283,6 +305,44 @@ class S3ClientProvider implements AutoCloseableAsync {
         return assumeRoleSessionDurationSeconds;
     }
 
+    @VisibleForTesting
+    boolean isUseCrt() {
+        return useCrt;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    Double getCrtTargetThroughputGbps() {
+        return crtTargetThroughputGbps;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    Long getCrtReadBufferSizeInBytes() {
+        return crtReadBufferSizeInBytes;
+    }
+
+    @VisibleForTesting
+    int getCrtMaxConcurrency() {
+        return crtMaxConcurrency;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    Long getCrtMaxNativeMemoryLimitInBytes() {
+        return crtMaxNativeMemoryLimitInBytes;
+    }
+
+    /**
+     * Exposed for tests to verify which async-client implementation (CRT vs 
Netty) was actually
+     * constructed. Not intended for production use; callers should go through 
{@link
+     * #getTransferManager()}.
+     */
+    @VisibleForTesting
+    S3AsyncClient getAsyncClient() {
+        return asyncClient;
+    }
+
     @Override
     public CompletableFuture<Void> closeAsync() {
         if (!closed.compareAndSet(false, true)) {
@@ -295,6 +355,11 @@ class S3ClientProvider implements AutoCloseableAsync {
                             } catch (Exception e) {
                                 LOG.warn("Error closing S3 TransferManager", 
e);
                             }
+                            try {
+                                asyncClient.close();
+                            } catch (Exception e) {
+                                LOG.warn("Error closing S3 async client", e);
+                            }
                             try {
                                 s3Client.close();
                             } catch (Exception e) {
@@ -315,10 +380,13 @@ class S3ClientProvider implements AutoCloseableAsync {
                                 }
                             }
                         })
-                .orTimeout(clientCloseTimeout.toSeconds(), TimeUnit.SECONDS)
+                .orTimeout(clientCloseTimeout.toMillis(), 
TimeUnit.MILLISECONDS)
                 .exceptionally(
                         ex -> {
-                            LOG.error("S3 client close timed out after {}", 
clientCloseTimeout, ex);
+                            LOG.error(
+                                    "S3 client close did not complete cleanly 
within {}",
+                                    clientCloseTimeout,
+                                    ex);
                             return null;
                         });
     }
@@ -338,26 +406,37 @@ class S3ClientProvider implements AutoCloseableAsync {
         private String secretKey;
         private String region;
         private String endpoint;
-        private boolean pathStyleAccess = false;
-        private boolean chunkedEncoding = true;
-        private boolean checksumValidation = true;
-        private int maxConnections = 50;
-        private Duration connectionTimeout = Duration.ofSeconds(60);
-        private Duration socketTimeout = Duration.ofSeconds(60);
-        private Duration connectionMaxIdleTime = Duration.ofSeconds(60);
-        private int maxRetries = 3;
+        // All defaults are sourced from NativeS3FileSystemFactory 
ConfigOption.defaultValue() so
+        // that NativeS3FileSystemFactory remains the single source of truth — 
if a default changes
+        // there, this Builder automatically picks it up without needing a 
parallel edit.
+        private boolean pathStyleAccess =
+                NativeS3FileSystemFactory.PATH_STYLE_ACCESS.defaultValue();
+        private boolean chunkedEncoding =
+                
NativeS3FileSystemFactory.CHUNKED_ENCODING_ENABLED.defaultValue();
+        private boolean checksumValidation =
+                
NativeS3FileSystemFactory.CHECKSUM_VALIDATION_ENABLED.defaultValue();
+        private int maxConnections = 
NativeS3FileSystemFactory.MAX_CONNECTIONS.defaultValue();
+        private Duration connectionTimeout =
+                NativeS3FileSystemFactory.CONNECTION_TIMEOUT.defaultValue();
+        private Duration socketTimeout = 
NativeS3FileSystemFactory.SOCKET_TIMEOUT.defaultValue();
+        private Duration connectionMaxIdleTime =
+                
NativeS3FileSystemFactory.CONNECTION_MAX_IDLE_TIME.defaultValue();
+        private int maxRetries = 
NativeS3FileSystemFactory.MAX_RETRIES.defaultValue();
         private Duration retryBaseDelay = 
NativeS3FileSystemFactory.RETRY_BASE_DELAY.defaultValue();
         private Duration retryThrottleBaseDelay =
                 
NativeS3FileSystemFactory.RETRY_THROTTLE_BASE_DELAY.defaultValue();
         private Duration retryMaxBackoff =
                 NativeS3FileSystemFactory.RETRY_MAX_BACKOFF.defaultValue();
-        private Duration clientCloseTimeout = Duration.ofSeconds(30);
+        private Duration clientCloseTimeout =
+                NativeS3FileSystemFactory.CLIENT_CLOSE_TIMEOUT.defaultValue();
 
         // AssumeRole configuration
         private String assumeRoleArn;
         private String assumeRoleExternalId;
-        private String assumeRoleSessionName = "flink-s3-session";
-        private int assumeRoleSessionDurationSeconds = 3600;
+        private String assumeRoleSessionName =
+                
NativeS3FileSystemFactory.ASSUME_ROLE_SESSION_NAME.defaultValue();
+        private int assumeRoleSessionDurationSeconds =
+                
NativeS3FileSystemFactory.ASSUME_ROLE_SESSION_DURATION_SECONDS.defaultValue();
 
         // Encryption configuration
         private S3EncryptionConfig encryptionConfig = 
S3EncryptionConfig.none();
@@ -365,6 +444,16 @@ class S3ClientProvider implements AutoCloseableAsync {
         // Custom credentials provider class names (comma-separated)
         @Nullable private String credentialsProviderClasses;
 
+        // CRT configuration
+        private boolean useCrt = 
NativeS3FileSystemFactory.CRT_ENABLED.defaultValue();
+        @Nullable private Double crtTargetThroughputGbps = null;
+        @Nullable private Long crtReadBufferSizeInBytes = null;
+        private int crtMaxConcurrency =
+                NativeS3FileSystemFactory.CRT_MAX_CONCURRENCY.defaultValue();
+        @Nullable private Long crtMaxNativeMemoryLimitInBytes = null;
+        private long crtMinPartSizeInBytes =
+                NativeS3FileSystemFactory.PART_UPLOAD_MIN_SIZE.defaultValue();
+
         public Builder accessKey(@Nullable String accessKey) {
             this.accessKey = accessKey;
             return this;
@@ -498,6 +587,37 @@ class S3ClientProvider implements AutoCloseableAsync {
             return this;
         }
 
+        public Builder useCrt(boolean useCrt) {
+            this.useCrt = useCrt;
+            return this;
+        }
+
+        public Builder crtTargetThroughputGbps(@Nullable Double 
crtTargetThroughputGbps) {
+            this.crtTargetThroughputGbps = crtTargetThroughputGbps;
+            return this;
+        }
+
+        public Builder crtReadBufferSizeInBytes(@Nullable Long 
crtReadBufferSizeInBytes) {
+            this.crtReadBufferSizeInBytes = crtReadBufferSizeInBytes;
+            return this;
+        }
+
+        public Builder crtMaxConcurrency(int crtMaxConcurrency) {
+            this.crtMaxConcurrency = crtMaxConcurrency;
+            return this;
+        }
+
+        public Builder crtMaxNativeMemoryLimitInBytes(
+                @Nullable Long crtMaxNativeMemoryLimitInBytes) {
+            this.crtMaxNativeMemoryLimitInBytes = 
crtMaxNativeMemoryLimitInBytes;
+            return this;
+        }
+
+        public Builder crtMinPartSizeInBytes(long crtMinPartSizeInBytes) {
+            this.crtMinPartSizeInBytes = crtMinPartSizeInBytes;
+            return this;
+        }
+
         S3ClientProvider build() {
             if (endpoint == null) {
                 endpoint = System.getProperty("s3.endpoint");
@@ -554,46 +674,27 @@ class S3ClientProvider implements AutoCloseableAsync {
                                             .build())
                             .build();
 
-            ApacheHttpClient.Builder httpClientBuilder =
-                    ApacheHttpClient.builder()
-                            .maxConnections(maxConnections)
-                            .connectionTimeout(connectionTimeout)
-                            .socketTimeout(socketTimeout)
-                            .tcpKeepAlive(true)
-                            .connectionMaxIdleTime(connectionMaxIdleTime);
-
-            S3ClientBuilder clientBuilder =
-                    S3Client.builder()
-                            .credentialsProvider(credentialsProvider)
-                            .region(awsRegion)
-                            .serviceConfiguration(s3Config)
-                            .httpClientBuilder(httpClientBuilder)
-                            .overrideConfiguration(overrideConfig);
-            if (endpointUri != null) {
-                clientBuilder.endpointOverride(endpointUri);
+            if (useCrt) {
+                LOG.info(
+                        "AWS CRT transport enabled (s3.crt.enabled=true) with 
target throughput {}",
+                        crtTargetThroughputGbps != null
+                                ? crtTargetThroughputGbps + " Gbps"
+                                : "(CRT runtime default)");
             }
-            S3Client s3Client = clientBuilder.build();
 
-            S3AsyncClientBuilder asyncClientBuilder =
-                    S3AsyncClient.builder()
-                            .credentialsProvider(credentialsProvider)
-                            .region(awsRegion)
-                            .serviceConfiguration(s3Config)
-                            .httpClientBuilder(
-                                    NettyNioAsyncHttpClient.builder()
-                                            .maxConcurrency(maxConnections)
-                                            
.connectionTimeout(connectionTimeout)
-                                            .readTimeout(socketTimeout)
-                                            
.connectionAcquisitionTimeout(connectionTimeout))
-                            .overrideConfiguration(overrideConfig);
-            if (endpointUri != null) {
-                asyncClientBuilder.endpointOverride(endpointUri);
-            }
+            S3Client s3Client =
+                    buildSyncClient(
+                            credentialsProvider, awsRegion, s3Config, 
overrideConfig, endpointUri);
+            S3AsyncClient asyncClient =
+                    buildAsyncClient(
+                            credentialsProvider, awsRegion, s3Config, 
overrideConfig, endpointUri);
+
             S3TransferManager transferManager =
-                    
S3TransferManager.builder().s3Client(asyncClientBuilder.build()).build();
+                    S3TransferManager.builder().s3Client(asyncClient).build();
 
             return new S3ClientProvider(
                     s3Client,
+                    asyncClient,
                     transferManager,
                     encryptionConfig,
                     credentialsProvider,
@@ -615,7 +716,161 @@ class S3ClientProvider implements AutoCloseableAsync {
                     assumeRoleArn,
                     assumeRoleExternalId,
                     assumeRoleSessionName,
-                    assumeRoleSessionDurationSeconds);
+                    assumeRoleSessionDurationSeconds,
+                    useCrt,
+                    crtTargetThroughputGbps,
+                    crtReadBufferSizeInBytes,
+                    crtMaxConcurrency,
+                    crtMaxNativeMemoryLimitInBytes);
+        }
+
+        /**
+         * Builds the synchronous {@link S3Client}, choosing between the 
Apache HTTP transport and
+         * the AWS CRT transport based on {@link #useCrt}.
+         */
+        private S3Client buildSyncClient(
+                AwsCredentialsProvider credentialsProvider,
+                Region awsRegion,
+                S3Configuration s3Config,
+                ClientOverrideConfiguration overrideConfig,
+                @Nullable URI endpointUri) {
+            S3ClientBuilder clientBuilder =
+                    S3Client.builder()
+                            .credentialsProvider(credentialsProvider)
+                            .region(awsRegion)
+                            .serviceConfiguration(s3Config)
+                            .overrideConfiguration(overrideConfig);
+
+            try {
+                if (useCrt) {
+                    // Note: AwsCrtHttpClient.Builder does not expose a 
`readTimeout(Duration)`
+                    // equivalent of the Apache client's socket timeout. The 
CRT runtime relies
+                    // on `ConnectionHealthConfiguration` for stalled-read 
detection instead, so
+                    // the `s3.socket.timeout` setting is silently ignored in 
CRT mode.
+                    AwsCrtHttpClient.Builder crtHttpBuilder =
+                            AwsCrtHttpClient.builder()
+                                    .maxConcurrency(crtMaxConcurrency)
+                                    .connectionTimeout(connectionTimeout)
+                                    
.connectionMaxIdleTime(connectionMaxIdleTime);
+                    if (crtReadBufferSizeInBytes != null) {
+                        
crtHttpBuilder.readBufferSizeInBytes(crtReadBufferSizeInBytes);
+                    }
+                    clientBuilder.httpClientBuilder(crtHttpBuilder);
+                } else {
+                    clientBuilder.httpClientBuilder(
+                            ApacheHttpClient.builder()
+                                    .maxConnections(maxConnections)
+                                    .connectionTimeout(connectionTimeout)
+                                    .socketTimeout(socketTimeout)
+                                    .tcpKeepAlive(true)
+                                    
.connectionMaxIdleTime(connectionMaxIdleTime));
+                }
+                if (endpointUri != null) {
+                    clientBuilder.endpointOverride(endpointUri);
+                }
+                return clientBuilder.build();
+            } catch (LinkageError e) {
+                if (useCrt) {
+                    throw new IllegalStateException(crtMissingJarsMessage(), 
e);
+                }
+                throw e;
+            } catch (IllegalStateException e) {
+                if (useCrt && isCrtClasspathFailure(e)) {
+                    throw new IllegalStateException(crtMissingJarsMessage(), 
e);
+                }
+                throw e;
+            }
+        }
+
+        /**
+         * Builds the asynchronous {@link S3AsyncClient}, choosing between the 
Netty transport and
+         * the AWS CRT transport based on {@link #useCrt}.
+         *
+         * <p>Note: when CRT is enabled the {@code 
s3.chunked-encoding.enabled} option is silently
+         * ignored. The CRT runtime manages wire encoding internally and {@link
+         * software.amazon.awssdk.services.s3.S3CrtAsyncClientBuilder} exposes 
no equivalent setter.
+         */
+        private S3AsyncClient buildAsyncClient(
+                AwsCredentialsProvider credentialsProvider,
+                Region awsRegion,
+                S3Configuration s3Config,
+                ClientOverrideConfiguration overrideConfig,
+                @Nullable URI endpointUri) {
+            if (useCrt) {
+                try {
+                    S3CrtAsyncClientBuilder crtAsyncBuilder =
+                            S3AsyncClient.crtBuilder()
+                                    .credentialsProvider(credentialsProvider)
+                                    .region(awsRegion)
+                                    .forcePathStyle(pathStyleAccess)
+                                    
.checksumValidationEnabled(checksumValidation)
+                                    .httpConfiguration(
+                                            S3CrtHttpConfiguration.builder()
+                                                    
.connectionTimeout(connectionTimeout)
+                                                    .build())
+                                    .retryConfiguration(
+                                            S3CrtRetryConfiguration.builder()
+                                                    .numRetries(maxRetries)
+                                                    .build())
+                                    .maxConcurrency(crtMaxConcurrency)
+                                    
.minimumPartSizeInBytes(crtMinPartSizeInBytes);
+                    if (crtReadBufferSizeInBytes != null) {
+                        
crtAsyncBuilder.initialReadBufferSizeInBytes(crtReadBufferSizeInBytes);
+                    }
+                    // Only override the CRT runtime's own default when the 
user has explicitly
+                    // configured s3.crt.target-throughput-gbps; otherwise let 
the SDK pick.
+                    if (crtTargetThroughputGbps != null) {
+                        
crtAsyncBuilder.targetThroughputInGbps(crtTargetThroughputGbps);
+                    }
+                    if (crtMaxNativeMemoryLimitInBytes != null) {
+                        
crtAsyncBuilder.maxNativeMemoryLimitInBytes(crtMaxNativeMemoryLimitInBytes);
+                    }
+                    if (endpointUri != null) {
+                        crtAsyncBuilder.endpointOverride(endpointUri);
+                    }
+                    return crtAsyncBuilder.build();
+                } catch (LinkageError e) {
+                    throw new IllegalStateException(crtMissingJarsMessage(), 
e);
+                } catch (IllegalStateException e) {
+                    if (isCrtClasspathFailure(e)) {
+                        throw new 
IllegalStateException(crtMissingJarsMessage(), e);
+                    }
+                    throw e;
+                }
+            }
+
+            S3AsyncClientBuilder asyncBuilder =
+                    S3AsyncClient.builder()
+                            .credentialsProvider(credentialsProvider)
+                            .region(awsRegion)
+                            .serviceConfiguration(s3Config)
+                            .httpClientBuilder(
+                                    NettyNioAsyncHttpClient.builder()
+                                            .maxConcurrency(maxConnections)
+                                            
.connectionTimeout(connectionTimeout)
+                                            .readTimeout(socketTimeout)
+                                            
.connectionAcquisitionTimeout(connectionTimeout))
+                            .overrideConfiguration(overrideConfig);
+            if (endpointUri != null) {
+                asyncBuilder.endpointOverride(endpointUri);
+            }
+            return asyncBuilder.build();
+        }
+
+        private static boolean isCrtClasspathFailure(IllegalStateException e) {
+            String message = e.getMessage();
+            return message != null
+                    && (message.contains("AWS Common Runtime")
+                            || message.contains("software.amazon.awssdk.crt"));
+        }
+
+        @VisibleForTesting
+        static String crtMissingJarsMessage() {
+            return "CRT transport requested (s3.crt.enabled=true) but the 
aws-crt JAR "
+                    + "is not on the classpath. Place it in the Flink plugin 
directory "
+                    + "(e.g. $FLINK_HOME/plugins/s3-fs-native/) alongside 
flink-s3-fs-native.jar. "
+                    + "Run tools/download-crt-jars.sh to download the matching 
version. "
+                    + "See the module README for setup details.";
         }
 
         private AwsCredentialsProvider buildBaseCredentialsProvider() {
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3ObjectOperations.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3ObjectOperations.java
index 14ff1eeab12..1a8fd361743 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3ObjectOperations.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3ObjectOperations.java
@@ -20,13 +20,15 @@ package org.apache.flink.fs.s3native.writer;
 
 import org.apache.flink.annotation.Internal;
 import org.apache.flink.core.fs.Path;
+import org.apache.flink.fs.s3native.NativeS3FileIoUtils;
 import org.apache.flink.fs.s3native.S3EncryptionConfig;
 import org.apache.flink.fs.s3native.S3ExceptionUtils;
+import org.apache.flink.util.IOUtils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.core.ResponseInputStream;
 import software.amazon.awssdk.core.sync.RequestBody;
-import software.amazon.awssdk.core.sync.ResponseTransformer;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
 import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
@@ -55,6 +57,7 @@ import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.util.List;
+import java.util.concurrent.ExecutionException;
 import java.util.stream.Collectors;
 
 /**
@@ -97,6 +100,8 @@ public class NativeS3ObjectOperations {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(NativeS3ObjectOperations.class);
 
+    private static final int DOWNLOAD_BUFFER_SIZE = 256 * 1024;
+
     private final S3Client s3Client;
     private final S3TransferManager transferManager;
     private final String bucketName;
@@ -259,8 +264,20 @@ public class NativeS3ObjectOperations {
                             .build();
 
             FileUpload fileUpload = transferManager.uploadFile(uploadRequest);
-            CompletedFileUpload completedUpload = 
fileUpload.completionFuture().join();
+            CompletedFileUpload completedUpload;
+            try {
+                completedUpload = fileUpload.completionFuture().get();
+            } catch (InterruptedException e) {
+                fileUpload.completionFuture().cancel(true);
+                Thread.currentThread().interrupt();
+                throw new IOException("Interrupted while uploading object for 
key: " + key, e);
+            } catch (ExecutionException e) {
+                throw new IOException(
+                        "Failed to async upload object for key: " + key, 
e.getCause());
+            }
             return new PutObjectResult(completedUpload.response().eTag());
+        } catch (IOException e) {
+            throw e;
         } catch (Exception e) {
             throw new IOException("Failed to async upload object for key: " + 
key, e);
         }
@@ -380,16 +397,45 @@ public class NativeS3ObjectOperations {
     }
 
     public long getObject(String key, File targetLocation) throws IOException {
+        java.nio.file.Path target = targetLocation.toPath().toAbsolutePath();
+        java.nio.file.Path parent = target.getParent();
+        if (parent != null) {
+            Files.createDirectories(parent);
+        }
+        java.nio.file.Path tempTarget =
+                NativeS3FileIoUtils.createTemporaryDownloadFile(parent, 
target);
+        ResponseInputStream<GetObjectResponse> responseStream = null;
+        boolean success = false;
         try {
             GetObjectRequest request =
                     
GetObjectRequest.builder().bucket(bucketName).key(key).build();
-            ResponseTransformer<GetObjectResponse, GetObjectResponse> 
responseTransformer =
-                    ResponseTransformer.toFile(targetLocation.toPath());
-            s3Client.getObject(request, responseTransformer);
-            return Files.size(targetLocation.toPath());
+            responseStream = s3Client.getObject(request);
+            NativeS3FileIoUtils.copyStream(responseStream, tempTarget, 
DOWNLOAD_BUFFER_SIZE);
+            NativeS3FileIoUtils.moveFile(tempTarget, target);
+            success = true;
+            return Files.size(target);
         } catch (S3Exception e) {
             throw new IOException("Failed to get object for key: " + key, e);
+        } finally {
+            if (success) {
+                IOUtils.closeQuietly(responseStream);
+            } else {
+                abortAndClose(responseStream);
+                IOUtils.deleteFileQuietly(tempTarget);
+            }
+        }
+    }
+
+    private static void abortAndClose(ResponseInputStream<GetObjectResponse> 
stream) {
+        if (stream == null) {
+            return;
+        }
+        try {
+            stream.abort();
+        } catch (RuntimeException e) {
+            LOG.debug("Error aborting S3 response stream", e);
         }
+        IOUtils.closeQuietly(stream);
     }
 
     public ObjectMetadata getObjectMetadata(String key) throws IOException {
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
index 7eabcb9db2b..0a0376fcfac 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableFsDataOutputStream.java
@@ -21,6 +21,7 @@ package org.apache.flink.fs.s3native.writer;
 import org.apache.flink.core.fs.RecoverableFsDataOutputStream;
 import org.apache.flink.core.fs.RecoverableWriter;
 import org.apache.flink.fs.s3native.writer.NativeS3Recoverable.PartETag;
+import org.apache.flink.util.ExceptionUtils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -128,9 +129,7 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
 
     private void createNewTempFile() throws IOException {
         File tmpDir = new File(localTmpDir);
-        if (!tmpDir.exists()) {
-            tmpDir.mkdirs();
-        }
+        Files.createDirectories(tmpDir.toPath());
 
         currentTempFile = new File(tmpDir, "s3-part-" + UUID.randomUUID());
         currentFileStream = new FileOutputStream(currentTempFile);
@@ -198,11 +197,14 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
     private void uploadCurrentPart() throws IOException {
         currentOutputStream.close();
 
-        int partNumber = nextPartNumber++;
+        // Do not delete the temp file if uploadPart fails: propagate the 
original exception
+        // unmasked and let close() perform cleanup. nextPartNumber is only 
advanced on success so a
+        // failed attempt does not leave a gap in the part sequence.
         NativeS3ObjectOperations.UploadPartResult result =
                 s3AccessHelper.uploadPart(
-                        key, uploadId, partNumber, currentTempFile, 
currentPartSize);
+                        key, uploadId, nextPartNumber, currentTempFile, 
currentPartSize);
 
+        nextPartNumber++;
         completedParts.add(new PartETag(result.getPartNumber(), 
result.getETag()));
         numBytesInParts += currentPartSize;
 
@@ -217,7 +219,6 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
                 throw new IOException("Stream is already closed");
             }
 
-            closed = true;
             currentOutputStream.close();
 
             if (currentPartSize > 0) {
@@ -230,6 +231,7 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
                     new NativeS3Recoverable(
                             key, uploadId, new ArrayList<>(completedParts), 
numBytesInParts);
 
+            closed = true;
             return new NativeS3Committer(s3AccessHelper, recoverable);
         } finally {
             unlock();
@@ -270,11 +272,20 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
         try {
             if (!closed) {
                 closed = true;
+                IOException cleanupException = null;
                 if (currentOutputStream != null) {
-                    currentOutputStream.close();
+                    try {
+                        currentOutputStream.close();
+                    } catch (IOException e) {
+                        cleanupException = ExceptionUtils.firstOrSuppressed(e, 
cleanupException);
+                    }
                 }
                 if (currentTempFile != null && currentTempFile.exists()) {
-                    Files.delete(currentTempFile.toPath());
+                    try {
+                        Files.delete(currentTempFile.toPath());
+                    } catch (IOException e) {
+                        cleanupException = ExceptionUtils.firstOrSuppressed(e, 
cleanupException);
+                    }
                 }
 
                 try {
@@ -287,6 +298,9 @@ class NativeS3RecoverableFsDataOutputStream extends 
RecoverableFsDataOutputStrea
                             uploadId,
                             e);
                 }
+                if (cleanupException != null) {
+                    throw cleanupException;
+                }
             }
         } finally {
             unlock();
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/resources/META-INF/NOTICE 
b/flink-filesystems/flink-s3-fs-native/src/main/resources/META-INF/NOTICE
index 427b561089b..a91513a5175 100644
--- a/flink-filesystems/flink-s3-fs-native/src/main/resources/META-INF/NOTICE
+++ b/flink-filesystems/flink-s3-fs-native/src/main/resources/META-INF/NOTICE
@@ -11,6 +11,7 @@ This project bundles the following dependencies under the 
Apache Software Licens
 - software.amazon.awssdk:arns:2.44.4
 - software.amazon.awssdk:auth:2.44.4
 - software.amazon.awssdk:aws-core:2.44.4
+- software.amazon.awssdk:aws-crt-client:2.44.4
 - software.amazon.awssdk:aws-query-protocol:2.44.4
 - software.amazon.awssdk:aws-xml-protocol:2.44.4
 - software.amazon.awssdk:checksums:2.44.4
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelperTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelperTest.java
index 5b244850dca..d8325d9aba5 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelperTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3BulkCopyHelperTest.java
@@ -18,24 +18,44 @@
 
 package org.apache.flink.fs.s3native;
 
+import org.apache.flink.core.fs.CloseableRegistry;
+import org.apache.flink.core.fs.PathsCopyingFileSystem.CopyRequest;
+
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.core.ResponseInputStream;
 import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Proxy;
+import java.nio.file.Path;
 import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static 
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link NativeS3BulkCopyHelper}. */
 class NativeS3BulkCopyHelperTest {
 
-    private static final NativeS3BulkCopyHelper helper = new 
NativeS3BulkCopyHelper(null, 1, 1);
+    private static final NativeS3BulkCopyHelper helper =
+            new NativeS3BulkCopyHelper(null, 1, 1, 256 * 1024);
 
     // --- URI parsing tests ---
 
@@ -153,7 +173,309 @@ class NativeS3BulkCopyHelperTest {
 
     @Test
     void testEmptyRequestListIsNoOp() throws Exception {
-        NativeS3BulkCopyHelper noOpHelper = new NativeS3BulkCopyHelper(null, 
16, 50);
+        NativeS3BulkCopyHelper noOpHelper = new NativeS3BulkCopyHelper(null, 
16, 50, 256 * 1024);
         noOpHelper.copyFiles(Collections.emptyList(), null);
     }
+
+    // --- download buffer size tests ---
+
+    @Test
+    void testDownloadBufferSizeIsExposed() {
+        NativeS3BulkCopyHelper h = new NativeS3BulkCopyHelper(null, 1, 1, 128 
* 1024);
+        assertThat(h.getDownloadBufferSize()).isEqualTo(128 * 1024);
+    }
+
+    @Test
+    void testNonPositiveDownloadBufferSizeRejected() {
+        assertThatIllegalArgumentException()
+                .isThrownBy(() -> new NativeS3BulkCopyHelper(null, 1, 1, 0));
+    }
+
+    @Test
+    void testCopyFilesCancellationClosesActiveResponseStream(@TempDir Path 
tempDir)
+            throws Exception {
+        BlockingInputStream blockingStream = new BlockingInputStream();
+        ResponseInputStream<GetObjectResponse> responseStream =
+                new ResponseInputStream<>(GetObjectResponse.builder().build(), 
blockingStream);
+        NativeS3BulkCopyHelper h =
+                new NativeS3BulkCopyHelper(
+                        
asyncClientReturning(CompletableFuture.completedFuture(responseStream)),
+                        1,
+                        1,
+                        1024);
+        CloseableRegistry registry = new CloseableRegistry();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        CompletableFuture<Throwable> copyFailure = new CompletableFuture<>();
+
+        try {
+            executor.submit(
+                    () -> {
+                        try {
+                            h.copyFiles(
+                                    Collections.singletonList(
+                                            CopyRequest.of(
+                                                    new 
org.apache.flink.core.fs.Path(
+                                                            "s3://bucket/key"),
+                                                    new 
org.apache.flink.core.fs.Path(
+                                                            
tempDir.resolve("out").toUri()),
+                                                    1L)),
+                                    registry);
+                            copyFailure.complete(null);
+                        } catch (Throwable t) {
+                            copyFailure.complete(t);
+                        }
+                    });
+
+            assertThat(blockingStream.awaitReadStarted()).isTrue();
+            registry.close();
+
+            assertThat(copyFailure.get(10, 
TimeUnit.SECONDS)).isInstanceOf(IOException.class);
+            assertThat(blockingStream.awaitClosed()).isTrue();
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCopyFilesClosesResponseStreamCompletedAfterCancellation(@TempDir 
Path tempDir)
+            throws Exception {
+        BlockingInputStream blockingStream = new BlockingInputStream();
+        ResponseInputStream<GetObjectResponse> responseStream =
+                new ResponseInputStream<>(GetObjectResponse.builder().build(), 
blockingStream);
+        NonCancellingCompletableFuture<ResponseInputStream<GetObjectResponse>> 
responseFuture =
+                new NonCancellingCompletableFuture<>();
+        CountDownLatch getObjectCalled = new CountDownLatch(1);
+        NativeS3BulkCopyHelper h =
+                new NativeS3BulkCopyHelper(
+                        asyncClientReturning(responseFuture, getObjectCalled), 
1, 1, 1024);
+        CloseableRegistry registry = new CloseableRegistry();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        CompletableFuture<Throwable> copyFailure = new CompletableFuture<>();
+
+        try {
+            executor.submit(
+                    () -> {
+                        try {
+                            h.copyFiles(
+                                    Collections.singletonList(
+                                            CopyRequest.of(
+                                                    new 
org.apache.flink.core.fs.Path(
+                                                            "s3://bucket/key"),
+                                                    new 
org.apache.flink.core.fs.Path(
+                                                            
tempDir.resolve("out").toUri()),
+                                                    1L)),
+                                    registry);
+                            copyFailure.complete(null);
+                        } catch (Throwable t) {
+                            copyFailure.complete(t);
+                        }
+                    });
+
+            assertThat(getObjectCalled.await(10, TimeUnit.SECONDS)).isTrue();
+            registry.close();
+            responseFuture.complete(responseStream);
+
+            assertThat(copyFailure.get(10, 
TimeUnit.SECONDS)).isInstanceOf(IOException.class);
+            assertThat(blockingStream.awaitClosed()).isTrue();
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCopyFilesDeletesTemporaryFileWhenRequestFails(@TempDir Path 
tempDir) {
+        NativeS3BulkCopyHelper h =
+                new NativeS3BulkCopyHelper(
+                        asyncClientReturning(
+                                CompletableFuture.failedFuture(new 
IOException("boom"))),
+                        1,
+                        1,
+                        1024);
+
+        assertThatThrownBy(
+                        () ->
+                                h.copyFiles(
+                                        Collections.singletonList(
+                                                CopyRequest.of(
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
"s3://bucket/key"),
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
tempDir.resolve("out").toUri()),
+                                                        1L)),
+                                        new CloseableRegistry()))
+                .isInstanceOf(IOException.class);
+
+        assertThat(tempDir).isEmptyDirectory();
+    }
+
+    @Test
+    void testCopyFilesSupportsShortDestinationFileNames(@TempDir Path tempDir) 
throws Exception {
+        ResponseInputStream<GetObjectResponse> responseStream =
+                new ResponseInputStream<>(
+                        GetObjectResponse.builder().build(),
+                        new ByteArrayInputStream("data".getBytes()));
+        NativeS3BulkCopyHelper h =
+                new NativeS3BulkCopyHelper(
+                        
asyncClientReturning(CompletableFuture.completedFuture(responseStream)),
+                        1,
+                        1,
+                        1024);
+        Path destination = tempDir.resolve("x");
+
+        h.copyFiles(
+                Collections.singletonList(
+                        CopyRequest.of(
+                                new 
org.apache.flink.core.fs.Path("s3://bucket/key"),
+                                new 
org.apache.flink.core.fs.Path(destination.toUri()),
+                                4L)),
+                new CloseableRegistry());
+
+        assertThat(destination).hasContent("data");
+    }
+
+    @Test
+    void testCopyFilesFailFastAbortsInFlightStreamsOnFirstFailure(@TempDir 
Path tempDir)
+            throws Exception {
+        BlockingInputStream blockingStream = new BlockingInputStream();
+        ResponseInputStream<GetObjectResponse> blockingResponse =
+                new ResponseInputStream<>(GetObjectResponse.builder().build(), 
blockingStream);
+        java.util.concurrent.ConcurrentLinkedQueue<
+                        
CompletableFuture<ResponseInputStream<GetObjectResponse>>>
+                responses = new java.util.concurrent.ConcurrentLinkedQueue<>();
+        responses.add(CompletableFuture.completedFuture(blockingResponse));
+        responses.add(CompletableFuture.failedFuture(new IOException("boom")));
+
+        S3AsyncClient client =
+                (S3AsyncClient)
+                        Proxy.newProxyInstance(
+                                S3AsyncClient.class.getClassLoader(),
+                                new Class<?>[] {S3AsyncClient.class},
+                                (proxy, method, args) -> {
+                                    switch (method.getName()) {
+                                        case "getObject":
+                                            return responses.poll();
+                                        case "close":
+                                            return null;
+                                        case "serviceName":
+                                            return "s3";
+                                        case "toString":
+                                            return "test-s3-async-client";
+                                        default:
+                                            throw new 
UnsupportedOperationException(
+                                                    method.toString());
+                                    }
+                                });
+        NativeS3BulkCopyHelper h = new NativeS3BulkCopyHelper(client, 2, 2, 
1024);
+        CloseableRegistry registry = new CloseableRegistry();
+
+        assertThatThrownBy(
+                        () ->
+                                h.copyFiles(
+                                        java.util.Arrays.asList(
+                                                CopyRequest.of(
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
"s3://bucket/blocking"),
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
tempDir.resolve("a").toUri()),
+                                                        1L),
+                                                CopyRequest.of(
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
"s3://bucket/failing"),
+                                                        new 
org.apache.flink.core.fs.Path(
+                                                                
tempDir.resolve("b").toUri()),
+                                                        1L)),
+                                        registry))
+                .isInstanceOf(IOException.class);
+
+        assertThat(blockingStream.awaitClosed()).isTrue();
+    }
+
+    private static S3AsyncClient asyncClientReturning(
+            CompletableFuture<ResponseInputStream<GetObjectResponse>> 
responseFuture) {
+        return asyncClientReturning(responseFuture, null);
+    }
+
+    private static S3AsyncClient asyncClientReturning(
+            CompletableFuture<ResponseInputStream<GetObjectResponse>> 
responseFuture,
+            CountDownLatch getObjectCalled) {
+        return (S3AsyncClient)
+                Proxy.newProxyInstance(
+                        S3AsyncClient.class.getClassLoader(),
+                        new Class<?>[] {S3AsyncClient.class},
+                        (proxy, method, args) -> {
+                            if (method.getName().equals("getObject")
+                                    && args != null
+                                    && args.length == 2) {
+                                if (getObjectCalled != null) {
+                                    getObjectCalled.countDown();
+                                }
+                                return responseFuture;
+                            }
+                            if (method.getName().equals("close")) {
+                                return null;
+                            }
+                            if (method.getName().equals("serviceName")) {
+                                return "s3";
+                            }
+                            if (method.getName().equals("toString")) {
+                                return "test-s3-async-client";
+                            }
+                            throw new 
UnsupportedOperationException(method.toString());
+                        });
+    }
+
+    private static final class NonCancellingCompletableFuture<T> extends 
CompletableFuture<T> {
+        @Override
+        public boolean cancel(boolean mayInterruptIfRunning) {
+            return false;
+        }
+    }
+
+    private static final class BlockingInputStream extends InputStream {
+        private boolean closed;
+        private boolean readStarted;
+
+        @Override
+        public synchronized int read() throws IOException {
+            byte[] buffer = new byte[1];
+            return read(buffer, 0, 1);
+        }
+
+        @Override
+        public synchronized int read(byte[] b, int off, int len) throws 
IOException {
+            readStarted = true;
+            notifyAll();
+            while (!closed) {
+                try {
+                    wait();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new IOException("interrupted", e);
+                }
+            }
+            throw new IOException("closed");
+        }
+
+        @Override
+        public synchronized void close() {
+            closed = true;
+            notifyAll();
+        }
+
+        synchronized boolean awaitReadStarted() throws InterruptedException {
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+            while (!readStarted && System.nanoTime() < deadline) {
+                TimeUnit.MILLISECONDS.timedWait(this, 10);
+            }
+            return readStarted;
+        }
+
+        synchronized boolean awaitClosed() throws InterruptedException {
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+            while (!closed && System.nanoTime() < deadline) {
+                TimeUnit.MILLISECONDS.timedWait(this, 10);
+            }
+            return closed;
+        }
+    }
 }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileIoUtilsTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileIoUtilsTest.java
new file mode 100644
index 00000000000..24fef8d3188
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileIoUtilsTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.fs.s3native;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.ByteArrayInputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link NativeS3FileIoUtils}. */
+class NativeS3FileIoUtilsTest {
+
+    @ParameterizedTest
+    @CsvSource({"1", "7", "1024", "4096"})
+    void testCopyStreamPreservesContentAcrossBufferSizes(int bufferSize, 
@TempDir Path tempDir)
+            throws Exception {
+        byte[] data = new byte[3000];
+        for (int i = 0; i < data.length; i++) {
+            data[i] = (byte) (i * 31 + 7);
+        }
+        Path dest = tempDir.resolve("out-" + bufferSize + ".bin");
+
+        NativeS3FileIoUtils.copyStream(new ByteArrayInputStream(data), dest, 
bufferSize);
+
+        assertThat(Files.readAllBytes(dest)).isEqualTo(data);
+    }
+
+    @Test
+    void testCopyStreamOverwritesExistingFile(@TempDir Path tempDir) throws 
Exception {
+        Path dest = tempDir.resolve("out.bin");
+        Files.write(dest, new byte[] {9, 9, 9, 9, 9});
+        byte[] data = {1, 2, 3};
+
+        NativeS3FileIoUtils.copyStream(new ByteArrayInputStream(data), dest, 
256 * 1024);
+
+        assertThat(Files.readAllBytes(dest)).isEqualTo(data);
+    }
+
+    @Test
+    void testCopyStreamEmptySource(@TempDir Path tempDir) throws Exception {
+        Path dest = tempDir.resolve("empty.bin");
+
+        NativeS3FileIoUtils.copyStream(new ByteArrayInputStream(new byte[0]), 
dest, 1024);
+
+        assertThat(Files.readAllBytes(dest)).isEmpty();
+    }
+
+    @Test
+    void testCreateTemporaryDownloadFilePadsShortNames(@TempDir Path tempDir) 
throws Exception {
+        Path shortName = tempDir.resolve("x");
+        Path temp = NativeS3FileIoUtils.createTemporaryDownloadFile(tempDir, 
shortName);
+        assertThat(temp).exists();
+        assertThat(temp.getFileName().toString()).endsWith(".tmp");
+    }
+
+    @Test
+    void testMoveFileOverwritesDestination(@TempDir Path tempDir) throws 
Exception {
+        Path source = tempDir.resolve("src.bin");
+        Path dest = tempDir.resolve("dst.bin");
+        Files.write(source, new byte[] {1, 2, 3});
+        Files.write(dest, new byte[] {9});
+
+        NativeS3FileIoUtils.moveFile(source, dest);
+
+        assertThat(Files.readAllBytes(dest)).containsExactly(1, 2, 3);
+        assertThat(source).doesNotExist();
+    }
+}
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
index f256b0a2040..bc6171919ae 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
@@ -20,19 +20,39 @@ package org.apache.flink.fs.s3native;
 
 import org.apache.flink.configuration.Configuration;
 import org.apache.flink.configuration.IllegalConfigurationException;
+import org.apache.flink.configuration.MemorySize;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import software.amazon.awssdk.auth.credentials.AwsCredentials;
 
 import java.io.IOException;
 import java.net.URI;
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link NativeS3FileSystemFactory}. */
 class NativeS3FileSystemFactoryTest {
+
+    private final List<NativeS3FileSystem> createdFileSystems = new 
ArrayList<>();
+
+    @AfterEach
+    void closeCreatedFileSystems() {
+        for (NativeS3FileSystem fs : createdFileSystems) {
+            try {
+                fs.closeAsync().get(10, TimeUnit.SECONDS);
+            } catch (Exception ignored) {
+                // best-effort cleanup of test resources
+            }
+        }
+        createdFileSystems.clear();
+    }
+
     private static Configuration baseConfig() {
         Configuration config = new Configuration();
         config.setString("s3.access-key", "test-access-key");
@@ -42,16 +62,22 @@ class NativeS3FileSystemFactoryTest {
         return config;
     }
 
-    private static NativeS3FileSystem createFs(Configuration config) throws 
Exception {
+    private NativeS3FileSystem createFs(Configuration config) throws Exception 
{
         NativeS3FileSystemFactory factory = new NativeS3FileSystemFactory();
         factory.configure(config);
-        return (NativeS3FileSystem) 
factory.create(URI.create("s3://test-bucket/"));
+        NativeS3FileSystem fs =
+                (NativeS3FileSystem) 
factory.create(URI.create("s3://test-bucket/"));
+        createdFileSystems.add(fs);
+        return fs;
     }
 
-    private static NativeS3FileSystem createS3aFs(Configuration config) throws 
Exception {
+    private NativeS3FileSystem createS3aFs(Configuration config) throws 
Exception {
         NativeS3AFileSystemFactory factory = new NativeS3AFileSystemFactory();
         factory.configure(config);
-        return (NativeS3FileSystem) 
factory.create(URI.create("s3a://test-bucket/"));
+        NativeS3FileSystem fs =
+                (NativeS3FileSystem) 
factory.create(URI.create("s3a://test-bucket/"));
+        createdFileSystems.add(fs);
+        return fs;
     }
 
     @Test
@@ -140,6 +166,14 @@ class NativeS3FileSystemFactoryTest {
         
assertThat(createFs(config).getClientProvider().isChecksumValidation()).isFalse();
     }
 
+    // --- Bulk copy download buffer ---
+
+    @Test
+    void testBulkCopyDownloadBufferSizeDefaultIs256KB() {
+        
assertThat(NativeS3FileSystemFactory.BULK_COPY_DOWNLOAD_BUFFER_SIZE.defaultValue())
+                .isEqualTo(256 * 1024);
+    }
+
     // --- Max connections ---
 
     @Test
@@ -164,6 +198,102 @@ class NativeS3FileSystemFactoryTest {
                 .hasMessageContaining("must be a positive integer");
     }
 
+    @Test
+    void testNonPositiveCrtTargetThroughputThrowsException() {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.CRT_TARGET_THROUGHPUT_GBPS, 0.0);
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.crt.target-throughput-gbps")
+                .hasMessageContaining("must be positive");
+
+        config.set(NativeS3FileSystemFactory.CRT_TARGET_THROUGHPUT_GBPS, -1.0);
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.crt.target-throughput-gbps")
+                .hasMessageContaining("must be positive");
+    }
+
+    @Test
+    void testCrtReadAndNativeMemorySettingsArePropagated() throws Exception {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.CRT_READ_BUFFER_SIZE, 
MemorySize.ofMebiBytes(1));
+        config.set(
+                NativeS3FileSystemFactory.CRT_MAX_NATIVE_MEMORY_LIMIT,
+                MemorySize.ofMebiBytes(2048));
+
+        S3ClientProvider provider = createFs(config).getClientProvider();
+
+        assertThat(provider.getCrtReadBufferSizeInBytes())
+                .isEqualTo(MemorySize.ofMebiBytes(1).getBytes());
+        assertThat(provider.getCrtMaxNativeMemoryLimitInBytes())
+                .isEqualTo(MemorySize.ofMebiBytes(2048).getBytes());
+    }
+
+    @Test
+    void testCrtReadBufferSizeDefaultsToNullWhenUnset() throws Exception {
+        S3ClientProvider provider = createFs(baseConfig()).getClientProvider();
+        assertThat(provider.getCrtReadBufferSizeInBytes()).isNull();
+    }
+
+    @Test
+    void testCrtMaxConcurrencyDefaultIs256() throws Exception {
+        
assertThat(NativeS3FileSystemFactory.CRT_MAX_CONCURRENCY.defaultValue()).isEqualTo(256);
+        S3ClientProvider provider = createFs(baseConfig()).getClientProvider();
+        assertThat(provider.getCrtMaxConcurrency()).isEqualTo(256);
+    }
+
+    @Test
+    void testCrtMaxConcurrencyIsIndependentOfConnectionMax() throws Exception {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.MAX_CONNECTIONS, 50);
+        config.set(NativeS3FileSystemFactory.CRT_MAX_CONCURRENCY, 512);
+
+        S3ClientProvider provider = createFs(config).getClientProvider();
+
+        assertThat(provider.getMaxConnections()).isEqualTo(50);
+        assertThat(provider.getCrtMaxConcurrency()).isEqualTo(512);
+    }
+
+    @Test
+    void testNonPositiveCrtMaxConcurrencyThrowsException() {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.CRT_MAX_CONCURRENCY, 0);
+
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.crt.max-concurrency")
+                .hasMessageContaining("must be a positive integer");
+    }
+
+    @Test
+    void testNonPositiveCrtReadBufferSizeThrowsException() {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.CRT_READ_BUFFER_SIZE, 
MemorySize.ZERO);
+
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.crt.read-buffer-size")
+                .hasMessageContaining("must be positive");
+    }
+
+    @Test
+    void testNonPositiveCrtMaxNativeMemoryLimitThrowsException() {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.CRT_ENABLED, true);
+        config.set(NativeS3FileSystemFactory.CRT_MAX_NATIVE_MEMORY_LIMIT, 
MemorySize.ZERO);
+
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.crt.max-native-memory-limit")
+                .hasMessageContaining("must be positive");
+    }
+
     // --- Max retries ---
 
     @Test
@@ -360,6 +490,27 @@ class NativeS3FileSystemFactoryTest {
         
assertThat(fs.getBulkCopyHelper().getMaxConcurrentCopies()).isEqualTo(10);
     }
 
+    @Test
+    void testBulkCopyAdvertisesOnlyS3ToLocalCopies() throws Exception {
+        NativeS3FileSystem fs = createFs(baseConfig());
+
+        assertThat(
+                        fs.canCopyPaths(
+                                new 
org.apache.flink.core.fs.Path("s3://test-bucket/state"),
+                                new 
org.apache.flink.core.fs.Path("file:///tmp/state")))
+                .isTrue();
+        assertThat(
+                        fs.canCopyPaths(
+                                new 
org.apache.flink.core.fs.Path("s3://test-bucket/source"),
+                                new 
org.apache.flink.core.fs.Path("s3://test-bucket/destination")))
+                .isFalse();
+        assertThat(
+                        fs.canCopyPaths(
+                                new 
org.apache.flink.core.fs.Path("file:///tmp/source"),
+                                new 
org.apache.flink.core.fs.Path("s3://test-bucket/destination")))
+                .isFalse();
+    }
+
     @Test
     void testInvalidBulkCopyMaxConcurrentThrowsException() {
         Configuration config = baseConfig();
@@ -370,6 +521,16 @@ class NativeS3FileSystemFactoryTest {
                 .hasMessageContaining("must be a positive integer");
     }
 
+    @Test
+    void testInvalidBulkCopyDownloadBufferSizeThrowsException() {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.BULK_COPY_DOWNLOAD_BUFFER_SIZE, 
0);
+        assertThatThrownBy(() -> createFs(config))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("s3.bulk-copy.download-buffer-size")
+                .hasMessageContaining("must be a positive integer");
+    }
+
     // --- Region ---
 
     @Test
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/S3ClientProviderTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/S3ClientProviderTest.java
index 51a457cc537..26985647165 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/S3ClientProviderTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/S3ClientProviderTest.java
@@ -20,6 +20,7 @@ package org.apache.flink.fs.s3native;
 
 import 
org.apache.flink.fs.s3native.token.DynamicTemporaryAWSCredentialsProvider;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
@@ -31,7 +32,9 @@ import 
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider
 
 import java.lang.reflect.Field;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -42,6 +45,25 @@ class S3ClientProviderTest {
     private static final String DUMMY_ENDPOINT = "http://localhost:9000";;
     private static final String DUMMY_REGION = "us-east-1";
 
+    private final List<S3ClientProvider> providers = new ArrayList<>();
+
+    /** Tracks a provider so it is closed after the test, releasing its 
SDK/CRT resources. */
+    private S3ClientProvider track(S3ClientProvider provider) {
+        providers.add(provider);
+        return provider;
+    }
+
+    @AfterEach
+    void closeProviders() {
+        for (S3ClientProvider provider : providers) {
+            try {
+                provider.closeAsync().get(10, TimeUnit.SECONDS);
+            } catch (Exception ignored) {
+            }
+        }
+        providers.clear();
+    }
+
     @Test
     void testMinimalChainWithoutStaticOrCustom() throws Exception {
         S3ClientProvider provider =
@@ -271,6 +293,73 @@ class S3ClientProviderTest {
                 .hasMessageContaining("retryThrottleBaseDelay");
     }
 
+    @Test
+    void testCrtDisabledByDefault() {
+        S3ClientProvider provider =
+                track(
+                        S3ClientProvider.builder()
+                                .endpoint(DUMMY_ENDPOINT)
+                                .region(DUMMY_REGION)
+                                .build());
+        assertThat(provider.isUseCrt()).isFalse();
+        // When CRT is disabled the async client must NOT be a CRT-backed 
implementation.
+        
assertThat(provider.getAsyncClient().getClass().getName()).doesNotContain("Crt");
+        // No Flink-level default applied; getter returns null when user did 
not set the value.
+        assertThat(provider.getCrtTargetThroughputGbps()).isNull();
+        assertThat(provider.getCrtReadBufferSizeInBytes()).isNull();
+        assertThat(provider.getCrtMaxNativeMemoryLimitInBytes()).isNull();
+        // CRT max concurrency keeps its builder default (independent of 
s3.connection.max).
+        assertThat(provider.getCrtMaxConcurrency())
+                
.isEqualTo(NativeS3FileSystemFactory.CRT_MAX_CONCURRENCY.defaultValue());
+    }
+
+    @Test
+    void testCrtFlagIsRecordedAndCrtBranchIsTaken() {
+        S3ClientProvider provider =
+                track(
+                        S3ClientProvider.builder()
+                                .endpoint(DUMMY_ENDPOINT)
+                                .region(DUMMY_REGION)
+                                .useCrt(true)
+                                .crtTargetThroughputGbps(20.0)
+                                .build());
+
+        assertThat(provider.isUseCrt()).isTrue();
+        assertThat(provider.getCrtTargetThroughputGbps()).isEqualTo(20.0);
+        
assertThat(provider.getAsyncClient().getClass().getName()).contains("Crt");
+    }
+
+    @Test
+    void testCrtEnabledWithoutThroughputOverrideStillBuildsCrtClient() {
+        S3ClientProvider provider =
+                track(
+                        S3ClientProvider.builder()
+                                .endpoint(DUMMY_ENDPOINT)
+                                .region(DUMMY_REGION)
+                                .useCrt(true)
+                                .build());
+
+        assertThat(provider.isUseCrt()).isTrue();
+        assertThat(provider.getCrtTargetThroughputGbps()).isNull();
+        
assertThat(provider.getAsyncClient().getClass().getName()).contains("Crt");
+    }
+
+    @Test
+    void testCrtMissingJarsMessageIsActionable() {
+        // Contract test: if CRT classes are missing at runtime the user must 
get a message
+        // that names the responsible config key, the missing JAR coordinates, 
and a setup
+        // pointer. A full classloader-isolation test would require 
multi-classloader infra
+        // disproportionate to the value; assert the message contract instead.
+        String msg = S3ClientProvider.Builder.crtMissingJarsMessage();
+        assertThat(msg).contains("s3.crt.enabled=true");
+        // aws-crt-client is now bundled in the fat JAR; only aws-crt (JNI) is 
external
+        assertThat(msg).doesNotContain("aws-crt-client");
+        assertThat(msg).contains("aws-crt");
+        assertThat(msg).contains("tools/download-crt-jars.sh");
+        assertThat(msg).contains("plugin");
+        assertThat(msg).contains("README");
+    }
+
     @SuppressWarnings("unchecked")
     private static List<AwsCredentialsProvider> 
extractChain(AwsCredentialsProvider provider)
             throws Exception {
diff --git a/flink-filesystems/flink-s3-fs-native/tools/download-crt-jars.sh 
b/flink-filesystems/flink-s3-fs-native/tools/download-crt-jars.sh
new file mode 100755
index 00000000000..027816717ef
--- /dev/null
+++ b/flink-filesystems/flink-s3-fs-native/tools/download-crt-jars.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# Downloads the aws-crt JAR that flink-s3-fs-native needs when
+# s3.crt.enabled=true.
+#
+# Why only aws-crt and not aws-crt-client:
+#   aws-crt-client is pure Java and is bundled (shaded) directly into the
+#   flink-s3-fs-native fat JAR at build time — no manual placement needed.
+#   aws-crt contains JNI-linked native libraries whose C-side FindClass paths
+#   are hardcoded, making Maven shade relocation incompatible. It must
+#   therefore be placed in the plugin directory with its original class names.
+#
+# Usage:
+#   ./download-crt-jars.sh [OUTPUT_DIR]
+#
+#   OUTPUT_DIR  Directory to place the JAR in. Defaults to ./crt-jars at the
+#               module root. Copy the resulting file to
+#               $FLINK_HOME/plugins/s3-fs-native/ alongside
+#               flink-s3-fs-native.jar.
+#
+# Environment:
+#   AWS_CRT_VERSION  Override the auto-resolved aws-crt version (rarely
+#                    needed; use only when the auto-resolver fails).
+#
+# Requirements: mvn (Apache Maven) on PATH.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+POM_FILE="${MODULE_DIR}/pom.xml"
+OUTPUT_DIR="${1:-${MODULE_DIR}/crt-jars}"
+
+if ! command -v mvn >/dev/null 2>&1; then
+    echo "ERROR: mvn (Apache Maven) is required but not on PATH." >&2
+    exit 1
+fi
+
+if [[ ! -f "${POM_FILE}" ]]; then
+    echo "ERROR: pom.xml not found at ${POM_FILE}." >&2
+    exit 1
+fi
+
+# Read the AWS SDK version that the module compiles against.
+SDK_VERSION="$(sed -n 
's|.*<fs.s3.aws.sdk.version>\([^<]*\)</fs.s3.aws.sdk.version>.*|\1|p' \
+    "${POM_FILE}" | head -n1)"
+if [[ -z "${SDK_VERSION}" ]]; then
+    echo "ERROR: could not read <fs.s3.aws.sdk.version> from ${POM_FILE}." >&2
+    exit 1
+fi
+
+# Resolve the aws-crt version. aws-crt uses an independent versioning scheme
+# from the AWS SDK; reading it from the aws-crt-client dependency tree is the
+# authoritative way to stay in sync when the SDK version is bumped.
+resolve_crt_version() {
+    if [[ -n "${AWS_CRT_VERSION:-}" ]]; then
+        echo "${AWS_CRT_VERSION}"
+        return
+    fi
+    local tmp_dir
+    tmp_dir="$(mktemp -d)"
+    # Clean up the probe directory on any return path from this function.
+    trap 'rm -rf "${tmp_dir}"' RETURN
+    local tmp_pom="${tmp_dir}/pom.xml"
+    cat >"${tmp_pom}" <<EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0";>
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.flink.s3native.tools</groupId>
+  <artifactId>crt-version-probe</artifactId>
+  <version>1.0.0</version>
+  <packaging>pom</packaging>
+  <dependencies>
+    <dependency>
+      <groupId>software.amazon.awssdk</groupId>
+      <artifactId>aws-crt-client</artifactId>
+      <version>${SDK_VERSION}</version>
+    </dependency>
+  </dependencies>
+</project>
+EOF
+    mvn -q -f "${tmp_pom}" dependency:list \
+        -DincludeGroupIds=software.amazon.awssdk.crt \
+        -DincludeArtifactIds=aws-crt \
+        -DexcludeTransitive=false \
+        -DoutputFile=/dev/stdout 2>/dev/null \
+        | awk -F: '/aws-crt/ && $1 ~ /software\.amazon\.awssdk\.crt/ 
{gsub(/[[:space:]]/, "", $0); print $4; exit}'
+}
+
+CRT_VERSION="$(resolve_crt_version || true)"
+
+if [[ -z "${CRT_VERSION}" ]]; then
+    echo "ERROR: aws-crt version could not be resolved for 
aws-crt-client:${SDK_VERSION}." >&2
+    echo "       Set AWS_CRT_VERSION explicitly and re-run, e.g." >&2
+    echo "       AWS_CRT_VERSION=0.33.6 $0 ${OUTPUT_DIR}" >&2
+    exit 1
+fi
+
+mkdir -p "${OUTPUT_DIR}"
+
+echo "Downloading aws-crt:${CRT_VERSION} into ${OUTPUT_DIR}"
+
+mvn -q dependency:copy \
+    -Dartifact="software.amazon.awssdk.crt:aws-crt:${CRT_VERSION}:jar" \
+    -DoutputDirectory="${OUTPUT_DIR}" \
+    -Dmdep.stripVersion=false
+
+cat <<EOF
+
+Done. Copy the JAR into your Flink plugin directory:
+  cp ${OUTPUT_DIR}/aws-crt-${CRT_VERSION}.jar 
\$FLINK_HOME/plugins/s3-fs-native/
+
+Then enable CRT in conf/config.yaml:
+  s3.crt.enabled: true
+
+Note: aws-crt-client is bundled inside flink-s3-fs-native.jar — no separate
+download needed for it.
+EOF

Reply via email to