Replace keychain password storage with file-based storage (#576)
Moves socket control password from the macOS login keychain to a plain file at ~/Library/Application Support/cmux/socket-control-password. This eliminates the system keychain prompt that interrupts users on first launch or after keychain changes. - Directory created with 0700, file written with 0600 permissions - One-time migration copies existing keychain password to the file, deletes the keychain entry, and records a migration version in UserDefaults so it runs only once - CLI SocketPasswordResolver also reads from the file path - Security framework import is now conditional (#if canImport) - Adds SocketControlPasswordStoreTests covering round-trip, env priority, path resolution, and migration behavior Fixes https://github.com/manaflow-ai/cmux/issues/541
This commit is contained in:
parent
780f959a48
commit
163f8572e4
5 changed files with 276 additions and 90 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import Foundation
|
||||
import Darwin
|
||||
import Security
|
||||
#if canImport(Sentry)
|
||||
import Sentry
|
||||
#endif
|
||||
|
|
@ -416,17 +415,17 @@ enum CLIIDFormat: String {
|
|||
}
|
||||
|
||||
private enum SocketPasswordResolver {
|
||||
private static let service = "com.cmuxterm.app.socket-control"
|
||||
private static let account = "local-socket-password"
|
||||
private static let directoryName = "cmux"
|
||||
private static let fileName = "socket-control-password"
|
||||
|
||||
static func resolve(explicit: String?) -> String? {
|
||||
if let explicit = normalized(explicit), !explicit.isEmpty {
|
||||
if let explicit = normalized(explicit) {
|
||||
return explicit
|
||||
}
|
||||
if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]), !env.isEmpty {
|
||||
if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) {
|
||||
return env
|
||||
}
|
||||
return loadFromKeychain()
|
||||
return loadFromFile()
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
|
|
@ -435,23 +434,20 @@ private enum SocketPasswordResolver {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func loadFromKeychain() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else {
|
||||
private static func loadFromFile() -> String? {
|
||||
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
let passwordURL = appSupport
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
guard let data = try? Data(contentsOf: passwordURL) else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
guard let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return normalized(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5261,7 +5257,7 @@ struct CMUXCLI {
|
|||
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
|
||||
|
||||
Socket Auth:
|
||||
--password takes precedence, then CMUX_SOCKET_PASSWORD env var, then keychain password saved in Settings.
|
||||
--password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings.
|
||||
|
||||
Commands:
|
||||
version
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -213,6 +214,7 @@
|
|||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -425,6 +427,7 @@
|
|||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -634,6 +637,7 @@
|
|||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import Foundation
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
|
||||
enum SocketControlMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
|
|
@ -37,7 +39,7 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
|
|||
case .automation:
|
||||
return "Allow external local automation clients from this macOS user (no ancestry check)."
|
||||
case .password:
|
||||
return "Require socket authentication with a password stored in your keychain."
|
||||
return "Require socket authentication with a password stored in a local file."
|
||||
case .allowAll:
|
||||
return "Allow any local process and user to connect with no auth. Unsafe."
|
||||
}
|
||||
|
|
@ -58,103 +60,175 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
|
|||
}
|
||||
|
||||
enum SocketControlPasswordStore {
|
||||
static let service = "com.cmuxterm.app.socket-control"
|
||||
static let account = "local-socket-password"
|
||||
|
||||
private static var baseQuery: [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
static let directoryName = "cmux"
|
||||
static let fileName = "socket-control-password"
|
||||
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
|
||||
private static let keychainMigrationVersion = 1
|
||||
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
|
||||
private static let legacyKeychainAccount = "local-socket-password"
|
||||
|
||||
static func configuredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil
|
||||
) -> String? {
|
||||
if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty {
|
||||
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
|
||||
return envPassword
|
||||
}
|
||||
return try? loadPassword()
|
||||
return try? loadPassword(fileURL: fileURL)
|
||||
}
|
||||
|
||||
static func hasConfiguredPassword(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil
|
||||
) -> Bool {
|
||||
guard let configured = configuredPassword(environment: environment) else { return false }
|
||||
guard let configured = configuredPassword(environment: environment, fileURL: fileURL) else { return false }
|
||||
return !configured.isEmpty
|
||||
}
|
||||
|
||||
static func verify(
|
||||
password candidate: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileURL: URL? = nil
|
||||
) -> Bool {
|
||||
guard let expected = configuredPassword(environment: environment), !expected.isEmpty else {
|
||||
guard let expected = configuredPassword(environment: environment, fileURL: fileURL), !expected.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return expected == candidate
|
||||
}
|
||||
|
||||
static func loadPassword() throws -> String? {
|
||||
var query = baseQuery
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func savePassword(_ password: String) throws {
|
||||
let normalized = password.trimmingCharacters(in: .newlines)
|
||||
if normalized.isEmpty {
|
||||
try clearPassword()
|
||||
static func migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: UserDefaults = .standard,
|
||||
fileURL: URL? = nil,
|
||||
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
|
||||
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
|
||||
) {
|
||||
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
|
||||
return
|
||||
}
|
||||
|
||||
let data = Data(normalized.utf8)
|
||||
var lookup = baseQuery
|
||||
lookup[kSecReturnData as String] = true
|
||||
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
guard let legacyPassword = normalized(loadLegacyPassword()) else {
|
||||
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
||||
return
|
||||
}
|
||||
|
||||
var existing: CFTypeRef?
|
||||
let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing)
|
||||
switch lookupStatus {
|
||||
case errSecSuccess:
|
||||
let attrsToUpdate: [String: Any] = [
|
||||
kSecValueData as String: data
|
||||
]
|
||||
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus))
|
||||
do {
|
||||
if try loadPassword(fileURL: fileURL) == nil {
|
||||
try savePassword(legacyPassword, fileURL: fileURL)
|
||||
}
|
||||
case errSecItemNotFound:
|
||||
var add = baseQuery
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus))
|
||||
guard deleteLegacyPassword() else {
|
||||
return
|
||||
}
|
||||
default:
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus))
|
||||
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
||||
} catch {
|
||||
// Leave migration unset so it retries on next launch.
|
||||
}
|
||||
}
|
||||
|
||||
static func clearPassword() throws {
|
||||
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
static func loadPassword(fileURL: URL? = nil) throws -> String? {
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
return nil
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let password = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return normalized(password)
|
||||
}
|
||||
|
||||
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
|
||||
let normalized = password.trimmingCharacters(in: .newlines)
|
||||
if normalized.isEmpty {
|
||||
try clearPassword(fileURL: fileURL)
|
||||
return
|
||||
}
|
||||
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
throw NSError(
|
||||
domain: NSCocoaErrorDomain,
|
||||
code: NSFileNoSuchFileError,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."]
|
||||
)
|
||||
}
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
let data = Data(normalized.utf8)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
||||
}
|
||||
|
||||
static func clearPassword(fileURL: URL? = nil) throws {
|
||||
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
||||
return
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
static func defaultPasswordFileURL(
|
||||
appSupportDirectory: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
let resolvedAppSupport: URL
|
||||
if let appSupportDirectory {
|
||||
resolvedAppSupport = appSupportDirectory
|
||||
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
resolvedAppSupport = discovered
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
return resolvedAppSupport
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func loadLegacyPasswordFromKeychain() -> String? {
|
||||
#if canImport(Security)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: legacyKeychainService,
|
||||
kSecAttrAccount: legacyKeychainAccount,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func deleteLegacyPasswordFromKeychain() -> Bool {
|
||||
#if canImport(Security)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: legacyKeychainService,
|
||||
kSecAttrAccount: legacyKeychainAccount,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .newlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ struct cmuxApp: App {
|
|||
defaults.set(legacy ? SocketControlMode.cmuxOnly.rawValue : SocketControlMode.off.rawValue,
|
||||
forKey: SocketControlSettings.appStorageKey)
|
||||
}
|
||||
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults)
|
||||
migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults)
|
||||
|
||||
// UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance
|
||||
|
|
@ -2751,7 +2752,7 @@ struct SettingsView: View {
|
|||
do {
|
||||
try SocketControlPasswordStore.savePassword(trimmed)
|
||||
socketPasswordDraft = ""
|
||||
socketPasswordStatusMessage = "Password saved to keychain."
|
||||
socketPasswordStatusMessage = "Password saved."
|
||||
socketPasswordStatusIsError = false
|
||||
} catch {
|
||||
socketPasswordStatusMessage = "Failed to save password (\(error.localizedDescription))."
|
||||
|
|
@ -3063,7 +3064,7 @@ struct SettingsView: View {
|
|||
SettingsCardRow(
|
||||
"Socket Password",
|
||||
subtitle: hasSocketPasswordConfigured
|
||||
? "Stored in login keychain."
|
||||
? "Stored in Application Support."
|
||||
: "No password set. External clients will be blocked until one is configured."
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
|
|
|
|||
111
cmuxTests/SocketControlPasswordStoreTests.swift
Normal file
111
cmuxTests/SocketControlPasswordStoreTests.swift
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SocketControlPasswordStoreTests: XCTestCase {
|
||||
func testSaveLoadAndClearRoundTripUsesFileStorage() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
|
||||
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
|
||||
try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL)
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2")
|
||||
XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
|
||||
try SocketControlPasswordStore.clearPassword(fileURL: fileURL)
|
||||
XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL))
|
||||
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
|
||||
}
|
||||
|
||||
func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
|
||||
|
||||
let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"]
|
||||
let configured = SocketControlPasswordStore.configuredPassword(
|
||||
environment: environment,
|
||||
fileURL: fileURL
|
||||
)
|
||||
XCTAssertEqual(configured, "env-secret")
|
||||
}
|
||||
|
||||
func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir)
|
||||
XCTAssertEqual(
|
||||
resolved?.path,
|
||||
tempDir.appendingPathComponent("cmux", isDirectory: true)
|
||||
.appendingPathComponent("socket-control-password", isDirectory: false).path
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
|
||||
let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else {
|
||||
XCTFail("Expected isolated UserDefaults suite for migration test")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: defaultsSuiteName) }
|
||||
|
||||
var lookupCount = 0
|
||||
var deleteCount = 0
|
||||
|
||||
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: defaults,
|
||||
fileURL: fileURL,
|
||||
loadLegacyPassword: {
|
||||
lookupCount += 1
|
||||
return "legacy-secret"
|
||||
},
|
||||
deleteLegacyPassword: {
|
||||
deleteCount += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
||||
XCTAssertEqual(lookupCount, 1)
|
||||
XCTAssertEqual(deleteCount, 1)
|
||||
|
||||
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
|
||||
defaults: defaults,
|
||||
fileURL: fileURL,
|
||||
loadLegacyPassword: {
|
||||
lookupCount += 1
|
||||
return "new-value"
|
||||
},
|
||||
deleteLegacyPassword: {
|
||||
deleteCount += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(lookupCount, 1)
|
||||
XCTAssertEqual(deleteCount, 1)
|
||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue