Commit 9a5889fafe
9a5889fafe80294ee796f2d7169e43f145455060
parent: 33770b09cc
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-17 15:59 UTC
Add CLI skeleton with init, paths, passphrase, atomic write
Layout: unified · split
Sources/keycask/AtomicFile.swift
added
+68
| @@ -0,0 +1,68 @@ |
| 1 | import Foundation |
| 2 | import KeycaskCore |
| 3 | |
| 4 | #if canImport(Darwin) |
| 5 | import Darwin |
| 6 | #elseif canImport(Glibc) |
| 7 | import Glibc |
| 8 | #elseif canImport(Musl) |
| 9 | import Musl |
| 10 | #elseif os(Windows) |
| 11 | import WinSDK |
| 12 | #endif |
| 13 | |
| 14 | enum AtomicFile { |
| 15 | static func write(_ data: Data, to url: URL) throws { |
| 16 | let directory = url.deletingLastPathComponent() |
| 17 | let temp = url.appendingPathExtension("tmp") |
| 18 | do { |
| 19 | try FileManager.default.createDirectory( |
| 20 | at: directory, withIntermediateDirectories: true) |
| 21 | try writePrivate(data, to: temp) |
| 22 | try replace(url, with: temp) |
| 23 | } catch let error as KeycaskError { |
| 24 | try? FileManager.default.removeItem(at: temp) |
| 25 | throw error |
| 26 | } catch { |
| 27 | try? FileManager.default.removeItem(at: temp) |
| 28 | throw KeycaskError.io("write \(url.path): \(error)") |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | #if os(Windows) |
| 33 | private static func writePrivate(_ data: Data, to url: URL) throws { |
| 34 | try data.write(to: url) |
| 35 | let handle = try FileHandle(forWritingTo: url) |
| 36 | try handle.synchronize() |
| 37 | try handle.close() |
| 38 | } |
| 39 | |
| 40 | private static func replace(_ target: URL, with temp: URL) throws { |
| 41 | let ok = temp.path.withCString(encodedAs: UTF16.self) { src in |
| 42 | target.path.withCString(encodedAs: UTF16.self) { dst in |
| 43 | MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) |
| 44 | } |
| 45 | } |
| 46 | guard ok.boolValue else { |
| 47 | throw KeycaskError.io("rename \(temp.path): error \(GetLastError())") |
| 48 | } |
| 49 | } |
| 50 | #else |
| 51 | private static func writePrivate(_ data: Data, to url: URL) throws { |
| 52 | let fd = open(url.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600) |
| 53 | guard fd >= 0 else { |
| 54 | throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))") |
| 55 | } |
| 56 | let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) |
| 57 | try handle.write(contentsOf: data) |
| 58 | try handle.synchronize() |
| 59 | try handle.close() |
| 60 | } |
| 61 | |
| 62 | private static func replace(_ target: URL, with temp: URL) throws { |
| 63 | guard rename(temp.path, target.path) == 0 else { |
| 64 | throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))") |
| 65 | } |
| 66 | } |
| 67 | #endif |
| 68 | } |
Sources/keycask/Commands/Init.swift
added
+12
| @@ -0,0 +1,12 @@ |
| 1 | import ArgumentParser |
| 2 | |
| 3 | struct Init: ParsableCommand { |
| 4 | static let configuration = CommandConfiguration(abstract: "Create an empty vault.") |
| 5 | |
| 6 | @OptionGroup var global: GlobalOptions |
| 7 | |
| 8 | func run() throws { |
| 9 | let url = try OpenVault.create(global) |
| 10 | print("created \(url.path)") |
| 11 | } |
| 12 | } |
Sources/keycask/Keycask.swift
added
+16
| @@ -0,0 +1,16 @@ |
| 1 | import ArgumentParser |
| 2 | |
| 3 | struct GlobalOptions: ParsableArguments { |
| 4 | @Option(name: .long, help: "Path to the vault file.") |
| 5 | var vault: String? |
| 6 | } |
| 7 | |
| 8 | struct Keycask: ParsableCommand { |
| 9 | static let configuration = CommandConfiguration( |
| 10 | commandName: "keycask", |
| 11 | abstract: "Command-line password manager. One passphrase-encrypted vault file.", |
| 12 | subcommands: [Init.self] |
| 13 | ) |
| 14 | |
| 15 | @OptionGroup var global: GlobalOptions |
| 16 | } |
Sources/keycask/OpenVault.swift
added
+46
| @@ -0,0 +1,46 @@ |
| 1 | import Foundation |
| 2 | import KeycaskCore |
| 3 | |
| 4 | struct OpenVault { |
| 5 | var vault: Vault |
| 6 | let kdf: Envelope.KDFParams |
| 7 | let url: URL |
| 8 | let passphrase: String |
| 9 | |
| 10 | static func load(_ options: GlobalOptions) throws -> OpenVault { |
| 11 | let url = Paths.vaultURL(override: options.vault) |
| 12 | let data: Data |
| 13 | do { |
| 14 | data = try Data(contentsOf: url) |
| 15 | } catch let error as CocoaError where error.code == .fileReadNoSuchFile { |
| 16 | throw KeycaskError.noVault(url.path) |
| 17 | } catch { |
| 18 | if !FileManager.default.fileExists(atPath: url.path) { |
| 19 | throw KeycaskError.noVault(url.path) |
| 20 | } |
| 21 | throw KeycaskError.io("read \(url.path): \(error)") |
| 22 | } |
| 23 | let envelope = try Envelope(parsing: data) |
| 24 | let passphrase = try Passphrase.obtain(confirm: false) |
| 25 | let plaintext = try envelope.open(passphrase: passphrase) |
| 26 | let vault = try VaultCodec.decode(plaintext) |
| 27 | return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase) |
| 28 | } |
| 29 | |
| 30 | static func create(_ options: GlobalOptions) throws -> URL { |
| 31 | let url = Paths.vaultURL(override: options.vault) |
| 32 | guard !FileManager.default.fileExists(atPath: url.path) else { |
| 33 | throw KeycaskError.vaultExists(url.path) |
| 34 | } |
| 35 | let passphrase = try Passphrase.obtain(confirm: true) |
| 36 | let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase) |
| 37 | try fresh.save() |
| 38 | return url |
| 39 | } |
| 40 | |
| 41 | func save() throws { |
| 42 | let plaintext = try VaultCodec.encode(vault) |
| 43 | let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf) |
| 44 | try AtomicFile.write(try envelope.encoded(), to: url) |
| 45 | } |
| 46 | } |
Sources/keycask/Passphrase.swift
added
+28
| @@ -0,0 +1,28 @@ |
| 1 | import Foundation |
| 2 | import KeycaskCore |
| 3 | |
| 4 | enum Passphrase { |
| 5 | static let variable = "KEYCASK_PASSPHRASE" |
| 6 | |
| 7 | static func obtain( |
| 8 | confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment |
| 9 | ) throws -> String { |
| 10 | if let fromEnv = environment[variable] { |
| 11 | return try validated(fromEnv) |
| 12 | } |
| 13 | guard Terminal.stdinIsTTY else { |
| 14 | throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal") |
| 15 | } |
| 16 | let first = try Terminal.readSecretLine(prompt: "Passphrase: ") |
| 17 | if confirm { |
| 18 | let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ") |
| 19 | guard first == second else { throw KeycaskError.failure("passphrases do not match") } |
| 20 | } |
| 21 | return try validated(first) |
| 22 | } |
| 23 | |
| 24 | private static func validated(_ passphrase: String) throws -> String { |
| 25 | guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") } |
| 26 | return passphrase |
| 27 | } |
| 28 | } |
Sources/keycask/Paths.swift
added
+27
| @@ -0,0 +1,27 @@ |
| 1 | import Foundation |
| 2 | |
| 3 | enum Paths { |
| 4 | static let variable = "KEYCASK_VAULT" |
| 5 | |
| 6 | static func vaultURL( |
| 7 | override: String?, environment: [String: String] = ProcessInfo.processInfo.environment |
| 8 | ) -> URL { |
| 9 | if let override { return URL(fileURLWithPath: override) } |
| 10 | if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) } |
| 11 | return defaultDirectory(environment: environment) |
| 12 | .appendingPathComponent("keycask").appendingPathComponent("vault.kc") |
| 13 | } |
| 14 | |
| 15 | private static func defaultDirectory(environment: [String: String]) -> URL { |
| 16 | #if os(Windows) |
| 17 | let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "." |
| 18 | return URL(fileURLWithPath: base) |
| 19 | #else |
| 20 | if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty { |
| 21 | return URL(fileURLWithPath: xdg) |
| 22 | } |
| 23 | let home = environment["HOME"] ?? "." |
| 24 | return URL(fileURLWithPath: home).appendingPathComponent(".local/share") |
| 25 | #endif |
| 26 | } |
| 27 | } |
Sources/keycask/Terminal.swift
added
+68
| @@ -0,0 +1,68 @@ |
| 1 | import Foundation |
| 2 | import KeycaskCore |
| 3 | |
| 4 | #if canImport(Darwin) |
| 5 | import Darwin |
| 6 | #elseif canImport(Glibc) |
| 7 | import Glibc |
| 8 | #elseif canImport(Musl) |
| 9 | import Musl |
| 10 | #elseif os(Windows) |
| 11 | import CRT |
| 12 | import WinSDK |
| 13 | #endif |
| 14 | |
| 15 | enum Terminal { |
| 16 | static var stdinIsTTY: Bool { |
| 17 | #if os(Windows) |
| 18 | return _isatty(_fileno(stdin)) != 0 |
| 19 | #else |
| 20 | return isatty(STDIN_FILENO) != 0 |
| 21 | #endif |
| 22 | } |
| 23 | |
| 24 | static func write(_ text: String) { |
| 25 | FileHandle.standardError.write(Data(text.utf8)) |
| 26 | } |
| 27 | |
| 28 | static func readLine(prompt: String) -> String? { |
| 29 | write(prompt) |
| 30 | return Swift.readLine(strippingNewline: true) |
| 31 | } |
| 32 | |
| 33 | static func confirm(_ question: String) -> Bool { |
| 34 | guard let answer = readLine(prompt: question + " [y/N] ") else { return false } |
| 35 | return answer.lowercased().hasPrefix("y") |
| 36 | } |
| 37 | |
| 38 | static func readSecretLine(prompt: String) throws -> String { |
| 39 | write(prompt) |
| 40 | defer { write("\n") } |
| 41 | return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" } |
| 42 | } |
| 43 | |
| 44 | #if os(Windows) |
| 45 | private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T { |
| 46 | let handle = GetStdHandle(DWORD(bitPattern: -10)) |
| 47 | var mode: DWORD = 0 |
| 48 | guard GetConsoleMode(handle, &mode).boolValue else { |
| 49 | throw KeycaskError.io("GetConsoleMode failed") |
| 50 | } |
| 51 | SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT)) |
| 52 | defer { SetConsoleMode(handle, mode) } |
| 53 | return try body() |
| 54 | } |
| 55 | #else |
| 56 | private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T { |
| 57 | var original = termios() |
| 58 | guard tcgetattr(STDIN_FILENO, &original) == 0 else { |
| 59 | throw KeycaskError.io("tcgetattr failed") |
| 60 | } |
| 61 | var quiet = original |
| 62 | quiet.c_lflag &= ~tcflag_t(ECHO) |
| 63 | tcsetattr(STDIN_FILENO, TCSANOW, &quiet) |
| 64 | defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) } |
| 65 | return try body() |
| 66 | } |
| 67 | #endif |
| 68 | } |
Sources/keycask/main.swift
+20 −1
| @@ -1,3 +1,22 @@ |
| 1 | import ArgumentParser |
| 2 | import Foundation |
| 1 | 3 | import KeycaskCore |
| 2 | 4 | |
| 3 | | print("keycask") |
| 5 | func fail(_ text: String, code: Int32) -> Never { |
| 6 | FileHandle.standardError.write(Data((text + "\n").utf8)) |
| 7 | exit(code) |
| 8 | } |
| 9 | |
| 10 | do { |
| 11 | var command = try Keycask.parseAsRoot() |
| 12 | try command.run() |
| 13 | } catch let error as KeycaskError { |
| 14 | fail(error.message, code: error.exitCode) |
| 15 | } catch { |
| 16 | let text = Keycask.fullMessage(for: error) |
| 17 | if Keycask.exitCode(for: error).isSuccess { |
| 18 | print(text) |
| 19 | exit(0) |
| 20 | } |
| 21 | fail(text, code: 2) |
| 22 | } |
Tests/KeycaskCLITests/CLI.swift
+64 −2
| @@ -27,6 +27,68 @@ enum Binary { |
| 27 | 27 | }() |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | | @Test func binaryIsBuilt() { |
| 31 | | #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path)) |
| 30 | struct CLI { |
| 31 | struct Result { |
| 32 | let status: Int32 |
| 33 | let stdout: String |
| 34 | let stderr: String |
| 35 | var lines: [String] { stdout.split(separator: "\n").map(String.init) } |
| 36 | } |
| 37 | |
| 38 | static let passphrase = "correct horse battery" |
| 39 | |
| 40 | let dir: URL |
| 41 | let vault: URL |
| 42 | |
| 43 | init() throws { |
| 44 | dir = FileManager.default.temporaryDirectory |
| 45 | .appendingPathComponent("keycask-tests-\(UUID().uuidString)") |
| 46 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| 47 | vault = dir.appendingPathComponent("vault.kc") |
| 48 | } |
| 49 | |
| 50 | @discardableResult |
| 51 | func run( |
| 52 | _ args: [String], |
| 53 | stdin: String? = nil, |
| 54 | passphrase: String? = CLI.passphrase, |
| 55 | extraEnvironment: [String: String] = [:] |
| 56 | ) throws -> Result { |
| 57 | let process = Process() |
| 58 | process.executableURL = Binary.url |
| 59 | process.arguments = args |
| 60 | var env = ProcessInfo.processInfo.environment |
| 61 | env["KEYCASK_VAULT"] = vault.path |
| 62 | env.removeValue(forKey: "KEYCASK_PASSPHRASE") |
| 63 | if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase } |
| 64 | for (k, v) in extraEnvironment { env[k] = v } |
| 65 | process.environment = env |
| 66 | |
| 67 | let out = Pipe() |
| 68 | let err = Pipe() |
| 69 | let input = Pipe() |
| 70 | process.standardOutput = out |
| 71 | process.standardError = err |
| 72 | process.standardInput = input |
| 73 | try process.run() |
| 74 | if let stdin { |
| 75 | input.fileHandleForWriting.write(Data(stdin.utf8)) |
| 76 | } |
| 77 | try input.fileHandleForWriting.close() |
| 78 | let outData = out.fileHandleForReading.readDataToEndOfFile() |
| 79 | let errData = err.fileHandleForReading.readDataToEndOfFile() |
| 80 | process.waitUntilExit() |
| 81 | return Result( |
| 82 | status: process.terminationStatus, |
| 83 | stdout: String(decoding: outData, as: UTF8.self), |
| 84 | stderr: String(decoding: errData, as: UTF8.self)) |
| 85 | } |
| 86 | |
| 87 | /// Runs `init` and returns the harness, for tests that need a vault. |
| 88 | static func initialized() throws -> CLI { |
| 89 | let cli = try CLI() |
| 90 | let r = try cli.run(["init"]) |
| 91 | precondition(r.status == 0, "init failed: \(r.stderr)") |
| 92 | return cli |
| 93 | } |
| 32 | 94 | } |
Tests/KeycaskCLITests/InitTests.swift
added
+75
| @@ -0,0 +1,75 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | |
| 4 | @Suite struct InitTests { |
| 5 | @Test func initCreatesVaultAndPrintsPath() throws { |
| 6 | let cli = try CLI() |
| 7 | let r = try cli.run(["init"]) |
| 8 | #expect(r.status == 0) |
| 9 | #expect(r.stdout.contains(cli.vault.path)) |
| 10 | #expect(FileManager.default.fileExists(atPath: cli.vault.path)) |
| 11 | let text = try String(contentsOf: cli.vault, encoding: .utf8) |
| 12 | #expect(text.contains("\"format\" : 1")) |
| 13 | #expect(text.contains("pbkdf2-hmac-sha256")) |
| 14 | #expect(!text.contains("entries")) |
| 15 | } |
| 16 | |
| 17 | @Test func initRefusesExistingVault() throws { |
| 18 | let cli = try CLI.initialized() |
| 19 | let r = try cli.run(["init"]) |
| 20 | #expect(r.status == 1) |
| 21 | #expect(r.stderr.contains("already exists")) |
| 22 | } |
| 23 | |
| 24 | @Test func initWithoutPassphraseOrTTYIsUsageError() throws { |
| 25 | let cli = try CLI() |
| 26 | let r = try cli.run(["init"], passphrase: nil) |
| 27 | #expect(r.status == 2) |
| 28 | #expect(r.stderr.contains("KEYCASK_PASSPHRASE")) |
| 29 | } |
| 30 | |
| 31 | @Test func emptyPassphraseIsRejected() throws { |
| 32 | let cli = try CLI() |
| 33 | let r = try cli.run(["init"], passphrase: "") |
| 34 | #expect(r.status == 1) |
| 35 | #expect(r.stderr.contains("empty")) |
| 36 | } |
| 37 | |
| 38 | @Test func vaultFlagBeatsEnvironment() throws { |
| 39 | let cli = try CLI() |
| 40 | let other = cli.dir.appendingPathComponent("elsewhere.kc") |
| 41 | let r = try cli.run(["--vault", other.path, "init"]) |
| 42 | #expect(r.status == 0) |
| 43 | #expect(FileManager.default.fileExists(atPath: other.path)) |
| 44 | #expect(!FileManager.default.fileExists(atPath: cli.vault.path)) |
| 45 | } |
| 46 | |
| 47 | @Test func unknownSubcommandIsUsageError() throws { |
| 48 | let cli = try CLI() |
| 49 | let r = try cli.run(["frobnicate"]) |
| 50 | #expect(r.status == 2) |
| 51 | #expect(r.stderr.contains("Usage")) |
| 52 | } |
| 53 | |
| 54 | @Test func helpExitsZero() throws { |
| 55 | let cli = try CLI() |
| 56 | let r = try cli.run(["--help"]) |
| 57 | #expect(r.status == 0) |
| 58 | #expect(r.stdout.contains("init")) |
| 59 | } |
| 60 | |
| 61 | #if !os(Windows) |
| 62 | @Test func vaultIsPrivateOnUnix() throws { |
| 63 | let cli = try CLI.initialized() |
| 64 | let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path) |
| 65 | let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777 |
| 66 | #expect(mode == 0o600) |
| 67 | } |
| 68 | #endif |
| 69 | |
| 70 | @Test func noTempFileLeftBehind() throws { |
| 71 | let cli = try CLI.initialized() |
| 72 | let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path) |
| 73 | #expect(names == ["vault.kc"]) |
| 74 | } |
| 75 | } |
Tests/KeycaskCLITests/PathsTests.swift
added
+38
| @@ -0,0 +1,38 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | |
| 4 | @testable import keycask |
| 5 | |
| 6 | @Suite struct PathsTests { |
| 7 | @Test func overrideWinsOverEverything() { |
| 8 | let url = Paths.vaultURL( |
| 9 | override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"]) |
| 10 | #expect(url.path == "/x/v.kc") |
| 11 | } |
| 12 | |
| 13 | @Test func environmentVariableWinsOverDefaults() { |
| 14 | let url = Paths.vaultURL( |
| 15 | override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"]) |
| 16 | #expect(url.path == "/y/v.kc") |
| 17 | } |
| 18 | |
| 19 | #if os(Windows) |
| 20 | @Test func windowsUsesLocalAppData() { |
| 21 | let url = Paths.vaultURL( |
| 22 | override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"]) |
| 23 | #expect( |
| 24 | url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc")) |
| 25 | } |
| 26 | #else |
| 27 | @Test func xdgDataHomeIsUsedWhenSet() { |
| 28 | let url = Paths.vaultURL( |
| 29 | override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"]) |
| 30 | #expect(url.path == "/d/keycask/vault.kc") |
| 31 | } |
| 32 | |
| 33 | @Test func homeFallback() { |
| 34 | let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"]) |
| 35 | #expect(url.path == "/h/.local/share/keycask/vault.kc") |
| 36 | } |
| 37 | #endif |
| 38 | } |