parthchandra commented on code in PR #6023:
URL: https://github.com/apache/datafusion-comet/pull/6023#discussion_r4077638324


##########
spark/src/main/java/org/apache/comet/cloud/s3/CometS3CredentialDispatcher.java:
##########
@@ -74,19 +79,43 @@ public static long ensureInitialized(
         catalogProperties == null
             ? Collections.emptyMap()
             : Collections.unmodifiableMap(new HashMap<>(catalogProperties));
+    // Key on a digest of the properties, not the values themselves: the 
KEY_TO_HANDLE map is
+    // static and lives for the JVM lifetime, and the property bag may carry 
secrets (vended
+    // credentials, static keys). The full map is still handed to initialize() 
below; only the
+    // long-lived cache key is reduced to a digest. A distinct config still 
yields a distinct key.
     InstanceKey key =
-        new InstanceKey(providerClassName, dispatchKey == null ? "" : 
dispatchKey, snapshot);
+        new InstanceKey(
+            providerClassName, dispatchKey == null ? "" : dispatchKey, 
digestOf(snapshot));
     return KEY_TO_HANDLE.computeIfAbsent(
         key,
         k -> {
-          CometS3CredentialProvider provider = 
instantiate(k.providerClassName);
-          provider.initialize(k.catalogProperties);
+          CometS3CredentialProvider provider = instantiate(providerClassName);
+          provider.initialize(snapshot);
           long handle = HANDLE_SEQ.getAndIncrement();
           INSTANCES.put(handle, new RegisteredProvider(provider, k));
           return handle;
         });
   }
 
+  /** Stable SHA-256 digest of the property bag, so secret values are not 
retained in the key. */
+  private static String digestOf(Map<String, String> props) {
+    MessageDigest md;
+    try {
+      md = MessageDigest.getInstance("SHA-256");
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException("SHA-256 not available", e);
+    }
+    // Sort by key for order-independence; NUL separators avoid key/value 
boundary ambiguity.
+    for (Map.Entry<String, String> e : new TreeMap<>(props).entrySet()) {
+      md.update(e.getKey().getBytes(StandardCharsets.UTF_8));
+      md.update((byte) 0);
+      String v = e.getValue();
+      md.update(v == null ? new byte[] {1} : 
v.getBytes(StandardCharsets.UTF_8));
+      md.update((byte) 0);

Review Comment:
   Resolved by removing the digest entirely rather than fixing its encoding. 
Per the discussion with
   Andy on this method, the digest wasn't actually reducing secret retention 
(INSTANCES and the
   provider hold the map for the same lifetime), so I reverted InstanceKey to 
the exact map-based key.
   That makes the U+0000 input-encoding ambiguity moot -- there's no 
serialization step anymore. The dispatcher isolation/multi-tenant tests still 
pass.



##########
native/core/src/parquet/objectstore/s3.rs:
##########
@@ -334,6 +337,22 @@ fn lookup_provider_class<'a>(
     get_config_trimmed(configs, bucket, PROVIDER_CLASS_PROPERTY).filter(|s| 
!s.is_empty())
 }
 
+/// Builds the `catalog_properties` map forwarded to the SPI on the Parquet 
path: the full
+/// `fs.s3a.*` subset. This matches the Iceberg path, which forwards its full 
property bag, so an
+/// adapter delegating to Hadoop's provider construction sees exactly the 
config Spark would --
+/// including the static keys a provider chain may resolve through. Stripping 
them would let a
+/// chain like `SimpleAWSCredentialsProvider,customProvider` silently resolve 
through a different
+/// entry than Spark, reading data as a different principal. These keys 
already cross JNI for the
+/// non-adapter path (see `build_credential_provider`). HashMap equality is 
order-independent, so
+/// the dispatcher instance-cache key stays stable regardless of iteration 
order.
+fn forward_catalog_properties(configs: &HashMap<String, String>) -> 
HashMap<String, String> {

Review Comment:
   Went with (a). AdapterSupport.toConfiguration now seeds from the executor's 
own Spark-derived Hadoop conf (spark.hadoop.* via SparkEnv) and overlays the 
forwarded fs.s3a.* on top, so hadoop.security.credential.provider.path -- and 
anything else a provider reads that isn't
   fs.s3a.* -- is present. Off the executor (unit tests) it falls back to a 
bare Configuration.
   
   Also fixed the Rust test to assert a realistic dropped key (fs.comet.*) 
rather than spark.master.



##########
spark/src/main/spark-4.x/org/apache/comet/cloud/s3/HadoopS3ACredentialProviderAdapter.java:
##########
@@ -0,0 +1,97 @@
+/*
+ * 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.comet.cloud.s3;
+
+import java.net.URI;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.s3a.S3AUtils;
+import org.apache.hadoop.fs.s3a.auth.CredentialProviderListFactory;
+
+import org.apache.comet.annotation.Public;
+
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+
+/**
+ * Delegates credential resolution to Hadoop S3A's own provider construction, 
so it accepts
+ * everything the {@code fs.s3a.aws.credentials.provider} chain accepts. This 
is the spark-4.x (AWS
+ * SDK v2) body; it calls {@link CredentialProviderListFactory} and returns v2 
credentials.
+ *
+ * <p>Enable it (leaving {@code fs.s3a.aws.credentials.provider} untouched) 
with:
+ *
+ * <pre>
+ * 
spark.hadoop.fs.s3a.comet.credential.provider.class=org.apache.comet.cloud.s3.HadoopS3ACredentialProviderAdapter
+ * </pre>
+ */
+@Public
+public class HadoopS3ACredentialProviderAdapter implements 
CometS3CredentialProvider {
+
+  private Map<String, String> properties;
+  private volatile AwsCredentialsProvider delegate;
+
+  @Override
+  public void initialize(Map<String, String> catalogProperties) {
+    this.properties = catalogProperties;
+  }
+
+  @Override
+  public CometS3Credentials getCredentialsForPath(CometS3CredentialContext 
context)
+      throws Exception {
+    AwsCredentialsProvider provider = ensureDelegate(context.getBucket());
+    return 
SdkCredentialExtraction.toCometCredentials(provider.resolveCredentials());
+  }
+
+  private AwsCredentialsProvider ensureDelegate(String bucket) throws 
Exception {

Review Comment:
   All four adapters now cache one delegate per bucket (ConcurrentHashMap), so 
a single instance on the Iceberg path serves each bucket correctly. 
   Added a unit test resolving two buckets with different per-bucket keys 
through one instance



##########
spark/src/main/java/org/apache/comet/cloud/s3/AdapterSupport.java:
##########
@@ -0,0 +1,151 @@
+/*
+ * 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.comet.cloud.s3;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.URI;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+
+/** Config and reflection helpers shared by the built-in S3 credential 
provider adapters. */
+final class AdapterSupport {
+
+  private AdapterSupport() {}
+
+  /**
+   * Rebuilds a Hadoop {@link Configuration} from the forwarded {@code 
fs.s3a.*} map. The adapters
+   * run on the executor without a live {@code S3AFileSystem}, so keys are 
copied onto a fresh
+   * Configuration (which still loads core-site defaults).
+   */
+  static Configuration toConfiguration(Map<String, String> props) {
+    Configuration conf = new Configuration();
+    for (Map.Entry<String, String> entry : props.entrySet()) {
+      if (entry.getValue() != null) {
+        conf.set(entry.getKey(), entry.getValue());
+      }
+    }
+    return conf;
+  }
+
+  /**
+   * Per-bucket then global lookup, mirroring Comet's native {@code fs.s3a} 
resolution: {@code
+   * fs.s3a.bucket.<bucket>.<property>} wins over {@code fs.s3a.<property>}. 
Returns null if neither
+   * is set (after trimming).
+   */
+  static String lookup(Map<String, String> props, String bucket, String 
property) {
+    String perBucket = props.get("fs.s3a.bucket." + bucket + "." + property);
+    if (perBucket != null && !perBucket.trim().isEmpty()) {
+      return perBucket.trim();
+    }
+    String global = props.get("fs.s3a." + property);
+    if (global != null && !global.trim().isEmpty()) {
+      return global.trim();
+    }
+    return null;
+  }
+
+  /** Returns the public static no-arg method {@code name} on {@code clazz}, 
or null if absent. */
+  private static Method staticMethod(Class<?> clazz, String name) {
+    try {
+      Method m = clazz.getMethod(name);
+      return Modifier.isStatic(m.getModifiers()) ? m : null;
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Instantiates a credential-provider delegate, trying the same ordered 
conventions for both the
+   * v1 and v2 adapters so their {@code @Public} contract is identical: the 
Hadoop-style {@code
+   * (URI, Configuration)} and {@code (Configuration)} constructors first 
(matching {@code
+   * S3AUtils.getInstanceFromReflection}), then the SDK static factories 
{@code create()} / {@code
+   * builder().build()} / {@code getInstance()}, then a public no-arg 
constructor.
+   *
+   * <p>Factory return types must be assignable to {@code targetType} (as 
Hadoop's {@code
+   * getFactoryMethod} requires), so an unrelated {@code static String 
create()} is skipped rather
+   * than invoked and failing later with a {@code ClassCastException}. Returns 
an untyped instance;
+   * the caller casts to its SDK provider interface.
+   */
+  static Object instantiateDelegate(
+      Class<?> targetType, Class<?> clazz, URI uri, Configuration conf) throws 
Exception {
+    Constructor<?> uriConf = constructor(clazz, URI.class, 
Configuration.class);
+    if (uriConf != null) {
+      return uriConf.newInstance(uri, conf);
+    }
+    Constructor<?> confOnly = constructor(clazz, Configuration.class);
+    if (confOnly != null) {
+      return confOnly.newInstance(conf);
+    }
+    Method create = factoryMethod(clazz, "create", targetType);
+    if (create != null) {
+      return create.invoke(null);
+    }
+    Method builder = staticMethod(clazz, "builder");
+    if (builder != null) {
+      // Resolve build() off builder()'s declared (public) return type, not 
the runtime object's
+      // class, which may be a non-public implementation.
+      Method build = builder.getReturnType().getMethod("build");
+      if (targetType.isAssignableFrom(build.getReturnType())) {

Review Comment:
   Fixed. The builder path now checks the built instance's runtime type instead 
of build()'s declared return type, so a public Builder that inherits an erased 
`Object build()` from a generic `SdkBuilder<B, T>` is accepted. 
   Added a V2GenericBuilderProvider fixture (build() inherited from a generic 
super-interface, not redeclared) plus a test; the existing concrete-return 
fixture stays as the covariant case.



##########
spark/src/main/java/org/apache/comet/cloud/s3/CometS3CredentialDispatcher.java:
##########
@@ -74,19 +79,43 @@ public static long ensureInitialized(
         catalogProperties == null
             ? Collections.emptyMap()
             : Collections.unmodifiableMap(new HashMap<>(catalogProperties));
+    // Key on a digest of the properties, not the values themselves: the 
KEY_TO_HANDLE map is
+    // static and lives for the JVM lifetime, and the property bag may carry 
secrets (vended
+    // credentials, static keys). The full map is still handed to initialize() 
below; only the
+    // long-lived cache key is reduced to a digest. A distinct config still 
yields a distinct key.
     InstanceKey key =
-        new InstanceKey(providerClassName, dispatchKey == null ? "" : 
dispatchKey, snapshot);
+        new InstanceKey(
+            providerClassName, dispatchKey == null ? "" : dispatchKey, 
digestOf(snapshot));
     return KEY_TO_HANDLE.computeIfAbsent(
         key,
         k -> {
-          CometS3CredentialProvider provider = 
instantiate(k.providerClassName);
-          provider.initialize(k.catalogProperties);
+          CometS3CredentialProvider provider = instantiate(providerClassName);
+          provider.initialize(snapshot);
           long handle = HANDLE_SEQ.getAndIncrement();
           INSTANCES.put(handle, new RegisteredProvider(provider, k));
           return handle;
         });
   }
 
+  /** Stable SHA-256 digest of the property bag, so secret values are not 
retained in the key. */
+  private static String digestOf(Map<String, String> props) {

Review Comment:
   Reverted to the map-based key as you suggested -- it's less code and makes 
@sunchao's encoding finding moot. The retention is inherent to 
INSTANCES/provider anyway, so the digest wasn't buying anything.



##########
docs/source/contributor-guide/s3-credential-provider-design.md:
##########
@@ -93,6 +95,19 @@ The full unfiltered FileIO property bag crosses JNI as 
`catalog_properties`. The
 
 `IcebergScanExec` derives a redacting `Debug` so plan dumps and tracing do not 
leak the property bag.
 
+## Property-bag handling on the Parquet path
+
+The Parquet path forwards the full `fs.s3a.*` config subset as 
`catalog_properties`, so an SPI provider sees exactly the config Spark would. 
`forward_catalog_properties` in `native/core/src/parquet/objectstore/s3.rs` 
keeps every `fs.s3a.*` key, including the static credentials (`*.access.key`, 
`*.secret.key`, `*.session.token`). Forwarding them is deliberate: 
`AWSCredentialProviderList` skips a provider that throws 
`NoAwsCredentialsException` and moves to the next entry, so stripping the 
static keys would let a chain such as 
`SimpleAWSCredentialsProvider,customProvider` silently resolve through a 
different provider than Spark, reading data as a different principal. These 
keys already cross JNI for the non-adapter native path 
(`build_credential_provider` reads them), and the Iceberg path already forwards 
its full bag, so this matches existing behavior rather than widening exposure. 
As on the Iceberg path, the dispatcher retains the forwarded map in its 
instance-cache key for the JVM 
 lifetime.

Review Comment:
   Fixed all of them -- the design doc keying/forwarding paragraphs no longer 
mention a digest and now describe the executor-conf seeding; the v2 test and 
bridge-suite comments no longer claim secrets are stripped.



##########
spark/src/test/scala/org/apache/comet/cloud/s3/HadoopS3ACredentialProviderAdapterBridgeSuite.scala:
##########
@@ -0,0 +1,108 @@
+/*
+ * 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.comet.cloud.s3
+
+import scala.collection.mutable
+import scala.util.Try
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.SaveMode
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.functions.{col, sum}
+
+import org.apache.comet.CometS3TestBase
+
+/**
+ * End-to-end MinIO test for [[HadoopS3ACredentialProviderAdapter]] on the 
native Parquet path.
+ *
+ * The delegate is the AWS default credential chain -- a provider class 
Comet's native Rust list
+ * deliberately does NOT recognize. Without the adapter, the native reader 
fails with `Unsupported
+ * credential provider`; a successful read here proves the adapter routed 
credential resolution
+ * through Hadoop S3A instead. This is the regression from the spec's failure 
report.
+ *
+ * Credentials are supplied via JVM system properties (the AWS default chain 
reads them) rather
+ * than `fs.s3a.access.key` / `secret.key`, because Comet does not forward 
those secrets to the
+ * SPI.
+ */
+class HadoopS3ACredentialProviderAdapterBridgeSuite
+    extends CometS3TestBase
+    with AdaptiveSparkPlanHelper {
+
+  override protected val testBucketName = "hadoop-adapter-bucket"
+
+  // The AWS default-chain FQCN must match what the active Hadoop-aws line's 
provider factory
+  // accepts, not merely which SDK jar is on the test classpath: the v2 SDK is 
present on the
+  // Spark 3.x test classpath too (Iceberg's S3 test deps), but Hadoop 3.3.4's 
factory only accepts
+  // the v1 interface. CredentialProviderListFactory exists only in Hadoop 
3.4+ (the v2 line), so
+  // its presence is the reliable per-profile signal. Neither class is in 
Comet's native list.
+  private val defaultChainClass: String =
+    if (Try(
+        
Class.forName("org.apache.hadoop.fs.s3a.auth.CredentialProviderListFactory")).isSuccess)
 {
+      "software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider"
+    } else {
+      "com.amazonaws.auth.DefaultAWSCredentialsProviderChain"
+    }
+
+  private val savedProps = mutable.Map[String, String]()
+  private def setProp(key: String, value: String): Unit = {
+    savedProps(key) = System.getProperty(key)
+    System.setProperty(key, value)
+  }
+
+  override protected def sparkConf: SparkConf = {
+    val conf = super.sparkConf
+    conf.set(
+      "spark.hadoop.fs.s3a.comet.credential.provider.class",
+      classOf[HadoopS3ACredentialProviderAdapter].getName)
+    conf.set("spark.hadoop.fs.s3a.aws.credentials.provider", defaultChainClass)
+  }
+
+  override def beforeAll(): Unit = {
+    // Both the v1 (aws.secretKey) and v2 (aws.secretAccessKey) secret 
property names are set so the
+    // default chain resolves regardless of which SDK is on the classpath.
+    setProp("aws.accessKeyId", userName)
+    setProp("aws.secretKey", password)
+    setProp("aws.secretAccessKey", password)
+    super.beforeAll()
+  }
+
+  override def afterAll(): Unit = {
+    super.afterAll()
+    savedProps.foreach {
+      case (key, null) => System.clearProperty(key)
+      case (key, value) => System.setProperty(key, value)
+    }
+  }
+
+  test(

Review Comment:
   Added a second MinIO case: 
fs.s3a.aws.credentials.provider=SimpleAWSCredentialsProvider,<default chain> 
with static keys and no system properties, so the fallback can't resolve and 
the read succeeds only if the static keys crossed JNI and reached 
SimpleAWSCredentialsProvider. Moved the default-chain test's system properties 
into that test so this one is a real regression check.



##########
spark/src/main/spark-4.x/org/apache/comet/cloud/s3/HadoopS3ACredentialProviderAdapter.java:
##########
@@ -0,0 +1,97 @@
+/*
+ * 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.comet.cloud.s3;
+
+import java.net.URI;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.s3a.S3AUtils;
+import org.apache.hadoop.fs.s3a.auth.CredentialProviderListFactory;
+
+import org.apache.comet.annotation.Public;
+
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+
+/**
+ * Delegates credential resolution to Hadoop S3A's own provider construction, 
so it accepts
+ * everything the {@code fs.s3a.aws.credentials.provider} chain accepts. This 
is the spark-4.x (AWS
+ * SDK v2) body; it calls {@link CredentialProviderListFactory} and returns v2 
credentials.
+ *
+ * <p>Enable it (leaving {@code fs.s3a.aws.credentials.provider} untouched) 
with:
+ *
+ * <pre>
+ * 
spark.hadoop.fs.s3a.comet.credential.provider.class=org.apache.comet.cloud.s3.HadoopS3ACredentialProviderAdapter
+ * </pre>
+ */
+@Public
+public class HadoopS3ACredentialProviderAdapter implements 
CometS3CredentialProvider {
+
+  private Map<String, String> properties;
+  private volatile AwsCredentialsProvider delegate;
+
+  @Override
+  public void initialize(Map<String, String> catalogProperties) {
+    this.properties = catalogProperties;
+  }
+
+  @Override
+  public CometS3Credentials getCredentialsForPath(CometS3CredentialContext 
context)
+      throws Exception {
+    AwsCredentialsProvider provider = ensureDelegate(context.getBucket());
+    return 
SdkCredentialExtraction.toCometCredentials(provider.resolveCredentials());
+  }
+
+  private AwsCredentialsProvider ensureDelegate(String bucket) throws 
Exception {
+    AwsCredentialsProvider local = delegate;
+    if (local != null) {
+      return local;
+    }
+    synchronized (this) {
+      if (delegate == null) {
+        Configuration conf =
+            
S3AUtils.propagateBucketOptions(AdapterSupport.toConfiguration(properties), 
bucket);
+        AdapterSupport.patchSecurityCredentialProviders(conf);
+        URI uri = new URI("s3a://" + bucket + "/");
+        try {
+          delegate = 
CredentialProviderListFactory.createAWSCredentialProviderList(uri, conf);

Review Comment:
   Added a guard: both Hadoop adapters now fail with a message naming 
fs.s3a.delegation.token.binding when it's set, rather than building the chain 
and resolving a different principal than Spark's DT provider. DT support 
remains its own piece of work.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to