diff --git a/src/main/java/land/oras/auth/AuthStore.java b/src/main/java/land/oras/auth/AuthStore.java index b47663bc..f309ddcf 100644 --- a/src/main/java/land/oras/auth/AuthStore.java +++ b/src/main/java/land/oras/auth/AuthStore.java @@ -209,6 +209,14 @@ public static Config load(List configFiles) throws OrasException { /** * Retrieves the {@code Credential} associated with the specified containerRef. + * Implements hierarchical credential lookup from most-specific to least-specific. + * For example, "my-registry.local/namespace/user/image:latest" is looked up as: + *
    + *
  1. my-registry.local/namespace/user/image
  2. + *
  3. my-registry.local/namespace/user
  4. + *
  5. my-registry.local/namespace
  6. + *
  7. my-registry.local
  8. + *
* * @param containerRef The containerRef whose credential is to be retrieved. * @return The {@code Credential} associated with the containerRef, or {@code null} if no credential is found. @@ -216,15 +224,30 @@ public static Config load(List configFiles) throws OrasException { public @Nullable Credential getCredential(ContainerRef containerRef) throws OrasException { String registry = containerRef.getRegistry(); - LOG.debug("Looking for credentials for registry '{}'", registry); + // Start at the most specific key: registry/namespace/repository (or registry/repository) + String key = registry + "/" + containerRef.getFullRepository(); - // Check direct credential first - Credential cred = credentialStore.get(registry); - if (cred != null) { - return cred; + LOG.debug("Looking for credentials for containerRef starting at key '{}'", key); + + // Iterate from most-specific to least-specific, stopping when only the registry remains + while (!key.equals(registry)) { + Credential cred = credentialStore.get(key); + if (cred != null) { + LOG.debug("Found credential for key '{}'", key); + return cred; + } + // Remove the last path segment and continue with the less specific key + key = key.substring(0, key.lastIndexOf('/')); } - // Then, try credential helper + // Check the registry-only key + Credential registryCred = credentialStore.get(key); + if (registryCred != null) { + LOG.debug("Found credential for registry '{}'", key); + return registryCred; + } + + // Try credential helper scoped to the registry String helperSuffix = credentialHelperStore.get(registry); if (helperSuffix != null) { try { @@ -234,6 +257,7 @@ public static Config load(List configFiles) throws OrasException { LOG.warn("Failed to get credential from helper for registry {}: {}", registry, e.getMessage()); } } + // Finally, try all-registries helper helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER); if (helperSuffix != null) { diff --git a/src/test/java/land/oras/auth/AuthStoreTest.java b/src/test/java/land/oras/auth/AuthStoreTest.java index 45ccc3b2..be462e1f 100644 --- a/src/test/java/land/oras/auth/AuthStoreTest.java +++ b/src/test/java/land/oras/auth/AuthStoreTest.java @@ -272,6 +272,90 @@ void testShouldReadCredentialsFromPodManConfig() throws Exception { }); } + // language=json + public static final String SAMPLE_HIERARCHICAL_CONFIG = + """ + { + "auths": { + "my-registry.local/namespace/user/image": { + "auth": "dXNlcjE6cGFzczE=" + }, + "my-registry.local/namespace": { + "auth": "dXNlcjM6cGFzczM=" + }, + "my-registry.local": { + "auth": "dXNlcjI6cGFzczI=" + } + } + } + """; + + @Test + void testHierarchicalCredentialLookupMostSpecific() throws Exception { + Path configFile = tempDir.resolve("hierarchical-config.json"); + Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG); + AuthStore store = AuthStore.newStore(List.of(configFile)); + + // Most specific key: my-registry.local/namespace/user/image + AuthStore.Credential credential = + store.get(ContainerRef.parse("my-registry.local/namespace/user/image:latest")); + assertNotNull(credential); + assertEquals("user1", credential.username()); + assertEquals("pass1", credential.password()); + } + + @Test + void testHierarchicalCredentialLookupNamespaceOnly() throws Exception { + Path configFile = tempDir.resolve("hierarchical-config.json"); + Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG); + AuthStore store = AuthStore.newStore(List.of(configFile)); + + // Credential stored at namespace level: my-registry.local/namespace + // Image under that namespace but not exact-matched should fall back to namespace credential + AuthStore.Credential credential = + store.get(ContainerRef.parse("my-registry.local/namespace/other-image:latest")); + assertNotNull(credential); + assertEquals("user3", credential.username()); + assertEquals("pass3", credential.password()); + } + + @Test + void testHierarchicalCredentialLookupFallsBackToRegistry() throws Exception { + Path configFile = tempDir.resolve("hierarchical-config.json"); + Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG); + AuthStore store = AuthStore.newStore(List.of(configFile)); + + // Different image under the same registry falls back to registry-level credential + AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/other/repo:latest")); + assertNotNull(credential); + assertEquals("user2", credential.username()); + assertEquals("pass2", credential.password()); + } + + @Test + void testHierarchicalCredentialLookupRegistryOnly() throws Exception { + Path configFile = tempDir.resolve("hierarchical-config.json"); + Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG); + AuthStore store = AuthStore.newStore(List.of(configFile)); + + // Image without namespace falls back to registry-level credential + AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/image:latest")); + assertNotNull(credential); + assertEquals("user2", credential.username()); + assertEquals("pass2", credential.password()); + } + + @Test + void testHierarchicalCredentialLookupNoMatch() throws Exception { + Path configFile = tempDir.resolve("hierarchical-config.json"); + Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG); + AuthStore store = AuthStore.newStore(List.of(configFile)); + + // Unknown registry returns null + AuthStore.Credential credential = store.get(ContainerRef.parse("unknown-registry.local/foo/bar:latest")); + assertNull(credential); + } + @Test void testWithoutXdgRuntimeDir() throws Exception { new EnvironmentVariables().remove("XDG_RUNTIME_DIR").execute(() -> {