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 | import KeycaskCore |
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() { |
30 | struct CLI { |
| 31 | #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path)) |
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 | } |