morningman commented on code in PR #68117:
URL: https://github.com/apache/doris/pull/68117#discussion_r4043956125
##########
be/src/util/s3_uri.cpp:
##########
@@ -37,53 +45,152 @@ const std::string S3URI::_FRAGMENT_DELIM = "#";
/// _schema: s3
/// _bucket: bucket1
/// _key: path/to/file.txt
-Status S3URI::parse() {
+bool S3URI::is_azure_endpoint(std::string_view authority) {
+ auto host = absl::AsciiStrToLower(authority.substr(0,
authority.find(':')));
+ static constexpr std::string_view suffixes[] = {
+ ".blob.core.windows.net", ".dfs.core.windows.net",
+ ".blob.core.chinacloudapi.cn", ".dfs.core.chinacloudapi.cn",
+ ".blob.core.usgovcloudapi.net", ".dfs.core.usgovcloudapi.net",
+ ".blob.core.cloudapi.de", ".dfs.core.cloudapi.de"};
+ return std::ranges::any_of(suffixes, [&host](std::string_view suffix) {
+ return host.ends_with(suffix) && host.size() > suffix.size();
+ });
+}
+
+Status S3URI::_parsing_error(std::string_view message, bool azure_provider)
const {
+ if (azure_provider || _is_azure) {
+ return Status::InvalidArgument("{}", message);
+ }
+ // The first HTTP parse may precede provider selection. Never echo a
+ // signed query, including for malformed or custom Azure endpoints.
+ const auto safe_location = std::string_view(_location).substr(0,
_location.find_first_of("?#"));
+ return Status::InvalidArgument("{}: {}", message, safe_location);
+}
+
+Status S3URI::_parse_authority(const std::string& scheme, const std::string&
rest,
+ bool azure_provider) {
+ if (scheme == _SCHEME_S3) {
+ // has scheme, eg: s3://bucket1/path/to/file.txt
+ std::vector<std::string> authority_split =
+ absl::StrSplit(rest, absl::MaxSplits(_PATH_DELIM, 1));
+ if (authority_split.empty() || authority_split[0].empty()) {
+ return _parsing_error("Invalid S3 URI", azure_provider);
+ }
+ _bucket = authority_split[0];
+ // support s3://bucket1
+ _key = authority_split.size() == 1 ? "/" : authority_split[1];
+ } else if (absl::EqualsIgnoreCase(scheme, _SCHEME_ABFS) ||
+ absl::EqualsIgnoreCase(scheme, _SCHEME_ABFSS) ||
+ absl::EqualsIgnoreCase(scheme, _SCHEME_WASB) ||
+ absl::EqualsIgnoreCase(scheme, _SCHEME_WASBS)) {
+ // Azure Data Lake paths use container@account-host as the
+ // authority. Keep the account host so the native Azure client
+ // can derive its endpoint without consulting Hadoop settings.
+ _is_azure = true;
+ std::vector<std::string> authority_split =
+ absl::StrSplit(rest, absl::MaxSplits(_PATH_DELIM, 1));
+ if (authority_split.empty() || authority_split[0].empty()) {
+ return _parsing_error("Invalid Azure URI", azure_provider);
+ }
+ const auto at = authority_split[0].find('@');
+ if (at == std::string::npos || at == 0 || at + 1 ==
authority_split[0].size() ||
+ authority_split[0].find('@', at + 1) != std::string::npos) {
+ return _parsing_error("Invalid Azure URI authority",
azure_provider);
+ }
+ _bucket = authority_split[0].substr(0, at);
+ _endpoint = authority_split[0].substr(at + 1);
+ if (_endpoint.empty()) {
+ return _parsing_error("Invalid Azure URI authority",
azure_provider);
+ }
+ const auto dot = _endpoint.find('.');
+ _account = dot == std::string::npos ? _endpoint : _endpoint.substr(0,
dot);
+ _key = authority_split.size() == 1 ? "/" : authority_split[1];
Review Comment:
Fixed in 41567d7e050. `S3URI::parse()` no longer strips a query or fragment
for the `abfs`/`abfss`/`wasb`/`wasbs` schemes: `ADLSLocation`
(`^(abfss?|wasbs?)://([^/?#]+)(.*)?$`) and Hadoop's `Path` ("query & fragment
not supported") both take everything after the authority as the object path, so
`abfss://[email protected]/path/a?b#c.parquet` now yields
the key `path/a?b#c.parquet`, with and without the provider flag. S3 and
HTTP(S) locations keep stripping (an HTTP(S) Azure location may carry a SAS
query, and `%3F`/`%23` remain the encoded spelling there).
`S3URITest.QueryAndFragmentAreLiteralOnlyInAdlsPaths` pins both sides and
`IcebergAdlsPathsPreserveLiteralPercentSequences` gained `?`/`#` object names
for all four schemes.
`FileFactory` is not part of this PR; the reader/writer-open coverage for
these names goes into BE-2 (#68103), the PR that changes `file_factory.cpp` and
adds the provider-aware second parse there.
##########
be/src/util/s3_util.cpp:
##########
@@ -165,8 +234,391 @@ constexpr char S3_NEED_OVERRIDE_ENDPOINT[] =
"AWS_NEED_OVERRIDE_ENDPOINT";
constexpr char S3_ROLE_ARN[] = "AWS_ROLE_ARN";
constexpr char S3_EXTERNAL_ID[] = "AWS_EXTERNAL_ID";
constexpr char S3_CREDENTIALS_PROVIDER_TYPE[] =
"AWS_CREDENTIALS_PROVIDER_TYPE";
+
+// Native Azure binding keys. The AWS_* aliases above remain accepted for
+// existing object-storage callers, but Azure scans use these provider-owned
+// names so their meaning does not depend on the S3 adapter.
+constexpr char AZURE_AUTH_TYPE[] = "AZURE_AUTH_TYPE";
+constexpr char AZURE_ENDPOINT[] = "AZURE_ENDPOINT";
+constexpr char AZURE_ACCOUNT_NAME[] = "AZURE_ACCOUNT_NAME";
+constexpr char AZURE_ACCOUNT_KEY[] = "AZURE_ACCOUNT_KEY";
+constexpr char AZURE_CONTAINER[] = "AZURE_CONTAINER";
+constexpr char AZURE_SAS_TOKEN[] = "AZURE_SAS_TOKEN";
+constexpr char AZURE_SAS_EXPIRY_MS[] = "AZURE_SAS_EXPIRY_MS";
+constexpr char AZURE_CLIENT_ID[] = "AZURE_CLIENT_ID";
+constexpr char AZURE_CLIENT_SECRET[] = "AZURE_CLIENT_SECRET";
+constexpr char AZURE_TENANT_ID[] = "AZURE_TENANT_ID";
+constexpr char AZURE_OAUTH_SERVER_URI[] = "AZURE_OAUTH_SERVER_URI";
+
+const std::string* find_property(const StringCaseMap<std::string>& properties,
+ std::initializer_list<const char*> names) {
+ for (const auto* name : names) {
+ auto it = properties.find(name);
+ if (it != properties.end()) {
+ return &it->second;
+ }
+ }
+ return nullptr;
+}
+
+bool has_property(const StringCaseMap<std::string>& properties,
+ std::initializer_list<const char*> names) {
+ return find_property(properties, names) != nullptr;
+}
+
+// Native protocol only: resolves the documented AZURE_ENDPOINT shorthands
+// (bare account name, official DFS host) into the Blob transport origin.
Legacy
+// SharedKey endpoints are literal and never pass through here.
+std::string normalize_azure_endpoint(std::string endpoint) {
+ if (endpoint.empty()) {
+ return endpoint;
+ }
+ const bool has_scheme = endpoint.find("://") != std::string::npos;
+ if (!has_scheme) {
+ endpoint = "https://" + endpoint;
+ }
+ const auto scheme_end = endpoint.find("://");
+ endpoint.replace(0, scheme_end, to_lower(endpoint.substr(0, scheme_end)));
+ const auto authority_begin = scheme_end == std::string::npos ? 0 :
scheme_end + 3;
+ const auto authority_end = endpoint.find('/', authority_begin);
+ const auto authority_length = authority_end == std::string::npos
+ ? endpoint.size() - authority_begin
+ : authority_end - authority_begin;
+ const auto authority = endpoint.substr(authority_begin, authority_length);
+ if (authority.empty()) {
+ return endpoint;
+ }
+
+ auto lower_authority = to_lower(authority);
+ endpoint.replace(authority_begin, authority_length, lower_authority);
+ // Match the host, not host:port, so explicit transport ports do not
disable
+ // the official DFS-to-Blob conversion. Custom proxy hosts stay unchanged.
+ const auto host = lower_authority.substr(0, lower_authority.find(':'));
+ const auto dfs_pos = host.find(".dfs.");
+ const bool official_dfs =
+ dfs_pos != std::string::npos &&
(host.ends_with(".dfs.core.windows.net") ||
+
host.ends_with(".dfs.core.chinacloudapi.cn") ||
+
host.ends_with(".dfs.core.usgovcloudapi.net") ||
+
host.ends_with(".dfs.core.cloudapi.de"));
+ if (official_dfs) {
+ endpoint.replace(authority_begin + dfs_pos, 5, ".blob.");
+ } else if (!has_scheme && authority.find('.') == std::string::npos &&
+ authority.find(':') == std::string::npos) {
+ endpoint.insert(authority_begin + authority.size(),
".blob.core.windows.net");
+ }
+ while (endpoint.ends_with('/')) {
+ endpoint.pop_back();
+ }
+ return endpoint;
+}
+
+std::string endpoint_authority(const std::string& endpoint) {
+ const auto begin = endpoint.find("://") + 3;
+ auto authority = endpoint.substr(begin, endpoint.find('/', begin) - begin);
+ if (endpoint.starts_with("https://") && authority.ends_with(":443")) {
+ authority.resize(authority.size() - 4);
+ } else if (endpoint.starts_with("http://") && authority.ends_with(":80")) {
+ authority.resize(authority.size() - 3);
+ }
+ return authority;
+}
+
+#ifdef USE_AZURE
+// The endpoint handed to the SDK. Native endpoints were already normalized
+// while parsing the native protocol. Legacy SharedKey producers hand over an
+// endpoint literal that only ever received a default scheme, so keep that
+// contract here: no account-name inference, no DFS-to-Blob rewrite and no path
+// rewriting, which would silently redirect single-label proxy hosts or custom
+// reverse-proxy routes after an upgrade. Only the endpoint/container join
+// boundary is normalized. Only _create_azure_client() consumes it, so keep it
+// under the same guard or a BUILD_AZURE=OFF build fails on -Wunused-function.
+std::string azure_transport_endpoint(std::string endpoint) {
+ if (endpoint.find("://") == std::string::npos) {
+ endpoint = "https://" + endpoint;
+ }
+ while (endpoint.ends_with('/')) {
+ endpoint.pop_back();
+ }
+ return endpoint;
+}
+#endif
+
+// Only established SharedKey wire producers use AWS fields for Azure. Once
+// translated here the native factory never inspects these fields again. The
+// endpoint stays byte-for-byte as configured, exactly like the old factory.
+void import_legacy_azure_shared_key(S3ClientConf* conf) {
+ conf->azure_credentials = {};
+ conf->azure_credentials.type = AzureCredentialType::SHARED_KEY;
+ conf->azure_credentials.account_name = std::move(conf->ak);
+ conf->azure_credentials.account_key = std::move(conf->sk);
+ conf->ak.clear();
+ conf->sk.clear();
+ conf->token.clear();
+ conf->region.clear();
+ conf->role_arn.clear();
+ conf->external_id.clear();
+ conf->cred_provider_type = CredProviderType::Default;
+}
+
+Status convert_legacy_azure_properties(const StringCaseMap<std::string>&
properties,
+ S3ClientConf* client_conf) {
+ auto& client = *client_conf;
+ // Compatibility is deliberately limited to the old SharedKey map. An
+ // incomplete native map must not be mistaken for that old protocol.
+ for (const auto& [key, value] : properties) {
+ const auto lower = to_lower(key);
+ if (lower.starts_with("azure") || (lower == "aws_token" &&
!value.empty())) {
+ return Status::InvalidArgument("Azure native credentials require
AZURE_AUTH_TYPE");
+ }
+ }
+ if (!has_property(properties, {S3_ENDPOINT}) || !has_property(properties,
{S3_AK}) ||
+ !has_property(properties, {S3_SK})) {
+ return Status::InvalidArgument("Azure native credentials require
AZURE_AUTH_TYPE");
+ }
+ client.endpoint = *find_property(properties, {S3_ENDPOINT});
+ client.ak = *find_property(properties, {S3_AK});
+ client.sk = *find_property(properties, {S3_SK});
+ import_legacy_azure_shared_key(&client);
+ for (const auto& [name, target] :
+ {std::pair {S3_MAX_CONN_SIZE, &client.max_connections},
+ std::pair {S3_REQUEST_TIMEOUT_MS, &client.request_timeout_ms},
+ std::pair {S3_CONN_TIMEOUT_MS, &client.connect_timeout_ms}}) {
+ if (const auto* value = find_property(properties, {name}); value !=
nullptr) {
+ if (!to_int(*value, *target)) {
+ return Status::InvalidArgument("invalid Azure connection
option {}", name);
+ }
+ }
+ }
+ return Status::OK();
+}
+
+Status validate_native_azure_shared_key_compatibility(const
StringCaseMap<std::string>& properties,
+ const
AzureCredentialOptions& credential) {
+ const auto* legacy_endpoint = find_property(properties, {S3_ENDPOINT});
+ const auto* legacy_account = find_property(properties, {S3_AK});
+ const auto* legacy_key = find_property(properties, {S3_SK});
+ const auto* native_endpoint = find_property(properties, {AZURE_ENDPOINT});
+ const auto* legacy_override = find_property(properties,
{"AWS_NEED_OVERRIDE_ENDPOINT"});
+ const bool has_legacy_fields =
+ legacy_endpoint != nullptr || legacy_account != nullptr ||
legacy_key != nullptr ||
+ find_property(properties, {S3_REGION}) != nullptr ||
legacy_override != nullptr;
+ if (has_legacy_fields &&
+ (legacy_endpoint == nullptr || legacy_account == nullptr || legacy_key
== nullptr ||
+ native_endpoint == nullptr || *legacy_endpoint != *native_endpoint ||
+ *legacy_account != credential.account_name || *legacy_key !=
credential.account_key)) {
+ return Status::InvalidArgument(
+ "Azure native SharedKey compatibility fields conflict with
native fields");
+ }
+ return Status::OK();
+}
+
+Status convert_native_azure_properties(const StringCaseMap<std::string>&
properties,
+ const S3URI& uri, const std::string&
auth_type,
+ S3ClientConf* client_conf) {
+ auto& client = *client_conf;
+ const auto is_legacy_shared_key_field = [&](const std::string& key) {
+ return auth_type == "SHARED_KEY" &&
+ (iequal(key, S3_ENDPOINT) || iequal(key, S3_REGION) ||
iequal(key, S3_AK) ||
+ iequal(key, S3_SK) || iequal(key,
"AWS_NEED_OVERRIDE_ENDPOINT"));
+ };
+ for (const auto& [key, value] : properties) {
+ const auto lower = to_lower(key);
+ if ((lower.starts_with("aws_") || lower.starts_with("azure.")) &&
+ !is_legacy_shared_key_field(key)) {
+ return Status::InvalidArgument(
+ "Azure native credentials cannot use AWS or catalog
property aliases");
+ }
+ }
+ // Older FE versions attach a Hadoop configuration view for OneLake to
+ // this map. Ignore those extra keys; they must never supply or override
+ // any native authentication field. FE routing separates the two views.
+ auto& credential = client.azure_credentials;
+ if (auth_type == "SHARED_KEY") {
+ credential.type = AzureCredentialType::SHARED_KEY;
+ } else if (auth_type == "SAS") {
+ credential.type = AzureCredentialType::SAS;
+ } else if (auth_type == "OAUTH2") {
+ credential.type = AzureCredentialType::OAUTH2;
+ } else {
+ return Status::InvalidArgument("unsupported AZURE_AUTH_TYPE in native
credentials");
+ }
+ auto set = [&](const char* key, std::string* target) {
+ if (const auto* value = find_property(properties, {key}); value !=
nullptr) {
+ *target = *value;
+ }
+ };
+ set(AZURE_ENDPOINT, &client.endpoint);
+ set(AZURE_ACCOUNT_NAME, &credential.account_name);
+ set(AZURE_ACCOUNT_KEY, &credential.account_key);
+ set(AZURE_SAS_TOKEN, &credential.sas_token);
+ set(AZURE_CLIENT_ID, &credential.oauth_client_id);
+ set(AZURE_CLIENT_SECRET, &credential.oauth_client_secret);
+ set(AZURE_TENANT_ID, &credential.oauth_tenant_id);
+ set(AZURE_OAUTH_SERVER_URI, &credential.oauth_server_uri);
+ if (auth_type == "SHARED_KEY") {
+
RETURN_IF_ERROR(validate_native_azure_shared_key_compatibility(properties,
credential));
+ }
+ if (const auto* expiry = find_property(properties, {AZURE_SAS_EXPIRY_MS});
expiry != nullptr) {
+ if (!to_int64(*expiry, credential.sas_expiration_time_ms) ||
+ credential.sas_expiration_time_ms <= 0) {
+ return Status::InvalidArgument("invalid Azure SAS expiry value");
+ }
+ }
+ if (client.endpoint.empty() || credential.account_name.empty()) {
+ return Status::InvalidArgument(
+ "Azure native credentials require endpoint and account name");
+ }
+ if (credential.type == AzureCredentialType::SHARED_KEY &&
credential.account_key.empty()) {
+ return Status::InvalidArgument("Azure native SharedKey requires an
account key");
+ }
+ // Existing Azure SharedKey catalogs also contain S3-spelled locations.
+ // This explicit compatibility case carries no account in its URI;
+ // SAS/OAuth2 native data locations must carry their Azure authority.
+ if (uri.get_scheme().empty() ||
+ (uri.get_scheme() == "s3" && credential.type !=
AzureCredentialType::SHARED_KEY)) {
+ return Status::InvalidArgument("Azure native credentials require an
Azure data URI");
+ }
+ if (client.endpoint.find_first_of("?#@\r\n") != std::string::npos) {
+ return Status::InvalidArgument(
+ "Azure endpoint must not contain credentials, query or
fragment");
+ }
+ client.endpoint = normalize_azure_endpoint(client.endpoint);
+ if (!client.endpoint.starts_with("https://") &&
!client.endpoint.starts_with("http://")) {
+ return Status::InvalidArgument("Azure endpoint must use HTTP or
HTTPS");
+ }
+ // A container property may constrain legacy callers, but is never a
+ // fallback location. Every native data URI identifies its container.
+ if (const auto* container = find_property(properties, {AZURE_CONTAINER});
+ container != nullptr && *container != uri.get_bucket()) {
+ return Status::InvalidArgument("Azure URI container conflicts with the
storage binding");
+ }
+ return Status::OK();
+}
+
+Status convert_azure_properties(const StringCaseMap<std::string>& properties,
const S3URI& uri,
+ S3Conf* conf) {
+ auto& client = conf->client_conf;
+ client.provider = io::ObjStorageProvider::AZURE;
+ const auto* auth_type = find_property(properties, {AZURE_AUTH_TYPE});
+ if (auth_type == nullptr) {
+ RETURN_IF_ERROR(convert_legacy_azure_properties(properties, &client));
+ } else {
+ RETURN_IF_ERROR(convert_native_azure_properties(properties, uri,
*auth_type, &client));
+ }
+ if (uri.get_bucket().empty()) {
+ return Status::InvalidArgument("Azure data URI requires a container");
+ }
+ conf->bucket = uri.get_bucket();
+ client.bucket = conf->bucket;
+ if (auth_type != nullptr) {
+ RETURN_IF_ERROR(S3ClientFactory::validate_azure_uri(uri, client));
+ }
+ return is_s3_conf_valid(client);
+}
} // namespace
+Status S3ClientFactory::validate_azure_uri(const S3URI& uri, const
S3ClientConf& conf) {
+ if (uri.get_scheme().empty()) {
+ return Status::OK(); // Internal file-system callers may pass a raw
object key.
+ }
+ if (uri.get_bucket() != conf.bucket) {
+ return Status::InvalidArgument("Azure URI container conflicts with the
storage binding");
+ }
+ if (uri.get_scheme() == "s3") {
+ if (conf.azure_credentials.type != AzureCredentialType::SHARED_KEY) {
+ return Status::InvalidArgument("Azure SAS/OAuth2 data access
requires an Azure URI");
+ }
+ return Status::OK(); // Old SharedKey file-system paths use the S3
wire spelling.
+ }
+ const auto uri_host = to_lower(uri.get_endpoint());
+ if (uri_host.ends_with(".dfs.fabric.microsoft.com") ||
Review Comment:
Fixed in 41567d7e050. The fence now matches on the DNS host rather than the
raw authority: `authority_host()` lower-cases it and drops an explicit port and
a trailing root dot before the Fabric suffix check, so
`abfss://[email protected]:443/...`, the
`https://onelake.blob.fabric.microsoft.com:443/...` spelling and
`ONELAKE.dfs.fabric.microsoft.com.` all return NotSupported before any endpoint
or account comparison runs.
`S3ClientFactoryTest.OneLakeFenceIgnoresPortAndDnsRootDot` covers the ABFS and
HTTPS forms with `:443` against both a Fabric endpoint and a custom transport
endpoint (the two paths that previously let it through).
##########
be/src/util/s3_util.cpp:
##########
@@ -165,8 +234,391 @@ constexpr char S3_NEED_OVERRIDE_ENDPOINT[] =
"AWS_NEED_OVERRIDE_ENDPOINT";
constexpr char S3_ROLE_ARN[] = "AWS_ROLE_ARN";
constexpr char S3_EXTERNAL_ID[] = "AWS_EXTERNAL_ID";
constexpr char S3_CREDENTIALS_PROVIDER_TYPE[] =
"AWS_CREDENTIALS_PROVIDER_TYPE";
+
+// Native Azure binding keys. The AWS_* aliases above remain accepted for
+// existing object-storage callers, but Azure scans use these provider-owned
+// names so their meaning does not depend on the S3 adapter.
+constexpr char AZURE_AUTH_TYPE[] = "AZURE_AUTH_TYPE";
+constexpr char AZURE_ENDPOINT[] = "AZURE_ENDPOINT";
+constexpr char AZURE_ACCOUNT_NAME[] = "AZURE_ACCOUNT_NAME";
+constexpr char AZURE_ACCOUNT_KEY[] = "AZURE_ACCOUNT_KEY";
+constexpr char AZURE_CONTAINER[] = "AZURE_CONTAINER";
+constexpr char AZURE_SAS_TOKEN[] = "AZURE_SAS_TOKEN";
+constexpr char AZURE_SAS_EXPIRY_MS[] = "AZURE_SAS_EXPIRY_MS";
+constexpr char AZURE_CLIENT_ID[] = "AZURE_CLIENT_ID";
+constexpr char AZURE_CLIENT_SECRET[] = "AZURE_CLIENT_SECRET";
+constexpr char AZURE_TENANT_ID[] = "AZURE_TENANT_ID";
+constexpr char AZURE_OAUTH_SERVER_URI[] = "AZURE_OAUTH_SERVER_URI";
+
+const std::string* find_property(const StringCaseMap<std::string>& properties,
+ std::initializer_list<const char*> names) {
+ for (const auto* name : names) {
+ auto it = properties.find(name);
+ if (it != properties.end()) {
+ return &it->second;
+ }
+ }
+ return nullptr;
+}
+
+bool has_property(const StringCaseMap<std::string>& properties,
+ std::initializer_list<const char*> names) {
+ return find_property(properties, names) != nullptr;
+}
+
+// Native protocol only: resolves the documented AZURE_ENDPOINT shorthands
+// (bare account name, official DFS host) into the Blob transport origin.
Legacy
+// SharedKey endpoints are literal and never pass through here.
+std::string normalize_azure_endpoint(std::string endpoint) {
+ if (endpoint.empty()) {
+ return endpoint;
+ }
+ const bool has_scheme = endpoint.find("://") != std::string::npos;
+ if (!has_scheme) {
+ endpoint = "https://" + endpoint;
+ }
+ const auto scheme_end = endpoint.find("://");
+ endpoint.replace(0, scheme_end, to_lower(endpoint.substr(0, scheme_end)));
+ const auto authority_begin = scheme_end == std::string::npos ? 0 :
scheme_end + 3;
+ const auto authority_end = endpoint.find('/', authority_begin);
+ const auto authority_length = authority_end == std::string::npos
+ ? endpoint.size() - authority_begin
+ : authority_end - authority_begin;
+ const auto authority = endpoint.substr(authority_begin, authority_length);
+ if (authority.empty()) {
+ return endpoint;
+ }
+
+ auto lower_authority = to_lower(authority);
+ endpoint.replace(authority_begin, authority_length, lower_authority);
+ // Match the host, not host:port, so explicit transport ports do not
disable
+ // the official DFS-to-Blob conversion. Custom proxy hosts stay unchanged.
+ const auto host = lower_authority.substr(0, lower_authority.find(':'));
+ const auto dfs_pos = host.find(".dfs.");
+ const bool official_dfs =
+ dfs_pos != std::string::npos &&
(host.ends_with(".dfs.core.windows.net") ||
+
host.ends_with(".dfs.core.chinacloudapi.cn") ||
+
host.ends_with(".dfs.core.usgovcloudapi.net") ||
+
host.ends_with(".dfs.core.cloudapi.de"));
+ if (official_dfs) {
+ endpoint.replace(authority_begin + dfs_pos, 5, ".blob.");
+ } else if (!has_scheme && authority.find('.') == std::string::npos &&
+ authority.find(':') == std::string::npos) {
+ endpoint.insert(authority_begin + authority.size(),
".blob.core.windows.net");
+ }
+ while (endpoint.ends_with('/')) {
+ endpoint.pop_back();
+ }
+ return endpoint;
+}
+
+std::string endpoint_authority(const std::string& endpoint) {
+ const auto begin = endpoint.find("://") + 3;
+ auto authority = endpoint.substr(begin, endpoint.find('/', begin) - begin);
+ if (endpoint.starts_with("https://") && authority.ends_with(":443")) {
+ authority.resize(authority.size() - 4);
+ } else if (endpoint.starts_with("http://") && authority.ends_with(":80")) {
+ authority.resize(authority.size() - 3);
+ }
+ return authority;
+}
+
+#ifdef USE_AZURE
+// The endpoint handed to the SDK. Native endpoints were already normalized
+// while parsing the native protocol. Legacy SharedKey producers hand over an
+// endpoint literal that only ever received a default scheme, so keep that
+// contract here: no account-name inference, no DFS-to-Blob rewrite and no path
+// rewriting, which would silently redirect single-label proxy hosts or custom
+// reverse-proxy routes after an upgrade. Only the endpoint/container join
+// boundary is normalized. Only _create_azure_client() consumes it, so keep it
+// under the same guard or a BUILD_AZURE=OFF build fails on -Wunused-function.
+std::string azure_transport_endpoint(std::string endpoint) {
+ if (endpoint.find("://") == std::string::npos) {
+ endpoint = "https://" + endpoint;
+ }
+ while (endpoint.ends_with('/')) {
+ endpoint.pop_back();
+ }
+ return endpoint;
+}
+#endif
+
+// Only established SharedKey wire producers use AWS fields for Azure. Once
+// translated here the native factory never inspects these fields again. The
+// endpoint stays byte-for-byte as configured, exactly like the old factory.
+void import_legacy_azure_shared_key(S3ClientConf* conf) {
+ conf->azure_credentials = {};
+ conf->azure_credentials.type = AzureCredentialType::SHARED_KEY;
+ conf->azure_credentials.account_name = std::move(conf->ak);
+ conf->azure_credentials.account_key = std::move(conf->sk);
+ conf->ak.clear();
+ conf->sk.clear();
+ conf->token.clear();
+ conf->region.clear();
+ conf->role_arn.clear();
+ conf->external_id.clear();
+ conf->cred_provider_type = CredProviderType::Default;
+}
+
+Status convert_legacy_azure_properties(const StringCaseMap<std::string>&
properties,
+ S3ClientConf* client_conf) {
+ auto& client = *client_conf;
+ // Compatibility is deliberately limited to the old SharedKey map. An
+ // incomplete native map must not be mistaken for that old protocol.
+ for (const auto& [key, value] : properties) {
+ const auto lower = to_lower(key);
+ if (lower.starts_with("azure") || (lower == "aws_token" &&
!value.empty())) {
+ return Status::InvalidArgument("Azure native credentials require
AZURE_AUTH_TYPE");
+ }
+ }
+ if (!has_property(properties, {S3_ENDPOINT}) || !has_property(properties,
{S3_AK}) ||
+ !has_property(properties, {S3_SK})) {
+ return Status::InvalidArgument("Azure native credentials require
AZURE_AUTH_TYPE");
+ }
+ client.endpoint = *find_property(properties, {S3_ENDPOINT});
+ client.ak = *find_property(properties, {S3_AK});
+ client.sk = *find_property(properties, {S3_SK});
+ import_legacy_azure_shared_key(&client);
+ for (const auto& [name, target] :
+ {std::pair {S3_MAX_CONN_SIZE, &client.max_connections},
+ std::pair {S3_REQUEST_TIMEOUT_MS, &client.request_timeout_ms},
+ std::pair {S3_CONN_TIMEOUT_MS, &client.connect_timeout_ms}}) {
+ if (const auto* value = find_property(properties, {name}); value !=
nullptr) {
+ if (!to_int(*value, *target)) {
+ return Status::InvalidArgument("invalid Azure connection
option {}", name);
+ }
+ }
+ }
+ return Status::OK();
+}
+
+Status validate_native_azure_shared_key_compatibility(const
StringCaseMap<std::string>& properties,
+ const
AzureCredentialOptions& credential) {
+ const auto* legacy_endpoint = find_property(properties, {S3_ENDPOINT});
+ const auto* legacy_account = find_property(properties, {S3_AK});
+ const auto* legacy_key = find_property(properties, {S3_SK});
+ const auto* native_endpoint = find_property(properties, {AZURE_ENDPOINT});
+ const auto* legacy_override = find_property(properties,
{"AWS_NEED_OVERRIDE_ENDPOINT"});
+ const bool has_legacy_fields =
+ legacy_endpoint != nullptr || legacy_account != nullptr ||
legacy_key != nullptr ||
+ find_property(properties, {S3_REGION}) != nullptr ||
legacy_override != nullptr;
+ if (has_legacy_fields &&
+ (legacy_endpoint == nullptr || legacy_account == nullptr || legacy_key
== nullptr ||
+ native_endpoint == nullptr || *legacy_endpoint != *native_endpoint ||
+ *legacy_account != credential.account_name || *legacy_key !=
credential.account_key)) {
+ return Status::InvalidArgument(
+ "Azure native SharedKey compatibility fields conflict with
native fields");
+ }
+ return Status::OK();
+}
+
+Status convert_native_azure_properties(const StringCaseMap<std::string>&
properties,
+ const S3URI& uri, const std::string&
auth_type,
+ S3ClientConf* client_conf) {
+ auto& client = *client_conf;
+ const auto is_legacy_shared_key_field = [&](const std::string& key) {
+ return auth_type == "SHARED_KEY" &&
+ (iequal(key, S3_ENDPOINT) || iequal(key, S3_REGION) ||
iequal(key, S3_AK) ||
+ iequal(key, S3_SK) || iequal(key,
"AWS_NEED_OVERRIDE_ENDPOINT"));
+ };
+ for (const auto& [key, value] : properties) {
+ const auto lower = to_lower(key);
+ if ((lower.starts_with("aws_") || lower.starts_with("azure.")) &&
+ !is_legacy_shared_key_field(key)) {
+ return Status::InvalidArgument(
+ "Azure native credentials cannot use AWS or catalog
property aliases");
+ }
+ }
+ // Older FE versions attach a Hadoop configuration view for OneLake to
+ // this map. Ignore those extra keys; they must never supply or override
+ // any native authentication field. FE routing separates the two views.
+ auto& credential = client.azure_credentials;
+ if (auth_type == "SHARED_KEY") {
+ credential.type = AzureCredentialType::SHARED_KEY;
+ } else if (auth_type == "SAS") {
+ credential.type = AzureCredentialType::SAS;
+ } else if (auth_type == "OAUTH2") {
+ credential.type = AzureCredentialType::OAUTH2;
+ } else {
+ return Status::InvalidArgument("unsupported AZURE_AUTH_TYPE in native
credentials");
+ }
+ auto set = [&](const char* key, std::string* target) {
+ if (const auto* value = find_property(properties, {key}); value !=
nullptr) {
+ *target = *value;
+ }
+ };
+ set(AZURE_ENDPOINT, &client.endpoint);
+ set(AZURE_ACCOUNT_NAME, &credential.account_name);
+ set(AZURE_ACCOUNT_KEY, &credential.account_key);
+ set(AZURE_SAS_TOKEN, &credential.sas_token);
+ set(AZURE_CLIENT_ID, &credential.oauth_client_id);
+ set(AZURE_CLIENT_SECRET, &credential.oauth_client_secret);
+ set(AZURE_TENANT_ID, &credential.oauth_tenant_id);
+ set(AZURE_OAUTH_SERVER_URI, &credential.oauth_server_uri);
+ if (auth_type == "SHARED_KEY") {
+
RETURN_IF_ERROR(validate_native_azure_shared_key_compatibility(properties,
credential));
+ }
+ if (const auto* expiry = find_property(properties, {AZURE_SAS_EXPIRY_MS});
expiry != nullptr) {
+ if (!to_int64(*expiry, credential.sas_expiration_time_ms) ||
+ credential.sas_expiration_time_ms <= 0) {
+ return Status::InvalidArgument("invalid Azure SAS expiry value");
+ }
+ }
+ if (client.endpoint.empty() || credential.account_name.empty()) {
+ return Status::InvalidArgument(
+ "Azure native credentials require endpoint and account name");
+ }
+ if (credential.type == AzureCredentialType::SHARED_KEY &&
credential.account_key.empty()) {
+ return Status::InvalidArgument("Azure native SharedKey requires an
account key");
+ }
+ // Existing Azure SharedKey catalogs also contain S3-spelled locations.
+ // This explicit compatibility case carries no account in its URI;
+ // SAS/OAuth2 native data locations must carry their Azure authority.
+ if (uri.get_scheme().empty() ||
+ (uri.get_scheme() == "s3" && credential.type !=
AzureCredentialType::SHARED_KEY)) {
+ return Status::InvalidArgument("Azure native credentials require an
Azure data URI");
+ }
+ if (client.endpoint.find_first_of("?#@\r\n") != std::string::npos) {
+ return Status::InvalidArgument(
+ "Azure endpoint must not contain credentials, query or
fragment");
+ }
+ client.endpoint = normalize_azure_endpoint(client.endpoint);
+ if (!client.endpoint.starts_with("https://") &&
!client.endpoint.starts_with("http://")) {
+ return Status::InvalidArgument("Azure endpoint must use HTTP or
HTTPS");
+ }
+ // A container property may constrain legacy callers, but is never a
+ // fallback location. Every native data URI identifies its container.
+ if (const auto* container = find_property(properties, {AZURE_CONTAINER});
+ container != nullptr && *container != uri.get_bucket()) {
+ return Status::InvalidArgument("Azure URI container conflicts with the
storage binding");
+ }
+ return Status::OK();
+}
+
+Status convert_azure_properties(const StringCaseMap<std::string>& properties,
const S3URI& uri,
+ S3Conf* conf) {
+ auto& client = conf->client_conf;
+ client.provider = io::ObjStorageProvider::AZURE;
+ const auto* auth_type = find_property(properties, {AZURE_AUTH_TYPE});
+ if (auth_type == nullptr) {
+ RETURN_IF_ERROR(convert_legacy_azure_properties(properties, &client));
+ } else {
+ RETURN_IF_ERROR(convert_native_azure_properties(properties, uri,
*auth_type, &client));
+ }
+ if (uri.get_bucket().empty()) {
+ return Status::InvalidArgument("Azure data URI requires a container");
+ }
+ conf->bucket = uri.get_bucket();
+ client.bucket = conf->bucket;
+ if (auth_type != nullptr) {
+ RETURN_IF_ERROR(S3ClientFactory::validate_azure_uri(uri, client));
+ }
+ return is_s3_conf_valid(client);
+}
} // namespace
+Status S3ClientFactory::validate_azure_uri(const S3URI& uri, const
S3ClientConf& conf) {
+ if (uri.get_scheme().empty()) {
+ return Status::OK(); // Internal file-system callers may pass a raw
object key.
+ }
+ if (uri.get_bucket() != conf.bucket) {
+ return Status::InvalidArgument("Azure URI container conflicts with the
storage binding");
+ }
+ if (uri.get_scheme() == "s3") {
+ if (conf.azure_credentials.type != AzureCredentialType::SHARED_KEY) {
+ return Status::InvalidArgument("Azure SAS/OAuth2 data access
requires an Azure URI");
+ }
+ return Status::OK(); // Old SharedKey file-system paths use the S3
wire spelling.
+ }
+ const auto uri_host = to_lower(uri.get_endpoint());
+ if (uri_host.ends_with(".dfs.fabric.microsoft.com") ||
+ uri_host.ends_with(".blob.fabric.microsoft.com")) {
+ return Status::NotSupported("OneLake data access requires its Hadoop
storage binding");
+ }
+ const auto endpoint = normalize_azure_endpoint(conf.endpoint);
+ const bool http_uri = uri.get_scheme() == "http" || uri.get_scheme() ==
"https";
+ const auto uri_endpoint = normalize_azure_endpoint(
+ http_uri ? uri.get_scheme() + "://" + uri.get_endpoint() :
uri.get_endpoint());
+ // ABFS/WASB authorities identify the logical Azure account, while a
configured custom
+ // endpoint may be a proxy or emulator that intentionally has a different
HTTP authority.
+ // The native client uses conf.endpoint as the transport origin; retain
account validation
+ // below, but do not reject this valid proxy form. HTTP(S) locations carry
their transport
+ // origin directly and must still match exactly.
+ const bool custom_transport =
!S3URI::is_azure_endpoint(endpoint_authority(endpoint));
+ if (endpoint_authority(endpoint) != endpoint_authority(uri_endpoint) &&
+ !(custom_transport && !http_uri)) {
+ return Status::InvalidArgument(
+ "Azure URI account host conflicts with the storage endpoint");
+ }
+ if (http_uri && !endpoint.starts_with(uri.get_scheme() + "://")) {
+ return Status::InvalidArgument("Azure URI scheme conflicts with the
storage endpoint");
+ }
+ if (S3URI::is_azure_endpoint(uri.get_endpoint()) &&
Review Comment:
Fixed in 41567d7e050. On official Azure service hosts the `-secondary`
account label is now folded to the primary account
(`azure_primary_authority()`), and that folded identity is what the ABFS/WASB
logical-account comparison against the configured endpoint and the credential
account check both use. That matches how the SDK signs secondary reads:
`SharedKeyPolicy` builds the canonicalized resource from
`m_credential->AccountName`, and `StorageSwitchToSecondaryPolicy` only swaps
the host, so the credential must keep the primary name. With
`AZURE_ENDPOINT=https://myaccount-secondary.blob.core.windows.net` and
`AZURE_ACCOUNT_NAME=myaccount` the factory now accepts
`abfss://[email protected]/...`, the HTTPS
secondary spelling, and `abfss://[email protected]/...`
(table metadata written against the primary host, which is the usual RA-GRS
read case and was rejected one check earlier by the same root cause).
HTTP(S) locations still have to match the configured transport origin
exactly, and the fold is limited to recognized Azure authorities:
`other-secondary`, an account literally named `secondary`, and
`myaccount-secondary-secondary` stay rejected. Covered by
`AllowsDocumentedAzureSecondaryEndpoints` and
`SecondaryFoldingKeepsOtherAccountsApart`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]