Skip to content
Open
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
8 changes: 8 additions & 0 deletions Sources/ContainerizationOCI/Client/KeychainHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public struct KeychainHelper: Sendable {
}
}

/// Lists all registry entries for this domain.
/// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found.
/// - Throws: An error if the keychain query fails.
public func list() throws -> [RegistryInfo] {
let kq = KeychainQuery()
return try kq.list(domain: self.id)
}

/// Delete authorization data for a given domain from the keychain.
public func delete(domain: String) throws {
let kq = KeychainQuery()
Expand Down
46 changes: 46 additions & 0 deletions Sources/ContainerizationOS/Keychain/KeychainQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,52 @@ public struct KeychainQuery {
)
}

/// List all registry entries in the keychain for a domain.
/// - Parameter domain: The security domain used to fetch registry entries in the keychain.
/// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found.
/// - Throws: An error if the keychain query fails or returns unexpected data.
public func list(domain: String) throws -> [RegistryInfo] {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrSecurityDomain as String: domain,
kSecReturnAttributes as String: true,
kSecReturnData as String: false,
kSecMatchLimit as String: kSecMatchLimitAll,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
let exists = try isQuerySuccessful(status)
if !exists {
return []
}

guard let fetched = item as? [[String: Any]] else {
throw Self.Error.unexpectedDataFetched
}

return try fetched.map { registry in
guard let hostname = registry[kSecAttrServer as String] as? String else {
throw Self.Error.keyNotPresent(key: kSecAttrServer as String)
}
guard let username = registry[kSecAttrAccount as String] as? String else {
throw Self.Error.keyNotPresent(key: kSecAttrAccount as String)
}
guard let modifiedDate = registry[kSecAttrModificationDate as String] as? Date else {
throw Self.Error.keyNotPresent(key: kSecAttrModificationDate as String)
}
guard let createdDate = registry[kSecAttrCreationDate as String] as? Date else {
throw Self.Error.keyNotPresent(key: kSecAttrCreationDate as String)
}

return RegistryInfo(
hostname: hostname,
username: username,
modifiedDate: modifiedDate,
createdDate: createdDate
)
}
}

private func isQuerySuccessful(_ status: Int32) throws -> Bool {
guard status != errSecItemNotFound else {
return false
Expand Down
29 changes: 29 additions & 0 deletions Sources/ContainerizationOS/Keychain/RegistryInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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

/// Holds the stored attributes for a registry.
public struct RegistryInfo: Sendable {
/// The registry host as a domain name with an optional port.
public var hostname: String
/// The username used to authenticate with the registry.
public var username: String
/// The date the registry was last modified.
public let modifiedDate: Date
/// The date the registry was created.
public let createdDate: Date
}
32 changes: 30 additions & 2 deletions Tests/ContainerizationOSTests/KeychainQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

//

import Foundation
import Testing

Expand Down Expand Up @@ -45,6 +43,36 @@ struct KeychainQueryTests {
}
}

@Test(.enabled(if: !isCI))
func list() throws {
let domain1 = "testing-1-keychain.example.com"
let domain2 = "testing-2-keychain.example.com"

defer {
try? kq.delete(id: id, host: domain1)
try? kq.delete(id: id, host: domain2)
}

do {
try kq.save(id: id, host: domain1, user: user, token: "foobar")
try kq.save(id: id, host: domain2, user: user, token: "foobar")

let entries = try kq.list(domain: id)

// Verify that both hostnames exist
let hostnames = entries.map { $0.hostname }
#expect(hostnames.contains(domain1))
#expect(hostnames.contains(domain2))

// Verify that the accounts exist
for entry in entries {
#expect(entry.username == user)
}
} catch KeychainQuery.Error.unhandledError(status: -25308) {
// ignore errSecInteractionNotAllowed
}
}

private static var isCI: Bool {
ProcessInfo.processInfo.environment["CI"] != nil
}
Expand Down