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

github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 3339132550 [Feature][Connector-V2] Support S3 source connectivity 
dry-run (#12242)
3339132550 is described below

commit 3339132550594fe3128534cf37c86f0cbbe435cc
Author: Goutam Adwant <[email protected]>
AuthorDate: Sun Sep 13 10:31:49 2026 +0000

    [Feature][Connector-V2] Support S3 source connectivity dry-run (#12242)
    
    Signed-off-by: Goutam Adwant <[email protected]>
---
 docs/en/connectors/source/S3File.md                |  40 ++
 docs/en/engines/zeta/user-command.md               |   1 +
 docs/zh/connectors/source/S3File.md                |  32 ++
 docs/zh/engines/zeta/user-command.md               |   1 +
 .../file/s3/source/S3FileSourceFactory.java        |  19 +-
 .../file/s3/source/S3SourceDryRunValidator.java    | 284 +++++++++++++
 .../seatunnel/file/s3/S3FileSourceFactoryTest.java |   8 +
 .../s3/source/S3SourceDryRunValidatorTest.java     | 463 +++++++++++++++++++++
 .../connector/file/s3/S3FileConnectDryRunIT.java   | 190 +++++++++
 .../file/s3/S3FileConnectDryRunWireIT.java         | 249 +++++++++++
 10 files changed, 1286 insertions(+), 1 deletion(-)

diff --git a/docs/en/connectors/source/S3File.md 
b/docs/en/connectors/source/S3File.md
index 37ec943dd6..5672df90fb 100644
--- a/docs/en/connectors/source/S3File.md
+++ b/docs/en/connectors/source/S3File.md
@@ -41,6 +41,46 @@ import ChangeLog from '../changelog/connector-file-s3.md';
 
 Read data from aws s3 file system.
 
+### Connectivity dry-run
+
+`--dry-run connect` can check a single S3A source before submitting a job. The
+source must use `bucket = "s3a://your-bucket"`, an absolute `path`, an explicit
+inline `schema.fields` or `schema.columns`, and `file_format_type` of `text`,
+`csv`, `json`, or `xml`. Set `parse_partition_from_path = false` and omit
+`read_columns`; file-derived schemas, projection, and partition inference are
+not validated by this metadata-only check. Unsupported configurations fail the
+connect dry-run with an explanation; normal job execution is unchanged.
+
+The check reuses Hadoop S3A endpoint, credential-chain, proxy and path-style
+configuration. It checks object metadata with HEAD, or makes one prefix listing
+with `maxKeys=1` and delimiter `/`. It does not open file contents, recursively
+list files, create readers, upload, delete, or initialize a shared filesystem.
+An exact object does not require listing permission. A prefix requires listing
+permission; a successful empty listing is accepted for `discovery_mode =
+"continuous"`, since files may arrive later. For a batch source, an empty
+virtual prefix without a directory marker fails; an accessible empty bucket
+root is accepted. Missing buckets and denied requests fail in both modes.
+
+Validation-only connection establishment and socket timeouts are capped at
+5 seconds, preserving smaller positive values. SDK request retries are disabled
+for validation, including bucket-specific overrides. These are network timeout
+settings, not a total deadline for DNS, credential-provider initialization, or
+SDK setup. Runtime timeouts and retries are unchanged.
+
+This initial check does not support `tables_configs`, legacy `s3n` buckets,
+SSE-C customer-provided encryption keys, 
`fs.s3a.security.credential.provider.path`,
+S3Guard, multipart purge, or custom S3 client factories. It does not prove 
object
+content readability, file-format correctness, schema compatibility with the
+stored data, worker-side credentials, or target/update/post-sync permissions.
+
+Save your job configuration meeting the requirements above as
+`config/s3-to-console.conf` (this is a user-created file, not a bundled 
template),
+then run from the SeaTunnel installation directory:
+
+```bash
+bin/seatunnel.sh --config config/s3-to-console.conf --dry-run connect -e local
+```
+
 ## Supported DataSource Info
 
 | Datasource | Supported versions |
diff --git a/docs/en/engines/zeta/user-command.md 
b/docs/en/engines/zeta/user-command.md
index 433f5517af..cbe38044e6 100644
--- a/docs/en/engines/zeta/user-command.md
+++ b/docs/en/engines/zeta/user-command.md
@@ -83,6 +83,7 @@ The `--dry-run connect` option runs the static checks first, 
then uses connector
 | Jdbc      | Yes (connectivity + schema inference) | Yes (connectivity + 
table existence + field compatibility) |
 | Kafka     | Yes ([topic metadata + runtime output 
schema](../../connectors/source/Kafka.md#connectivity-dry-run), not 
consumer/group permissions) | No |
 | FakeSource | Yes (schema inference only, no external system) | - |
+| S3File | Yes (metadata connectivity + inline schema, single-table 
text/csv/json/xml; see [supported 
scope](../../connectors/source/S3File.md#connectivity-dry-run)) | - |
 
 Every plugin in the job is reported in a validation summary with one of two 
statuses:
 
diff --git a/docs/zh/connectors/source/S3File.md 
b/docs/zh/connectors/source/S3File.md
index 479ffe6418..4e9e0a4011 100644
--- a/docs/zh/connectors/source/S3File.md
+++ b/docs/zh/connectors/source/S3File.md
@@ -41,6 +41,38 @@ import ChangeLog from '../changelog/connector-file-s3.md';
 
 从aws s3文件系统读取数据。
 
+### 连接预检查
+
+`--dry-run connect` 可以在提交作业前检查单表 S3A 数据源。需要配置
+`bucket = "s3a://your-bucket"`、绝对 `path`、显式内联 `schema.fields` 或
+`schema.columns`,并将 `file_format_type` 设置为 `text`、`csv`、`json` 或
+`xml`。同时设置 `parse_partition_from_path = false`,且不配置 `read_columns`。
+此元数据检查不推断文件结构、投影或分区字段。不支持的配置会使连接预检查失败并
+返回说明,不改变正常作业的行为。
+
+检查复用 Hadoop S3A 的端点、凭证链、代理和路径访问配置,使用 HEAD 获取对象元数据,
+或执行一次 `maxKeys=1`、分隔符为 `/` 的前缀列表请求。它不会打开文件内容、递归列举
+文件、创建读取器、上传或删除对象,也不会初始化共享文件系统。检查具体对象不需要
+列举权限,检查前缀需要列举权限。对于 `discovery_mode = "continuous"`,成功的空列表
+可以通过检查,因为文件可能稍后到达。批处理模式下,没有目录标记的空虚拟前缀会失败,
+但可访问的空桶根路径可以通过。桶不存在或请求被拒绝时,两种模式均失败。
+
+预检查将建立连接和套接字超时限制为最多 5 秒,并保留更小的正值,禁用 SDK 请求重试,
+这些限制也适用于桶级覆盖配置。它们不是 DNS、凭证提供器初始化或 SDK 初始化的总耗时
+限制,不改变正常作业的超时和重试设置。
+
+首个版本不支持 `tables_configs`、旧 `s3n` 桶、SSE-C 客户提供的加密密钥、
+`fs.s3a.security.credential.provider.path`、S3Guard、分段上传清理以及自定义 S3 客户端工厂。
+通过检查不代表文件内容可读、格式或数据结构正确,也不验证工作节点凭证、目标端、更新
+或同步后操作的权限。
+
+将满足上述条件的作业配置保存为 `config/s3-to-console.conf`(这是用户自行创建的文件,
+并非内置模板),然后在 SeaTunnel 安装目录中执行:
+
+```bash
+bin/seatunnel.sh --config config/s3-to-console.conf --dry-run connect -e local
+```
+
 ## 支持的数据源信息
 
 | 数据源 | 支持的版本 |
diff --git a/docs/zh/engines/zeta/user-command.md 
b/docs/zh/engines/zeta/user-command.md
index cded907c75..d708124799 100644
--- a/docs/zh/engines/zeta/user-command.md
+++ b/docs/zh/engines/zeta/user-command.md
@@ -99,6 +99,7 @@ bin/seatunnel.sh --config 
$SEATUNNEL_HOME/config/v2.batch.config.template --dry-
 | Jdbc   | 支持(连通性 + schema 推断) | 支持(连通性 + 表存在性 + 字段兼容性) |
 | Kafka  | 支持([主题元数据 + 运行时输出 
schema](../../connectors/source/Kafka.md#连通性-dry-run),不含消费或消费组权限) | 不支持 |
 | FakeSource | 支持(仅 schema 推断,无外部系统) | - |
+| S3File | 支持(元数据连通性 + 内联 schema,仅单表 
text/csv/json/xml;参见[支持范围](../../connectors/source/S3File.md#连接预检查)) | - |
 
 作业中的每个插件都会在校验汇总中报告以下两种状态之一:
 
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3FileSourceFactory.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3FileSourceFactory.java
index 083d6ee211..02a85e7cd4 100644
--- 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3FileSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3FileSourceFactory.java
@@ -21,8 +21,10 @@ import 
org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.options.ConnectorCommonOptions;
 import org.apache.seatunnel.api.source.SeaTunnelSource;
 import org.apache.seatunnel.api.source.SourceSplit;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.connector.TableSource;
 import org.apache.seatunnel.api.table.factory.Factory;
+import org.apache.seatunnel.api.table.factory.SupportSourceDryRunValidation;
 import org.apache.seatunnel.api.table.factory.TableSourceFactory;
 import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
 import 
org.apache.seatunnel.connectors.seatunnel.file.config.FileBaseSourceOptions;
@@ -36,9 +38,24 @@ import com.google.auto.service.AutoService;
 
 import java.io.Serializable;
 import java.util.Arrays;
+import java.util.List;
 
 @AutoService(Factory.class)
-public class S3FileSourceFactory implements TableSourceFactory {
+public class S3FileSourceFactory implements TableSourceFactory, 
SupportSourceDryRunValidation {
+    /** Returns the configured schema without discovering or opening source 
files. */
+    @Override
+    public List<CatalogTable> inferSchemaForDryRun(TableSourceFactoryContext 
context) {
+        S3SourceDryRunValidator.validateSchemaOptions(context.getOptions());
+        return discoverTableSchemas(context);
+    }
+
+    /** Checks S3A path metadata using an independently owned client. */
+    @Override
+    public void validateConnectionForDryRun(
+            TableSourceFactoryContext context, List<CatalogTable> 
catalogTables) throws Exception {
+        S3SourceDryRunValidator.validate(context.getOptions());
+    }
+
     @Override
     public String factoryIdentifier() {
         return FileSystemType.S3.getFileSystemPluginName();
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidator.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidator.java
new file mode 100644
index 0000000000..ec1da6dd5b
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidator.java
@@ -0,0 +1,284 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.file.s3.source;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.options.ConnectorCommonOptions;
+import org.apache.seatunnel.api.options.table.ColumnOptions;
+import org.apache.seatunnel.api.options.table.FieldOptions;
+import 
org.apache.seatunnel.connectors.seatunnel.file.config.FileBaseSourceOptions;
+import org.apache.seatunnel.connectors.seatunnel.file.config.FileDiscoveryMode;
+import org.apache.seatunnel.connectors.seatunnel.file.config.FileFormat;
+import org.apache.seatunnel.connectors.seatunnel.file.config.HadoopConf;
+import 
org.apache.seatunnel.connectors.seatunnel.file.s3.config.S3FileSourceOptions;
+import org.apache.seatunnel.connectors.seatunnel.file.s3.config.S3HadoopConf;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.s3a.AWSCredentialProviderList;
+import org.apache.hadoop.fs.s3a.Constants;
+import org.apache.hadoop.fs.s3a.DefaultS3ClientFactory;
+import org.apache.hadoop.fs.s3a.S3AUtils;
+
+import com.amazonaws.ClientConfiguration;
+import com.amazonaws.auth.AWSCredentialsProvider;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.amazonaws.services.s3.model.ListObjectsRequest;
+import com.amazonaws.services.s3.model.ListObjectsV2Request;
+import com.amazonaws.services.s3.model.ListObjectsV2Result;
+import com.amazonaws.services.s3.model.ObjectListing;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Map;
+
+final class S3SourceDryRunValidator {
+    private static final int NETWORK_TIMEOUT_MILLIS = 5000;
+
+    private S3SourceDryRunValidator() {}
+
+    /** Rejects schemas that require opening files or altering the configured 
field layout. */
+    static void validateSchemaOptions(ReadonlyConfig options) {
+        if 
(options.getOptional(ConnectorCommonOptions.TABLE_CONFIGS).isPresent()
+                || 
!options.getOptional(ConnectorCommonOptions.SCHEMA).isPresent()) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires a single table with an 
explicit inline schema.");
+        }
+        Map<String, Object> schema = 
options.get(ConnectorCommonOptions.SCHEMA);
+        if (schema.containsKey(ColumnOptions.METADATA_TABLE_ID.key())
+                || (!options.getOptional(FieldOptions.FIELDS).isPresent()
+                        && !schema.containsKey(ColumnOptions.COLUMNS.key()))) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires schema.fields or 
schema.columns, not metadata schema lookup.");
+        }
+        if (!Arrays.asList(FileFormat.TEXT, FileFormat.CSV, FileFormat.JSON, 
FileFormat.XML)
+                
.contains(options.get(FileBaseSourceOptions.FILE_FORMAT_TYPE))) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run supports text, csv, json and xml 
without reading file contents.");
+        }
+        if (options.get(FileBaseSourceOptions.PARSE_PARTITION_FROM_PATH)
+                || 
options.getOptional(FileBaseSourceOptions.READ_COLUMNS).isPresent()) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires 
parse_partition_from_path=false and no read_columns to preserve the runtime 
schema.");
+        }
+    }
+
+    /** Checks metadata with a private client, closing credentials even when 
client setup fails. */
+    static void validate(ReadonlyConfig options) throws IOException {
+        // The connection hook can be called directly, without the preceding 
schema hook.
+        validateSchemaOptions(options);
+        URI bucket = bucketUri(options);
+        Path path = sourcePath(options, bucket);
+        Configuration configuration = validationConfiguration(options, bucket);
+        // Validate pure SDK settings before Hadoop creates any credential 
providers.
+        DefaultS3ClientFactory.createAwsConf(configuration);
+        try (DryRunClientFactory factory = new DryRunClientFactory()) {
+            factory.setConf(configuration);
+            AmazonS3 client = factory.createS3Client(bucket);
+            validatePath(
+                    client,
+                    bucket.getHost(),
+                    path,
+                    options.get(FileBaseSourceOptions.DISCOVERY_MODE),
+                    configuration.getInt(Constants.LIST_VERSION, 
Constants.DEFAULT_LIST_VERSION));
+        }
+    }
+
+    /** Accepts only a bare S3A bucket URI, without echoing potentially 
sensitive input. */
+    static URI bucketUri(ReadonlyConfig options) {
+        URI bucket;
+        try {
+            bucket = URI.create(options.get(S3FileSourceOptions.S3_BUCKET));
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires an s3a://bucket URI.");
+        }
+        if (!"s3a".equals(bucket.getScheme())
+                || bucket.getHost() == null
+                || bucket.getUserInfo() != null
+                || bucket.getPort() != -1
+                || bucket.getQuery() != null
+                || bucket.getFragment() != null
+                || (!bucket.getPath().isEmpty() && 
!"/".equals(bucket.getPath()))) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires an s3a://bucket URI.");
+        }
+        return bucket;
+    }
+
+    /** Keeps metadata requests inside the configured bucket, using the 
source's absolute path. */
+    static Path sourcePath(ReadonlyConfig options, URI bucket) {
+        Path path;
+        try {
+            path = new Path(options.get(FileBaseSourceOptions.FILE_PATH));
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires an absolute path in the 
configured bucket.");
+        }
+        URI uri = path.toUri();
+        if (!path.isAbsolute()
+                || (uri.getScheme() != null && !"s3a".equals(uri.getScheme()))
+                || (uri.getAuthority() != null
+                        && !bucket.getAuthority().equals(uri.getAuthority()))) 
{
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires an absolute path in the 
configured bucket.");
+        }
+        return path;
+    }
+
+    /**
+     * Preserves S3A connection settings but rejects filesystem side effects, 
custom client
+     * lifecycle behavior, and encryption/credential-store paths not supported 
by this metadata-only
+     * client. Disables retries so a preflight check does not inherit the 
production retry budget.
+     */
+    static Configuration validationConfiguration(ReadonlyConfig options, URI 
bucket) {
+        HadoopConf hadoopConf = S3HadoopConf.buildWithReadOnlyConfig(options);
+        Configuration configuration = hadoopConf.toConfiguration();
+        hadoopConf.setExtraOptionsForConfiguration(configuration);
+        configuration = S3AUtils.propagateBucketOptions(configuration, 
bucket.getHost());
+        if (configuration.getBoolean(Constants.PURGE_EXISTING_MULTIPART, false)
+                || !Constants.S3GUARD_METASTORE_NULL.equals(
+                        configuration.get(
+                                Constants.S3_METADATA_STORE_IMPL, 
Constants.S3GUARD_METASTORE_NULL))
+                || !Constants.DEFAULT_S3_CLIENT_FACTORY_IMPL
+                        .getName()
+                        .equals(
+                                configuration.get(
+                                        Constants.S3_CLIENT_FACTORY_IMPL,
+                                        
Constants.DEFAULT_S3_CLIENT_FACTORY_IMPL.getName()))) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run does not support multipart purge, 
S3Guard or a custom S3 client factory.");
+        }
+        if 
(!configuration.getTrimmed(Constants.S3A_SECURITY_CREDENTIAL_PROVIDER_PATH, 
"").isEmpty()
+                || "SSE-C"
+                        .equalsIgnoreCase(
+                                configuration.getTrimmed(
+                                        
Constants.SERVER_SIDE_ENCRYPTION_ALGORITHM, ""))) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run does not support SSE-C or 
fs.s3a.security.credential.provider.path.");
+        }
+        clampTimeout(configuration, bucket, Constants.ESTABLISH_TIMEOUT);
+        clampTimeout(configuration, bucket, Constants.SOCKET_TIMEOUT);
+        for (String key :
+                Arrays.asList(
+                        Constants.MAX_ERROR_RETRIES,
+                        Constants.RETRY_LIMIT,
+                        Constants.RETRY_THROTTLE_LIMIT)) {
+            S3AUtils.clearBucketOption(configuration, bucket.getHost(), key);
+            configuration.setInt(key, 0);
+        }
+        return configuration;
+    }
+
+    /** Caps each network wait at five seconds for preflight, retaining 
stricter user limits. */
+    private static void clampTimeout(Configuration configuration, URI bucket, 
String key) {
+        int configured = configuration.getInt(key, NETWORK_TIMEOUT_MILLIS);
+        if (configured < 0) {
+            throw new IllegalArgumentException(
+                    "S3File connect dry-run requires non-negative " + key);
+        }
+        S3AUtils.clearBucketOption(configuration, bucket.getHost(), key);
+        configuration.setInt(
+                key,
+                configured == 0
+                        ? NETWORK_TIMEOUT_MILLIS
+                        : Math.min(configured, NETWORK_TIMEOUT_MILLIS));
+    }
+
+    /**
+     * Tries HEAD first so exact objects need no listing permission. Only a 
missing object falls
+     * back to one bounded prefix listing; permission and other failures must 
not be treated as
+     * absence.
+     */
+    static void validatePath(
+            AmazonS3 client,
+            String bucket,
+            Path path,
+            FileDiscoveryMode discoveryMode,
+            int listVersion)
+            throws IOException {
+        String key = path.toUri().getPath().substring(1);
+        if (!key.isEmpty()) {
+            try {
+                client.getObjectMetadata(bucket, key);
+                return;
+            } catch (AmazonS3Exception e) {
+                if (e.getStatusCode() != 404) {
+                    throw e;
+                }
+            }
+        }
+        String prefix = key.isEmpty() || key.endsWith("/") ? key : key + "/";
+        boolean empty;
+        if (listVersion == 1) {
+            ObjectListing result =
+                    client.listObjects(
+                            new ListObjectsRequest()
+                                    .withBucketName(bucket)
+                                    .withPrefix(prefix)
+                                    .withDelimiter("/")
+                                    .withMaxKeys(1));
+            empty = result.getObjectSummaries().isEmpty() && 
result.getCommonPrefixes().isEmpty();
+        } else {
+            ListObjectsV2Result result =
+                    client.listObjectsV2(
+                            new ListObjectsV2Request()
+                                    .withBucketName(bucket)
+                                    .withPrefix(prefix)
+                                    .withDelimiter("/")
+                                    .withMaxKeys(1));
+            empty = result.getObjectSummaries().isEmpty() && 
result.getCommonPrefixes().isEmpty();
+        }
+        // Only a successful empty listing proves an accessible prefix 
awaiting its first file.
+        if (empty && !key.isEmpty() && discoveryMode != 
FileDiscoveryMode.CONTINUOUS) {
+            throw new FileNotFoundException(
+                    "S3File connect dry-run could not find the configured 
batch source path.");
+        }
+    }
+
+    /** Owns the client and credential chain without creating a shared S3A 
filesystem. */
+    private static final class DryRunClientFactory extends 
DefaultS3ClientFactory
+            implements AutoCloseable {
+        private AmazonS3 client;
+        private AWSCredentialProviderList credentials;
+
+        @Override
+        protected AmazonS3 newAmazonS3Client(
+                AWSCredentialsProvider credentials, ClientConfiguration 
configuration) {
+            this.credentials = (AWSCredentialProviderList) credentials;
+            this.client = super.newAmazonS3Client(credentials, configuration);
+            return client;
+        }
+
+        @Override
+        public void close() {
+            try {
+                if (client != null) {
+                    client.shutdown();
+                }
+            } finally {
+                if (credentials != null) {
+                    credentials.close();
+                }
+            }
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/S3FileSourceFactoryTest.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/S3FileSourceFactoryTest.java
index a60221fbc2..c222b7d19a 100644
--- 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/S3FileSourceFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/S3FileSourceFactoryTest.java
@@ -21,6 +21,7 @@ import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.configuration.util.ConfigValidator;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.table.factory.SupportSourceDryRunValidation;
 import 
org.apache.seatunnel.connectors.seatunnel.file.config.FileBaseSourceOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.file.s3.config.S3FileSourceOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.file.s3.source.S3FileSourceFactory;
@@ -36,6 +37,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class S3FileSourceFactoryTest {
 
+    @Test
+    void shouldSupportConnectivityDryRun() {
+        assertTrue(
+                new S3FileSourceFactory() instanceof 
SupportSourceDryRunValidation,
+                "S3File source connectivity is skipped when the factory does 
not expose the dry-run SPI");
+    }
+
     @Test
     void shouldExposeContinuousDiscoveryOptions() {
         OptionRule optionRule = new S3FileSourceFactory().optionRule();
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidatorTest.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidatorTest.java
new file mode 100644
index 0000000000..5be35b9230
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-s3/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/s3/source/S3SourceDryRunValidatorTest.java
@@ -0,0 +1,463 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.file.s3.source;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.connectors.seatunnel.file.config.FileDiscoveryMode;
+import 
org.apache.seatunnel.connectors.seatunnel.file.s3.config.S3FileSourceOptions;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.s3a.Constants;
+import org.apache.hadoop.fs.s3a.S3AUtils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.amazonaws.services.s3.model.ListObjectsRequest;
+import com.amazonaws.services.s3.model.ListObjectsV2Request;
+import com.amazonaws.services.s3.model.ListObjectsV2Result;
+import com.amazonaws.services.s3.model.ObjectListing;
+import com.amazonaws.services.s3.model.ObjectMetadata;
+import com.amazonaws.services.s3.model.S3ObjectSummary;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+class S3SourceDryRunValidatorTest {
+    private static final URI BUCKET = URI.create("s3a://warehouse");
+
+    @ParameterizedTest
+    @ValueSource(strings = {"text", "csv", "json", "xml"})
+    void shouldReturnConfiguredSchemaWithoutCreatingSource(String format) {
+        Map<String, Object> config = sourceConfig();
+        config.put("file_format_type", format);
+        TableSourceFactoryContext context = context(config);
+        S3FileSourceFactory factory = new S3FileSourceFactory();
+
+        List<CatalogTable> tables = factory.inferSchemaForDryRun(context);
+
+        assertEquals(1, tables.size());
+        assertEquals(
+                
factory.discoverTableSchemas(context).get(0).getSeaTunnelRowType(),
+                tables.get(0).getSeaTunnelRowType());
+        assertEquals("event_id", 
tables.get(0).getSeaTunnelRowType().getFieldName(0));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"binary", "markdown", "pdf", "parquet", "orc", 
"excel"})
+    void shouldRefuseFormatsRequiringDifferentSchemaHandling(String format) {
+        Map<String, Object> config = sourceConfig();
+        config.put("file_format_type", format);
+        assertSchemaFailure(config, "supports text, csv, json and xml");
+    }
+
+    @Test
+    void shouldRefuseMissingSchema() {
+        Map<String, Object> config = sourceConfig();
+        config.remove("schema");
+        assertSchemaFailure(config, "explicit inline schema");
+    }
+
+    @Test
+    void shouldRefuseMetadataSchemaLookup() {
+        Map<String, Object> config = sourceConfig();
+        config.put("schema", Collections.singletonMap("metadata_table_id", 
"warehouse.events"));
+        assertSchemaFailure(config, "not metadata schema lookup");
+    }
+
+    @Test
+    void shouldRefuseTableConfigs() {
+        Map<String, Object> config = sourceConfig();
+        config.put("tables_configs", 
Collections.singletonList(sourceConfig()));
+        assertSchemaFailure(config, "single table");
+    }
+
+    @Test
+    void shouldRefuseDefaultPartitionInference() {
+        Map<String, Object> config = sourceConfig();
+        config.remove("parse_partition_from_path");
+        assertSchemaFailure(config, "parse_partition_from_path=false");
+    }
+
+    @Test
+    void shouldRefuseProjection() {
+        Map<String, Object> config = sourceConfig();
+        config.put("read_columns", Collections.singletonList("event_id"));
+        assertSchemaFailure(config, "no read_columns");
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "s3n://warehouse",
+                "s3a://warehouse/folder",
+                "s3a://secret@warehouse",
+                "s3a://warehouse?key=secret"
+            })
+    void shouldRefuseUnsupportedBucketUriWithoutEchoingIt(String bucket) {
+        Map<String, Object> config = sourceConfig();
+        config.put("bucket", bucket);
+        IllegalArgumentException failure =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> 
S3SourceDryRunValidator.bucketUri(ReadonlyConfig.fromMap(config)));
+        assertTrue(failure.getMessage().contains("s3a://bucket URI"));
+        assertFalse(failure.getMessage().contains("secret"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "events",
+                "s3a://other/events",
+                "file:///events",
+                "s3a://secret@warehouse/events",
+                "s3a://[secret"
+            })
+    void shouldRefusePathOutsideConfiguredBucket(String path) {
+        Map<String, Object> config = sourceConfig();
+        config.put("path", path);
+        IllegalArgumentException failure =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () ->
+                                S3SourceDryRunValidator.sourcePath(
+                                        ReadonlyConfig.fromMap(config), 
BUCKET));
+        assertTrue(failure.getMessage().contains("absolute path in the 
configured bucket"));
+        assertFalse(failure.getMessage().contains("secret"));
+    }
+
+    @Test
+    void shouldRetainEndpointAndCredentialsWithoutChangingInputProperties() {
+        Map<String, Object> config = sourceConfig();
+        Map<String, String> properties = new HashMap<>();
+        properties.put(Constants.PATH_STYLE_ACCESS, "true");
+        properties.put(Constants.SOCKET_TIMEOUT, "1200");
+        config.put("hadoop_s3_properties", properties);
+
+        Configuration configuration = configuration(config);
+
+        assertEquals("http://localhost:9000";, 
configuration.get(Constants.ENDPOINT));
+        assertEquals("access-key", configuration.get(Constants.ACCESS_KEY));
+        assertEquals("secret-key", configuration.get(Constants.SECRET_KEY));
+        assertEquals(
+                S3FileSourceOptions.SIMPLE_AWS_CREDENTIALS_PROVIDER,
+                configuration.get(Constants.AWS_CREDENTIALS_PROVIDER));
+        assertEquals(1200, configuration.getInt(Constants.SOCKET_TIMEOUT, -1));
+        assertTrue(configuration.getBoolean(Constants.PATH_STYLE_ACCESS, 
false));
+        assertEquals(2, properties.size());
+        assertEquals("1200", properties.get(Constants.SOCKET_TIMEOUT));
+    }
+
+    @Test
+    void 
shouldBoundBucketSpecificTimeoutsAndRetriesAfterS3AReappliesOverrides() {
+        Map<String, Object> config = sourceConfig();
+        Map<String, String> properties = new HashMap<>();
+        properties.put("fs.s3a.bucket.warehouse.connection.timeout", "0");
+        properties.put("fs.s3a.bucket.warehouse.connection.establish.timeout", 
"2000");
+        properties.put("fs.s3a.bucket.warehouse.attempts.maximum", "99");
+        properties.put("fs.s3a.bucket.warehouse.retry.limit", "99");
+        properties.put("fs.s3a.bucket.warehouse.retry.throttle.limit", "99");
+        config.put("hadoop_s3_properties", properties);
+
+        Configuration configuration =
+                S3AUtils.propagateBucketOptions(configuration(config), 
"warehouse");
+
+        assertEquals(5000, configuration.getInt(Constants.SOCKET_TIMEOUT, -1));
+        assertEquals(2000, configuration.getInt(Constants.ESTABLISH_TIMEOUT, 
-1));
+        assertEquals(0, configuration.getInt(Constants.MAX_ERROR_RETRIES, -1));
+        assertEquals(0, configuration.getInt(Constants.RETRY_LIMIT, -1));
+        assertEquals(0, configuration.getInt(Constants.RETRY_THROTTLE_LIMIT, 
-1));
+        assertEquals("99", 
properties.get("fs.s3a.bucket.warehouse.retry.limit"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"fs.s3a.multipart.purge", 
"fs.s3a.bucket.warehouse.multipart.purge"})
+    void shouldRefuseMultipartPurgeBeforeOpeningFilesystem(String key) {
+        assertConfigurationFailure(key, "true", "multipart purge");
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {"fs.s3a.metadatastore.impl", 
"fs.s3a.bucket.warehouse.metadatastore.impl"})
+    void shouldRefuseMetadataStoreInitialization(String key) {
+        assertConfigurationFailure(key, Constants.S3GUARD_METASTORE_DYNAMO, 
"S3Guard");
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "fs.s3a.s3.client.factory.impl",
+                "fs.s3a.bucket.warehouse.s3.client.factory.impl"
+            })
+    void shouldRefuseCustomClientInitialization(String key) {
+        assertConfigurationFailure(key, "example.CustomClientFactory", "custom 
S3 client factory");
+    }
+
+    @Test
+    void shouldRefuseNegativeTimeout() {
+        assertConfigurationFailure(Constants.SOCKET_TIMEOUT, "-1", 
"non-negative");
+    }
+
+    @Test
+    void shouldCheckOnlyPathMetadata() throws IOException {
+        AmazonS3 client = mock(AmazonS3.class);
+        Path path = new Path("/events");
+        when(client.getObjectMetadata("warehouse", "events")).thenReturn(new 
ObjectMetadata());
+        S3SourceDryRunValidator.validatePath(client, "warehouse", path, 
FileDiscoveryMode.ONCE, 2);
+        verify(client).getObjectMetadata("warehouse", "events");
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    void shouldFailForMissingBatchPath() throws IOException {
+        AmazonS3 client = emptyPrefixClient();
+        Path path = new Path("/events");
+        FileNotFoundException failure =
+                assertThrows(
+                        FileNotFoundException.class,
+                        () ->
+                                S3SourceDryRunValidator.validatePath(
+                                        client, "warehouse", path, 
FileDiscoveryMode.ONCE, 2));
+        assertTrue(failure.getMessage().contains("batch source path"));
+    }
+
+    @Test
+    void shouldAllowEmptyContinuousPrefix() throws IOException {
+        AmazonS3 client = emptyPrefixClient();
+        Path path = new Path("/events");
+        assertDoesNotThrow(
+                () ->
+                        S3SourceDryRunValidator.validatePath(
+                                client, "warehouse", path, 
FileDiscoveryMode.CONTINUOUS, 2));
+    }
+
+    @Test
+    void shouldPropagateDeniedAccessForContinuousDiscovery() throws 
IOException {
+        AmazonS3 client = emptyPrefixClient();
+        Path path = new Path("/events");
+        AmazonS3Exception denied = serviceFailure(403);
+        
when(client.listObjectsV2(any(ListObjectsV2Request.class))).thenThrow(denied);
+        AmazonS3Exception failure =
+                assertThrows(
+                        AmazonS3Exception.class,
+                        () ->
+                                S3SourceDryRunValidator.validatePath(
+                                        client,
+                                        "warehouse",
+                                        path,
+                                        FileDiscoveryMode.CONTINUOUS,
+                                        2));
+        assertSame(denied, failure);
+        assertTrue(failure.getMessage().contains("metadata failure"));
+    }
+
+    @Test
+    void shouldFailForMissingBucketDuringContinuousDiscovery() {
+        AmazonS3 client = emptyPrefixClient();
+        AmazonS3Exception missing = serviceFailure(404);
+        
when(client.listObjectsV2(any(ListObjectsV2Request.class))).thenThrow(missing);
+        AmazonS3Exception failure =
+                assertThrows(
+                        AmazonS3Exception.class,
+                        () ->
+                                S3SourceDryRunValidator.validatePath(
+                                        client,
+                                        "warehouse",
+                                        new Path("/events"),
+                                        FileDiscoveryMode.CONTINUOUS,
+                                        2));
+        assertSame(missing, failure);
+        assertEquals(404, failure.getStatusCode());
+    }
+
+    @Test
+    void shouldBoundPrefixListingWithoutFollowingContinuation() throws 
IOException {
+        AmazonS3 client = emptyPrefixClient();
+        when(client.listObjectsV2(any(ListObjectsV2Request.class)))
+                .thenAnswer(
+                        invocation -> {
+                            ListObjectsV2Request request = 
invocation.getArgument(0);
+                            assertEquals("warehouse", request.getBucketName());
+                            assertEquals("events/", request.getPrefix());
+                            assertEquals("/", request.getDelimiter());
+                            assertEquals(1, request.getMaxKeys());
+                            ListObjectsV2Result result = new 
ListObjectsV2Result();
+                            result.getObjectSummaries().add(new 
S3ObjectSummary());
+                            result.setTruncated(true);
+                            result.setNextContinuationToken("more-events");
+                            return result;
+                        });
+        S3SourceDryRunValidator.validatePath(
+                client, "warehouse", new Path("/events"), 
FileDiscoveryMode.ONCE, 2);
+        verify(client).getObjectMetadata("warehouse", "events");
+        verify(client).listObjectsV2(any(ListObjectsV2Request.class));
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    void shouldRefuseCustomerProvidedEncryptionKeys() {
+        assertConfigurationFailure(Constants.SERVER_SIDE_ENCRYPTION_ALGORITHM, 
"SSE-C", "SSE-C");
+    }
+
+    @Test
+    void shouldHonorLegacyListVersion() throws IOException {
+        AmazonS3 client = mock(AmazonS3.class);
+        when(client.getObjectMetadata("warehouse", 
"events")).thenThrow(serviceFailure(404));
+        when(client.listObjects(any(ListObjectsRequest.class)))
+                .thenAnswer(
+                        invocation -> {
+                            ListObjectsRequest request = 
invocation.getArgument(0);
+                            assertEquals(Integer.valueOf(1), 
request.getMaxKeys());
+                            assertEquals("events/", request.getPrefix());
+                            assertEquals("/", request.getDelimiter());
+                            return new ObjectListing();
+                        });
+        S3SourceDryRunValidator.validatePath(
+                client, "warehouse", new Path("/events"), 
FileDiscoveryMode.CONTINUOUS, 1);
+        verify(client).getObjectMetadata("warehouse", "events");
+        verify(client).listObjects(any(ListObjectsRequest.class));
+        verifyNoMoreInteractions(client);
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {0, 3})
+    void shouldMatchS3AFallbackForOtherListVersions(int listVersion) throws 
IOException {
+        AmazonS3 client = emptyPrefixClient();
+        S3SourceDryRunValidator.validatePath(
+                client,
+                "warehouse",
+                new Path("/events"),
+                FileDiscoveryMode.CONTINUOUS,
+                listVersion);
+        verify(client).getObjectMetadata("warehouse", "events");
+        verify(client).listObjectsV2(any(ListObjectsV2Request.class));
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    void shouldRefuseS3SpecificCredentialStoreBeforeClientInitialization() {
+        assertConfigurationFailure(
+                Constants.S3A_SECURITY_CREDENTIAL_PROVIDER_PATH,
+                "jceks://file/credentials",
+                "credential.provider.path");
+    }
+
+    private static AmazonS3 emptyPrefixClient() {
+        AmazonS3 client = mock(AmazonS3.class);
+        when(client.getObjectMetadata("warehouse", 
"events")).thenThrow(serviceFailure(404));
+        when(client.listObjectsV2(any(ListObjectsV2Request.class)))
+                .thenReturn(new ListObjectsV2Result());
+        return client;
+    }
+
+    @Test
+    void shouldReturnExplicitColumnSchema() {
+        Map<String, Object> config = sourceConfig();
+        Map<String, Object> column = new HashMap<>();
+        column.put("name", "event_id");
+        column.put("type", "bigint");
+        config.put(
+                "schema", Collections.singletonMap("columns", 
Collections.singletonList(column)));
+        CatalogTable table = new 
S3FileSourceFactory().inferSchemaForDryRun(context(config)).get(0);
+        assertEquals("event_id", table.getSeaTunnelRowType().getFieldName(0));
+    }
+
+    @Test
+    void shouldRetainDefaultCredentialProvider() {
+        Map<String, Object> config = sourceConfig();
+        config.remove("fs.s3a.aws.credentials.provider");
+        Configuration configuration = configuration(config);
+        assertEquals(
+                S3FileSourceOptions.INSTANCE_PROFILE_CREDENTIALS_PROVIDER,
+                configuration.get(Constants.AWS_CREDENTIALS_PROVIDER));
+    }
+
+    private static AmazonS3Exception serviceFailure(int status) {
+        AmazonS3Exception failure = new AmazonS3Exception("metadata failure");
+        failure.setStatusCode(status);
+        return failure;
+    }
+
+    static Map<String, Object> sourceConfig() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("path", "/events");
+        config.put("file_format_type", "json");
+        config.put("bucket", BUCKET.toString());
+        config.put("fs.s3a.endpoint", "http://localhost:9000";);
+        config.put(
+                "fs.s3a.aws.credentials.provider",
+                S3FileSourceOptions.SIMPLE_AWS_CREDENTIALS_PROVIDER);
+        config.put("access_key", "access-key");
+        config.put("secret_key", "secret-key");
+        config.put("parse_partition_from_path", false);
+        config.put(
+                "schema",
+                Collections.singletonMap("fields", 
Collections.singletonMap("event_id", "bigint")));
+        return config;
+    }
+
+    private static TableSourceFactoryContext context(Map<String, Object> 
config) {
+        return new TableSourceFactoryContext(
+                ReadonlyConfig.fromMap(config), 
S3SourceDryRunValidatorTest.class.getClassLoader());
+    }
+
+    private static Configuration configuration(Map<String, Object> config) {
+        return S3SourceDryRunValidator.validationConfiguration(
+                ReadonlyConfig.fromMap(config), BUCKET);
+    }
+
+    private static void assertSchemaFailure(Map<String, Object> config, String 
message) {
+        IllegalArgumentException failure =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> new 
S3FileSourceFactory().inferSchemaForDryRun(context(config)));
+        assertTrue(failure.getMessage().contains(message), 
failure.getMessage());
+    }
+
+    private static void assertConfigurationFailure(String key, String value, 
String message) {
+        Map<String, Object> config = sourceConfig();
+        config.put("hadoop_s3_properties", Collections.singletonMap(key, 
value));
+        IllegalArgumentException failure =
+                assertThrows(IllegalArgumentException.class, () -> 
configuration(config));
+        assertTrue(failure.getMessage().contains(message), 
failure.getMessage());
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunIT.java
new file mode 100644
index 0000000000..954f64013c
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunIT.java
@@ -0,0 +1,190 @@
+/*
+ * 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.seatunnel.e2e.connector.file.s3;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.factory.FactoryUtil;
+import org.apache.seatunnel.api.table.factory.SupportSourceDryRunValidation;
+import org.apache.seatunnel.api.table.factory.TableSourceFactory;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.e2e.common.TestResource;
+import org.apache.seatunnel.e2e.common.TestSuiteBase;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.DockerLoggerFactory;
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.client.builder.AwsClientBuilder;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+
+import java.io.FileNotFoundException;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Exercises the source factory metadata contract against MinIO without 
submitting a job. */
+@Timeout(60)
+public class S3FileConnectDryRunIT extends TestSuiteBase implements 
TestResource {
+    private static final String IMAGE = 
"minio/minio:RELEASE.2024-06-13T22-53-53Z";
+    private static final String BUCKET = "dry-run-events";
+    private static final String ACCESS_KEY = "minioadmin";
+    private static final String SECRET_KEY = "minioadmin";
+    private GenericContainer<?> minio;
+    private AmazonS3 admin;
+    private String endpoint;
+
+    @BeforeAll
+    @Override
+    public void startUp() {
+        minio =
+                new GenericContainer<>(DockerImageName.parse(IMAGE))
+                        .withEnv("MINIO_ROOT_USER", ACCESS_KEY)
+                        .withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
+                        .withCommand("server", "/data")
+                        .withExposedPorts(9000)
+                        
.waitingFor(Wait.forHttp("/minio/health/ready").forPort(9000))
+                        .withStartupTimeout(Duration.ofMinutes(2))
+                        .withLogConsumer(
+                                new 
Slf4jLogConsumer(DockerLoggerFactory.getLogger(IMAGE)));
+        minio.start();
+        endpoint = "http://"; + minio.getHost() + ":" + 
minio.getMappedPort(9000);
+        admin =
+                AmazonS3ClientBuilder.standard()
+                        .withCredentials(
+                                new AWSStaticCredentialsProvider(
+                                        new BasicAWSCredentials(ACCESS_KEY, 
SECRET_KEY)))
+                        .withEndpointConfiguration(
+                                new 
AwsClientBuilder.EndpointConfiguration(endpoint, "us-east-1"))
+                        .withPathStyleAccessEnabled(true)
+                        .build();
+        admin.createBucket(BUCKET);
+        admin.putObject(BUCKET, "events/data.json", "{\"event_id\":1}");
+        admin.createBucket("dry-run-empty");
+    }
+
+    @AfterAll
+    @Override
+    public void tearDown() {
+        try {
+            if (admin != null) {
+                admin.shutdown();
+            }
+        } finally {
+            if (minio != null) {
+                minio.stop();
+            }
+        }
+    }
+
+    @Test
+    void shouldValidateObjectAndPrefixWithoutChangingStoredData() throws 
Exception {
+        String etag = admin.getObjectMetadata(BUCKET, 
"events/data.json").getETag();
+        validate(sourceConfig(endpoint, BUCKET, "/events/data.json"));
+        validate(sourceConfig(endpoint, BUCKET, "/events"));
+        assertEquals(etag, admin.getObjectMetadata(BUCKET, 
"events/data.json").getETag());
+        assertEquals("{\"event_id\":1}", admin.getObjectAsString(BUCKET, 
"events/data.json"));
+        assertEquals(1, admin.listObjectsV2(BUCKET).getKeyCount());
+    }
+
+    @Test
+    void shouldRejectMissingBatchPrefix() {
+        FileNotFoundException failure =
+                assertThrows(
+                        FileNotFoundException.class,
+                        () -> validate(sourceConfig(endpoint, BUCKET, 
"/future")));
+        assertTrue(failure.getMessage().contains("batch source path"));
+    }
+
+    @Test
+    void shouldAcceptEmptyContinuousPrefix() {
+        Map<String, Object> config = sourceConfig(endpoint, BUCKET, "/future");
+        config.put("discovery_mode", "continuous");
+        assertDoesNotThrow(() -> validate(config));
+    }
+
+    @Test
+    void shouldAcceptAccessibleEmptyBucketRoot() {
+        assertDoesNotThrow(() -> validate(sourceConfig(endpoint, 
"dry-run-empty", "/")));
+    }
+
+    @Test
+    void shouldRejectMissingBucketInContinuousMode() {
+        Map<String, Object> config = sourceConfig(endpoint, "dry-run-missing", 
"/future");
+        config.put("discovery_mode", "continuous");
+        AmazonS3Exception failure = assertThrows(AmazonS3Exception.class, () 
-> validate(config));
+        assertEquals(404, failure.getStatusCode());
+        assertEquals("NoSuchBucket", failure.getErrorCode());
+    }
+
+    @Test
+    void shouldRejectIncorrectCredentials() {
+        Map<String, Object> config = sourceConfig(endpoint, BUCKET, 
"/events/data.json");
+        config.put("secret_key", "incorrect-test-secret");
+        AmazonS3Exception failure = assertThrows(AmazonS3Exception.class, () 
-> validate(config));
+        assertEquals(403, failure.getStatusCode());
+        assertTrue(failure.getMessage().contains("403"));
+    }
+
+    static Map<String, Object> sourceConfig(String endpoint, String bucket, 
String path) {
+        Map<String, Object> config = new HashMap<>();
+        config.put("path", path);
+        config.put("file_format_type", "json");
+        config.put("bucket", "s3a://" + bucket);
+        config.put("fs.s3a.endpoint", endpoint);
+        config.put(
+                "fs.s3a.aws.credentials.provider",
+                "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider");
+        config.put("access_key", ACCESS_KEY);
+        config.put("secret_key", SECRET_KEY);
+        config.put("parse_partition_from_path", false);
+        config.put(
+                "hadoop_s3_properties",
+                Collections.singletonMap("fs.s3a.path.style.access", "true"));
+        config.put(
+                "schema",
+                Collections.singletonMap("fields", 
Collections.singletonMap("event_id", "bigint")));
+        return config;
+    }
+
+    static void validate(Map<String, Object> config) throws Exception {
+        ClassLoader classLoader = S3FileConnectDryRunIT.class.getClassLoader();
+        TableSourceFactory factory =
+                FactoryUtil.discoverFactory(classLoader, 
TableSourceFactory.class, "S3File");
+        assertTrue(factory instanceof SupportSourceDryRunValidation);
+        SupportSourceDryRunValidation validator = 
(SupportSourceDryRunValidation) factory;
+        TableSourceFactoryContext context =
+                new TableSourceFactoryContext(ReadonlyConfig.fromMap(config), 
classLoader);
+        validator.validateConnectionForDryRun(context, 
validator.inferSchemaForDryRun(context));
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunWireIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunWireIT.java
new file mode 100644
index 0000000000..2cedd84c2c
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-file-s3-e2e/src/test/java/org/apache/seatunnel/e2e/connector/file/s3/S3FileConnectDryRunWireIT.java
@@ -0,0 +1,249 @@
+/*
+ * 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.seatunnel.e2e.connector.file.s3;
+
+import org.apache.seatunnel.e2e.common.TestSuiteBase;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import com.amazonaws.AmazonClientException;
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.AWSCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Records actual SDK requests; no SeaTunnel jobs or object-content requests 
are used. */
+@Timeout(30)
+public class S3FileConnectDryRunWireIT extends TestSuiteBase {
+    @Test
+    void shouldUseOnlyObjectHeadForExactPath() throws Exception {
+        try (MetadataServer server = new MetadataServer("object")) {
+            S3FileConnectDryRunIT.validate(server.config());
+            assertEquals(Arrays.asList("HEAD /dry-run-events/events"), 
server.requests());
+        }
+    }
+
+    @Test
+    void shouldListOnlyOnePrefixEntryWithoutFetchingContentsOrNextPage() 
throws Exception {
+        try (MetadataServer server = new MetadataServer("prefix")) {
+            assertDoesNotThrow(
+                    () -> S3FileConnectDryRunIT.validate(server.config()),
+                    () -> server.requests().toString());
+            List<String> requests = server.requests();
+            assertEquals(2, requests.size());
+            assertEquals("HEAD /dry-run-events/events", requests.get(0));
+            assertTrue(requests.get(1).startsWith("GET /dry-run-events/?"), 
requests.toString());
+            assertTrue(requests.get(1).contains("max-keys=1"));
+            assertTrue(requests.get(1).contains("prefix=events%2F"));
+            assertTrue(requests.get(1).contains("delimiter=%2F"));
+            assertFalse(requests.get(1).contains("continuation-token"));
+        }
+    }
+
+    @Test
+    void shouldPropagateForbiddenMetadataWithoutRetrying() throws Exception {
+        try (MetadataServer server = new MetadataServer("forbidden")) {
+            AmazonS3Exception failure =
+                    assertThrows(
+                            AmazonS3Exception.class,
+                            () -> 
S3FileConnectDryRunIT.validate(server.config()));
+            assertEquals(403, failure.getStatusCode());
+            assertEquals(1, server.requests().size());
+        }
+    }
+
+    @Test
+    void shouldUseConfiguredSmallerSocketTimeoutAndCloseCredentialsOnFailure() 
throws Exception {
+        try (MetadataServer server = new MetadataServer("stalled")) {
+            Map<String, Object> config = server.config();
+            Map<String, String> properties = new HashMap<>();
+            properties.put("fs.s3a.path.style.access", "true");
+            properties.put("fs.s3a.connection.timeout", "200");
+            config.put("hadoop_s3_properties", properties);
+            config.put(
+                    "fs.s3a.aws.credentials.provider", 
TrackingCredentialsProvider.class.getName());
+            int closedBefore = TrackingCredentialsProvider.CLOSED.get();
+            AmazonClientException failure =
+                    assertThrows(
+                            AmazonClientException.class,
+                            () -> S3FileConnectDryRunIT.validate(config));
+            assertTrue(failure.getMessage().contains("Unable to execute HTTP 
request"));
+            assertEquals(1, server.requests().size());
+            assertEquals(closedBefore + 1, 
TrackingCredentialsProvider.CLOSED.get());
+        }
+    }
+
+    @Test
+    void shouldCloseCredentialsAfterSuccessfulMetadataRequest() throws 
Exception {
+        try (MetadataServer server = new MetadataServer("object")) {
+            Map<String, Object> config = server.config();
+            config.put(
+                    "fs.s3a.aws.credentials.provider", 
TrackingCredentialsProvider.class.getName());
+            int closedBefore = TrackingCredentialsProvider.CLOSED.get();
+            S3FileConnectDryRunIT.validate(config);
+            assertEquals(closedBefore + 1, 
TrackingCredentialsProvider.CLOSED.get());
+        }
+    }
+
+    public static class TrackingCredentialsProvider implements 
AWSCredentialsProvider, Closeable {
+        private static final AtomicInteger CLOSED = new AtomicInteger();
+        private static final AtomicInteger CREATED = new AtomicInteger();
+
+        public TrackingCredentialsProvider() {
+            CREATED.incrementAndGet();
+        }
+
+        @Override
+        public AWSCredentials getCredentials() {
+            return new BasicAWSCredentials("minioadmin", "minioadmin");
+        }
+
+        @Override
+        public void refresh() {}
+
+        @Override
+        public void close() {
+            CLOSED.incrementAndGet();
+        }
+    }
+
+    @Test
+    void shouldCloseCredentialsWhenEndpointInitializationFails() {
+        Map<String, Object> config =
+                S3FileConnectDryRunIT.sourceConfig("http://[";, 
"dry-run-events", "/events");
+        config.put("fs.s3a.aws.credentials.provider", 
TrackingCredentialsProvider.class.getName());
+        int closedBefore = TrackingCredentialsProvider.CLOSED.get();
+        IllegalArgumentException failure =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> S3FileConnectDryRunIT.validate(config));
+        assertTrue(failure.getMessage().contains("endpoint"));
+        assertEquals(closedBefore + 1, 
TrackingCredentialsProvider.CLOSED.get());
+    }
+
+    @Test
+    void shouldRejectInvalidClientSettingsBeforeCreatingCredentials() {
+        Map<String, Object> config =
+                S3FileConnectDryRunIT.sourceConfig("http://localhost";, 
"dry-run-events", "/events");
+        config.put("fs.s3a.aws.credentials.provider", 
TrackingCredentialsProvider.class.getName());
+        Map<String, String> properties = new HashMap<>();
+        properties.put("fs.s3a.connection.maximum", "0");
+        config.put("hadoop_s3_properties", properties);
+        int createdBefore = TrackingCredentialsProvider.CREATED.get();
+        IllegalArgumentException failure =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> S3FileConnectDryRunIT.validate(config));
+        assertTrue(failure.getMessage().contains("fs.s3a.connection.maximum"));
+        assertEquals(createdBefore, TrackingCredentialsProvider.CREATED.get());
+    }
+
+    private static final class MetadataServer implements AutoCloseable {
+        private final HttpServer server;
+        private final ExecutorService executor = 
Executors.newCachedThreadPool();
+        private final ConcurrentLinkedQueue<String> requests = new 
ConcurrentLinkedQueue<>();
+        private final CountDownLatch release = new CountDownLatch(1);
+
+        private MetadataServer(String response) throws IOException {
+            server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 
0);
+            server.setExecutor(executor);
+            server.createContext("/", exchange -> reply(exchange, response));
+            server.start();
+        }
+
+        private void reply(HttpExchange exchange, String response) throws 
IOException {
+            requests.add(exchange.getRequestMethod() + " " + 
exchange.getRequestURI());
+            try {
+                exchange.getResponseHeaders().add("Connection", "close");
+                if ("stalled".equals(response)) {
+                    try {
+                        release.await();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                    return;
+                }
+                if ("HEAD".equals(exchange.getRequestMethod())) {
+                    int status =
+                            "object".equals(response)
+                                    ? 200
+                                    : "forbidden".equals(response) ? 403 : 404;
+                    exchange.getResponseHeaders().add("ETag", 
"\"event-etag\"");
+                    exchange.getResponseHeaders()
+                            .add("Last-Modified", "Tue, 08 Sep 2026 00:00:00 
GMT");
+                    exchange.sendResponseHeaders(status, -1);
+                    return;
+                }
+                String xml =
+                        "<ListBucketResult 
xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\";>"
+                                + 
"<Name>dry-run-events</Name><Prefix>events/</Prefix><MaxKeys>1</MaxKeys>"
+                                + 
"<IsTruncated>true</IsTruncated><NextContinuationToken>remaining-events</NextContinuationToken>"
+                                + 
"<Contents><Key>events/data.json</Key><LastModified>2026-09-08T00:00:00.000Z</LastModified>"
+                                + 
"<ETag>event-etag</ETag><Size>14</Size><StorageClass>STANDARD</StorageClass></Contents></ListBucketResult>";
+                byte[] body = xml.getBytes(StandardCharsets.UTF_8);
+                exchange.getResponseHeaders().add("Content-Type", 
"application/xml");
+                exchange.sendResponseHeaders(200, body.length);
+                exchange.getResponseBody().write(body);
+            } finally {
+                exchange.close();
+            }
+        }
+
+        private Map<String, Object> config() {
+            return S3FileConnectDryRunIT.sourceConfig(
+                    "http://127.0.0.1:"; + server.getAddress().getPort(),
+                    "dry-run-events",
+                    "/events");
+        }
+
+        private List<String> requests() {
+            return new ArrayList<>(requests);
+        }
+
+        @Override
+        public void close() {
+            release.countDown();
+            server.stop(0);
+            executor.shutdownNow();
+        }
+    }
+}

Reply via email to