Adds CmuxCLIPathInstaller with symlink management at /usr/local/bin/cmux, exposed via Cmd+Shift+P command palette (VS Code style). Install entry shown when CLI is not in PATH, uninstall entry shown when it is. Falls back to osascript admin privilege escalation when /usr/local/bin is not user-writable. Uses attributesOfItem instead of fileExists to correctly handle dangling symlinks from relocated app bundles. Closes https://github.com/manaflow-ai/cmux/issues/618
333 lines
14 KiB
Swift
333 lines
14 KiB
Swift
import XCTest
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
final class SocketControlPasswordStoreTests: XCTestCase {
|
|
override func setUp() {
|
|
super.setUp()
|
|
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
|
|
}
|
|
|
|
override func tearDown() {
|
|
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
|
|
super.tearDown()
|
|
}
|
|
|
|
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 testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() {
|
|
var readCount = 0
|
|
|
|
let withoutFallback = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: false,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return "legacy-secret"
|
|
}
|
|
)
|
|
XCTAssertNil(withoutFallback)
|
|
XCTAssertEqual(readCount, 0)
|
|
|
|
let firstWithFallback = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return "legacy-secret"
|
|
}
|
|
)
|
|
XCTAssertEqual(firstWithFallback, "legacy-secret")
|
|
XCTAssertEqual(readCount, 1)
|
|
|
|
let secondWithFallback = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return "new-secret"
|
|
}
|
|
)
|
|
XCTAssertEqual(secondWithFallback, "legacy-secret")
|
|
XCTAssertEqual(readCount, 1)
|
|
}
|
|
|
|
func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() {
|
|
var readCount = 0
|
|
|
|
let first = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return nil
|
|
}
|
|
)
|
|
XCTAssertNil(first)
|
|
XCTAssertEqual(readCount, 1)
|
|
|
|
let second = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return "should-not-be-read"
|
|
}
|
|
)
|
|
XCTAssertNil(second)
|
|
XCTAssertEqual(readCount, 1)
|
|
}
|
|
|
|
func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() 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)
|
|
|
|
var readCount = 0
|
|
let configured = SocketControlPasswordStore.configuredPassword(
|
|
environment: [:],
|
|
fileURL: fileURL,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: {
|
|
readCount += 1
|
|
return "legacy-secret"
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(configured, "stored-secret")
|
|
XCTAssertEqual(readCount, 0)
|
|
}
|
|
|
|
func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() {
|
|
var readCount = 0
|
|
let loader = {
|
|
readCount += 1
|
|
return "legacy-secret"
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
SocketControlPasswordStore.hasConfiguredPassword(
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: loader
|
|
)
|
|
)
|
|
XCTAssertEqual(readCount, 1)
|
|
|
|
XCTAssertTrue(
|
|
SocketControlPasswordStore.verify(
|
|
password: "legacy-secret",
|
|
environment: [:],
|
|
fileURL: nil,
|
|
allowLazyKeychainFallback: true,
|
|
loadKeychainPassword: loader
|
|
)
|
|
)
|
|
XCTAssertEqual(readCount, 1)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
final class CmuxCLIPathInstallerTests: XCTestCase {
|
|
func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws {
|
|
let fileManager = FileManager.default
|
|
let root = fileManager.temporaryDirectory
|
|
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: root) }
|
|
|
|
let bundledCLIURL = root
|
|
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
|
try fileManager.createDirectory(
|
|
at: bundledCLIURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
|
|
|
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
|
|
|
var privilegedInstallCallCount = 0
|
|
var privilegedUninstallCallCount = 0
|
|
let installer = CmuxCLIPathInstaller(
|
|
fileManager: fileManager,
|
|
destinationURL: destinationURL,
|
|
bundledCLIURLProvider: { bundledCLIURL },
|
|
expectedBundledCLIPath: bundledCLIURL.path,
|
|
privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 },
|
|
privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 }
|
|
)
|
|
|
|
let installOutcome = try installer.install()
|
|
XCTAssertFalse(installOutcome.usedAdministratorPrivileges)
|
|
XCTAssertEqual(privilegedInstallCallCount, 0)
|
|
XCTAssertTrue(installer.isInstalled())
|
|
XCTAssertEqual(
|
|
try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path),
|
|
bundledCLIURL.path
|
|
)
|
|
|
|
let uninstallOutcome = try installer.uninstall()
|
|
XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges)
|
|
XCTAssertTrue(uninstallOutcome.removedExistingEntry)
|
|
XCTAssertEqual(privilegedUninstallCallCount, 0)
|
|
XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path))
|
|
XCTAssertFalse(installer.isInstalled())
|
|
}
|
|
|
|
func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws {
|
|
let fileManager = FileManager.default
|
|
let root = fileManager.temporaryDirectory
|
|
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? fileManager.removeItem(at: root) }
|
|
|
|
let bundledCLIURL = root
|
|
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
|
try fileManager.createDirectory(
|
|
at: bundledCLIURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
|
|
|
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
|
let destinationDir = destinationURL.deletingLastPathComponent()
|
|
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
|
try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path)
|
|
defer {
|
|
try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
|
}
|
|
|
|
var privilegedInstallCallCount = 0
|
|
let installer = CmuxCLIPathInstaller(
|
|
fileManager: fileManager,
|
|
destinationURL: destinationURL,
|
|
bundledCLIURLProvider: { bundledCLIURL },
|
|
expectedBundledCLIPath: bundledCLIURL.path,
|
|
privilegedInstaller: { sourceURL, privilegedDestinationURL in
|
|
privilegedInstallCallCount += 1
|
|
XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL)
|
|
XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL)
|
|
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
|
try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL)
|
|
}
|
|
)
|
|
|
|
let installOutcome = try installer.install()
|
|
XCTAssertTrue(installOutcome.usedAdministratorPrivileges)
|
|
XCTAssertEqual(privilegedInstallCallCount, 1)
|
|
XCTAssertTrue(installer.isInstalled())
|
|
}
|
|
}
|