From 6867818fc15ae89225726065ef1f00b632eaf3b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:10:33 +0000 Subject: [PATCH 1/4] Initial plan From 204e219a521428e036f181acb982751f46d2eea1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:16:50 +0000 Subject: [PATCH 2/4] Support hierarchical credentials on AuthStore Co-authored-by: jonesbusy <825750+jonesbusy@users.noreply.github.com> --- src/main/java/land/oras/auth/AuthStore.java | 78 +++++++++++++++---- .../java/land/oras/auth/AuthStoreTest.java | 65 ++++++++++++++++ 2 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/main/java/land/oras/auth/AuthStore.java b/src/main/java/land/oras/auth/AuthStore.java index b47663bc..c5b8bff0 100644 --- a/src/main/java/land/oras/auth/AuthStore.java +++ b/src/main/java/land/oras/auth/AuthStore.java @@ -209,33 +209,46 @@ 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. */ public @Nullable Credential getCredential(ContainerRef containerRef) throws OrasException { String registry = containerRef.getRegistry(); + List keys = getHierarchicalKeys(containerRef); - LOG.debug("Looking for credentials for registry '{}'", registry); + LOG.debug("Looking for credentials for containerRef with hierarchical keys: {}", keys); - // Check direct credential first - Credential cred = credentialStore.get(registry); - if (cred != null) { - return cred; - } + for (String key : keys) { + // Check direct credential first + Credential cred = credentialStore.get(key); + if (cred != null) { + LOG.debug("Found credential for key '{}'", key); + return cred; + } - // Then, try credential helper - String helperSuffix = credentialHelperStore.get(registry); - if (helperSuffix != null) { - try { - LOG.debug("Using credential helper '{}' for registry '{}'", helperSuffix, registry); - return getFromCredentialHelper(helperSuffix, registry); - } catch (OrasException e) { - LOG.warn("Failed to get credential from helper for registry {}: {}", registry, e.getMessage()); + // Then, try credential helper for this key + String helperSuffix = credentialHelperStore.get(key); + if (helperSuffix != null) { + try { + LOG.debug("Using credential helper '{}' for key '{}'", helperSuffix, key); + return getFromCredentialHelper(helperSuffix, key); + } catch (OrasException e) { + LOG.warn("Failed to get credential from helper for key {}: {}", key, e.getMessage()); + } } } + // Finally, try all-registries helper - helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER); + String helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER); if (helperSuffix != null) { try { LOG.debug("Using all-registries credential helper for registry '{}'", registry); @@ -251,6 +264,41 @@ public static Config load(List configFiles) throws OrasException { return null; } + /** + * Returns hierarchical lookup keys for the given containerRef, ordered from most-specific to least-specific. + * For example, "my-registry.local/namespace/user/image:latest" produces: + * ["my-registry.local/namespace/user/image", "my-registry.local/namespace/user", + * "my-registry.local/namespace", "my-registry.local"] + * + * @param containerRef The containerRef to generate keys for. + * @return List of lookup keys ordered from most to least specific. + */ + private static List getHierarchicalKeys(ContainerRef containerRef) { + List keys = new ArrayList<>(); + String registry = containerRef.getRegistry(); + String namespace = containerRef.getNamespace(); + String repository = containerRef.getRepository(); + + // Build the full path: registry/namespace/repository or registry/repository + String path = namespace != null + ? registry + "/" + namespace + "/" + repository + : registry + "/" + repository; + + // Add progressively less specific keys + keys.add(path); + int lastSlash = path.lastIndexOf('/'); + while (lastSlash > registry.length()) { + path = path.substring(0, lastSlash); + keys.add(path); + lastSlash = path.lastIndexOf('/'); + } + + // Always include just the registry as the least specific key + keys.add(registry); + + return keys; + } + private static Credential getFromCredentialHelper(String suffix, String hostname) throws OrasException { LOG.debug("Looking for credential helper 'docker-credential-{}' for hostname '{}'", suffix, hostname); diff --git a/src/test/java/land/oras/auth/AuthStoreTest.java b/src/test/java/land/oras/auth/AuthStoreTest.java index 45ccc3b2..1a4951b8 100644 --- a/src/test/java/land/oras/auth/AuthStoreTest.java +++ b/src/test/java/land/oras/auth/AuthStoreTest.java @@ -272,6 +272,71 @@ 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": { + "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 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(() -> { From 6420cb3afcb341fcacd98f3594700a58c488354c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:47:04 +0000 Subject: [PATCH 3/4] Refactor hierarchical credential lookup to iterative approach using getFullRepository Co-authored-by: jonesbusy <825750+jonesbusy@users.noreply.github.com> --- src/main/java/land/oras/auth/AuthStore.java | 56 ++++++------------- .../java/land/oras/auth/AuthStoreTest.java | 21 ++++++- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/src/main/java/land/oras/auth/AuthStore.java b/src/main/java/land/oras/auth/AuthStore.java index c5b8bff0..b2111308 100644 --- a/src/main/java/land/oras/auth/AuthStore.java +++ b/src/main/java/land/oras/auth/AuthStore.java @@ -223,19 +223,22 @@ public static Config load(List configFiles) throws OrasException { */ public @Nullable Credential getCredential(ContainerRef containerRef) throws OrasException { String registry = containerRef.getRegistry(); - List keys = getHierarchicalKeys(containerRef); - LOG.debug("Looking for credentials for containerRef with hierarchical keys: {}", keys); + // Start at the most specific key: registry/namespace/repository (or registry/repository) + String key = registry + "/" + containerRef.getFullRepository(); - for (String key : keys) { - // Check direct credential first + LOG.debug("Looking for credentials for containerRef starting at key '{}'", key); + + // Iterate from most-specific to least-specific, ending at the registry level + while (true) { + // Check direct credential Credential cred = credentialStore.get(key); if (cred != null) { LOG.debug("Found credential for key '{}'", key); return cred; } - // Then, try credential helper for this key + // Check credential helper for this key String helperSuffix = credentialHelperStore.get(key); if (helperSuffix != null) { try { @@ -245,6 +248,14 @@ public static Config load(List configFiles) throws OrasException { LOG.warn("Failed to get credential from helper for key {}: {}", key, e.getMessage()); } } + + // Stop once the registry level has been checked + if (key.equals(registry)) { + break; + } + + // Remove the last path segment and continue with the less specific key + key = key.substring(0, key.lastIndexOf('/')); } // Finally, try all-registries helper @@ -264,41 +275,6 @@ public static Config load(List configFiles) throws OrasException { return null; } - /** - * Returns hierarchical lookup keys for the given containerRef, ordered from most-specific to least-specific. - * For example, "my-registry.local/namespace/user/image:latest" produces: - * ["my-registry.local/namespace/user/image", "my-registry.local/namespace/user", - * "my-registry.local/namespace", "my-registry.local"] - * - * @param containerRef The containerRef to generate keys for. - * @return List of lookup keys ordered from most to least specific. - */ - private static List getHierarchicalKeys(ContainerRef containerRef) { - List keys = new ArrayList<>(); - String registry = containerRef.getRegistry(); - String namespace = containerRef.getNamespace(); - String repository = containerRef.getRepository(); - - // Build the full path: registry/namespace/repository or registry/repository - String path = namespace != null - ? registry + "/" + namespace + "/" + repository - : registry + "/" + repository; - - // Add progressively less specific keys - keys.add(path); - int lastSlash = path.lastIndexOf('/'); - while (lastSlash > registry.length()) { - path = path.substring(0, lastSlash); - keys.add(path); - lastSlash = path.lastIndexOf('/'); - } - - // Always include just the registry as the least specific key - keys.add(registry); - - return keys; - } - private static Credential getFromCredentialHelper(String suffix, String hostname) throws OrasException { LOG.debug("Looking for credential helper 'docker-credential-{}' for hostname '{}'", suffix, hostname); diff --git a/src/test/java/land/oras/auth/AuthStoreTest.java b/src/test/java/land/oras/auth/AuthStoreTest.java index 1a4951b8..be462e1f 100644 --- a/src/test/java/land/oras/auth/AuthStoreTest.java +++ b/src/test/java/land/oras/auth/AuthStoreTest.java @@ -280,6 +280,9 @@ void testShouldReadCredentialsFromPodManConfig() throws Exception { "my-registry.local/namespace/user/image": { "auth": "dXNlcjE6cGFzczE=" }, + "my-registry.local/namespace": { + "auth": "dXNlcjM6cGFzczM=" + }, "my-registry.local": { "auth": "dXNlcjI6cGFzczI=" } @@ -294,12 +297,28 @@ void testHierarchicalCredentialLookupMostSpecific() throws Exception { 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")); + 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"); From ec3c13c36e9c51bee4099b8f33d6274abff1dc26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:58:15 +0000 Subject: [PATCH 4/4] Replace while(true) with while(!key.equals(registry)); only check credentialStore hierarchically Co-authored-by: jonesbusy <825750+jonesbusy@users.noreply.github.com> --- src/main/java/land/oras/auth/AuthStore.java | 40 ++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/java/land/oras/auth/AuthStore.java b/src/main/java/land/oras/auth/AuthStore.java index b2111308..f309ddcf 100644 --- a/src/main/java/land/oras/auth/AuthStore.java +++ b/src/main/java/land/oras/auth/AuthStore.java @@ -229,37 +229,37 @@ public static Config load(List configFiles) throws OrasException { LOG.debug("Looking for credentials for containerRef starting at key '{}'", key); - // Iterate from most-specific to least-specific, ending at the registry level - while (true) { - // Check direct credential + // 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('/')); + } - // Check credential helper for this key - String helperSuffix = credentialHelperStore.get(key); - if (helperSuffix != null) { - try { - LOG.debug("Using credential helper '{}' for key '{}'", helperSuffix, key); - return getFromCredentialHelper(helperSuffix, key); - } catch (OrasException e) { - LOG.warn("Failed to get credential from helper for key {}: {}", key, e.getMessage()); - } - } + // Check the registry-only key + Credential registryCred = credentialStore.get(key); + if (registryCred != null) { + LOG.debug("Found credential for registry '{}'", key); + return registryCred; + } - // Stop once the registry level has been checked - if (key.equals(registry)) { - break; + // Try credential helper scoped to the registry + String helperSuffix = credentialHelperStore.get(registry); + if (helperSuffix != null) { + try { + LOG.debug("Using credential helper '{}' for registry '{}'", helperSuffix, registry); + return getFromCredentialHelper(helperSuffix, registry); + } catch (OrasException e) { + LOG.warn("Failed to get credential from helper for registry {}: {}", registry, e.getMessage()); } - - // Remove the last path segment and continue with the less specific key - key = key.substring(0, key.lastIndexOf('/')); } // Finally, try all-registries helper - String helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER); + helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER); if (helperSuffix != null) { try { LOG.debug("Using all-registries credential helper for registry '{}'", registry);