From 9b3415989c8f6ab98f48123ff88d84bf101587ea Mon Sep 17 00:00:00 2001 From: saehejkang Date: Thu, 29 Jan 2026 22:20:50 -0800 Subject: [PATCH 1/3] add functions for container registry list command --- .../Client/KeychainHelper.swift | 6 ++++ .../Keychain/KeychainQuery.swift | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Sources/ContainerizationOCI/Client/KeychainHelper.swift b/Sources/ContainerizationOCI/Client/KeychainHelper.swift index 63970e92..95f8d357 100644 --- a/Sources/ContainerizationOCI/Client/KeychainHelper.swift +++ b/Sources/ContainerizationOCI/Client/KeychainHelper.swift @@ -46,6 +46,12 @@ public struct KeychainHelper: Sendable { } } } + + /// List all registry domains this id has credentials for. + public func listDomains() throws -> [String] { + let kq = KeychainQuery() + return try kq.listHosts(id: self.id) + } /// Delete authorization data for a given domain from the keychain. public func delete(domain: String) throws { diff --git a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift index 0cdf6b15..93070ec4 100644 --- a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift +++ b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift @@ -107,6 +107,35 @@ public struct KeychainQuery { createdDate: createdDate ) } + + /// List all hostnames in the keychain. + public func listHosts(id: String) throws -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrSecurityDomain as String: id, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnData as String: false, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status != errSecItemNotFound else { + return [] + } + guard status == errSecSuccess else { + throw Error.unhandledError(status: status) + } + + guard let items = result as? [[String: Any]] else { + throw Error.unexpectedDataFetched + } + + return items.compactMap { + $0[kSecAttrServer as String] as? String + } + } private func isQuerySuccessful(_ status: Int32) throws -> Bool { guard status != errSecItemNotFound else { From e46086eab2dd8ad2ddfbad79648df1084929ce07 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Sun, 1 Feb 2026 20:25:39 -0800 Subject: [PATCH 2/3] updates to list function + add test --- .../Client/KeychainHelper.swift | 8 +-- .../Keychain/KeychainQuery.swift | 56 +++++++++++++------ .../KeychainQueryTests.swift | 32 ++++++++++- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/Sources/ContainerizationOCI/Client/KeychainHelper.swift b/Sources/ContainerizationOCI/Client/KeychainHelper.swift index 95f8d357..b7155570 100644 --- a/Sources/ContainerizationOCI/Client/KeychainHelper.swift +++ b/Sources/ContainerizationOCI/Client/KeychainHelper.swift @@ -46,11 +46,11 @@ public struct KeychainHelper: Sendable { } } } - - /// List all registry domains this id has credentials for. - public func listDomains() throws -> [String] { + + /// List all registry entries for this domain. + public func list() throws -> [RegistryInfo] { let kq = KeychainQuery() - return try kq.listHosts(id: self.id) + return try kq.list(domain: self.id) } /// Delete authorization data for a given domain from the keychain. diff --git a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift index 93070ec4..7454d846 100644 --- a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift +++ b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift @@ -25,6 +25,14 @@ public struct KeychainQueryResult { public var createdDate: Date } +/// Holds the stored attributes for a registry. +public struct RegistryInfo: Sendable { + public var hostname: String + public var account: String + public let modifiedDate: Date + public let createdDate: Date +} + /// Type that facilitates interacting with the macOS keychain. public struct KeychainQuery { public init() {} @@ -107,33 +115,47 @@ public struct KeychainQuery { createdDate: createdDate ) } - - /// List all hostnames in the keychain. - public func listHosts(id: String) throws -> [String] { + + /// List all registry entries in the keychain for a domain. + public func list(domain: String) throws -> [RegistryInfo] { let query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, - kSecAttrSecurityDomain as String: id, + kSecAttrSecurityDomain as String: domain, kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitAll, ] - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status != errSecItemNotFound else { + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + let exists = try isQuerySuccessful(status) + if !exists { return [] } - guard status == errSecSuccess else { - throw Error.unhandledError(status: status) - } - guard let items = result as? [[String: Any]] else { - throw Error.unexpectedDataFetched + guard let fetched = item as? [[String: Any]] else { + throw Self.Error.unexpectedDataFetched } - return items.compactMap { - $0[kSecAttrServer as String] as? String + 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 account = 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, + account: account, + modifiedDate: modifiedDate, + createdDate: createdDate + ) } } diff --git a/Tests/ContainerizationOSTests/KeychainQueryTests.swift b/Tests/ContainerizationOSTests/KeychainQueryTests.swift index 3b209f73..faf3cc83 100644 --- a/Tests/ContainerizationOSTests/KeychainQueryTests.swift +++ b/Tests/ContainerizationOSTests/KeychainQueryTests.swift @@ -14,8 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - import Foundation import Testing @@ -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.account == user) + } + } catch KeychainQuery.Error.unhandledError(status: -25308) { + // ignore errSecInteractionNotAllowed + } + } + private static var isCI: Bool { ProcessInfo.processInfo.environment["CI"] != nil } From 4e142cab8038c904b50bb462b373627c2b8f44d2 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Mon, 2 Feb 2026 23:26:47 -0500 Subject: [PATCH 3/3] add docc + move registry info --- .../Client/KeychainHelper.swift | 4 ++- .../Keychain/KeychainQuery.swift | 15 ++++------ .../Keychain/RegistryInfo.swift | 29 +++++++++++++++++++ .../KeychainQueryTests.swift | 2 +- 4 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 Sources/ContainerizationOS/Keychain/RegistryInfo.swift diff --git a/Sources/ContainerizationOCI/Client/KeychainHelper.swift b/Sources/ContainerizationOCI/Client/KeychainHelper.swift index b7155570..fbf1a8f8 100644 --- a/Sources/ContainerizationOCI/Client/KeychainHelper.swift +++ b/Sources/ContainerizationOCI/Client/KeychainHelper.swift @@ -47,7 +47,9 @@ public struct KeychainHelper: Sendable { } } - /// List all registry entries for this domain. + /// 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) diff --git a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift index 7454d846..6b79342b 100644 --- a/Sources/ContainerizationOS/Keychain/KeychainQuery.swift +++ b/Sources/ContainerizationOS/Keychain/KeychainQuery.swift @@ -25,14 +25,6 @@ public struct KeychainQueryResult { public var createdDate: Date } -/// Holds the stored attributes for a registry. -public struct RegistryInfo: Sendable { - public var hostname: String - public var account: String - public let modifiedDate: Date - public let createdDate: Date -} - /// Type that facilitates interacting with the macOS keychain. public struct KeychainQuery { public init() {} @@ -117,6 +109,9 @@ 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, @@ -140,7 +135,7 @@ public struct KeychainQuery { guard let hostname = registry[kSecAttrServer as String] as? String else { throw Self.Error.keyNotPresent(key: kSecAttrServer as String) } - guard let account = registry[kSecAttrAccount as String] as? String else { + 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 { @@ -152,7 +147,7 @@ public struct KeychainQuery { return RegistryInfo( hostname: hostname, - account: account, + username: username, modifiedDate: modifiedDate, createdDate: createdDate ) diff --git a/Sources/ContainerizationOS/Keychain/RegistryInfo.swift b/Sources/ContainerizationOS/Keychain/RegistryInfo.swift new file mode 100644 index 00000000..12dd67d9 --- /dev/null +++ b/Sources/ContainerizationOS/Keychain/RegistryInfo.swift @@ -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 +} diff --git a/Tests/ContainerizationOSTests/KeychainQueryTests.swift b/Tests/ContainerizationOSTests/KeychainQueryTests.swift index faf3cc83..edbe9b73 100644 --- a/Tests/ContainerizationOSTests/KeychainQueryTests.swift +++ b/Tests/ContainerizationOSTests/KeychainQueryTests.swift @@ -66,7 +66,7 @@ struct KeychainQueryTests { // Verify that the accounts exist for entry in entries { - #expect(entry.account == user) + #expect(entry.username == user) } } catch KeychainQuery.Error.unhandledError(status: -25308) { // ignore errSecInteractionNotAllowed