Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion mkdocs/docs/file-io.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ implementations:
| Registry name | Schemes |
|---|---|
| `arrow-fs-local` | paths without a scheme, `file` |
| `arrow-fs-s3` | `s3`, `s3a`, `s3n` |
| `arrow-fs-s3` | `s3`, `s3a`, `s3n`, `oss` |

The S3 implementation requires Arrow S3 support.

Expand All @@ -56,6 +56,54 @@ For a REST catalog, set `io-impl` to the registry name. If it is omitted, the
REST catalog uses `ResolvingFileIO` and selects a registered implementation for
each file location's scheme.

## Configure S3

| Key | Example | Description |
|---|---|---|
| `s3.access-key-id` | `admin` | Static access key ID; must be set together with the secret key |
| `s3.secret-access-key` | `password` | Static secret access key |
| `s3.session-token` | `AQoDYXdzEJr...` | Session token, for temporary credentials. Ignored unless both static keys are set |
| `client.region` | `us-east-1` | Region to sign requests for |
| `s3.endpoint` | `https://127.0.0.1:9000` | Endpoint to use instead of the AWS one. When absent, the `AWS_ENDPOINT_URL_S3` / `AWS_ENDPOINT_URL` environment variables are consulted |
| `s3.path-style-access` | `true` | Address buckets as a path (`endpoint/bucket`) instead of a virtual host (`bucket.endpoint`). Only takes effect together with a custom endpoint |

The following keys are specific to iceberg-cpp; they are not part of the Java
Iceberg or REST specification property set:

| Key | Example | Description |
|---|---|---|
| `s3.ssl.enabled` | `true` | Scheme to use for the endpoint, overriding the one it carries |
Comment thread
wgtmac marked this conversation as resolved.
| `s3.connect-timeout-ms` | `1000` | Connection timeout |
| `s3.socket-timeout-ms` | `5000` | Request timeout. Ignored outside Windows and macOS |

Without credentials, the AWS default credential chain is used, which covers
environment variables, the shared configuration file, and the various role and
identity providers.

### S3-compatible storage

Stores that speak the S3 API are served by the same implementation. The scheme
selects it; `s3.endpoint` decides where requests actually go. A location keeps
its own scheme and is canonicalized internally, so a credential vended for the
`s3` prefix applies to it.

For Alibaba Cloud OSS, point `s3.endpoint` at the S3-compatible endpoint of the
bucket's region and set `s3.path-style-access` to `false`: with a custom
endpoint, buckets are addressed as a path unless told otherwise, and the
service rejects that with
`SecondLevelDomainForbidden: Please use virtual hosted style to access`:

```cpp
auto file_io = iceberg::FileIORegistry::Load(
iceberg::FileIORegistry::kArrowS3FileIO,
{{std::string(iceberg::arrow::S3Properties::kEndpoint),
"https://s3.oss-cn-hangzhou.aliyuncs.com"},
{std::string(iceberg::arrow::S3Properties::kClientRegion), "cn-hangzhou"},
{std::string(iceberg::arrow::S3Properties::kPathStyleAccess), "false"}});

file_io.value()->NewInputFile("oss://bucket/path/to/file.parquet");
```

## Register a custom FileIO

Register the factory before creating the catalog or resolver:
Expand Down
10 changes: 6 additions & 4 deletions src/iceberg/arrow/s3/arrow_s3_file_io.cc
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ Result<std::shared_ptr<::arrow::fs::FileSystem>> BuildArrowS3FileSystem(
return std::shared_ptr<::arrow::fs::FileSystem>(std::move(fs));
}

// Rewrites any alias of `s3://` (any case — routing is case-insensitive) to
// exactly that, so locations and credential prefixes compare equal. An alias
// missing from kS3Schemes would silently stop matching its credential.
std::string CanonicalizeS3Scheme(std::string_view location) {
for (std::string_view scheme : {"s3a://", "s3n://"}) {
if (location.starts_with(scheme)) {
return std::string("s3://").append(location.substr(scheme.size()));
}
const auto separator = location.find("://");
if (separator != std::string_view::npos && IsS3Scheme(location.substr(0, separator))) {
return std::string("s3://").append(location.substr(separator + 3));
}
return std::string(location);
}
Expand Down
31 changes: 25 additions & 6 deletions src/iceberg/arrow/s3/s3_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,37 @@ struct S3Properties {

/// \brief URI schemes served by the Arrow S3 FileIO, lower-case.
///
/// Single source of truth: both the registry registration and IsS3Scheme derive
/// from this list, so a new alias only has to be added here.
inline constexpr std::array<std::string_view, 3> kS3Schemes = {"s3", "s3a", "s3n"};
/// Single source of truth: registration, IsS3Scheme and alias canonicalization
/// all derive from this list, so a new alias only has to be added here.
///
/// `oss` is served because the store is S3-compatible; see the FileIO docs.
inline constexpr std::array<std::string_view, 4> kS3Schemes = {"s3", "s3a", "s3n", "oss"};

/// \brief ASCII-only case-insensitive comparison: schemes are ASCII (RFC 3986),
/// and <cctype> is locale-sensitive.
inline constexpr bool EqualsIgnoreAsciiCase(std::string_view left,
std::string_view right) {
constexpr auto lower = [](char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
};
return std::ranges::equal(left, right,
[&](char a, char b) { return lower(a) == lower(b); });
}

/// \brief Return whether a normalized URI scheme is S3-compatible.
/// \brief Return whether a URI scheme is S3-compatible; case-insensitive,
/// because scheme routing is.
inline constexpr bool IsS3Scheme(std::string_view scheme) {
return std::ranges::contains(kS3Schemes, scheme);
return std::ranges::any_of(kS3Schemes, [scheme](std::string_view alias) {
return EqualsIgnoreAsciiCase(scheme, alias);
});
}

/// \brief Return whether a storage credential prefix belongs to S3.
///
/// Accepts the bare `s3` prefix or a URI prefix such as `s3a://bucket`.
/// Accepts the bare `s3` prefix (exactly: a case variant would be stored as a
/// key no canonicalized location can match) or a URI prefix such as
/// `s3a://bucket`. Bare aliases such as `oss` are deliberately rejected,
/// matching Java S3FileIO's credential filter.
inline constexpr bool IsS3CredentialPrefix(std::string_view prefix) {
if (prefix == S3Properties::kS3Schema) {
return true;
Expand Down
13 changes: 12 additions & 1 deletion src/iceberg/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,11 @@ endif()

if(ICEBERG_BUILD_REST)
function(add_rest_iceberg_test test_name)
set(options USE_BUNDLE)
set(oneValueArgs)
set(multiValueArgs SOURCES)
cmake_parse_arguments(ARG
""
"${options}"
"${oneValueArgs}"
"${multiValueArgs}"
${ARGN})
Expand All @@ -292,12 +293,22 @@ if(ICEBERG_BUILD_REST)
target_include_directories(${test_name} PRIVATE "${CMAKE_BINARY_DIR}/iceberg/test/")
target_sources(${test_name} PRIVATE ${ARG_SOURCES})
target_link_libraries(${test_name} PRIVATE GTest::gmock_main iceberg_rest_static)
if(ARG_USE_BUNDLE)
target_link_libraries(${test_name}
PRIVATE "$<IF:$<TARGET_EXISTS:iceberg_bundle_static>,iceberg_bundle_static,iceberg_bundle_shared>"
)
endif()
if(MSVC_TOOLCHAIN)
target_compile_options(${test_name} PRIVATE /bigobj)
endif()
add_test(NAME ${test_name} COMMAND ${test_name})
endfunction()

if(ICEBERG_BUILD_BUNDLE)
add_rest_iceberg_test(rest_arrow_file_io_test USE_BUNDLE SOURCES
rest_arrow_file_io_test.cc)
endif()

add_rest_iceberg_test(rest_catalog_test
SOURCES
auth_manager_test.cc
Expand Down
2 changes: 1 addition & 1 deletion src/iceberg/test/arrow_io_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ TEST(ArrowRegisterTest, RegistersBuiltInFileIOs) {
EXPECT_THAT(io.NewInputFile("file:///tmp/file"), IsOk());

#if ICEBERG_S3_ENABLED
for (std::string_view scheme : {"s3", "s3a", "s3n"}) {
for (std::string_view scheme : {"s3", "s3a", "s3n", "oss"}) {
EXPECT_THAT(FileIORegistry::Resolve(scheme),
HasValue(::testing::Eq(FileIORegistry::kArrowS3FileIO)));
}
Expand Down
56 changes: 50 additions & 6 deletions src/iceberg/test/arrow_s3_file_io_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,12 @@ TEST_F(ArrowS3FileIOTest, SkipsNonS3CredentialPrefix) {
EXPECT_FALSE(HasWarning(*logger));
}

// Every prefix form this FileIO claims to serve must actually be applied: a
// credential that is silently skipped leaves S3 access on the default
// credentials, which only surfaces much later as an auth error.
TEST_F(ArrowS3FileIOTest, AppliesEveryS3CompatibleCredentialPrefix) {
// Every prefix form this FileIO serves must be accepted without the warning;
// real selection is covered by AppliesOssCredentialInRealRoundTrip.
TEST_F(ArrowS3FileIOTest, AcceptsEveryS3CompatibleCredentialPrefix) {
for (std::string_view prefix :
{"s3", "s3://bucket/table", "s3a://bucket/table", "s3n://bucket/table"}) {
{"s3", "s3://bucket/table", "s3a://bucket/table", "s3n://bucket/table",
"oss://bucket/table", "OSS://bucket/table"}) {
SCOPED_TRACE(prefix);
auto result = MakeS3FileIO({});
ASSERT_THAT(result, IsOk());
Expand All @@ -217,10 +217,12 @@ TEST_F(ArrowS3FileIOTest, WarnsWhenNoCredentialApplies) {
ASSERT_NE(credentialed, nullptr);

// Succeeds (S3 falls back to the default credentials) but must not be silent.
// Bare `S3` is foreign: only URI-form prefixes match case-insensitively.
auto logger = std::make_shared<CapturingLogger>();
ScopedDefaultLogger scoped(logger);
std::vector<StorageCredential> credentials = {
{.prefix = "gs://bucket/table", .config = {{"k", "v"}}}};
{.prefix = "gs://bucket/table", .config = {{"k", "v"}}},
{.prefix = "S3", .config = {{"k", "v"}}}};
EXPECT_THAT(credentialed->SetStorageCredentials(credentials), IsOk());
EXPECT_EQ(credentialed->credentials(), credentials);
EXPECT_TRUE(HasWarning(*logger));
Expand Down Expand Up @@ -294,6 +296,48 @@ TEST_F(ArrowS3FileIOTest, LongestCredentialPrefix) {
IsOk());
}

// The credential is vended under the oss spelling and the object addressed as
// `s3://`, so they only meet through canonicalization — and every other path
// to authentication is broken. (rest_arrow_file_io_test covers the mirrored
// direction.)
TEST_F(ArrowS3FileIOTest, AppliesOssCredentialInRealRoundTrip) {
if (!HasIntegrationEnv()) {
GTEST_SKIP() << "Set ICEBERG_TEST_S3_URI to enable S3 IO test";
}

auto properties = PropertiesFromEnv();
if (!properties.contains(std::string(S3Properties::kAccessKeyId)) ||
!properties.contains(std::string(S3Properties::kSecretAccessKey))) {
GTEST_SKIP() << "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable "
"credential routing test";
}

auto bad_defaults = properties;
for (const auto& [key, value] : BadS3Credentials()) {
bad_defaults.insert_or_assign(key, value);
}
auto io_res = MakeS3FileIO(std::move(bad_defaults));
ASSERT_THAT(io_res, IsOk());
auto io = std::move(io_res).value();
auto* credentialed = io->AsSupportsStorageCredentials();
ASSERT_NE(credentialed, nullptr);

constexpr std::string_view object_name = "iceberg_oss_credential_test.txt";
const auto object_uri = ObjectUri(object_name);
const auto scheme_end = object_uri.find("://");
ASSERT_NE(scheme_end, std::string::npos) << "ICEBERG_TEST_S3_URI must carry a scheme";
// Both spellings are forced, so they cross whatever scheme the env URI uses.
const auto oss_spelling = std::string("oss").append(object_uri.substr(scheme_end));
const auto oss_prefix =
oss_spelling.substr(0, oss_spelling.size() - object_name.size());
const auto s3_uri = std::string("s3").append(object_uri.substr(scheme_end));

EXPECT_THAT(credentialed->SetStorageCredentials(
{{.prefix = oss_prefix, .config = std::move(properties)}}),
IsOk());
EXPECT_THAT(CheckReadWrite(*io, s3_uri, "hello oss with vended credentials"), IsOk());
}

#if ICEBERG_S3_ENABLED
TEST_F(ArrowS3FileIOTest, ClientRegion) {
auto result =
Expand Down
13 changes: 13 additions & 0 deletions src/iceberg/test/location_util_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ TEST(LocationUtilTest, ParseScheme) {

auto empty_scheme = LocationUtil::ParseScheme("://bucket/path");
EXPECT_TRUE(empty_scheme.empty());

// Not syntactically a scheme -> a path; the extended-length Windows form.
EXPECT_TRUE(LocationUtil::ParseScheme("\\\\?\\C:\\long\\file.parquet").empty());
EXPECT_TRUE(LocationUtil::ParseScheme("1:/file.parquet").empty());

#ifdef _WIN32
// Drive letters are drives; both slash directions occur.
EXPECT_TRUE(LocationUtil::ParseScheme("C:/tmp/file.parquet").empty());
EXPECT_TRUE(LocationUtil::ParseScheme("D:\\a\\file.parquet").empty());
#else
// Elsewhere a single letter stays a scheme for registered implementations.
EXPECT_EQ(LocationUtil::ParseScheme("C:/tmp/file.parquet"), "C");
#endif
}

} // namespace iceberg
Loading
Loading