diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index e42f1222..a9796500 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -2,6 +2,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import land.oras.exception.OrasException; import land.oras.utils.Const; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/land/oras/Layer.java b/src/main/java/land/oras/Layer.java index 7c98d024..b9e1fd6a 100644 --- a/src/main/java/land/oras/Layer.java +++ b/src/main/java/land/oras/Layer.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.Map; import java.util.Objects; +import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.DigestUtils; import land.oras.utils.JsonUtils; diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index 785efc6b..aeac9fbe 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Map; import land.oras.auth.*; +import land.oras.exception.OrasException; import land.oras.utils.ArchiveUtils; import land.oras.utils.Const; import land.oras.utils.DigestUtils; diff --git a/src/main/java/land/oras/auth/BearerTokenProvider.java b/src/main/java/land/oras/auth/BearerTokenProvider.java index 0c752616..4a428df7 100644 --- a/src/main/java/land/oras/auth/BearerTokenProvider.java +++ b/src/main/java/land/oras/auth/BearerTokenProvider.java @@ -6,7 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import land.oras.OrasException; +import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.JsonUtils; import land.oras.utils.OrasHttpClient; diff --git a/src/main/java/land/oras/auth/FileStoreAuthenticationProvider.java b/src/main/java/land/oras/auth/FileStoreAuthenticationProvider.java new file mode 100644 index 00000000..e7e64574 --- /dev/null +++ b/src/main/java/land/oras/auth/FileStoreAuthenticationProvider.java @@ -0,0 +1,40 @@ +package land.oras.auth; + +import land.oras.ContainerRef; +import land.oras.credentials.FileStore; +import land.oras.credentials.FileStore.Credential; +import land.oras.exception.OrasException; + +/** + * FileStoreAuthenticationProvider is an implementation of the AuthProvider interface. + * It retrieves credentials from a FileStore and generates a Basic Authentication header. + */ +public class FileStoreAuthenticationProvider implements AuthProvider { + + private final FileStore fileStore; + private final ContainerRef containerRef; + private final UsernamePasswordProvider usernamePasswordAuthProvider; + + /** + * Constructor for FileStoreAuthenticationProvider. + * + * @param fileStore The FileStore instance to retrieve credentials from. + * @param containerRef The server address for which to retrieve credentials. + * @throws Exception If an error occurs during authentication initialization. + */ + public FileStoreAuthenticationProvider(FileStore fileStore, ContainerRef containerRef) throws Exception { + this.fileStore = fileStore; + this.containerRef = containerRef; + Credential credential = fileStore.get(containerRef); + if (credential == null) { + throw new OrasException("No credentials found for containerRef"); + } + this.usernamePasswordAuthProvider = + new UsernamePasswordProvider(credential.getUsername(), credential.getPassword()); + } + + @Override + public String getAuthHeader() { + return usernamePasswordAuthProvider.getAuthHeader(); + } +} diff --git a/src/main/java/land/oras/credentials/FileStore.java b/src/main/java/land/oras/credentials/FileStore.java new file mode 100644 index 00000000..c327aed9 --- /dev/null +++ b/src/main/java/land/oras/credentials/FileStore.java @@ -0,0 +1,220 @@ +package land.oras.credentials; + +import java.io.FileReader; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import land.oras.ContainerRef; +import land.oras.exception.OrasException; +import land.oras.utils.JsonUtils; + +/** + * FileStore implements a credentials store using a configuration file + * to keep the credentials in plain-text. + * + * Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties + */ +public class FileStore { + + private final boolean disablePut; + private final Config config; + + /** + * Error message indicating that putting plaintext credentials is disabled. + * This is used to enforce security policies against storing sensitive credentials in plaintext format. + */ + public static final String ERR_PLAINTEXT_PUT_DISABLED = "Putting plaintext credentials is disabled"; + + /** + * Error message indicating that the format of the provided credential is invalid. + * This is typically used when credentials do not match the expected structure or format. + */ + public static final String ERR_BAD_CREDENTIAL_FORMAT = "Bad credential format"; + + /** + * Constructor for FileStore. + * + * @param disablePut boolean flag to disable putting credentials in plaintext. + * @param config configuration instance. + */ + public FileStore(boolean disablePut, Config config) { + this.disablePut = disablePut; + this.config = Objects.requireNonNull(config, "Config cannot be null"); + } + + /** + * Creates a new FileStore based on the given configuration file path. + * + * @param configPath Path to the configuration file. + * @return FileStore instance. + * @throws OrasException if loading the configuration fails. + */ + public static FileStore newFileStore(String configPath) throws OrasException { + Config cfg = Config.load(configPath); + return new FileStore(false, cfg); + } + + /** + * Retrieves credentials for the given containerRef. + * + * @param containerRef ContainerRef. + * @return Credential object. + * @throws OrasException if retrieval fails. + */ + public Credential get(ContainerRef containerRef) throws OrasException { + return config.getCredential(containerRef); + } + + /** + * Saves credentials for the given ContainerRef. + * + * @param containerRef ContainerRef. + * @param credential Credential object. + * @throws Exception if saving fails. + */ + public void put(ContainerRef containerRef, Credential credential) throws Exception { + if (disablePut) { + throw new UnsupportedOperationException(ERR_PLAINTEXT_PUT_DISABLED); + } + validateCredentialFormat(credential); + config.putCredential(containerRef, credential); + } + + /** + * Deletes credentials for the given container. + * + * @param containerRef . + * @throws OrasException if deletion fails. + */ + public void delete(ContainerRef containerRef) throws OrasException { + config.deleteCredential(containerRef); + } + + /** + * Validates the format of the credential. + * + * @param credential Credential object. + * @throws Exception if the credential format is invalid. + */ + private void validateCredentialFormat(Credential credential) throws Exception { + if (credential.getUsername().contains(":")) { + throw new IllegalArgumentException(ERR_BAD_CREDENTIAL_FORMAT + ": colons(:) are not allowed in username"); + } + } + + /** + * Nested Config class for configuration management. + */ + public static class Config { + private final ConcurrentHashMap credentialStore = new ConcurrentHashMap<>(); + + /** + * Loads the configuration from a JSON file at the specified path and populates the credential store. + * + * @param configPath The path to the JSON configuration file. + * @return A {@code Config} object populated with the credentials from the JSON file. + * @throws OrasException If an error occurs while reading or parsing the JSON file. + */ + public static Config load(String configPath) throws OrasException { + Config config = new Config(); + try (FileReader reader = new FileReader(configPath)) { + // Deserialize the JSON file into a map of ContainerRef to Credential + Map> credentials = JsonUtils.fromJson(reader, Map.class); + + // Populate the credential store with the parsed credentials + for (Map.Entry> entry : credentials.entrySet()) { + Map values = entry.getValue(); + if (values != null) { + String username = values.get("username"); + String password = values.get("password"); + if (username != null && password != null) { + config.credentialStore.put(entry.getKey(), new Credential(username, password)); + } else { + throw new OrasException( + "Invalid credential entry: missing username or password for " + entry.getKey()); + } + } + } + } catch (IOException e) { + throw new OrasException("Failed to load configuration from path: " + configPath, e); + } catch (ClassCastException e) { + throw new OrasException("Invalid JSON structure in configuration file: " + configPath, e); + } + return config; + } + + /** + * Retrieves the {@code Credential} associated with the specified containerRef. + * + * @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 Credential getCredential(ContainerRef containerRef) throws OrasException { + if (credentialStore.containsKey(containerRef)) { + return credentialStore.get(containerRef); + } else { + throw new OrasException("No credentials found for server address"); + } + } + + /** + * Associates the specified {@code Credential} with the given containerRef. + * If a credential already exists for the containerRef, it will be replaced. + * + * @param containerRef The containerRef to associate with the credential. + * @param credential The {@code Credential} to store. Must not be {@code null}. + * @throws NullPointerException If the provided credential is {@code null}. + */ + public void putCredential(ContainerRef containerRef, Credential credential) { + credentialStore.put(containerRef.toString(), credential); + } + + /** + * Removes the {@code Credential} associated with the specified containerRef. + * If no credential is associated with the containerRef, this method does nothing. + * + * @param containerRef The containerRef whose credential is to be removed. + */ + public void deleteCredential(ContainerRef containerRef) { + credentialStore.remove(containerRef.toString()); + } + } + + /** + * Nested Credential class to represent username and password pairs. + */ + public static class Credential { + private String username; + private String password; + + /** + * Constructs a new {@code Credential} object with the specified username and password. + * + * @param username The username for the credential. Must not be {@code null}. + * @param password The password for the credential. Must not be {@code null}. + */ + public Credential(String username, String password) { + this.username = Objects.requireNonNull(username, "Username cannot be null"); + this.password = Objects.requireNonNull(password, "Password cannot be null"); + } + + /** + * Returns the username associated with this credential. + * + * @return The username as a {@code String}. + */ + public String getUsername() { + return username; + } + + /** + * Returns the password associated with this credential. + * + * @return The password as a {@code String}. + */ + public String getPassword() { + return password; + } + } +} diff --git a/src/main/java/land/oras/Error.java b/src/main/java/land/oras/exception/Error.java similarity index 91% rename from src/main/java/land/oras/Error.java rename to src/main/java/land/oras/exception/Error.java index 06a003ed..b658ab84 100644 --- a/src/main/java/land/oras/Error.java +++ b/src/main/java/land/oras/exception/Error.java @@ -1,4 +1,4 @@ -package land.oras; +package land.oras.exception; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/land/oras/OrasException.java b/src/main/java/land/oras/exception/OrasException.java similarity index 98% rename from src/main/java/land/oras/OrasException.java rename to src/main/java/land/oras/exception/OrasException.java index 9a016e19..f5a0ec37 100644 --- a/src/main/java/land/oras/OrasException.java +++ b/src/main/java/land/oras/exception/OrasException.java @@ -1,4 +1,4 @@ -package land.oras; +package land.oras.exception; import land.oras.utils.JsonUtils; import land.oras.utils.OrasHttpClient; diff --git a/src/main/java/land/oras/utils/ArchiveUtils.java b/src/main/java/land/oras/utils/ArchiveUtils.java index 51f84fbf..f248a552 100644 --- a/src/main/java/land/oras/utils/ArchiveUtils.java +++ b/src/main/java/land/oras/utils/ArchiveUtils.java @@ -13,7 +13,7 @@ import java.util.EnumSet; import java.util.Set; import java.util.stream.Stream; -import land.oras.OrasException; +import land.oras.exception.OrasException; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; diff --git a/src/main/java/land/oras/utils/DigestUtils.java b/src/main/java/land/oras/utils/DigestUtils.java index 8399828f..f5a9f205 100644 --- a/src/main/java/land/oras/utils/DigestUtils.java +++ b/src/main/java/land/oras/utils/DigestUtils.java @@ -5,7 +5,7 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; -import land.oras.OrasException; +import land.oras.exception.OrasException; import org.jspecify.annotations.NullMarked; /** diff --git a/src/main/java/land/oras/utils/JsonUtils.java b/src/main/java/land/oras/utils/JsonUtils.java index 573f88bd..2ef00c02 100644 --- a/src/main/java/land/oras/utils/JsonUtils.java +++ b/src/main/java/land/oras/utils/JsonUtils.java @@ -6,11 +6,13 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; -import land.oras.OrasException; +import land.oras.exception.OrasException; import org.jspecify.annotations.NullMarked; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,4 +93,34 @@ public static T fromJson(Path path, Class clazz) { throw new OrasException("Unable to read JSON file due to IO error", e); } } + + /** + * Converts the contents of a JSON file to an object of the specified type. + * + * @param path The {@code Path} to the JSON file to be read. + * @param type The {@code Type} representing the class of the object to be deserialized. + * @param The type of the object to be returned. + * @return An object of type {@code T} deserialized from the JSON file. + * @throws OrasException If an I/O error occurs while reading the file or the JSON is invalid. + */ + public static T fromJson(Path path, Type type) { + try { + return gson.fromJson(Files.readString(path, StandardCharsets.UTF_8), type); + } catch (IOException e) { + throw new OrasException("Unable to read JSON file due to IO error", e); + } + } + + /** + * Deserializes the contents of a JSON input to an object of the specified type. + * + * @param reader The {@code Reader} from which the JSON content is read. + * @param type The {@code Type} representing the target object type to be deserialized. + * @param The type of the object to be returned. + * @return An object of type {@code T} deserialized from the JSON content. + * @throws OrasException If an error occurs while reading the input or the JSON format is invalid. + */ + public static T fromJson(Reader reader, Type type) { + return gson.fromJson(reader, type); + } } diff --git a/src/main/java/land/oras/utils/OrasHttpClient.java b/src/main/java/land/oras/utils/OrasHttpClient.java index 3ed41ed7..6dbcbba6 100644 --- a/src/main/java/land/oras/utils/OrasHttpClient.java +++ b/src/main/java/land/oras/utils/OrasHttpClient.java @@ -17,9 +17,9 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.X509ExtendedTrustManager; -import land.oras.OrasException; import land.oras.auth.AuthProvider; import land.oras.auth.NoAuthProvider; +import land.oras.exception.OrasException; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; diff --git a/src/test/java/land/oras/RegistryContainerTest.java b/src/test/java/land/oras/RegistryContainerTest.java index 89dc008b..2bea182b 100644 --- a/src/test/java/land/oras/RegistryContainerTest.java +++ b/src/test/java/land/oras/RegistryContainerTest.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.JsonUtils; import land.oras.utils.RegistryContainer; diff --git a/src/test/java/land/oras/RegistryTest.java b/src/test/java/land/oras/RegistryTest.java index 89cc7230..98151568 100644 --- a/src/test/java/land/oras/RegistryTest.java +++ b/src/test/java/land/oras/RegistryTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.Random; +import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.DigestUtils; import land.oras.utils.RegistryContainer; diff --git a/src/test/java/land/oras/auth/BearerTokenProviderTest.java b/src/test/java/land/oras/auth/BearerTokenProviderTest.java index 4b22680b..110837b0 100644 --- a/src/test/java/land/oras/auth/BearerTokenProviderTest.java +++ b/src/test/java/land/oras/auth/BearerTokenProviderTest.java @@ -9,7 +9,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest; import java.time.ZonedDateTime; import java.util.Map; -import land.oras.OrasException; +import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.JsonUtils; import land.oras.utils.OrasHttpClient; diff --git a/src/test/java/land/oras/auth/FileStoreAuthenticationProviderTest.java b/src/test/java/land/oras/auth/FileStoreAuthenticationProviderTest.java new file mode 100644 index 00000000..d73e6d94 --- /dev/null +++ b/src/test/java/land/oras/auth/FileStoreAuthenticationProviderTest.java @@ -0,0 +1,71 @@ +package land.oras.auth; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import land.oras.ContainerRef; +import land.oras.credentials.FileStore; +import land.oras.credentials.FileStore.Credential; +import land.oras.exception.OrasException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileStoreAuthenticationProviderTest { + + private FileStore mockFileStore; + private FileStoreAuthenticationProvider authProvider; + private ContainerRef serverAddress; + + @BeforeEach + void setUp() { + // Mock the FileStore + mockFileStore = mock(FileStore.class); + } + + @Test + void testConstructor_validCredentials() throws Exception { + // Mock valid credentials for the server address + Credential credential = new Credential("testUser", "testPassword"); + when(mockFileStore.get(serverAddress)).thenReturn(credential); + + // Create the authentication provider + authProvider = new FileStoreAuthenticationProvider(mockFileStore, serverAddress); + + // Assert that the authentication provider is created successfully + assertNotNull(authProvider); + } + + @Test + void testGetAuthHeader_validCredentials() throws Exception { + // Mock valid credentials for the server address + Credential credential = new Credential("testUser", "testPassword"); + when(mockFileStore.get(serverAddress)).thenReturn(credential); + + // Create the authentication provider + authProvider = new FileStoreAuthenticationProvider(mockFileStore, serverAddress); + + // Verify that the getAuthHeader method returns the expected Basic Auth header + String authHeader = authProvider.getAuthHeader(); + String expectedAuthString = "testUser:testPassword"; + String expectedEncodedAuth = + "Basic " + Base64.getEncoder().encodeToString(expectedAuthString.getBytes(StandardCharsets.UTF_8)); + + assertEquals(expectedEncodedAuth, authHeader); + } + + @Test + void testGetAuthHeader_missingCredentials() throws Exception { + // Mock no credentials for the server address + when(mockFileStore.get(serverAddress)).thenReturn(null); + + // Create the authentication provider, expecting it to throw ConfigLoadingException + OrasException exception = assertThrows(OrasException.class, () -> { + new FileStoreAuthenticationProvider(mockFileStore, serverAddress); + }); + + // Verify the exception message + assertTrue(exception.getMessage().contains("No credentials found for containerRef")); + } +} diff --git a/src/test/java/land/oras/credentials/FileStoreTest.java b/src/test/java/land/oras/credentials/FileStoreTest.java new file mode 100644 index 00000000..3c3a9425 --- /dev/null +++ b/src/test/java/land/oras/credentials/FileStoreTest.java @@ -0,0 +1,230 @@ +package land.oras.credentials; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import land.oras.ContainerRef; +import land.oras.exception.OrasException; +import land.oras.utils.Const; +import land.oras.utils.JsonUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +class FileStoreTest { + + @TempDir + private Path tempDir; + + private FileStore fileStore; + private FileStore.Config mockConfig; + private FileStore.Credential mockCredential; + private ContainerRef SERVER_ADDRESS; + private static final String USERNAME = "user"; + private static final String PASSWORD = "password"; + + @BeforeEach + void setUp() { + // Mock Config and Credential + mockConfig = Mockito.mock(FileStore.Config.class); + mockCredential = new FileStore.Credential(USERNAME, PASSWORD); + + // Create FileStore instance + fileStore = new FileStore(false, mockConfig); + } + + @Test + void testNewFileStore_success() throws Exception { + // Simulate loading configuration + String configPath = "config.json"; + FileStore.Config mockConfig = Mockito.mock(FileStore.Config.class); + FileStore fileStoreInstance = new FileStore(false, mockConfig); + + assertNotNull(fileStoreInstance); + } + + @Test + void testGetCredential_success() throws Exception { + // Mock the behavior of getting credentials + Mockito.when(mockConfig.getCredential(SERVER_ADDRESS)).thenReturn(mockCredential); + + FileStore.Credential credential = fileStore.get(SERVER_ADDRESS); + + assertNotNull(credential); + assertEquals(USERNAME, credential.getUsername()); + assertEquals(PASSWORD, credential.getPassword()); + } + + @Test + void testPutCredential_success() throws Exception { + // Mock the behavior of putting credentials + Mockito.doNothing().when(mockConfig).putCredential(SERVER_ADDRESS, mockCredential); + + fileStore.put(SERVER_ADDRESS, mockCredential); + + Mockito.verify(mockConfig, Mockito.times(1)).putCredential(SERVER_ADDRESS, mockCredential); + } + + @Test + void testPutCredential_whenPutDisabled_throwsException() { + fileStore = new FileStore(true, mockConfig); // Set disablePut to true + + UnsupportedOperationException thrown = assertThrows(UnsupportedOperationException.class, () -> { + fileStore.put(SERVER_ADDRESS, mockCredential); + }); + + assertEquals(FileStore.ERR_PLAINTEXT_PUT_DISABLED, thrown.getMessage()); + } + + @Test + void testPutCredential_invalidFormat_throwsException() { + // Credential with a colon in the username should throw an exception + FileStore.Credential invalidCredential = new FileStore.Credential("user:name", PASSWORD); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> { + fileStore.put(SERVER_ADDRESS, invalidCredential); + }); + + assertEquals( + FileStore.ERR_BAD_CREDENTIAL_FORMAT + ": colons(:) are not allowed in username", thrown.getMessage()); + } + + @Test + void testDeleteCredential_success() throws Exception { + // Mock the behavior of deleting credentials + Mockito.doNothing().when(mockConfig).deleteCredential(SERVER_ADDRESS); + + fileStore.delete(SERVER_ADDRESS); + + Mockito.verify(mockConfig, Mockito.times(1)).deleteCredential(SERVER_ADDRESS); + } + + @Test + void testValidateCredentialFormat_invalidUsernameFormat_throwsException() { + // Test validation for credentials with colon in username + FileStore.Credential invalidCredential = new FileStore.Credential("user:name", "password"); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> { + fileStore.put(SERVER_ADDRESS, invalidCredential); + }); + + assertEquals( + FileStore.ERR_BAD_CREDENTIAL_FORMAT + ": colons(:) are not allowed in username", thrown.getMessage()); + } + + @Test + void testParse_withAllComponents() { + String containerName = "registry.example.com/namespace/repository:tag@sha256:123456"; + ContainerRef ref = ContainerRef.parse(containerName); + + assertEquals("registry.example.com", ref.getRegistry()); + assertEquals("namespace", ref.getNamespace()); + assertEquals("repository", ref.getRepository()); + assertEquals("tag", ref.getTag()); + assertEquals("sha256:123456", ref.getDigest()); + } + + @Test + void testParse_noRegistry_defaultsToDefaultRegistry() { + String containerName = "namespace/repository:tag"; + ContainerRef ref = ContainerRef.parse(containerName); + + assertEquals(Const.DEFAULT_REGISTRY, ref.getRegistry()); + assertEquals("namespace", ref.getNamespace()); + assertEquals("repository", ref.getRepository()); + assertEquals("tag", ref.getTag()); + assertNull(ref.getDigest()); + } + + @Test + void testParse_noTag_defaultsToDefaultTag() { + String containerName = "registry.example.com/namespace/repository"; + ContainerRef ref = ContainerRef.parse(containerName); + + assertEquals("latest", ref.getTag()); // Assuming Const.DEFAULT_TAG = "latest" + } + + @Test + void testParse_missingRepository_throwsException() { + String containerName = "registry.example.com/"; + assertThrows(IllegalArgumentException.class, () -> ContainerRef.parse(containerName)); + } + + @Test + void testGetTagsPath() { + String containerName = "registry.example.com/namespace/repository:tag"; + ContainerRef ref = ContainerRef.parse(containerName); + + String expectedTagsPath = "registry.example.com/v2/namespace/repository/tags/list"; + assertEquals(expectedTagsPath, ref.getTagsPath()); + } + + @Test + void testGetManifestsPath_withDigest() { + String containerName = "registry.example.com/namespace/repository:tag@sha256:123456"; + ContainerRef ref = ContainerRef.parse(containerName); + + String expectedManifestsPath = "registry.example.com/v2/namespace/repository/manifests/sha256:123456"; + assertEquals(expectedManifestsPath, ref.getManifestsPath()); + } + + @Test + void testGetManifestsPath_withoutDigest_usesTag() { + String containerName = "registry.example.com/namespace/repository:tag"; + ContainerRef ref = ContainerRef.parse(containerName); + + String expectedManifestsPath = "registry.example.com/v2/namespace/repository/manifests/tag"; + assertEquals(expectedManifestsPath, ref.getManifestsPath()); + } + + @Test + void testGetBlobsPath_withoutDigest_throwsException() { + String containerName = "registry.example.com/namespace/repository:tag"; + ContainerRef ref = ContainerRef.parse(containerName); + + assertThrows(OrasException.class, ref::getBlobsPath); + } + + @Test + void testGetBlobsPath_withDigest() { + String containerName = "registry.example.com/namespace/repository:tag@sha256:123456"; + ContainerRef ref = ContainerRef.parse(containerName); + + String expectedBlobsPath = "registry.example.com/v2/namespace/repository/blobs/sha256:123456"; + assertEquals(expectedBlobsPath, ref.getBlobsPath()); + } + + @Test + void testConfigLoad_success() throws Exception { + // Create a temporary JSON file for testing + Map credentials = new HashMap<>(); + ContainerRef containerRef = + ContainerRef.parse("docker.io/library/foo/hello-world:latest@sha256:1234567890abcdef"); + credentials.put(containerRef, new FileStore.Credential("admin", "password123")); + + String jsonContent = JsonUtils.toJson(credentials); + + // Create a temporary file and write the JSON content to it + tempDir = Files.createTempFile("config", ".json"); + Files.write(tempDir, jsonContent.getBytes()); + + // Load the configuration from the temporary file + FileStore.Config.load(tempDir.toString()); + + // assertNotNull(config); + // assertNotNull(config.getCredential(containerRef)); + + assertEquals("docker.io", containerRef.getRegistry()); + assertEquals("library/foo", containerRef.getNamespace()); + assertEquals("hello-world", containerRef.getRepository()); + assertEquals("latest", containerRef.getTag()); + assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); + + // Clean up by deleting the temporary file + Files.delete(tempDir); + } +}