diff --git a/Sources/ContainerClient/CredentialHelper.swift b/Sources/ContainerClient/CredentialHelper.swift new file mode 100644 index 00000000..d87ae37c --- /dev/null +++ b/Sources/ContainerClient/CredentialHelper.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed 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 +// +// https://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. +//===----------------------------------------------------------------------===// + +import Containerization +import Foundation + +/// Response from a credential helper execution +struct CredentialHelperResponse: Codable { + let ServerURL: String + let Username: String + let Secret: String +} + +/// Executes Docker credential helpers to retrieve registry credentials +public struct CredentialHelperExecutor: Sendable { + public init() {} + + /// Execute a credential helper for a given host + /// - Parameters: + /// - helperName: The name of the credential helper (e.g., "cgr" for "docker-credential-cgr") + /// - host: The registry host to get credentials for + /// - Returns: Authentication if the helper succeeded, nil otherwise + public func execute(helperName: String, for host: String) async -> Authentication? { + // Validate helper name to prevent unexpected characters + let allowedCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + guard helperName.rangeOfCharacter(from: allowedCharacterSet.inverted) == nil else { + return nil + } + + let executableName = "docker-credential-\(helperName)" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executableName, "get"] + + let inputPipe = Pipe() + let outputPipe = Pipe() + let errorPipe = Pipe() + + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + + // Write the host to stdin + let hostData = Data("\(host)\n".utf8) + inputPipe.fileHandleForWriting.write(hostData) + // Close stdin to signal EOF to the credential helper + try inputPipe.fileHandleForWriting.close() + + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return nil + } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + + let decoder = JSONDecoder() + let response = try decoder.decode(CredentialHelperResponse.self, from: outputData) + + return BasicAuthentication(username: response.Username, password: response.Secret) + } catch { + // Ensure stdin is closed even if we error out + try? inputPipe.fileHandleForWriting.close() + return nil + } + } +} diff --git a/Sources/ContainerClient/DockerConfig.swift b/Sources/ContainerClient/DockerConfig.swift new file mode 100644 index 00000000..06e1b618 --- /dev/null +++ b/Sources/ContainerClient/DockerConfig.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed 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 +// +// https://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. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Represents the Docker configuration file structure +struct DockerConfig: Codable { + /// Maps registry hosts to credential helper names + let credHelpers: [String: String]? + + enum CodingKeys: String, CodingKey { + case credHelpers + } +} + +/// Helper to read and parse Docker configuration +public struct DockerConfigReader: Sendable { + private let configPath: URL + + /// Initialize with a custom config path + public init(configPath: URL) { + self.configPath = configPath + } + + /// Initialize with default Docker config path (~/.docker/config.json) + public init() { + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser + self.configPath = homeDirectory.appendingPathComponent(".docker/config.json") + } + + /// Get the credential helper name for a given registry host + public func credentialHelper(for host: String) -> String? { + guard let config = try? readConfig() else { + return nil + } + return config.credHelpers?[host] + } + + private func readConfig() throws -> DockerConfig { + let data = try Data(contentsOf: configPath) + let decoder = JSONDecoder() + return try decoder.decode(DockerConfig.self, from: data) + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 6ca43f8c..a95add31 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -163,6 +163,13 @@ extension ImagesService { if let authentication { return try await body(authentication) } + + // Try credential helper if configured + authentication = await Self.authenticationFromCredentialHelper(host: host) + if let authentication { + return try await body(authentication) + } + let keychain = KeychainHelper(id: Constants.keychainID) do { authentication = try keychain.lookup(domain: host) @@ -197,6 +204,16 @@ extension ImagesService { } return BasicAuthentication(username: user, password: password) } + + private static func authenticationFromCredentialHelper(host: String) async -> Authentication? { + let configReader = DockerConfigReader() + guard let helperName = configReader.credentialHelper(for: host) else { + return nil + } + + let executor = CredentialHelperExecutor() + return await executor.execute(helperName: helperName, for: host) + } } extension ImageDescription { diff --git a/Tests/ContainerClientTests/CredentialHelperTests.swift b/Tests/ContainerClientTests/CredentialHelperTests.swift new file mode 100644 index 00000000..fbd034f2 --- /dev/null +++ b/Tests/ContainerClientTests/CredentialHelperTests.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed 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 +// +// https://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. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerClient + +struct CredentialHelperTests { + + @Test("Execute credential helper with nonexistent helper") + func testCredentialHelperWithNonexistentHelper() async { + let executor = CredentialHelperExecutor() + + // Try to execute a nonexistent credential helper + // This should return nil without crashing + let auth = await executor.execute(helperName: "nonexistent-helper-\(UUID().uuidString)", for: "example.com") + + #expect(auth == nil) + } + + @Test("Execute credential helper with invalid helper name") + func testCredentialHelperWithInvalidHelperName() async { + let executor = CredentialHelperExecutor() + + // Try to execute a credential helper with invalid characters + // This should return nil to prevent command injection + let auth = await executor.execute(helperName: "../../../bin/sh", for: "example.com") + + #expect(auth == nil) + } + + @Test("Execute credential helper with valid helper name") + func testCredentialHelperWithValidHelperName() async { + let executor = CredentialHelperExecutor() + + // Valid helper names should be accepted (even if they don't exist) + // The validation should pass, but execution will fail + let auth = await executor.execute(helperName: "valid-helper_123", for: "example.com") + + // Should be nil because the helper doesn't exist, but validation passed + #expect(auth == nil) + } + + @Test("Validate helper name with special characters") + func testValidateHelperNameWithSpecialCharacters() async { + let executor = CredentialHelperExecutor() + + // Test various invalid characters + let invalidNames = ["helper;echo", "helper|cat", "helper&ls", "helper$PWD", "helper`ls`", "helper/bin"] + + for name in invalidNames { + let auth = await executor.execute(helperName: name, for: "example.com") + #expect(auth == nil) + } + } + + @Test("Validate helper name with valid characters") + func testValidateHelperNameWithValidCharacters() async { + let executor = CredentialHelperExecutor() + + // These should pass validation (though they won't exist) + let validNames = ["cgr", "gcloud", "ecr-login", "helper_123", "my-helper"] + + for name in validNames { + // Should not crash, returns nil because helper doesn't exist + let auth = await executor.execute(helperName: name, for: "example.com") + #expect(auth == nil) + } + } +} diff --git a/Tests/ContainerClientTests/DockerConfigTests.swift b/Tests/ContainerClientTests/DockerConfigTests.swift new file mode 100644 index 00000000..578e128e --- /dev/null +++ b/Tests/ContainerClientTests/DockerConfigTests.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed 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 +// +// https://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. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerClient + +struct DockerConfigTests { + + @Test("Read Docker config with credHelpers") + func testReadDockerConfig() throws { + let tempDir = FileManager.default.temporaryDirectory + let configPath = tempDir.appendingPathComponent("test-docker-config-\(UUID().uuidString).json") + + let configJSON = """ + { + "credHelpers": { + "cgr.dev": "cgr", + "us-east4-docker.pkg.dev": "gcloud" + } + } + """ + + try configJSON.write(to: configPath, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(at: configPath) + } + + let reader = DockerConfigReader(configPath: configPath) + + #expect(reader.credentialHelper(for: "cgr.dev") == "cgr") + #expect(reader.credentialHelper(for: "us-east4-docker.pkg.dev") == "gcloud") + #expect(reader.credentialHelper(for: "docker.io") == nil) + } + + @Test("Read Docker config without credHelpers section") + func testReadDockerConfigWithoutCredHelpers() throws { + let tempDir = FileManager.default.temporaryDirectory + let configPath = tempDir.appendingPathComponent("test-docker-config-\(UUID().uuidString).json") + + let configJSON = """ + { + "auths": {} + } + """ + + try configJSON.write(to: configPath, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(at: configPath) + } + + let reader = DockerConfigReader(configPath: configPath) + + #expect(reader.credentialHelper(for: "cgr.dev") == nil) + } + + @Test("Read nonexistent config file") + func testReadNonexistentConfig() { + let tempDir = FileManager.default.temporaryDirectory + let configPath = tempDir.appendingPathComponent("nonexistent-\(UUID().uuidString).json") + + let reader = DockerConfigReader(configPath: configPath) + + #expect(reader.credentialHelper(for: "cgr.dev") == nil) + } + + @Test("Read config with empty credHelpers") + func testReadConfigWithEmptyCredHelpers() throws { + let tempDir = FileManager.default.temporaryDirectory + let configPath = tempDir.appendingPathComponent("test-docker-config-\(UUID().uuidString).json") + + let configJSON = """ + { + "credHelpers": {} + } + """ + + try configJSON.write(to: configPath, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(at: configPath) + } + + let reader = DockerConfigReader(configPath: configPath) + + #expect(reader.credentialHelper(for: "cgr.dev") == nil) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 68fda381..f47070cc 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -781,6 +781,28 @@ No options. The registry commands manage authentication and defaults for container registries. +### Credential Helpers + +`container` supports Docker-compatible credential helpers for dynamic authentication with registries. Credential helpers are external programs that provide short-lived credentials on demand, which is useful for registries that prefer or require token-based authentication. + +To configure credential helpers, create or edit `~/.docker/config.json` with a `credHelpers` section: + +```json +{ + "credHelpers": { + "cgr.dev": "cgr", + "us-east4-docker.pkg.dev": "gcloud" + } +} +``` + +When pulling from or pushing to a configured registry, `container` will execute `docker-credential-` (e.g., `docker-credential-cgr`) to retrieve credentials dynamically. + +The authentication priority order is: +1. Environment variables (`CONTAINER_REGISTRY_HOST`, `CONTAINER_REGISTRY_USER`, `CONTAINER_REGISTRY_TOKEN`) +2. Credential helpers (from `~/.docker/config.json`) +3. Keychain (from `container registry login`) + ### `container registry login` Authenticates with a registry. Credentials can be provided interactively or via flags. The login is stored for reuse by subsequent commands.