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

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


The following commit(s) were added to refs/heads/master by this push:
     new 99ad64c3c292 [SPARK-57890][CORE] Add core credential types 
(UserContext, ServiceCredential, UserCredentials)
99ad64c3c292 is described below

commit 99ad64c3c29296f1f5df1011c9f7b17f150fcfe1
Author: Anupam Yadav <[email protected]>
AuthorDate: Fri Jul 10 08:27:13 2026 +0900

    [SPARK-57890][CORE] Add core credential types (UserContext, 
ServiceCredential, UserCredentials)
    
    ### What changes were proposed in this pull request?
    
    This adds the foundational credential types for the OIDC Credential 
Propagation SPIP 
([SPARK-57703](https://issues.apache.org/jira/browse/SPARK-57703)).
    
    Three types are introduced in `org.apache.spark.security` (spark-core), all 
`DeveloperApi`:
    
    - `UserContext` : driver-side OIDC identity (`principal`, `issuer`, 
`rawToken`, `issuedAt`, `expiresAt`). It is intentionally **not** 
`Serializable` (driver-only, never transmitted to executors). Its `toString()` 
redacts `rawToken` as `[REDACTED]`, and `getRawToken()` is annotated 
`JsonIgnore` so the raw token is excluded from Jackson serialization.
    - `ServiceCredential` : a short-lived, scoped delegated credential 
(`properties: Map<String, String>`, `expiresAt`). `Serializable`. Its 
`toString()` redacts the property values (keys shown, values `[REDACTED]`) 
since they may hold cloud secret keys.
    - `UserCredentials` : a per-scheme `Map<String, ServiceCredential>` bundle. 
`Serializable`, with a `forScheme(String)` lookup returning 
`Optional<ServiceCredential>`. It contains no `UserContext` or raw token.
    
    The types are implemented in Java so they are directly usable from both 
Scala and Java and from the Java `CredentialProvider` SPI added in the 
follow-up (SPARK-57891). All are immutable.
    
    ### Why are the changes needed?
    
    There are currently no types in Spark to represent an OIDC identity or 
short-lived delegated credentials for propagation to executors. These are the 
foundational types the rest of the credential-propagation framework builds on. 
See the SPIP design document, Appendix A.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No behavior change. It adds new `DeveloperApi` types only.
    
    ### How was this patch tested?
    
    New JUnit `CredentialTypesSuite` covering the acceptance criteria: 
`rawToken` redaction in `toString()`, exclusion of `rawToken` from Jackson 
serialization, `ServiceCredential.toString()` value redaction, 
Java-serialization round-trip for `ServiceCredential`/`UserCredentials` (no 
loss), `forScheme` present/absent lookup, and expiry checks.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    No.
    
    Closes #57140 from yadavay-amzn/SPARK-57890.
    
    Authored-by: Anupam Yadav <[email protected]>
    Signed-off-by: Kousuke Saruta <[email protected]>
---
 .../apache/spark/security/ServiceCredential.java   | 122 ++++++++
 .../org/apache/spark/security/UserContext.java     | 139 +++++++++
 .../org/apache/spark/security/UserCredentials.java | 113 ++++++++
 .../spark/security/CredentialTypesSuite.java       | 317 +++++++++++++++++++++
 4 files changed, 691 insertions(+)

diff --git 
a/core/src/main/java/org/apache/spark/security/ServiceCredential.java 
b/core/src/main/java/org/apache/spark/security/ServiceCredential.java
new file mode 100644
index 000000000000..b0502cf922a4
--- /dev/null
+++ b/core/src/main/java/org/apache/spark/security/ServiceCredential.java
@@ -0,0 +1,122 @@
+/*
+ * 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.spark.security;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.spark.annotation.DeveloperApi;
+
+/**
+ * :: DeveloperApi ::
+ * A short-lived, service-specific credential derived from the user's identity 
token.
+ * <p>
+ * Instances are produced by credential providers on the driver and transmitted
+ * to executors via {@link UserCredentials}. The {@code properties} map holds
+ * service-specific key-value pairs (e.g., temporary AWS credentials).
+ * <p>
+ * This class is immutable and {@link Serializable}.
+ *
+ * @since 4.3.0
+ */
+@DeveloperApi
+public final class ServiceCredential implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private final Map<String, String> properties;
+  private final Instant expiresAt;
+
+  /**
+   * Constructs a new {@code ServiceCredential}.
+   *
+   * @param properties service-specific credential properties (must not be 
null; defensively copied)
+   * @param expiresAt credential expiry time (may be null)
+   */
+  public ServiceCredential(Map<String, String> properties, Instant expiresAt) {
+    Objects.requireNonNull(properties, "properties must not be null");
+    this.properties = new HashMap<>(properties);
+    this.expiresAt = expiresAt;
+  }
+
+  /**
+   * Returns an unmodifiable view of the credential properties.
+   */
+  public Map<String, String> getProperties() {
+    return Collections.unmodifiableMap(properties);
+  }
+
+  /** Returns the credential expiry time, or {@code null} if not set. */
+  public Instant getExpiresAt() {
+    return expiresAt;
+  }
+
+  /**
+   * Returns {@code true} if this credential has expired relative to the given 
instant.
+   * If {@code expiresAt} is {@code null}, this method returns {@code false}.
+   *
+   * @param now the current time to compare against (must not be null)
+   * @return whether the credential is expired
+   */
+  public boolean isExpired(Instant now) {
+    Objects.requireNonNull(now, "now must not be null");
+    return expiresAt != null && !now.isBefore(expiresAt);
+  }
+
+  @Override
+  public String toString() {
+    String redactedProps;
+    if (properties.isEmpty()) {
+      redactedProps = "{}";
+    } else {
+      StringBuilder sb = new StringBuilder("{");
+      boolean first = true;
+      for (String key : properties.keySet()) {
+        if (!first) {
+          sb.append(", ");
+        }
+        sb.append(key).append("=[REDACTED]");
+        first = false;
+      }
+      sb.append("}");
+      redactedProps = sb.toString();
+    }
+    return "ServiceCredential{" +
+        "properties=" + redactedProps +
+        ", expiresAt=" + expiresAt +
+        '}';
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+    ServiceCredential that = (ServiceCredential) o;
+    return properties.equals(that.properties)
+        && Objects.equals(expiresAt, that.expiresAt);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(properties, expiresAt);
+  }
+}
diff --git a/core/src/main/java/org/apache/spark/security/UserContext.java 
b/core/src/main/java/org/apache/spark/security/UserContext.java
new file mode 100644
index 000000000000..9d9f372dad34
--- /dev/null
+++ b/core/src/main/java/org/apache/spark/security/UserContext.java
@@ -0,0 +1,139 @@
+/*
+ * 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.spark.security;
+
+import java.time.Instant;
+import java.util.Objects;
+
+import org.apache.spark.annotation.DeveloperApi;
+
+/**
+ * :: DeveloperApi ::
+ * Represents the authenticated user's identity context on the driver side.
+ * <p>
+ * This class holds the OIDC token information used to derive short-lived
+ * {@link ServiceCredential} instances via credential providers. It is 
intentionally
+ * <b>not</b> {@link java.io.Serializable} and must never be transmitted to 
executors.
+ * The {@code rawToken} field is always redacted in {@link #toString()}.
+ *
+ * @since 4.3.0
+ */
+@DeveloperApi
+public final class UserContext {
+
+  private final String principal;
+  private final String issuer;
+  private final String rawToken;
+  private final Instant issuedAt;
+  private final Instant expiresAt;
+
+  /**
+   * Constructs a new {@code UserContext}.
+   *
+   * @param principal the {@code sub} claim from the JWT (must not be null)
+   * @param issuer the {@code iss} claim from the JWT (must not be null)
+   * @param rawToken the raw OIDC JWT string (must not be null)
+   * @param issuedAt token issue time (may be null)
+   * @param expiresAt token expiry time (may be null)
+   */
+  public UserContext(
+      String principal,
+      String issuer,
+      String rawToken,
+      Instant issuedAt,
+      Instant expiresAt) {
+    this.principal = Objects.requireNonNull(principal, "principal must not be 
null");
+    this.issuer = Objects.requireNonNull(issuer, "issuer must not be null");
+    this.rawToken = Objects.requireNonNull(rawToken, "rawToken must not be 
null");
+    this.issuedAt = issuedAt;
+    this.expiresAt = expiresAt;
+  }
+
+  /** Returns the {@code sub} claim (principal identifier). */
+  public String getPrincipal() {
+    return principal;
+  }
+
+  /** Returns the {@code iss} claim (token issuer). */
+  public String getIssuer() {
+    return issuer;
+  }
+
+  /** Returns the raw OIDC JWT. This value must never be logged or transmitted 
to executors. */
+  public String getRawToken() {
+    return rawToken;
+  }
+
+  /** Returns the token issue time, or {@code null} if not set. */
+  public Instant getIssuedAt() {
+    return issuedAt;
+  }
+
+  /** Returns the token expiry time, or {@code null} if not set. */
+  public Instant getExpiresAt() {
+    return expiresAt;
+  }
+
+  /**
+   * Returns {@code true} if this context's token has expired relative to the 
given instant.
+   * If {@code expiresAt} is {@code null}, this method returns {@code false}.
+   *
+   * @param now the current time to compare against (must not be null)
+   * @return whether the token is expired
+   */
+  public boolean isExpired(Instant now) {
+    Objects.requireNonNull(now, "now must not be null");
+    return expiresAt != null && !now.isBefore(expiresAt);
+  }
+
+  /**
+   * Returns a string representation with the {@code rawToken} redacted as 
{@code [REDACTED]}.
+   */
+  @Override
+  public String toString() {
+    return "UserContext{" +
+        "principal='" + principal + '\'' +
+        ", issuer='" + issuer + '\'' +
+        ", rawToken='[REDACTED]'" +
+        ", issuedAt=" + issuedAt +
+        ", expiresAt=" + expiresAt +
+        '}';
+  }
+
+  /**
+   * Equality is based on identity fields ({@code principal}, {@code issuer}) 
and token
+   * validity window ({@code issuedAt}, {@code expiresAt}). The secret {@code 
rawToken} is
+   * intentionally excluded: {@code UserContext} is driver-only and never used 
as a map key,
+   * so there is no need to compare secret material.
+   */
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+    UserContext that = (UserContext) o;
+    return principal.equals(that.principal)
+        && issuer.equals(that.issuer)
+        && Objects.equals(issuedAt, that.issuedAt)
+        && Objects.equals(expiresAt, that.expiresAt);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(principal, issuer, issuedAt, expiresAt);
+  }
+}
diff --git a/core/src/main/java/org/apache/spark/security/UserCredentials.java 
b/core/src/main/java/org/apache/spark/security/UserCredentials.java
new file mode 100644
index 000000000000..3f5e1d54b923
--- /dev/null
+++ b/core/src/main/java/org/apache/spark/security/UserCredentials.java
@@ -0,0 +1,113 @@
+/*
+ * 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.spark.security;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.spark.annotation.DeveloperApi;
+
+/**
+ * :: DeveloperApi ::
+ * A bundle of {@link ServiceCredential} instances keyed by scheme (e.g., 
"s3a", "abfss").
+ * <p>
+ * Scheme keys are normalized to lowercase ({@link Locale#ROOT}) at 
construction time, and
+ * lookups via {@link #forScheme(String)} are case-insensitive. If the 
supplied map contains
+ * keys that differ only by case, an {@link IllegalArgumentException} is 
thrown.
+ * <p>
+ * This class is transmitted to executors and does <b>not</b> contain any 
reference
+ * to {@link UserContext} or raw identity tokens. It is immutable and {@link 
Serializable}.
+ *
+ * @since 4.3.0
+ */
+@DeveloperApi
+public final class UserCredentials implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private final Map<String, ServiceCredential> credentials;
+
+  /**
+   * Constructs a new {@code UserCredentials} bundle.
+   * <p>
+   * Scheme keys are normalized to lowercase using {@link Locale#ROOT}. If 
multiple keys
+   * collide after lowercasing (e.g. {@code "s3a"} and {@code "S3A"}), an
+   * {@link IllegalArgumentException} is thrown rather than silently keeping 
one of them.
+   *
+   * @param credentials per-scheme map of service credentials (must not be 
null; defensively copied)
+   * @throws IllegalArgumentException if two keys collide after case 
normalization
+   */
+  public UserCredentials(Map<String, ServiceCredential> credentials) {
+    Objects.requireNonNull(credentials, "credentials must not be null");
+    Map<String, ServiceCredential> normalized = new 
HashMap<>(credentials.size());
+    for (Map.Entry<String, ServiceCredential> entry : credentials.entrySet()) {
+      Objects.requireNonNull(entry.getKey(), "scheme key must not be null");
+      String scheme = entry.getKey().toLowerCase(Locale.ROOT);
+      if (normalized.containsKey(scheme)) {
+        throw new IllegalArgumentException(
+            "Duplicate scheme after case normalization: " + entry.getKey());
+      }
+      normalized.put(scheme, entry.getValue());
+    }
+    this.credentials = normalized;
+  }
+
+  /**
+   * Returns an unmodifiable view of all credentials keyed by scheme.
+   */
+  public Map<String, ServiceCredential> getCredentials() {
+    return Collections.unmodifiableMap(credentials);
+  }
+
+  /**
+   * Looks up the {@link ServiceCredential} for the given scheme. The lookup is
+   * case-insensitive (the argument is lowercased with {@link Locale#ROOT}).
+   *
+   * @param scheme the target scheme (e.g., "s3a", "S3A"); must not be null
+   * @return an {@link Optional} containing the credential, or empty if no 
credential is registered
+   */
+  public Optional<ServiceCredential> forScheme(String scheme) {
+    Objects.requireNonNull(scheme, "scheme must not be null");
+    return 
Optional.ofNullable(credentials.get(scheme.toLowerCase(Locale.ROOT)));
+  }
+
+  @Override
+  public String toString() {
+    return "UserCredentials{" +
+        "credentials=" + credentials +
+        '}';
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+    UserCredentials that = (UserCredentials) o;
+    return credentials.equals(that.credentials);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(credentials);
+  }
+}
diff --git 
a/core/src/test/java/org/apache/spark/security/CredentialTypesSuite.java 
b/core/src/test/java/org/apache/spark/security/CredentialTypesSuite.java
new file mode 100644
index 000000000000..59d8ed66135d
--- /dev/null
+++ b/core/src/test/java/org/apache/spark/security/CredentialTypesSuite.java
@@ -0,0 +1,317 @@
+/*
+ * 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.spark.security;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.NotSerializableException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CredentialTypesSuite {
+
+  private static final String TOKEN = 
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.secret";
+  private static final Instant NOW = Instant.parse("2026-01-15T12:00:00Z");
+  private static final Instant PAST = Instant.parse("2026-01-15T11:00:00Z");
+  private static final Instant FUTURE = Instant.parse("2026-01-15T13:00:00Z");
+
+  // --- UserContext tests ---
+
+  @Test
+  public void testUserContextGettersRoundTrip() {
+    UserContext ctx = new UserContext("user1", "https://idp.example.com";, 
TOKEN, NOW, FUTURE);
+    assertEquals("user1", ctx.getPrincipal());
+    assertEquals("https://idp.example.com";, ctx.getIssuer());
+    assertEquals(TOKEN, ctx.getRawToken());
+    assertEquals(NOW, ctx.getIssuedAt());
+    assertEquals(FUTURE, ctx.getExpiresAt());
+  }
+
+  @Test
+  public void testUserContextToStringRedactsToken() {
+    UserContext ctx = new UserContext("user1", "https://idp.example.com";, 
TOKEN, NOW, FUTURE);
+    String str = ctx.toString();
+    assertFalse(str.contains(TOKEN), "toString must not contain the raw token 
value");
+    assertTrue(str.contains("[REDACTED]"), "toString must contain [REDACTED]");
+    assertTrue(str.contains("user1"));
+    assertTrue(str.contains("https://idp.example.com";));
+  }
+
+  @Test
+  public void testUserContextIsExpiredBoundary() {
+    // expiresAt == NOW -> expired (now >= expiresAt)
+    UserContext ctx = new UserContext("u", "iss", TOKEN, PAST, NOW);
+    assertTrue(ctx.isExpired(NOW), "Should be expired when now == expiresAt");
+    assertFalse(ctx.isExpired(PAST), "Should not be expired when now < 
expiresAt");
+    assertTrue(ctx.isExpired(FUTURE), "Should be expired when now > 
expiresAt");
+  }
+
+  @Test
+  public void testUserContextIsExpiredWithNullExpiry() {
+    UserContext ctx = new UserContext("u", "iss", TOKEN, NOW, null);
+    assertFalse(ctx.isExpired(NOW), "Should not be expired when expiresAt is 
null");
+  }
+
+  @Test
+  public void testUserContextEqualsAndHashCode() {
+    UserContext a = new UserContext("u", "iss", TOKEN, NOW, FUTURE);
+    UserContext b = new UserContext("u", "iss", TOKEN, NOW, FUTURE);
+    UserContext c = new UserContext("other", "iss", TOKEN, NOW, FUTURE);
+    assertEquals(a, b);
+    assertEquals(a.hashCode(), b.hashCode());
+    assertNotEquals(a, c);
+  }
+
+  @Test
+  public void testUserContextEqualsExcludesRawToken() {
+    // rawToken is secret material and intentionally excluded from 
equals/hashCode:
+    // two contexts that differ only by rawToken are considered equal.
+    UserContext a = new UserContext("u", "iss", "token-A", NOW, FUTURE);
+    UserContext b = new UserContext("u", "iss", "token-B", NOW, FUTURE);
+    assertEquals(a, b, "equals must not depend on rawToken");
+    assertEquals(a.hashCode(), b.hashCode(), "hashCode must not depend on 
rawToken");
+    // A differing identity field still breaks equality.
+    UserContext c = new UserContext("u", "other-iss", "token-A", NOW, FUTURE);
+    assertNotEquals(a, c);
+  }
+
+  @Test
+  public void testUserContextNotSerializable() {
+    UserContext ctx = new UserContext("user1", "https://idp.example.com";, 
TOKEN, NOW, FUTURE);
+    ByteArrayOutputStream bos = new ByteArrayOutputStream();
+    assertThrows(NotSerializableException.class, () -> {
+      try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+        oos.writeObject(ctx);
+      }
+    });
+  }
+
+  // --- ServiceCredential tests ---
+
+  @Test
+  public void testServiceCredentialDefensiveCopy() {
+    Map<String, String> props = new HashMap<>();
+    props.put("key", "value");
+    ServiceCredential cred = new ServiceCredential(props, FUTURE);
+    // Mutating the original map must not affect the credential
+    props.put("key2", "value2");
+    assertFalse(cred.getProperties().containsKey("key2"));
+    assertEquals(1, cred.getProperties().size());
+  }
+
+  @Test
+  public void testServiceCredentialIsExpired() {
+    ServiceCredential cred = new ServiceCredential(Map.of("k", "v"), NOW);
+    assertTrue(cred.isExpired(NOW), "Should be expired when now == expiresAt");
+    assertFalse(cred.isExpired(PAST), "Should not be expired when now < 
expiresAt");
+    assertTrue(cred.isExpired(FUTURE), "Should be expired when now > 
expiresAt");
+  }
+
+  @Test
+  public void testServiceCredentialIsExpiredWithNullExpiry() {
+    ServiceCredential cred = new ServiceCredential(Map.of("k", "v"), null);
+    assertFalse(cred.isExpired(NOW));
+  }
+
+  @Test
+  public void testServiceCredentialSerializationRoundTrip() throws Exception {
+    Map<String, String> props = new HashMap<>();
+    props.put("fs.s3a.access.key", "AKIAIOSFODNN7EXAMPLE");
+    props.put("fs.s3a.secret.key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE");
+    ServiceCredential original = new ServiceCredential(props, FUTURE);
+
+    byte[] bytes = serialize(original);
+    ServiceCredential deserialized = (ServiceCredential) deserialize(bytes);
+
+    assertEquals(original, deserialized);
+    assertEquals(original.getProperties(), deserialized.getProperties());
+    assertEquals(original.getExpiresAt(), deserialized.getExpiresAt());
+  }
+
+  @Test
+  public void testServiceCredentialEqualsAndHashCode() {
+    ServiceCredential a = new ServiceCredential(Map.of("k", "v"), FUTURE);
+    ServiceCredential b = new ServiceCredential(Map.of("k", "v"), FUTURE);
+    ServiceCredential c = new ServiceCredential(Map.of("k", "other"), FUTURE);
+    assertEquals(a, b);
+    assertEquals(a.hashCode(), b.hashCode());
+    assertNotEquals(a, c);
+  }
+
+  @Test
+  public void testServiceCredentialToStringRedactsValues() {
+    Map<String, String> props = new HashMap<>();
+    props.put("accessKey", "AKIAIOSFODNN7EXAMPLE");
+    props.put("secretKey", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE");
+    ServiceCredential cred = new ServiceCredential(props, FUTURE);
+    String str = cred.toString();
+    // Secret values must NOT appear
+    assertFalse(str.contains("AKIAIOSFODNN7EXAMPLE"),
+        "toString must not contain secret property values");
+    assertFalse(str.contains("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE"),
+        "toString must not contain secret property values");
+    // Keys SHOULD appear for debuggability
+    assertTrue(str.contains("accessKey"), "toString should contain property 
key names");
+    assertTrue(str.contains("secretKey"), "toString should contain property 
key names");
+    // Values are replaced with [REDACTED]
+    assertTrue(str.contains("[REDACTED]"), "toString must contain [REDACTED] 
for values");
+    // expiresAt should still appear
+    assertTrue(str.contains("expiresAt"), "toString should contain expiresAt");
+  }
+
+  @Test
+  public void testServiceCredentialToStringEmptyProperties() {
+    ServiceCredential cred = new ServiceCredential(Map.of(), null);
+    String str = cred.toString();
+    assertTrue(str.contains("properties={}"),
+        "toString should handle empty properties map");
+    assertTrue(str.contains("expiresAt=null"));
+  }
+
+  // --- UserCredentials tests ---
+
+  @Test
+  public void testUserCredentialsForSchemePresent() {
+    ServiceCredential s3Cred = new ServiceCredential(Map.of("k", "v"), FUTURE);
+    UserCredentials uc = new UserCredentials(Map.of("s3a", s3Cred));
+    Optional<ServiceCredential> result = uc.forScheme("s3a");
+    assertTrue(result.isPresent());
+    assertEquals(s3Cred, result.get());
+  }
+
+  @Test
+  public void testUserCredentialsForSchemeAbsent() {
+    ServiceCredential s3Cred = new ServiceCredential(Map.of("k", "v"), FUTURE);
+    UserCredentials uc = new UserCredentials(Map.of("s3a", s3Cred));
+    Optional<ServiceCredential> result = uc.forScheme("abfss");
+    assertFalse(result.isPresent());
+  }
+
+  @Test
+  public void testUserCredentialsDoesNotExposeToken() {
+    // UserCredentials only contains ServiceCredentials, not UserContext or 
tokens
+    ServiceCredential cred = new ServiceCredential(Map.of("token", 
"short-lived"), FUTURE);
+    UserCredentials uc = new UserCredentials(Map.of("s3a", cred));
+    String str = uc.toString();
+    assertFalse(str.contains(TOKEN), "UserCredentials must not contain raw 
identity token");
+    // ServiceCredential values should also be redacted in nested toString
+    assertFalse(str.contains("short-lived"),
+        "UserCredentials must not expose ServiceCredential property values");
+  }
+
+  @Test
+  public void testUserCredentialsSerializationRoundTrip() throws Exception {
+    Map<String, ServiceCredential> creds = new HashMap<>();
+    creds.put("s3a", new ServiceCredential(Map.of("key", "val1"), FUTURE));
+    creds.put("abfss", new ServiceCredential(Map.of("token", "val2"), NOW));
+    UserCredentials original = new UserCredentials(creds);
+
+    byte[] bytes = serialize(original);
+    UserCredentials deserialized = (UserCredentials) deserialize(bytes);
+
+    assertEquals(original, deserialized);
+    assertEquals(original.getCredentials(), deserialized.getCredentials());
+  }
+
+  @Test
+  public void testUserCredentialsDefensiveCopy() {
+    Map<String, ServiceCredential> creds = new HashMap<>();
+    creds.put("s3a", new ServiceCredential(Map.of("k", "v"), FUTURE));
+    UserCredentials uc = new UserCredentials(creds);
+    // Mutating the original map must not affect the credentials
+    creds.put("extra", new ServiceCredential(Map.of("x", "y"), NOW));
+    assertFalse(uc.forScheme("extra").isPresent());
+  }
+
+  @Test
+  public void testUserCredentialsEqualsAndHashCode() {
+    ServiceCredential cred = new ServiceCredential(Map.of("k", "v"), FUTURE);
+    UserCredentials a = new UserCredentials(Map.of("s3a", cred));
+    UserCredentials b = new UserCredentials(Map.of("s3a", cred));
+    UserCredentials c = new UserCredentials(Map.of("abfss", cred));
+    assertEquals(a, b);
+    assertEquals(a.hashCode(), b.hashCode());
+    assertNotEquals(a, c);
+  }
+
+  @Test
+  public void testUserCredentialsForSchemeCaseInsensitive() {
+    ServiceCredential cred = new ServiceCredential(Map.of("key", "val"), 
FUTURE);
+    // Store with lowercase key, lookup with uppercase
+    UserCredentials uc1 = new UserCredentials(Map.of("s3a", cred));
+    assertTrue(uc1.forScheme("S3A").isPresent(), "Uppercase lookup should find 
lowercase key");
+    assertEquals(cred, uc1.forScheme("S3A").get());
+    assertTrue(uc1.forScheme("s3a").isPresent(), "Exact case lookup should 
still work");
+    assertTrue(uc1.forScheme("S3a").isPresent(), "Mixed case lookup should 
work");
+
+    // Store with uppercase key, lookup with lowercase
+    UserCredentials uc2 = new UserCredentials(Map.of("ABFSS", cred));
+    assertTrue(uc2.forScheme("abfss").isPresent(), "Lowercase lookup should 
find uppercase key");
+    assertEquals(cred, uc2.forScheme("abfss").get());
+
+    // Absent scheme still returns empty
+    assertFalse(uc2.forScheme("hdfs").isPresent(), "Absent scheme should 
return empty");
+  }
+
+  @Test
+  public void testUserCredentialsRejectsCaseCollidingSchemes() {
+    Map<String, ServiceCredential> creds = new HashMap<>();
+    creds.put("s3a", new ServiceCredential(Map.of("k", "v1"), FUTURE));
+    creds.put("S3A", new ServiceCredential(Map.of("k", "v2"), FUTURE));
+    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+        () -> new UserCredentials(creds));
+    assertTrue(e.getMessage().contains("Duplicate scheme"),
+        "Exception message should identify the collision");
+  }
+
+  @Test
+  public void testUserCredentialsRejectsNullSchemeKey() {
+    Map<String, ServiceCredential> creds = new HashMap<>();
+    creds.put(null, new ServiceCredential(Map.of("k", "v"), FUTURE));
+    assertThrows(NullPointerException.class, () -> new UserCredentials(creds));
+  }
+
+  // --- Helpers ---
+
+  private static byte[] serialize(Object obj) throws Exception {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+      oos.writeObject(obj);
+    }
+    return baos.toByteArray();
+  }
+
+  private static Object deserialize(byte[] bytes) throws Exception {
+    ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
+    try (ObjectInputStream ois = new ObjectInputStream(bais)) {
+      return ois.readObject();
+    }
+  }
+}


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

Reply via email to