Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions Sources/ContainerClient/CredentialHelper.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
57 changes: 57 additions & 0 deletions Sources/ContainerClient/DockerConfig.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 17 additions & 0 deletions Sources/Services/ContainerImagesService/Server/ImageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions Tests/ContainerClientTests/CredentialHelperTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
101 changes: 101 additions & 0 deletions Tests/ContainerClientTests/DockerConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<helper-name>` (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.
Expand Down