diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e81d4d0a..4b338421a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254) + ### Fixed - Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 3181e4892..5633acda8 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -479,6 +479,9 @@ enum DatabaseDriverFactory { return try await resolveIAMPassword(for: connection, fields: fields) } if let override { return override } + if let passwordSource = connection.passwordSource { + return try await PasswordSourceResolver.resolve(passwordSource) + } if connection.usePgpass { let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host let pgpassPort = connection.additionalFields["pgpassOriginalPort"] diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 39daef5c0..95147a9ce 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -41,6 +41,7 @@ extension DatabaseManager { type: connection.type, sshConfig: SSHConfiguration(), sslConfig: tunnelSSL, + passwordSource: connection.passwordSource, additionalFields: effectiveFields ) } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 9ea56aac8..5d5287479 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -278,6 +278,7 @@ final class ConnectionStorage { startupCommands: connection.startupCommands, sortOrder: connection.sortOrder, localOnly: connection.localOnly, + passwordSource: connection.passwordSource, additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields ) @@ -591,6 +592,9 @@ private struct StoredConnection: Codable { // Plugin-driven additional fields let additionalFields: [String: String]? + // Password source (file, env, or command) for connections provisioned outside the app + let passwordSource: PasswordSource? + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -676,6 +680,9 @@ private struct StoredConnection: Codable { // Plugin-driven additional fields self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields + + // Password source (not synced to iCloud; see SyncRecordMapper) + self.passwordSource = connection.passwordSource } private enum CodingKeys: String, CodingKey { @@ -698,6 +705,7 @@ private struct StoredConnection: Codable { case localOnly case isSample case isFavorite + case passwordSource } func encode(to encoder: Encoder) throws { @@ -741,6 +749,7 @@ private struct StoredConnection: Codable { try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) try container.encode(isFavorite, forKey: .isFavorite) + try container.encodeIfPresent(passwordSource, forKey: .passwordSource) } // Custom decoder to handle migration from old format @@ -807,6 +816,7 @@ private struct StoredConnection: Codable { sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson) cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) + passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false @@ -914,6 +924,7 @@ private struct StoredConnection: Codable { localOnly: localOnly, isSample: isSample, isFavorite: isFavorite, + passwordSource: passwordSource, additionalFields: mergedFields ) } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fa0fd3577..56f55c590 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -517,6 +517,7 @@ final class SyncCoordinator { } var merged = remoteConnection merged.localOnly = connections[index].localOnly + merged.passwordSource = connections[index].passwordSource connections[index] = merged } else { connections.append(remoteConnection) diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 6eba88390..50d7e2b65 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -113,6 +113,8 @@ struct SyncRecordMapper { // the sync schema in the future, apply path contraction to its snapshot. // cloudflareTunnelMode is also NOT synced: it is device-local runtime // config and its service-token secrets live in the Keychain. + // passwordSource is also NOT synced: its file path, env var, or command + // is device-local and may not exist or resolve on another Mac. do { let sshData = try encoder.encode(Self.makePortable(connection.sshConfig)) record["sshConfigJson"] = sshData as CKRecordValue diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift new file mode 100644 index 000000000..45cb71a7c --- /dev/null +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -0,0 +1,237 @@ +// +// PasswordSourceResolver.swift +// TablePro +// + +import Foundation +import os + +/// Resolves a connection password from an external source declared in connections.json. +/// File and command sources require a non-sandboxed build; TablePro ships with the hardened +/// runtime and no App Sandbox, so spawning a process and reading arbitrary files is allowed. +enum PasswordSourceResolver { + private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSourceResolver") + + private static let commandTimeoutSeconds: UInt64 = 30 + private static let maxOutputBytes = 1_048_576 + + enum ResolutionError: LocalizedError { + case fileNotFound(path: String) + case fileUnreadable(path: String) + case environmentVariableNotSet(name: String) + case commandFailed(exitCode: Int32, stderr: String) + case commandTimedOut + case outputTooLarge + case emptyPassword + + var errorDescription: String? { + switch self { + case let .fileNotFound(path): + return String(format: String(localized: "Password file not found: %@"), path) + case let .fileUnreadable(path): + return String(format: String(localized: "Could not read password file: %@"), path) + case let .environmentVariableNotSet(name): + return String( + format: String(localized: """ + Environment variable %@ is not set in TablePro's environment. \ + Apps launched from the Dock do not inherit shell exports. Launch TablePro \ + from a terminal, or set the variable with launchctl setenv. + """), + name + ) + case let .commandFailed(exitCode, stderr): + let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if message.isEmpty { + return String(format: String(localized: "Password command failed with exit code %d"), exitCode) + } + return String(format: String(localized: "Password command failed (exit %d): %@"), exitCode, message) + case .commandTimedOut: + return String(localized: "Password command timed out after 30 seconds") + case .outputTooLarge: + return String(localized: "Password command produced too much output") + case .emptyPassword: + return String(localized: "The password source produced an empty password") + } + } + } + + static func resolve(_ source: PasswordSource) async throws -> String { + switch source { + case let .file(path): + return try resolveFile(path: path) + case let .env(variable): + return try resolveEnvironment(variable: variable) + case let .command(shell): + return try await resolveCommand(shell: shell, timeoutSeconds: commandTimeoutSeconds) + } + } + + private static func resolveFile(path: String) throws -> String { + let expandedPath = (path as NSString).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw ResolutionError.fileNotFound(path: expandedPath) + } + warnIfPermissionsInsecure(path: expandedPath) + guard let contents = try? String(contentsOfFile: expandedPath, encoding: .utf8) else { + throw ResolutionError.fileUnreadable(path: expandedPath) + } + return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func resolveEnvironment(variable: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[variable] else { + throw ResolutionError.environmentVariableNotSet(name: variable) + } + return try nonEmpty(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + static func resolveCommand(shell: String, timeoutSeconds: UInt64) async throws -> String { + let output = try await Task.detached(priority: .userInitiated) { () throws -> String in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", shell] + process.environment = augmentedEnvironment() + process.standardInput = FileHandle.nullDevice + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes) + let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes) + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + stdoutCollector.append(chunk) + if stdoutCollector.overflowed, process.isRunning { + process.terminate() + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if !chunk.isEmpty { stderrCollector.append(chunk) } + } + + try process.run() + + let didTimeout = AtomicFlag() + let timeoutTask = Task.detached { + try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + if process.isRunning { + didTimeout.set() + process.terminate() + } + } + + process.waitUntilExit() + timeoutTask.cancel() + + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + if stdoutCollector.overflowed { + throw ResolutionError.outputTooLarge + } + if didTimeout.isSet { + throw ResolutionError.commandTimedOut + } + if process.terminationStatus != 0 { + throw ResolutionError.commandFailed( + exitCode: process.terminationStatus, + stderr: stderrCollector.string + ) + } + return stdoutCollector.string + }.value + + guard !output.contains("\0") else { + throw ResolutionError.emptyPassword + } + return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func augmentedEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + var pathComponents = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + for toolPath in toolPaths where !pathComponents.contains(toolPath) { + pathComponents.append(toolPath) + } + environment["PATH"] = pathComponents.joined(separator: ":") + return environment + } + + private static func warnIfPermissionsInsecure(path: String) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let permissions = attributes[.posixPermissions] as? Int else { + return + } + if permissions & 0o077 != 0 { + logger.warning("Password file is group or world accessible; restrict it with chmod 600") + } + } + + private static func nonEmpty(_ password: String) throws -> String { + guard !password.isEmpty else { + throw ResolutionError.emptyPassword + } + return password + } +} + +private final class PipeDataCollector: @unchecked Sendable { + private let lock = NSLock() + private let maxBytes: Int + private var data = Data() + private var didOverflow = false + + init(maxBytes: Int) { + self.maxBytes = maxBytes + } + + func append(_ chunk: Data) { + lock.lock() + defer { lock.unlock() } + let remaining = maxBytes - data.count + guard remaining > 0 else { + didOverflow = true + return + } + if chunk.count > remaining { + data.append(chunk.prefix(remaining)) + didOverflow = true + } else { + data.append(chunk) + } + } + + var overflowed: Bool { + lock.lock() + defer { lock.unlock() } + return didOverflow + } + + var string: String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } +} + +private final class AtomicFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index dda3adc46..ccf097497 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -334,6 +334,7 @@ struct DatabaseConnection: Identifiable, Hashable { var localOnly: Bool = false var isSample: Bool = false var isFavorite: Bool = false + var passwordSource: PasswordSource? var mongoAuthSource: String? { get { additionalFields["mongoAuthSource"]?.nilIfEmpty } @@ -430,6 +431,7 @@ struct DatabaseConnection: Identifiable, Hashable { localOnly: Bool = false, isSample: Bool = false, isFavorite: Bool = false, + passwordSource: PasswordSource? = nil, additionalFields: [String: String]? = nil ) { self.id = id @@ -472,6 +474,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.localOnly = localOnly self.isSample = isSample self.isFavorite = isFavorite + self.passwordSource = passwordSource if let additionalFields { self.additionalFields = additionalFields } else { @@ -520,6 +523,7 @@ extension DatabaseConnection: Codable { case sshConfig, sslConfig, color, tagId, groupId, sshProfileId case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite + case passwordSource } init(from decoder: Decoder) throws { @@ -549,6 +553,7 @@ extension DatabaseConnection: Codable { localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present @@ -600,6 +605,7 @@ extension DatabaseConnection: Codable { try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) try container.encode(isFavorite, forKey: .isFavorite) + try container.encodeIfPresent(passwordSource, forKey: .passwordSource) } } diff --git a/TablePro/Models/Connection/PasswordSource.swift b/TablePro/Models/Connection/PasswordSource.swift new file mode 100644 index 000000000..f86da5acc --- /dev/null +++ b/TablePro/Models/Connection/PasswordSource.swift @@ -0,0 +1,73 @@ +// +// PasswordSource.swift +// TablePro +// + +import Foundation +import os + +/// Declares where a connection's password comes from when it is not stored in the Keychain. +/// Resolved at connect time from a file, an environment variable, or the stdout of a shell command. +enum PasswordSource: Codable, Hashable, Sendable { + case file(path: String) + case env(variable: String) + case command(shell: String) + + private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSource") + + private enum CodingKeys: String, CodingKey { + case kind, path, variable, shell + } + + private enum Kind: String { + case file, env, command + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case Kind.file.rawValue: + self = .file(path: try container.decode(String.self, forKey: .path)) + case Kind.env.rawValue: + self = .env(variable: try container.decode(String.self, forKey: .variable)) + case Kind.command.rawValue: + self = .command(shell: try container.decode(String.self, forKey: .shell)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown passwordSource kind: \(kind)" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .file(path): + try container.encode(Kind.file.rawValue, forKey: .kind) + try container.encode(path, forKey: .path) + case let .env(variable): + try container.encode(Kind.env.rawValue, forKey: .kind) + try container.encode(variable, forKey: .variable) + case let .command(shell): + try container.encode(Kind.command.rawValue, forKey: .kind) + try container.encode(shell, forKey: .shell) + } + } + + /// Decodes a password source from a connection container, treating a present-but-malformed + /// entry as absent so one bad connection cannot fail loading of the whole store. + static func resilientlyDecoded( + from container: KeyedDecodingContainer, + forKey key: Key + ) -> PasswordSource? { + do { + return try container.decodeIfPresent(PasswordSource.self, forKey: key) + } catch { + logger.warning("Ignoring malformed passwordSource in a connection") + return nil + } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 95c45755d..568f64c5e 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -288,6 +288,7 @@ final class ConnectionFormCoordinator { startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, localOnly: advanced.localOnly, + passwordSource: originalConnection?.passwordSource, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields ) @@ -459,6 +460,7 @@ final class ConnectionFormCoordinator { redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, + passwordSource: auth.password.isEmpty ? originalConnection?.passwordSource : nil, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields ) temporaryTestIds.insert(testConn.id) diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift new file mode 100644 index 000000000..ac6201789 --- /dev/null +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -0,0 +1,30 @@ +// +// DatabaseManagerTunnelTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("DatabaseManager tunnel rewrite") +@MainActor +struct DatabaseManagerTunnelTests { + @Test("Tunneled connection rewrites the endpoint and keeps the password source") + func tunnelPreservesPasswordSource() { + var connection = DatabaseConnection( + name: "tunneled", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.passwordSource = .env(variable: "DB_PASS") + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 61_234) + + #expect(tunneled.host == "127.0.0.1") + #expect(tunneled.port == 61_234) + #expect(tunneled.passwordSource == .env(variable: "DB_PASS")) + } +} diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 22f13b0aa..93b537536 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -66,6 +66,20 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.name == "Round Trip Test") } + @Test("duplicating a connection preserves its password source") + func duplicatePreservesPasswordSource() { + var connection = DatabaseConnection(name: "Source", type: .postgresql) + connection.passwordSource = .file(path: "~/.config/tablepro/db.pw") + storage.addConnection(connection) + + let duplicate = storage.duplicateConnection(connection) + #expect(duplicate.id != connection.id) + #expect(duplicate.passwordSource == .file(path: "~/.config/tablepro/db.pw")) + + let reloaded = storage.loadConnections().first { $0.id == duplicate.id } + #expect(reloaded?.passwordSource == .file(path: "~/.config/tablepro/db.pw")) + } + @Test("connections default to not favorited") func defaultsToNotFavorited() { let connection = DatabaseConnection(name: "Plain Test") diff --git a/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift new file mode 100644 index 000000000..bdd8df248 --- /dev/null +++ b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift @@ -0,0 +1,200 @@ +// +// PasswordSourceResolverTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PasswordSourceResolver", .serialized) +struct PasswordSourceResolverTests { + @Test("Reads a password from a file") + func fileHappyPath() async throws { + let url = try makeTempFile(contents: "filesecret") + defer { try? FileManager.default.removeItem(at: url) } + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "filesecret") + } + + @Test("Trims a trailing newline from file contents") + func fileTrimsNewline() async throws { + let url = try makeTempFile(contents: "filesecret\n") + defer { try? FileManager.default.removeItem(at: url) } + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "filesecret") + } + + @Test("Expands a tilde in the file path") + func fileExpandsTilde() async throws { + let name = "tablepro_pwtest_\(UUID().uuidString).pw" + let homeURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(name) + try "tildesecret".write(to: homeURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: homeURL) } + let password = try await PasswordSourceResolver.resolve(.file(path: "~/\(name)")) + #expect(password == "tildesecret") + } + + @Test("Throws when the file does not exist") + func fileNotFound() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: "/nonexistent/tablepro/\(UUID().uuidString)")) + } + } + + @Test("Throws when the file is empty") + func fileEmpty() async throws { + let url = try makeTempFile(contents: "") + defer { try? FileManager.default.removeItem(at: url) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: url.path)) + } + } + + @Test("Throws when the file holds only whitespace") + func fileWhitespaceOnly() async throws { + let url = try makeTempFile(contents: " \n\t ") + defer { try? FileManager.default.removeItem(at: url) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: url.path)) + } + } + + @Test("Resolves a file with loose permissions instead of refusing") + func fileLoosePermissions() async throws { + let url = try makeTempFile(contents: "loosesecret") + defer { try? FileManager.default.removeItem(at: url) } + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "loosesecret") + } + + @Test("Reads a password from an environment variable") + func envHappyPath() async throws { + let name = uniqueEnvName() + setenv(name, "envsecret", 1) + defer { unsetenv(name) } + let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + #expect(password == "envsecret") + } + + @Test("Trims whitespace from an environment variable value") + func envTrimsWhitespace() async throws { + let name = uniqueEnvName() + setenv(name, " envsecret ", 1) + defer { unsetenv(name) } + let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + #expect(password == "envsecret") + } + + @Test("Throws when the environment variable is not set") + func envNotSet() async { + let name = uniqueEnvName() + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.env(variable: name)) + } + } + + @Test("Throws when the environment variable is empty") + func envEmpty() async { + let name = uniqueEnvName() + setenv(name, "", 1) + defer { unsetenv(name) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.env(variable: name)) + } + } + + @Test("Reads a password from command stdout") + func commandHappyPath() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "printf 'cmdsecret'")) + #expect(password == "cmdsecret") + } + + @Test("Trims a trailing newline from command stdout") + func commandTrimsNewline() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "echo cmdsecret")) + #expect(password == "cmdsecret") + } + + @Test("Preserves interior spaces in command stdout") + func commandPreservesSpaces() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "printf 'a b c'")) + #expect(password == "a b c") + } + + @Test("Throws with exit code and stderr on non-zero exit") + func commandNonZeroExit() async { + do { + _ = try await PasswordSourceResolver.resolveCommand(shell: "echo boom >&2; exit 7", timeoutSeconds: 30) + Issue.record("Expected resolveCommand to throw") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case let .commandFailed(exitCode, stderr) = error else { + Issue.record("Expected commandFailed, got \(error)") + return + } + #expect(exitCode == 7) + #expect(stderr.contains("boom")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Throws when command produces empty stdout") + func commandEmptyOutput() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.command(shell: "true")) + } + } + + @Test("Rejects command output containing a NUL byte") + func commandRejectsNul() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.command(shell: "printf 'a\\000b'")) + } + } + + @Test("Throws when command output exceeds the size cap") + func commandOutputTooLarge() async { + do { + _ = try await PasswordSourceResolver.resolveCommand( + shell: "head -c 2000000 /dev/zero | tr '\\0' 'a'", + timeoutSeconds: 30 + ) + Issue.record("Expected resolveCommand to reject oversized output") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case .outputTooLarge = error else { + Issue.record("Expected outputTooLarge, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Times out a slow command") + func commandTimesOut() async { + do { + _ = try await PasswordSourceResolver.resolveCommand(shell: "sleep 5", timeoutSeconds: 1) + Issue.record("Expected resolveCommand to time out") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case .commandTimedOut = error else { + Issue.record("Expected commandTimedOut, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private func makeTempFile(contents: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro_pwtest_\(UUID().uuidString).pw") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + private func uniqueEnvName() -> String { + "TABLEPRO_PWTEST_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + } +} diff --git a/TableProTests/Models/PasswordSourceCodableTests.swift b/TableProTests/Models/PasswordSourceCodableTests.swift new file mode 100644 index 000000000..306c9c2dc --- /dev/null +++ b/TableProTests/Models/PasswordSourceCodableTests.swift @@ -0,0 +1,70 @@ +// +// PasswordSourceCodableTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PasswordSource Codable") +struct PasswordSourceCodableTests { + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + @Test("Encodes each kind with the documented field names") + func encodesDocumentedFieldNames() throws { + #expect(try shape(.file(path: "p")) == ["kind": "file", "path": "p"]) + #expect(try shape(.env(variable: "V")) == ["kind": "env", "variable": "V"]) + #expect(try shape(.command(shell: "s")) == ["kind": "command", "shell": "s"]) + } + + private func shape(_ source: PasswordSource) throws -> [String: String] { + try decoder.decode([String: String].self, from: encoder.encode(source)) + } + + @Test("Round-trips all three kinds") + func roundTrips() throws { + let sources: [PasswordSource] = [ + .file(path: "~/db.pw"), + .env(variable: "DB_PASS"), + .command(shell: "op read op://vault/db/password"), + ] + for source in sources { + let decoded = try decoder.decode(PasswordSource.self, from: encoder.encode(source)) + #expect(decoded == source) + } + } + + @Test("Decodes the documented JSON shape") + func decodesDocumentedShape() throws { + let json = #"{"kind":"file","path":"~/.config/tablepro/secrets/feature-x.pw"}"# + let data = try #require(json.data(using: .utf8)) + let decoded = try decoder.decode(PasswordSource.self, from: data) + #expect(decoded == .file(path: "~/.config/tablepro/secrets/feature-x.pw")) + } + + @Test("Throws on an unknown kind") + func throwsOnUnknownKind() throws { + let json = #"{"kind":"vault","path":"x"}"# + let data = try #require(json.data(using: .utf8)) + #expect(throws: DecodingError.self) { + _ = try decoder.decode(PasswordSource.self, from: data) + } + } + + @Test("Round-trips through DatabaseConnection") + func roundTripsThroughConnection() throws { + var connection = DatabaseConnection(name: "worktree", type: .postgresql) + connection.passwordSource = .command(shell: "op read op://vault/feature-x/password") + let decoded = try decoder.decode(DatabaseConnection.self, from: encoder.encode(connection)) + #expect(decoded.passwordSource == .command(shell: "op read op://vault/feature-x/password")) + } + + @Test("A connection without a password source decodes to nil") + func absentPasswordSourceIsNil() throws { + let connection = DatabaseConnection(name: "plain", type: .mysql) + let decoded = try decoder.decode(DatabaseConnection.self, from: encoder.encode(connection)) + #expect(decoded.passwordSource == nil) + } +} diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 2d608f1e9..334ab5b35 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -168,6 +168,24 @@ Use `$VAR` and `${VAR}` in `.tablepro` files. Resolved at connection time. Works with `.env` files, 1Password CLI (`op run`), direnv. +## Password Sources + +Connections in `~/Library/Application Support/TablePro/connections.json` can declare where their password comes from instead of storing it in the Keychain. This helps when a script provisions connections, for example one Docker database per git worktree. The password is resolved at connect time. It is not synced to iCloud, since the path, variable, or command is specific to one Mac. + +Add a `passwordSource` object to the connection: + +```json +{ "passwordSource": { "kind": "file", "path": "~/.config/tablepro/secrets/feature-x.pw" } } +{ "passwordSource": { "kind": "env", "variable": "STAGING_DB_PASSWORD" } } +{ "passwordSource": { "kind": "command", "shell": "op read op://vault/feature-x/password" } } +``` + +- `file`: reads the password from the file. A trailing newline is trimmed. Use `chmod 600` to keep it private. +- `env`: reads the named environment variable. Apps launched from the Dock do not inherit your shell exports, so set it with `launchctl setenv NAME value` or launch TablePro from a terminal. +- `command`: runs the command through `/bin/bash` and reads stdout. Works with `op`, `vault`, `pass`, and `sops`. A trailing newline is trimmed, a non-zero exit fails the connection, and the command has a 30 second timeout. + +When `passwordSource` is set it replaces the Keychain lookup for that connection. If resolution fails, the connection reports the error instead of falling back to the Keychain. + ## File Format JSON. Required fields: `name`, `host`, `type`.