CLI: init, add, show, ls, find, edit, rm, generate, clip !8
29 files changed, +1526 −9
Layout: unified · split
README.md +47 −1
| @@ -3,11 +3,57 @@ | ||
| 3 | 3 | Command-line password manager. One passphrase-encrypted vault file. |
| 4 | 4 | Swift, runs on macOS, Linux, and Windows. |
| 5 | 5 | |
| 6 | Work in progress. Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`. | |
| 6 | ## install | |
| 7 | ||
| 8 | ```sh | |
| 9 | swift build -c release | |
| 10 | cp .build/release/keycask ~/.local/bin/ | |
| 11 | ``` | |
| 12 | ||
| 13 | ## use | |
| 14 | ||
| 15 | ```sh | |
| 16 | keycask init | |
| 17 | keycask add github -u cmc --url https://github.com --tag dev --generate | |
| 18 | keycask add mail --words 6 | |
| 19 | keycask add bank # prompts for the password | |
| 20 | keycask show github # password masked | |
| 21 | keycask show github --reveal | |
| 22 | keycask show github --field password # raw value, for scripts | |
| 23 | keycask clip github # clipboard, clears after 45s | |
| 24 | keycask ls --tag dev | |
| 25 | keycask find example | |
| 26 | keycask edit github --tag work --untag dev | |
| 27 | keycask rm github --yes | |
| 28 | keycask generate --words 5 --copy | |
| 29 | ``` | |
| 30 | ||
| 31 | Every read command takes `--json`. Passwords are masked unless `--reveal`. | |
| 32 | ||
| 33 | Names are labels and may repeat. Every command that takes a name also | |
| 34 | takes the entry's 8-character id, which `ls` and `add` print. An | |
| 35 | ambiguous name lists the candidates. | |
| 36 | ||
| 37 | ## files | |
| 38 | ||
| 39 | | what | default | override | | |
| 40 | |---|---|---| | |
| 41 | | vault | `$XDG_DATA_HOME/keycask/vault.kc`, else `~/.local/share/keycask/vault.kc` (`%LOCALAPPDATA%\keycask\vault.kc` on Windows) | `KEYCASK_VAULT`, `--vault` | | |
| 42 | | passphrase | prompted | `KEYCASK_PASSPHRASE` | | |
| 43 | ||
| 44 | The vault is a JSON envelope: PBKDF2-HMAC-SHA256 (600000 rounds) over | |
| 45 | the passphrase, ChaCha20-Poly1305 over the entries. Writes are atomic. | |
| 46 | ||
| 47 | ## exit codes | |
| 48 | ||
| 49 | 0 ok, 1 failure, 2 usage, 3 not found, 4 cannot decrypt, 5 ambiguous name. | |
| 7 | 50 | |
| 8 | 51 | ## develop |
| 9 | 52 | |
| 10 | 53 | ```sh |
| 11 | 54 | swift build |
| 12 | 55 | swift test |
| 56 | swift format lint --strict --recursive Sources Tests | |
| 13 | 57 | ``` |
| 58 | ||
| 59 | Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`. | |
Sources/keycask/AtomicFile.swift added +69
| @@ -0,0 +1,69 @@ | ||
| 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 | _ = unlink(url.path) | |
| 53 | let fd = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600) | |
| 54 | guard fd >= 0 else { | |
| 55 | throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))") | |
| 56 | } | |
| 57 | let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) | |
| 58 | try handle.write(contentsOf: data) | |
| 59 | try handle.synchronize() | |
| 60 | try handle.close() | |
| 61 | } | |
| 62 | ||
| 63 | private static func replace(_ target: URL, with temp: URL) throws { | |
| 64 | guard rename(temp.path, target.path) == 0 else { | |
| 65 | throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))") | |
| 66 | } | |
| 67 | } | |
| 68 | #endif | |
| 69 | } | |
Sources/keycask/Clipboard.swift added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | import Foundation | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | enum Clipboard { | |
| 5 | struct Handoff: Codable, Equatable { | |
| 6 | var secret: String | |
| 7 | } | |
| 8 | ||
| 9 | struct Tool: Equatable { | |
| 10 | let copy: [String] | |
| 11 | let paste: [String] | |
| 12 | } | |
| 13 | ||
| 14 | static let timeoutSeconds = 45 | |
| 15 | ||
| 16 | static func shouldClear(secret: String, current: String?) -> Bool { | |
| 17 | current == secret | |
| 18 | } | |
| 19 | ||
| 20 | static func findTool( | |
| 21 | path: String = ProcessInfo.processInfo.environment["PATH"] ?? "", | |
| 22 | fileManager: FileManager = .default | |
| 23 | ) -> Tool? { | |
| 24 | #if os(Windows) | |
| 25 | let separator: Character = ";" | |
| 26 | let candidates: [(copy: [String], paste: [String])] = [ | |
| 27 | (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"]) | |
| 28 | ] | |
| 29 | #elseif os(macOS) | |
| 30 | let separator: Character = ":" | |
| 31 | let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])] | |
| 32 | #else | |
| 33 | let separator: Character = ":" | |
| 34 | let candidates: [(copy: [String], paste: [String])] = [ | |
| 35 | (["wl-copy"], ["wl-paste", "--no-newline"]), | |
| 36 | ( | |
| 37 | ["xclip", "-selection", "clipboard"], | |
| 38 | ["xclip", "-selection", "clipboard", "-o"] | |
| 39 | ), | |
| 40 | ] | |
| 41 | #endif | |
| 42 | let dirs = path.split(separator: separator).map(String.init) | |
| 43 | func locate(_ name: String) -> String? { | |
| 44 | for dir in dirs { | |
| 45 | let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path | |
| 46 | if fileManager.isExecutableFile(atPath: full) { return full } | |
| 47 | } | |
| 48 | return nil | |
| 49 | } | |
| 50 | for candidate in candidates { | |
| 51 | guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0]) | |
| 52 | else { | |
| 53 | continue | |
| 54 | } | |
| 55 | return Tool( | |
| 56 | copy: [copy] + candidate.copy.dropFirst(), | |
| 57 | paste: [paste] + candidate.paste.dropFirst()) | |
| 58 | } | |
| 59 | return nil | |
| 60 | } | |
| 61 | ||
| 62 | /// Removes exactly one trailing line break, which some paste tools add. | |
| 63 | static func stripTrailingNewline(_ s: String) -> String { | |
| 64 | var scalars = s.unicodeScalars | |
| 65 | guard scalars.last == "\n" else { return s } | |
| 66 | scalars.removeLast() | |
| 67 | if scalars.last == "\r" { scalars.removeLast() } | |
| 68 | return String(scalars) | |
| 69 | } | |
| 70 | ||
| 71 | static func read() throws -> String { | |
| 72 | let tool = try requireTool() | |
| 73 | let (status, output) = try runTool(tool.paste, input: nil) | |
| 74 | guard status == 0 else { return "" } | |
| 75 | return stripTrailingNewline(output) | |
| 76 | } | |
| 77 | ||
| 78 | static func write(_ text: String) throws { | |
| 79 | let tool = try requireTool() | |
| 80 | let (status, _) = try runTool(tool.copy, input: text) | |
| 81 | guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") } | |
| 82 | } | |
| 83 | ||
| 84 | static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws { | |
| 85 | _ = try requireTool() | |
| 86 | let handoff = Handoff(secret: secret) | |
| 87 | try write(secret) | |
| 88 | let process = Process() | |
| 89 | process.executableURL = Bundle.main.executableURL | |
| 90 | process.arguments = ["clipboard-daemon", String(seconds)] | |
| 91 | process.standardOutput = FileHandle.nullDevice | |
| 92 | process.standardError = FileHandle.nullDevice | |
| 93 | let input = Pipe() | |
| 94 | process.standardInput = input | |
| 95 | do { | |
| 96 | try process.run() | |
| 97 | input.fileHandleForWriting.write(try JSONEncoder().encode(handoff)) | |
| 98 | try input.fileHandleForWriting.close() | |
| 99 | } catch { | |
| 100 | throw KeycaskError.failure("start clipboard daemon: \(error)") | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | static func runDaemon(seconds: Int) throws { | |
| 105 | let data = FileHandle.standardInput.readDataToEndOfFile() | |
| 106 | let handoff: Handoff | |
| 107 | do { | |
| 108 | handoff = try JSONDecoder().decode(Handoff.self, from: data) | |
| 109 | } catch { | |
| 110 | throw KeycaskError.failure("bad handoff") | |
| 111 | } | |
| 112 | Thread.sleep(forTimeInterval: TimeInterval(seconds)) | |
| 113 | let current = try? read() | |
| 114 | guard shouldClear(secret: handoff.secret, current: current) else { return } | |
| 115 | try write("") | |
| 116 | } | |
| 117 | ||
| 118 | private static func requireTool() throws -> Tool { | |
| 119 | guard let tool = findTool() else { | |
| 120 | #if os(Windows) | |
| 121 | let hint = "clip.exe and powershell.exe" | |
| 122 | #elseif os(macOS) | |
| 123 | let hint = "pbcopy and pbpaste" | |
| 124 | #else | |
| 125 | let hint = "wl-clipboard or xclip" | |
| 126 | #endif | |
| 127 | throw KeycaskError.failure("no clipboard tool found: install \(hint)") | |
| 128 | } | |
| 129 | return tool | |
| 130 | } | |
| 131 | ||
| 132 | private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) { | |
| 133 | let process = Process() | |
| 134 | process.executableURL = URL(fileURLWithPath: argv[0]) | |
| 135 | process.arguments = Array(argv.dropFirst()) | |
| 136 | let out = Pipe() | |
| 137 | process.standardOutput = out | |
| 138 | process.standardError = FileHandle.nullDevice | |
| 139 | let inPipe = Pipe() | |
| 140 | process.standardInput = inPipe | |
| 141 | do { | |
| 142 | try process.run() | |
| 143 | } catch { | |
| 144 | throw KeycaskError.failure("run \(argv[0]): \(error)") | |
| 145 | } | |
| 146 | if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) } | |
| 147 | try? inPipe.fileHandleForWriting.close() | |
| 148 | let data = out.fileHandleForReading.readDataToEndOfFile() | |
| 149 | process.waitUntilExit() | |
| 150 | return (process.terminationStatus, String(decoding: data, as: UTF8.self)) | |
| 151 | } | |
| 152 | } | |
Sources/keycask/Commands/Add.swift added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import Foundation | |
| 3 | import KeycaskCore | |
| 4 | ||
| 5 | enum PasswordInput { | |
| 6 | static func read(prompt: String) throws -> String { | |
| 7 | if Terminal.stdinIsTTY { | |
| 8 | return try Terminal.readSecretLine(prompt: prompt) | |
| 9 | } | |
| 10 | guard let line = Swift.readLine(strippingNewline: true) else { | |
| 11 | throw KeycaskError.usage("no password: pass one on stdin or run on a terminal") | |
| 12 | } | |
| 13 | return line | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | struct PasswordOptions: ParsableArguments { | |
| 18 | @Flag(name: .long, help: "Generate a random password.") | |
| 19 | var generate = false | |
| 20 | ||
| 21 | @Option(name: .long, help: "Length of the generated password (default 24).") | |
| 22 | var length: Int? | |
| 23 | ||
| 24 | @Option(name: .long, help: "Generate a passphrase of this many words instead.") | |
| 25 | var words: Int? | |
| 26 | ||
| 27 | mutating func validate() throws { | |
| 28 | if generate, words != nil { | |
| 29 | throw ValidationError("--generate and --words are mutually exclusive") | |
| 30 | } | |
| 31 | if words != nil, length != nil { | |
| 32 | throw ValidationError("--length and --words are mutually exclusive") | |
| 33 | } | |
| 34 | if let length, length < 1 { throw ValidationError("--length must be at least 1") } | |
| 35 | if let words, words < 1 { throw ValidationError("--words must be at least 1") } | |
| 36 | if length != nil, !generate, words == nil { | |
| 37 | throw ValidationError("--length requires --generate") | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /// nil means the caller must prompt. | |
| 42 | func newPassword() -> String? { | |
| 43 | if let words { return Generator.passphrase(words: words) } | |
| 44 | if generate { return Generator.password(length: length ?? Generator.defaultLength) } | |
| 45 | return nil | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | struct Add: ParsableCommand { | |
| 50 | static let configuration = CommandConfiguration(abstract: "Add an entry.") | |
| 51 | ||
| 52 | @OptionGroup var global: GlobalOptions | |
| 53 | @Argument(help: "Entry name. Names may repeat; the printed id is unique.") var name: String | |
| 54 | @Option(name: [.short, .customLong("username")], help: "Username.") var username: String? | |
| 55 | @Option(name: .long, help: "URL.") var url: String? | |
| 56 | @Option(name: .long, help: "Notes.") var notes: String? | |
| 57 | @Option(name: .long, help: "Tag. Repeatable.") var tag: [String] = [] | |
| 58 | @OptionGroup var password: PasswordOptions | |
| 59 | ||
| 60 | func run() throws { | |
| 61 | var open = try OpenVault.load(global) | |
| 62 | let secret = try password.newPassword() ?? PasswordInput.read(prompt: "Password: ") | |
| 63 | var entry = Entry( | |
| 64 | name: name, username: username, password: secret, url: url, | |
| 65 | notes: notes, tags: tag) | |
| 66 | while open.vault.entry(id: entry.id) != nil { | |
| 67 | entry = Entry( | |
| 68 | name: name, username: username, password: secret, url: url, | |
| 69 | notes: notes, tags: tag) | |
| 70 | } | |
| 71 | try open.vault.add(entry) | |
| 72 | try open.save() | |
| 73 | print(entry.id.rawValue) | |
| 74 | } | |
| 75 | } | |
Sources/keycask/Commands/Clip.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Clip: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration( | |
| 6 | abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds." | |
| 7 | ) | |
| 8 | ||
| 9 | @OptionGroup var global: GlobalOptions | |
| 10 | @Argument(help: "Entry id or name.") var ref: String | |
| 11 | @Option(name: .long, help: "Field to copy (default password).") var field = "password" | |
| 12 | ||
| 13 | func run() throws { | |
| 14 | let open = try OpenVault.load(global) | |
| 15 | let entry = try open.vault.resolve(ref) | |
| 16 | let value = try Output.field(entry, named: field) | |
| 17 | try Clipboard.copyWithTimeout(value) | |
| 18 | print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s") | |
| 19 | } | |
| 20 | } | |
Sources/keycask/Commands/ClipboardDaemon.swift added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import ArgumentParser | |
| 2 | ||
| 3 | struct ClipboardDaemon: ParsableCommand { | |
| 4 | static let configuration = CommandConfiguration( | |
| 5 | commandName: "clipboard-daemon", shouldDisplay: false) | |
| 6 | ||
| 7 | @Argument var seconds: Int | |
| 8 | ||
| 9 | func run() throws { | |
| 10 | try Clipboard.runDaemon(seconds: seconds) | |
| 11 | } | |
| 12 | } | |
Sources/keycask/Commands/Edit.swift added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Edit: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Change an entry.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Argument(help: "Entry id or name.") var ref: String | |
| 9 | @Option(name: .long, help: "New name.") var name: String? | |
| 10 | @Option(name: [.short, .customLong("username")], help: "New username.") var username: String? | |
| 11 | @Option(name: .long, help: "New URL.") var url: String? | |
| 12 | @Option(name: .long, help: "New notes.") var notes: String? | |
| 13 | @Option(name: .long, help: "Add a tag. Repeatable.") var tag: [String] = [] | |
| 14 | @Option(name: .long, help: "Remove a tag. Repeatable.") var untag: [String] = [] | |
| 15 | @Flag(name: .long, help: "Prompt for a new password.") var password = false | |
| 16 | @OptionGroup var generated: PasswordOptions | |
| 17 | ||
| 18 | mutating func validate() throws { | |
| 19 | let changes = | |
| 20 | [name, username, url, notes].contains { $0 != nil } | |
| 21 | || !tag.isEmpty || !untag.isEmpty || password || generated.generate | |
| 22 | || generated.words != nil | |
| 23 | guard changes else { throw ValidationError("nothing to change") } | |
| 24 | if password, generated.newPassword() != nil { | |
| 25 | throw ValidationError("--password cannot be combined with --generate or --words") | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | func run() throws { | |
| 30 | var open = try OpenVault.load(global) | |
| 31 | let target = try open.vault.resolve(ref) | |
| 32 | let newSecret: String? = | |
| 33 | password ? try PasswordInput.read(prompt: "New password: ") : generated.newPassword() | |
| 34 | try open.vault.update(id: target.id) { e in | |
| 35 | if let name { e.name = name } | |
| 36 | if let username { e.username = username } | |
| 37 | if let url { e.url = url } | |
| 38 | if let notes { e.notes = notes } | |
| 39 | if let newSecret { e.password = newSecret } | |
| 40 | let drop = Set(untag.map { $0.lowercased() }) | |
| 41 | e.tags = e.tags.filter { !drop.contains($0.lowercased()) } + tag | |
| 42 | } | |
| 43 | try open.save() | |
| 44 | } | |
| 45 | } | |
Sources/keycask/Commands/Find.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Find: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Search entries.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Argument(help: "Case-insensitive substring.") var query: String | |
| 9 | @Flag(name: .long, help: "JSON output.") var json = false | |
| 10 | ||
| 11 | func run() throws { | |
| 12 | let open = try OpenVault.load(global) | |
| 13 | let entries = open.vault.search(query) | |
| 14 | if json { | |
| 15 | print(try Output.json(entries, reveal: false), terminator: "") | |
| 16 | } else { | |
| 17 | print(Output.table(entries), terminator: "") | |
| 18 | } | |
| 19 | } | |
| 20 | } | |
Sources/keycask/Commands/Generate.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Generate: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Generate a password.") | |
| 6 | ||
| 7 | @Option(name: .long, help: "Password length (default 24).") var length: Int? | |
| 8 | @Option(name: .long, help: "Passphrase of this many words instead.") var words: Int? | |
| 9 | @Flag(name: .long, help: "Copy to the clipboard instead of printing.") var copy = false | |
| 10 | ||
| 11 | mutating func validate() throws { | |
| 12 | if length != nil, words != nil { | |
| 13 | throw ValidationError("--length and --words are mutually exclusive") | |
| 14 | } | |
| 15 | if let length, length < 1 { throw ValidationError("--length must be at least 1") } | |
| 16 | if let words, words < 1 { throw ValidationError("--words must be at least 1") } | |
| 17 | } | |
| 18 | ||
| 19 | func run() throws { | |
| 20 | let secret = | |
| 21 | words.map { Generator.passphrase(words: $0) } | |
| 22 | ?? Generator.password(length: length ?? Generator.defaultLength) | |
| 23 | if copy { | |
| 24 | try Clipboard.copyWithTimeout(secret) | |
| 25 | print("copied; clears in \(Clipboard.timeoutSeconds)s") | |
| 26 | return | |
| 27 | } | |
| 28 | print(secret) | |
| 29 | } | |
| 30 | } | |
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/Commands/Ls.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Ls: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "List entries.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Option(name: .long, help: "Only entries with this tag.") var tag: String? | |
| 9 | @Flag(name: .long, help: "JSON output.") var json = false | |
| 10 | ||
| 11 | func run() throws { | |
| 12 | let open = try OpenVault.load(global) | |
| 13 | let entries = tag.map { open.vault.filter(tag: $0) } ?? open.vault.sortedEntries | |
| 14 | if json { | |
| 15 | print(try Output.json(entries, reveal: false), terminator: "") | |
| 16 | } else { | |
| 17 | print(Output.table(entries), terminator: "") | |
| 18 | } | |
| 19 | } | |
| 20 | } | |
Sources/keycask/Commands/Rm.swift added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Rm: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Remove an entry.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Argument(help: "Entry id or name.") var ref: String | |
| 9 | @Flag(name: .long, help: "Do not ask for confirmation.") var yes = false | |
| 10 | ||
| 11 | func run() throws { | |
| 12 | var open = try OpenVault.load(global) | |
| 13 | let target = try open.vault.resolve(ref) | |
| 14 | if !yes { | |
| 15 | guard Terminal.stdinIsTTY else { | |
| 16 | throw KeycaskError.usage("refusing to remove without --yes when not on a terminal") | |
| 17 | } | |
| 18 | guard Terminal.confirm("remove \(target.name) (\(target.id.rawValue))?") else { | |
| 19 | throw KeycaskError.failure("aborted") | |
| 20 | } | |
| 21 | } | |
| 22 | try open.vault.remove(id: target.id) | |
| 23 | try open.save() | |
| 24 | } | |
| 25 | } | |
Sources/keycask/Commands/Show.swift added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import ArgumentParser | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | struct Show: ParsableCommand { | |
| 5 | static let configuration = CommandConfiguration(abstract: "Show an entry.") | |
| 6 | ||
| 7 | @OptionGroup var global: GlobalOptions | |
| 8 | @Argument(help: "Entry id or name.") var ref: String | |
| 9 | @Flag(name: .long, help: "Show the password.") var reveal = false | |
| 10 | @Option(name: .long, help: "Print one field, unmasked.") var field: String? | |
| 11 | @Flag(name: .long, help: "JSON output.") var json = false | |
| 12 | ||
| 13 | func run() throws { | |
| 14 | let open = try OpenVault.load(global) | |
| 15 | let entry = try open.vault.resolve(ref) | |
| 16 | if let field { | |
| 17 | print(try Output.field(entry, named: field)) | |
| 18 | } else if json { | |
| 19 | print(try Output.json(entry, reveal: reveal), terminator: "") | |
| 20 | } else { | |
| 21 | print(Output.text(entry, reveal: reveal), terminator: "") | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
Sources/keycask/Keycask.swift added +19
| @@ -0,0 +1,19 @@ | ||
| 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: [ | |
| 13 | Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self, | |
| 14 | Clip.self, ClipboardDaemon.self, | |
| 15 | ] | |
| 16 | ) | |
| 17 | ||
| 18 | @OptionGroup var global: GlobalOptions | |
| 19 | } | |
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/Output.swift added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | import Foundation | |
| 2 | import KeycaskCore | |
| 3 | ||
| 4 | enum Output { | |
| 5 | static let mask = "********" | |
| 6 | ||
| 7 | static func masked(_ entry: Entry, reveal: Bool) -> Entry { | |
| 8 | guard !reveal else { return entry } | |
| 9 | var copy = entry | |
| 10 | copy.password = mask | |
| 11 | return copy | |
| 12 | } | |
| 13 | ||
| 14 | static func text(_ entry: Entry, reveal: Bool) -> String { | |
| 15 | let e = masked(entry, reveal: reveal) | |
| 16 | var lines = ["id: \(e.id.rawValue)", "name: \(e.name)"] | |
| 17 | if let u = e.username { lines.append("username: \(u)") } | |
| 18 | lines.append("password: \(e.password)") | |
| 19 | if let u = e.url { lines.append("url: \(u)") } | |
| 20 | if !e.tags.isEmpty { lines.append("tags: \(e.tags.joined(separator: ", "))") } | |
| 21 | if let n = e.notes { lines.append("notes: \(n)") } | |
| 22 | lines.append("created: \(iso(e.created))") | |
| 23 | lines.append("updated: \(iso(e.updated))") | |
| 24 | return lines.joined(separator: "\n") + "\n" | |
| 25 | } | |
| 26 | ||
| 27 | static func table(_ entries: [Entry]) -> String { | |
| 28 | guard !entries.isEmpty else { return "" } | |
| 29 | let rows = entries.map { [$0.id.rawValue, $0.name, $0.username ?? "", $0.url ?? ""] } | |
| 30 | let widths = (0..<3).map { col in rows.map { $0[col].count }.max() ?? 0 } | |
| 31 | return rows.map { row in | |
| 32 | let padded = (0..<3).map { | |
| 33 | row[$0].padding(toLength: widths[$0], withPad: " ", startingAt: 0) | |
| 34 | } | |
| 35 | return (padded + [row[3]]).joined(separator: " ") | |
| 36 | .trimmingCharacters(in: .whitespaces) | |
| 37 | }.joined(separator: "\n") + "\n" | |
| 38 | } | |
| 39 | ||
| 40 | static func json(_ entries: [Entry], reveal: Bool) throws -> String { | |
| 41 | guard !entries.isEmpty else { return "[]\n" } | |
| 42 | return try encode(entries.map { masked($0, reveal: reveal) }) | |
| 43 | } | |
| 44 | ||
| 45 | static func json(_ entry: Entry, reveal: Bool) throws -> String { | |
| 46 | try encode(masked(entry, reveal: reveal)) | |
| 47 | } | |
| 48 | ||
| 49 | static func field(_ entry: Entry, named name: String) throws -> String { | |
| 50 | switch name { | |
| 51 | case "id": entry.id.rawValue | |
| 52 | case "name": entry.name | |
| 53 | case "username": entry.username ?? "" | |
| 54 | case "password": entry.password | |
| 55 | case "url": entry.url ?? "" | |
| 56 | case "notes": entry.notes ?? "" | |
| 57 | case "tags": entry.tags.joined(separator: ",") | |
| 58 | case "created": iso(entry.created) | |
| 59 | case "updated": iso(entry.updated) | |
| 60 | default: throw KeycaskError.usage("unknown field \(name)") | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | private static func encode(_ value: some Encodable) throws -> String { | |
| 65 | let encoder = VaultCodec.makeEncoder() | |
| 66 | encoder.outputFormatting.insert(.prettyPrinted) | |
| 67 | do { | |
| 68 | return String(decoding: try encoder.encode(value), as: UTF8.self) + "\n" | |
| 69 | } catch { | |
| 70 | throw KeycaskError.io("encode json: \(error)") | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | private static func iso(_ date: Date) -> String { | |
| 75 | date.formatted(.iso8601) | |
| 76 | } | |
| 77 | } | |
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 +21 −1
| @@ -1,3 +1,23 @@ | ||
| 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 | let code = Keycask.exitCode(for: error) | |
| 18 | if code.isSuccess { | |
| 19 | print(text) | |
| 20 | exit(0) | |
| 21 | } | |
| 22 | fail(text, code: code.rawValue == 64 ? 2 : 1) | |
| 23 | } | |
Tests/KeycaskCLITests/AddShowTests.swift added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @Suite struct AddShowTests { | |
| 5 | @Test func addReadsPasswordFromStdinAndPrintsID() throws { | |
| 6 | let cli = try CLI.initialized() | |
| 7 | let r = try cli.run( | |
| 8 | ["add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "dev"], | |
| 9 | stdin: "hunter2\n") | |
| 10 | #expect(r.status == 0) | |
| 11 | let id = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 12 | #expect(id.count == 8) | |
| 13 | ||
| 14 | let shown = try cli.run(["show", id]) | |
| 15 | #expect(shown.status == 0) | |
| 16 | #expect(shown.stdout.contains("name: github")) | |
| 17 | #expect(shown.stdout.contains("username: cmc")) | |
| 18 | #expect(shown.stdout.contains("password: ********")) | |
| 19 | #expect(shown.stdout.contains("tags: dev")) | |
| 20 | #expect(!shown.stdout.contains("hunter2")) | |
| 21 | } | |
| 22 | ||
| 23 | @Test func showByNameRevealAndField() throws { | |
| 24 | let cli = try CLI.initialized() | |
| 25 | try cli.run(["add", "github"], stdin: "hunter2\n") | |
| 26 | let revealed = try cli.run(["show", "github", "--reveal"]) | |
| 27 | #expect(revealed.stdout.contains("password: hunter2")) | |
| 28 | let field = try cli.run(["show", "github", "--field", "password"]) | |
| 29 | #expect(field.stdout == "hunter2\n") | |
| 30 | let missing = try cli.run(["show", "github", "--field", "url"]) | |
| 31 | #expect(missing.status == 0) | |
| 32 | #expect(missing.stdout == "\n") | |
| 33 | let unknown = try cli.run(["show", "github", "--field", "nope"]) | |
| 34 | #expect(unknown.status == 2) | |
| 35 | } | |
| 36 | ||
| 37 | @Test func jsonMasksUnlessReveal() throws { | |
| 38 | let cli = try CLI.initialized() | |
| 39 | try cli.run(["add", "github", "-u", "cmc"], stdin: "hunter2\n") | |
| 40 | let masked = try cli.run(["show", "github", "--json"]) | |
| 41 | let obj = try JSONSerialization.jsonObject(with: Data(masked.stdout.utf8)) as! [String: Any] | |
| 42 | #expect(obj["name"] as? String == "github") | |
| 43 | #expect(obj["username"] as? String == "cmc") | |
| 44 | #expect(obj["password"] as? String == "********") | |
| 45 | #expect((obj["id"] as? String)?.count == 8) | |
| 46 | #expect((obj["created"] as? String)?.hasSuffix("Z") == true) | |
| 47 | let revealed = try cli.run(["show", "github", "--json", "--reveal"]) | |
| 48 | let obj2 = | |
| 49 | try JSONSerialization.jsonObject(with: Data(revealed.stdout.utf8)) as! [String: Any] | |
| 50 | #expect(obj2["password"] as? String == "hunter2") | |
| 51 | } | |
| 52 | ||
| 53 | @Test func addGenerateAndWords() throws { | |
| 54 | let cli = try CLI.initialized() | |
| 55 | try cli.run(["add", "a", "--generate"]) | |
| 56 | try cli.run(["add", "b", "--generate", "--length", "40"]) | |
| 57 | try cli.run(["add", "c", "--words", "4"]) | |
| 58 | #expect(try cli.run(["show", "a", "--field", "password"]).stdout.count == 25) | |
| 59 | #expect(try cli.run(["show", "b", "--field", "password"]).stdout.count == 41) | |
| 60 | let words = try cli.run(["show", "c", "--field", "password"]).stdout | |
| 61 | .trimmingCharacters(in: .newlines).split(separator: "-") | |
| 62 | #expect(words.count == 4) | |
| 63 | } | |
| 64 | ||
| 65 | @Test func generateAndWordsTogetherIsUsageError() throws { | |
| 66 | let cli = try CLI.initialized() | |
| 67 | let r = try cli.run(["add", "a", "--generate", "--words", "3"]) | |
| 68 | #expect(r.status == 2) | |
| 69 | } | |
| 70 | ||
| 71 | @Test func duplicateNamesAreAllowedAndAmbiguousOnShow() throws { | |
| 72 | let cli = try CLI.initialized() | |
| 73 | let a = try cli.run(["add", "gh", "-u", "one", "--generate"]).stdout.trimmingCharacters( | |
| 74 | in: .newlines) | |
| 75 | let b = try cli.run(["add", "gh", "-u", "two", "--generate"]).stdout.trimmingCharacters( | |
| 76 | in: .newlines) | |
| 77 | let r = try cli.run(["show", "gh"]) | |
| 78 | #expect(r.status == 5) | |
| 79 | #expect(r.stderr.contains(a) && r.stderr.contains(b)) | |
| 80 | #expect(try cli.run(["show", a]).stdout.contains("username: one")) | |
| 81 | } | |
| 82 | ||
| 83 | @Test func missingEntryIsNotFound() throws { | |
| 84 | let cli = try CLI.initialized() | |
| 85 | let r = try cli.run(["show", "nope"]) | |
| 86 | #expect(r.status == 3) | |
| 87 | #expect(r.stderr == "nope: not found\n") | |
| 88 | } | |
| 89 | ||
| 90 | @Test func wrongPassphraseCannotDecrypt() throws { | |
| 91 | let cli = try CLI.initialized() | |
| 92 | let r = try cli.run(["show", "x"], passphrase: "wrong") | |
| 93 | #expect(r.status == 4) | |
| 94 | #expect(r.stderr.contains("cannot decrypt")) | |
| 95 | } | |
| 96 | ||
| 97 | @Test func missingVaultIsNotFound() throws { | |
| 98 | let cli = try CLI() | |
| 99 | let r = try cli.run(["show", "x"]) | |
| 100 | #expect(r.status == 3) | |
| 101 | #expect(r.stderr.contains("keycask init")) | |
| 102 | } | |
| 103 | ||
| 104 | @Test func lengthWithWordsIsUsageError() throws { | |
| 105 | let cli = try CLI.initialized() | |
| 106 | let r = try cli.run(["add", "a", "--words", "4", "--length", "30"]) | |
| 107 | #expect(r.status == 2) | |
| 108 | } | |
| 109 | ||
| 110 | @Test func addWithoutPasswordSourceIsUsageError() throws { | |
| 111 | let cli = try CLI.initialized() | |
| 112 | let r = try cli.run(["add", "x"], stdin: "") | |
| 113 | #expect(r.status == 2) | |
| 114 | #expect(r.stderr.contains("no password")) | |
| 115 | } | |
| 116 | ||
| 117 | @Test func corruptVaultIsFailure() throws { | |
| 118 | let cli = try CLI.initialized() | |
| 119 | try Data("{}".utf8).write(to: cli.vault) | |
| 120 | let r = try cli.run(["show", "x"]) | |
| 121 | #expect(r.status == 1) | |
| 122 | #expect(r.stderr.hasPrefix("vault is corrupt")) | |
| 123 | } | |
| 124 | } | |
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/ClipboardTests.swift added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @testable import keycask | |
| 5 | ||
| 6 | @Suite struct ClipboardTests { | |
| 7 | @Test func clearsOnlyWhenClipboardStillHoldsTheSecret() { | |
| 8 | #expect(Clipboard.shouldClear(secret: "s", current: "s")) | |
| 9 | #expect(!Clipboard.shouldClear(secret: "s", current: "user pasted")) | |
| 10 | #expect(!Clipboard.shouldClear(secret: "s", current: nil)) | |
| 11 | } | |
| 12 | ||
| 13 | @Test func handoffRoundTrips() throws { | |
| 14 | let h = Clipboard.Handoff(secret: "s3cret") | |
| 15 | let data = try JSONEncoder().encode(h) | |
| 16 | #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h) | |
| 17 | } | |
| 18 | ||
| 19 | @Test func stripsOneTrailingNewline() { | |
| 20 | #expect(Clipboard.stripTrailingNewline("s\r\n") == "s") | |
| 21 | #expect(Clipboard.stripTrailingNewline("s\n") == "s") | |
| 22 | #expect(Clipboard.stripTrailingNewline("s") == "s") | |
| 23 | #expect(Clipboard.stripTrailingNewline("s\n\n") == "s\n") | |
| 24 | } | |
| 25 | ||
| 26 | @Test func findToolScansPathInOrder() throws { | |
| 27 | let dir = FileManager.default.temporaryDirectory | |
| 28 | .appendingPathComponent("keycask-clip-\(UUID().uuidString)") | |
| 29 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 30 | #expect(Clipboard.findTool(path: dir.path) == nil) | |
| 31 | ||
| 32 | #if os(macOS) | |
| 33 | let names = ["pbcopy", "pbpaste"] | |
| 34 | #elseif os(Windows) | |
| 35 | let names = ["clip.exe", "powershell.exe"] | |
| 36 | #else | |
| 37 | let names = ["xclip"] | |
| 38 | #endif | |
| 39 | for n in names { | |
| 40 | let f = dir.appendingPathComponent(n) | |
| 41 | try Data("#!/bin/sh\n".utf8).write(to: f) | |
| 42 | try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path) | |
| 43 | } | |
| 44 | let tool = Clipboard.findTool(path: dir.path) | |
| 45 | #expect(tool != nil) | |
| 46 | #expect(tool?.copy.first?.hasPrefix(dir.path) == true) | |
| 47 | } | |
| 48 | ||
| 49 | @Test func daemonWithoutHandoffFails() throws { | |
| 50 | let cli = try CLI() | |
| 51 | let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil) | |
| 52 | #expect(r.status == 1) | |
| 53 | } | |
| 54 | ||
| 55 | @Test func daemonIsHiddenFromHelp() throws { | |
| 56 | let cli = try CLI() | |
| 57 | let r = try cli.run(["--help"], passphrase: nil) | |
| 58 | #expect(!r.stdout.contains("clipboard-daemon")) | |
| 59 | #expect(r.stdout.contains("clip")) | |
| 60 | } | |
| 61 | ||
| 62 | @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws { | |
| 63 | let cli = try CLI.initialized() | |
| 64 | let r = try cli.run(["clip", "nope"]) | |
| 65 | #expect(r.status == 3) | |
| 66 | } | |
| 67 | ||
| 68 | #if !os(Windows) | |
| 69 | /// A temp directory of clipboard tools backed by a file, so tests never | |
| 70 | /// touch the real clipboard. | |
| 71 | struct Stub { | |
| 72 | let dir: URL | |
| 73 | ||
| 74 | var file: URL { dir.appendingPathComponent("clip.txt") } | |
| 75 | var environment: [String: String] { ["PATH": "\(dir.path):/bin:/usr/bin"] } | |
| 76 | ||
| 77 | init() throws { | |
| 78 | dir = FileManager.default.temporaryDirectory | |
| 79 | .appendingPathComponent("keycask-clip-stub-\(UUID().uuidString)") | |
| 80 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 81 | #if os(macOS) | |
| 82 | try install("pbcopy", "#!/bin/sh\ncat > \"$(dirname \"$0\")/clip.txt\"\n") | |
| 83 | try install( | |
| 84 | "pbpaste", "#!/bin/sh\ncat \"$(dirname \"$0\")/clip.txt\" 2>/dev/null\n") | |
| 85 | #else | |
| 86 | try install( | |
| 87 | "xclip", | |
| 88 | """ | |
| 89 | #!/bin/sh | |
| 90 | for a in "$@"; do | |
| 91 | if [ "$a" = "-o" ]; then | |
| 92 | cat "$(dirname "$0")/clip.txt" 2>/dev/null | |
| 93 | exit 0 | |
| 94 | fi | |
| 95 | done | |
| 96 | cat > "$(dirname "$0")/clip.txt" | |
| 97 | ||
| 98 | """) | |
| 99 | #endif | |
| 100 | } | |
| 101 | ||
| 102 | func contents() -> String { | |
| 103 | (try? String(contentsOf: file, encoding: .utf8)) ?? "" | |
| 104 | } | |
| 105 | ||
| 106 | func set(_ text: String) throws { | |
| 107 | try Data(text.utf8).write(to: file) | |
| 108 | } | |
| 109 | ||
| 110 | private func install(_ name: String, _ script: String) throws { | |
| 111 | let url = dir.appendingPathComponent(name) | |
| 112 | try Data(script.utf8).write(to: url) | |
| 113 | try FileManager.default.setAttributes( | |
| 114 | [.posixPermissions: 0o755], ofItemAtPath: url.path) | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | @Test func clipCopiesTheFieldThroughTheTool() throws { | |
| 119 | let cli = try CLI.initialized() | |
| 120 | let stub = try Stub() | |
| 121 | try cli.run(["add", "t", "--generate"]) | |
| 122 | let expected = try cli.run(["show", "t", "--field", "password"]).stdout | |
| 123 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 124 | ||
| 125 | let start = Date() | |
| 126 | let r = try cli.run(["clip", "t"], extraEnvironment: stub.environment) | |
| 127 | let elapsed = Date().timeIntervalSince(start) | |
| 128 | ||
| 129 | #expect(r.status == 0) | |
| 130 | #expect(r.stdout.contains("copied password of t")) | |
| 131 | #expect(stub.contents() == expected) | |
| 132 | #expect(elapsed < 5) | |
| 133 | } | |
| 134 | ||
| 135 | @Test func generateCopyWritesTheTool() throws { | |
| 136 | let cli = try CLI() | |
| 137 | let stub = try Stub() | |
| 138 | let r = try cli.run( | |
| 139 | ["generate", "--copy"], passphrase: nil, extraEnvironment: stub.environment) | |
| 140 | #expect(r.status == 0) | |
| 141 | let copied = stub.contents() | |
| 142 | #expect(copied.count == 24) | |
| 143 | #expect(!r.stdout.contains(copied)) | |
| 144 | } | |
| 145 | ||
| 146 | @Test func daemonClearsWhenClipboardStillHoldsSecret() throws { | |
| 147 | let cli = try CLI() | |
| 148 | let stub = try Stub() | |
| 149 | try stub.set("probe") | |
| 150 | let r = try cli.run( | |
| 151 | ["clipboard-daemon", "1"], stdin: #"{"secret":"probe"}"#, passphrase: nil, | |
| 152 | extraEnvironment: stub.environment) | |
| 153 | #expect(r.status == 0) | |
| 154 | #expect(stub.contents() == "") | |
| 155 | } | |
| 156 | ||
| 157 | @Test func daemonLeavesForeignContentAlone() throws { | |
| 158 | let cli = try CLI() | |
| 159 | let stub = try Stub() | |
| 160 | try stub.set("other") | |
| 161 | let r = try cli.run( | |
| 162 | ["clipboard-daemon", "1"], stdin: #"{"secret":"probe"}"#, passphrase: nil, | |
| 163 | extraEnvironment: stub.environment) | |
| 164 | #expect(r.status == 0) | |
| 165 | #expect(stub.contents() == "other") | |
| 166 | } | |
| 167 | #endif | |
| 168 | } | |
Tests/KeycaskCLITests/EditRmTests.swift added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @Suite struct EditRmTests { | |
| 5 | @Test func editChangesFieldsAndBumpsUpdated() throws { | |
| 6 | let cli = try CLI.initialized() | |
| 7 | try cli.run(["add", "gh", "--tag", "a", "--generate"]) | |
| 8 | let before = | |
| 9 | try JSONSerialization.jsonObject( | |
| 10 | with: Data(try cli.run(["show", "gh", "--json"]).stdout.utf8)) as! [String: Any] | |
| 11 | let r = try cli.run([ | |
| 12 | "edit", "gh", "--name", "github", "-u", "cmc", "--url", "https://x", "--notes", "n", | |
| 13 | "--tag", "b", "--untag", "a", | |
| 14 | ]) | |
| 15 | #expect(r.status == 0) | |
| 16 | let after = | |
| 17 | try JSONSerialization.jsonObject( | |
| 18 | with: Data(try cli.run(["show", "github", "--json"]).stdout.utf8)) as! [String: Any] | |
| 19 | #expect(after["name"] as? String == "github") | |
| 20 | #expect(after["username"] as? String == "cmc") | |
| 21 | #expect(after["url"] as? String == "https://x") | |
| 22 | #expect(after["notes"] as? String == "n") | |
| 23 | #expect(after["tags"] as? [String] == ["b"]) | |
| 24 | #expect(after["created"] as? String == before["created"] as? String) | |
| 25 | #expect(after["id"] as? String == before["id"] as? String) | |
| 26 | } | |
| 27 | ||
| 28 | @Test func editPasswordFromStdinAndGenerate() throws { | |
| 29 | let cli = try CLI.initialized() | |
| 30 | try cli.run(["add", "gh", "--generate"]) | |
| 31 | try cli.run(["edit", "gh", "--password"], stdin: "newpass\n") | |
| 32 | #expect(try cli.run(["show", "gh", "--field", "password"]).stdout == "newpass\n") | |
| 33 | try cli.run(["edit", "gh", "--generate", "--length", "30"]) | |
| 34 | #expect(try cli.run(["show", "gh", "--field", "password"]).stdout.count == 31) | |
| 35 | } | |
| 36 | ||
| 37 | @Test func editWithNoChangesIsUsageError() throws { | |
| 38 | let cli = try CLI.initialized() | |
| 39 | try cli.run(["add", "gh", "--generate"]) | |
| 40 | let r = try cli.run(["edit", "gh"]) | |
| 41 | #expect(r.status == 2) | |
| 42 | } | |
| 43 | ||
| 44 | @Test func editUnknownIsNotFound() throws { | |
| 45 | let cli = try CLI.initialized() | |
| 46 | #expect(try cli.run(["edit", "nope", "--url", "x"]).status == 3) | |
| 47 | } | |
| 48 | ||
| 49 | @Test func rmWithYesRemoves() throws { | |
| 50 | let cli = try CLI.initialized() | |
| 51 | let id = try cli.run(["add", "gh", "--generate"]).stdout.trimmingCharacters(in: .newlines) | |
| 52 | let r = try cli.run(["rm", id, "--yes"]) | |
| 53 | #expect(r.status == 0) | |
| 54 | #expect(try cli.run(["show", id]).status == 3) | |
| 55 | #expect(try cli.run(["ls"]).stdout == "") | |
| 56 | } | |
| 57 | ||
| 58 | @Test func rmWithoutYesAndWithoutTTYIsUsageError() throws { | |
| 59 | let cli = try CLI.initialized() | |
| 60 | try cli.run(["add", "gh", "--generate"]) | |
| 61 | let r = try cli.run(["rm", "gh"], stdin: "y\n") | |
| 62 | #expect(r.status == 2) | |
| 63 | #expect(r.stderr.contains("--yes")) | |
| 64 | #expect(try cli.run(["ls"]).lines.count == 1) | |
| 65 | } | |
| 66 | ||
| 67 | @Test func rmAmbiguousNameLists() throws { | |
| 68 | let cli = try CLI.initialized() | |
| 69 | try cli.run(["add", "gh", "--generate"]) | |
| 70 | try cli.run(["add", "gh", "--generate"]) | |
| 71 | let r = try cli.run(["rm", "gh", "--yes"]) | |
| 72 | #expect(r.status == 5) | |
| 73 | #expect(try cli.run(["ls"]).lines.count == 2) | |
| 74 | } | |
| 75 | } | |
Tests/KeycaskCLITests/GenerateTests.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @Suite struct GenerateTests { | |
| 5 | @Test func defaultIs24Characters() throws { | |
| 6 | let cli = try CLI() | |
| 7 | let r = try cli.run(["generate"], passphrase: nil) | |
| 8 | #expect(r.status == 0) | |
| 9 | #expect(r.stdout.count == 25) | |
| 10 | } | |
| 11 | ||
| 12 | @Test func lengthAndWords() throws { | |
| 13 | let cli = try CLI() | |
| 14 | let lenResult = try cli.run(["generate", "--length", "12"], passphrase: nil) | |
| 15 | #expect(lenResult.stdout.count == 13) | |
| 16 | let wordsOutput = try cli.run(["generate", "--words", "6"], passphrase: nil).stdout | |
| 17 | let w = wordsOutput.trimmingCharacters(in: .newlines).split(separator: "-") | |
| 18 | #expect(w.count == 6) | |
| 19 | } | |
| 20 | ||
| 21 | @Test func doesNotNeedAVault() throws { | |
| 22 | let cli = try CLI() | |
| 23 | #expect(!FileManager.default.fileExists(atPath: cli.vault.path)) | |
| 24 | #expect(try cli.run(["generate"], passphrase: nil).status == 0) | |
| 25 | } | |
| 26 | ||
| 27 | @Test func lengthAndWordsTogetherIsUsageError() throws { | |
| 28 | let cli = try CLI() | |
| 29 | #expect( | |
| 30 | try cli.run(["generate", "--length", "3", "--words", "3"], passphrase: nil).status == 2) | |
| 31 | } | |
| 32 | } | |
Tests/KeycaskCLITests/InitTests.swift added +105
| @@ -0,0 +1,105 @@ | ||
| 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 | ||
| 69 | @Test func preExistingTempFileDoesNotWeakenPermissions() throws { | |
| 70 | let cli = try CLI() | |
| 71 | let temp = cli.dir.appendingPathComponent("vault.kc.tmp") | |
| 72 | FileManager.default.createFile( | |
| 73 | atPath: temp.path, contents: Data(), attributes: [.posixPermissions: 0o644]) | |
| 74 | ||
| 75 | let r = try cli.run(["init"]) | |
| 76 | #expect(r.status == 0) | |
| 77 | let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path) | |
| 78 | let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777 | |
| 79 | #expect(mode == 0o600) | |
| 80 | #expect(!FileManager.default.fileExists(atPath: temp.path)) | |
| 81 | } | |
| 82 | ||
| 83 | @Test func symlinkedTempFileIsNotFollowed() throws { | |
| 84 | let cli = try CLI() | |
| 85 | let victim = cli.dir.appendingPathComponent("victim.txt") | |
| 86 | FileManager.default.createFile(atPath: victim.path, contents: Data()) | |
| 87 | let temp = cli.dir.appendingPathComponent("vault.kc.tmp") | |
| 88 | try FileManager.default.createSymbolicLink(at: temp, withDestinationURL: victim) | |
| 89 | ||
| 90 | let r = try cli.run(["init"]) | |
| 91 | #expect(r.status == 0) | |
| 92 | let victimAttrs = try FileManager.default.attributesOfItem(atPath: victim.path) | |
| 93 | #expect((victimAttrs[.size] as! NSNumber).intValue == 0) | |
| 94 | let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path) | |
| 95 | #expect(attrs[.type] as? FileAttributeType == .typeRegular) | |
| 96 | #expect((attrs[.posixPermissions] as! NSNumber).intValue & 0o777 == 0o600) | |
| 97 | } | |
| 98 | #endif | |
| 99 | ||
| 100 | @Test func noTempFileLeftBehind() throws { | |
| 101 | let cli = try CLI.initialized() | |
| 102 | let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path) | |
| 103 | #expect(names == ["vault.kc"]) | |
| 104 | } | |
| 105 | } | |
Tests/KeycaskCLITests/LsFindTests.swift added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | ||
| 4 | @Suite struct LsFindTests { | |
| 5 | func seeded() throws -> CLI { | |
| 6 | let cli = try CLI.initialized() | |
| 7 | try cli.run([ | |
| 8 | "add", "github", "-u", "cmc", "--url", "https://github.com", "--tag", "Dev", | |
| 9 | "--generate", | |
| 10 | ]) | |
| 11 | try cli.run([ | |
| 12 | "add", "bank", "--url", "https://bank.example", "--notes", "downtown branch", | |
| 13 | "--generate", | |
| 14 | ]) | |
| 15 | try cli.run(["add", "Alpha", "--tag", "dev", "--generate"]) | |
| 16 | return cli | |
| 17 | } | |
| 18 | ||
| 19 | @Test func lsSortsByNameAndShowsColumns() throws { | |
| 20 | let cli = try seeded() | |
| 21 | let r = try cli.run(["ls"]) | |
| 22 | #expect(r.status == 0) | |
| 23 | let names = r.lines.map { | |
| 24 | String($0.split(separator: " ", omittingEmptySubsequences: true)[1]) | |
| 25 | } | |
| 26 | #expect(names == ["Alpha", "bank", "github"]) | |
| 27 | #expect(r.stdout.contains("cmc")) | |
| 28 | #expect(r.stdout.contains("https://github.com")) | |
| 29 | } | |
| 30 | ||
| 31 | @Test func lsTagFilterIsCaseInsensitive() throws { | |
| 32 | let cli = try seeded() | |
| 33 | let r = try cli.run(["ls", "--tag", "DEV"]) | |
| 34 | #expect(r.lines.count == 2) | |
| 35 | #expect(!r.stdout.contains("bank")) | |
| 36 | } | |
| 37 | ||
| 38 | @Test func lsJsonIsAnArrayWithMaskedPasswords() throws { | |
| 39 | let cli = try seeded() | |
| 40 | let r = try cli.run(["ls", "--json"]) | |
| 41 | let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]] | |
| 42 | #expect(arr.count == 3) | |
| 43 | #expect(arr.allSatisfy { $0["password"] as? String == "********" }) | |
| 44 | } | |
| 45 | ||
| 46 | @Test func emptyVaultListsNothing() throws { | |
| 47 | let cli = try CLI.initialized() | |
| 48 | let r = try cli.run(["ls"]) | |
| 49 | #expect(r.status == 0) | |
| 50 | #expect(r.stdout == "") | |
| 51 | let j = try cli.run(["ls", "--json"]) | |
| 52 | #expect(j.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "[]") | |
| 53 | } | |
| 54 | ||
| 55 | @Test func findMatchesNotesURLTagsCaseInsensitively() throws { | |
| 56 | let cli = try seeded() | |
| 57 | #expect(try cli.run(["find", "DOWNTOWN"]).lines.count == 1) | |
| 58 | #expect(try cli.run(["find", "github.com"]).lines.count == 1) | |
| 59 | #expect(try cli.run(["find", "dev"]).lines.count == 2) | |
| 60 | let none = try cli.run(["find", "zzz"]) | |
| 61 | #expect(none.status == 0) | |
| 62 | #expect(none.stdout == "") | |
| 63 | } | |
| 64 | ||
| 65 | @Test func findJson() throws { | |
| 66 | let cli = try seeded() | |
| 67 | let r = try cli.run(["find", "bank", "--json"]) | |
| 68 | let arr = try JSONSerialization.jsonObject(with: Data(r.stdout.utf8)) as! [[String: Any]] | |
| 69 | #expect(arr.count == 1) | |
| 70 | #expect(arr[0]["name"] as? String == "bank") | |
| 71 | } | |
| 72 | } | |
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 | } | |
docs/superpowers/specs/2026-09-17-keycask-design.md +11 −5
| @@ -183,6 +183,10 @@ Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It | ||
| 183 | 183 | exists so tests and scripts run unattended. Otherwise the CLI prompts on |
| 184 | 184 | the terminal with echo off. No TTY and no variable is exit 2. |
| 185 | 185 | |
| 186 | Password input for `add` and `edit --password`: on a terminal, a hidden | |
| 187 | prompt. Without a terminal, the first line of stdin. Neither available is | |
| 188 | exit 2. | |
| 189 | ||
| 186 | 190 | Exit codes: |
| 187 | 191 | |
| 188 | 192 | | Code | Meaning | |
| @@ -220,10 +224,12 @@ and a Windows body where they differ. | ||
| 220 | 224 | Linux; `clip.exe` to write and `powershell -command Get-Clipboard` to |
| 221 | 225 | read on Windows. No tool found is exit 1 with a message naming the |
| 222 | 226 | tools. `clip` spawns `keycask clipboard-daemon` detached with stdout |
| 223 | and stderr to null, writes `{"secret": ..., "previous": ...}` to its | |
| 224 | stdin, and exits without waiting. The daemon sets the clipboard, | |
| 225 | sleeps 45 seconds, reads the clipboard, and if it still equals the | |
| 226 | secret restores `previous` or clears when `previous` is empty. | |
| 227 | and stderr to null and exits without waiting. `clip` writes the | |
| 228 | clipboard itself, then spawns the daemon with `{"secret": ...}` on its | |
| 229 | stdin. The daemon sleeps 45 seconds, reads the clipboard, and clears | |
| 230 | it if it still equals the secret. It never restores earlier contents, | |
| 231 | so a second `clip` inside the window cannot bring an earlier secret | |
| 232 | back. | |
| 227 | 233 | |
| 228 | 234 | ## Errors |
| 229 | 235 | |
| @@ -267,7 +273,7 @@ CLI, black box: | ||
| 267 | 273 | masking versus `--reveal`, `--field` raw output, the ambiguous-name |
| 268 | 274 | listing, `rm` without `--yes` and without a TTY, `edit` with no flags, |
| 269 | 275 | `--generate` with `--words`, and `init` on an existing vault. |
| 270 | - The clipboard daemon's restore decision is unit-tested in process; | |
| 276 | - The clipboard daemon's clear decision is unit-tested in process; | |
| 271 | 277 | the tests do not touch the real clipboard. |
| 272 | 278 | |
| 273 | 279 | The CLI suite is the conformance suite. When the app exists, its |