krz/keycask

Password manager: Swift core library, CLI for macOS/Linux/Windows, iOS/macOS app. cli password-manager swift

Commit bcbfb8db53

bcbfb8db5344399a44c3828cae461c961bc58422

parent: 091bf37c55

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 15:15 UTC

Add Vault operations and deterministic JSON codec

Layout: unified · split

Sources/KeycaskCore/Vault.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2
3public struct Vault: Codable, Equatable, Sendable {
4 public var entries: [Entry]
5
6 public init(entries: [Entry] = []) {
7 self.entries = entries
8 }
9
10 public func entry(id: EntryID) -> Entry? {
11 entries.first { $0.id == id }
12 }
13
14 public mutating func add(_ entry: Entry) throws {
15 guard self.entry(id: entry.id) == nil else { throw KeycaskError.duplicateID(entry.id) }
16 entries.append(entry)
17 }
18
19 public mutating func remove(id: EntryID) throws {
20 guard let index = entries.firstIndex(where: { $0.id == id }) else {
21 throw KeycaskError.notFound(id.rawValue)
22 }
23 entries.remove(at: index)
24 }
25
26 public mutating func update(
27 id: EntryID, now: Date = .now, _ change: (inout Entry) -> Void
28 ) throws {
29 guard let index = entries.firstIndex(where: { $0.id == id }) else {
30 throw KeycaskError.notFound(id.rawValue)
31 }
32 change(&entries[index])
33 entries[index].tags = Entry.normalize(tags: entries[index].tags)
34 entries[index].updated = Entry.truncateToSeconds(now)
35 }
36
37 public func resolve(_ ref: String) throws -> Entry {
38 if let id = EntryID(ref), let hit = entry(id: id) {
39 return hit
40 }
41 let byName = entries.filter { $0.name == ref }
42 switch byName.count {
43 case 0: throw KeycaskError.notFound(ref)
44 case 1: return byName[0]
45 default: throw KeycaskError.ambiguous(name: ref, candidates: byName)
46 }
47 }
48
49 public func filter(tag: String) -> [Entry] {
50 sortedEntries.filter { $0.hasTag(tag) }
51 }
52
53 public func search(_ query: String) -> [Entry] {
54 sortedEntries.filter { $0.matches(query) }
55 }
56
57 public var sortedEntries: [Entry] {
58 entries.sorted { a, b in
59 let (la, lb) = (a.name.lowercased(), b.name.lowercased())
60 return la == lb ? a.id.rawValue < b.id.rawValue : la < lb
61 }
62 }
63}
Sources/KeycaskCore/VaultCodec.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3public enum VaultCodec {
4 public static func makeEncoder() -> JSONEncoder {
5 let encoder = JSONEncoder()
6 encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
7 encoder.dateEncodingStrategy = .iso8601
8 return encoder
9 }
10
11 public static func makeDecoder() -> JSONDecoder {
12 let decoder = JSONDecoder()
13 decoder.dateDecodingStrategy = .iso8601
14 return decoder
15 }
16
17 public static func encode(_ vault: Vault) throws -> Data {
18 do {
19 return try makeEncoder().encode(vault)
20 } catch {
21 throw KeycaskError.io("encode vault: \(error)")
22 }
23 }
24
25 public static func decode(_ data: Data) throws -> Vault {
26 do {
27 return try makeDecoder().decode(Vault.self, from: data)
28 } catch {
29 throw KeycaskError.corrupt("\(error)")
30 }
31 }
32}
Tests/KeycaskCoreTests/VaultCodecTests.swift added +52
@@ -0,0 +1,52 @@
1import Foundation
2import Testing
3
4@testable import KeycaskCore
5
6@Suite struct VaultCodecTests {
7 @Test func roundTripsAndIsDeterministic() throws {
8 var v = Vault()
9 try v.add(
10 Entry(
11 id: EntryID("aaaa2222")!, name: "gh", username: "cmc", password: "p",
12 url: "https://github.com", notes: "n", tags: ["dev"],
13 now: Date(timeIntervalSince1970: 1_700_000_000)))
14 let a = try VaultCodec.encode(v)
15 let b = try VaultCodec.encode(v)
16 #expect(a == b)
17 #expect(try VaultCodec.decode(a) == v)
18 }
19
20 @Test func datesAreISO8601WholeSeconds() throws {
21 var v = Vault()
22 try v.add(
23 Entry(
24 id: EntryID("aaaa2222")!, name: "gh", password: "p",
25 now: Date(timeIntervalSince1970: 1_700_000_000)))
26 let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
27 #expect(text.contains("\"created\":\"2023-11-14T22:13:20Z\""))
28 }
29
30 @Test func keysAreSorted() throws {
31 var v = Vault()
32 try v.add(Entry(id: EntryID("aaaa2222")!, name: "gh", password: "p"))
33 let text = String(decoding: try VaultCodec.encode(v), as: UTF8.self)
34 let created = text.range(of: "\"created\"")!.lowerBound
35 let id = text.range(of: "\"id\"")!.lowerBound
36 let updated = text.range(of: "\"updated\"")!.lowerBound
37 #expect(created < id && id < updated)
38 }
39
40 @Test func garbageIsCorrupt() {
41 #expect(throws: KeycaskError.self) { try VaultCodec.decode(Data("nope".utf8)) }
42 do {
43 _ = try VaultCodec.decode(Data("{\"entries\":[{\"id\":1}]}".utf8))
44 Issue.record("expected corrupt")
45 } catch let e as KeycaskError {
46 #expect(e.exitCode == 1)
47 #expect(e.message.hasPrefix("vault is corrupt:"))
48 } catch {
49 Issue.record("wrong error \(error)")
50 }
51 }
52}
Tests/KeycaskCoreTests/VaultTests.swift added +80
@@ -0,0 +1,80 @@
1import Foundation
2import Testing
3
4@testable import KeycaskCore
5
6@Suite struct VaultTests {
7 func idA() -> EntryID { EntryID("aaaa2222")! }
8 func idB() -> EntryID { EntryID("bbbb3333")! }
9
10 @Test func addRejectsDuplicateID() throws {
11 var v = Vault()
12 try v.add(Entry(id: idA(), name: "gh", password: "p"))
13 #expect(throws: KeycaskError.duplicateID(idA())) {
14 try v.add(Entry(id: idA(), name: "other", password: "p"))
15 }
16 #expect(v.entries.count == 1)
17 }
18
19 @Test func removeUnknownIsNotFound() {
20 var v = Vault()
21 #expect(throws: KeycaskError.notFound("aaaa2222")) { try v.remove(id: idA()) }
22 }
23
24 @Test func updateSetsUpdatedAndNormalizesTags() throws {
25 let t0 = Date(timeIntervalSince1970: 1_000)
26 let t1 = Date(timeIntervalSince1970: 2_000.9)
27 var v = Vault()
28 try v.add(Entry(id: idA(), name: "gh", password: "p", now: t0))
29 try v.update(id: idA(), now: t1) { e in
30 e.tags = ["z", "A", "a"]
31 e.password = "q"
32 }
33 let e = v.entry(id: idA())!
34 #expect(e.password == "q")
35 #expect(e.tags == ["A", "z"])
36 #expect(e.created == t0)
37 #expect(e.updated == Date(timeIntervalSince1970: 2_000))
38 }
39
40 @Test func resolvePrefersIDThenUniqueName() throws {
41 var v = Vault()
42 try v.add(Entry(id: idA(), name: "gh", password: "p"))
43 try v.add(Entry(id: idB(), name: "aaaa2222", password: "p"))
44 #expect(try v.resolve("aaaa2222").id == idA())
45 #expect(try v.resolve("gh").id == idA())
46 #expect(try v.resolve("bbbb3333").id == idB())
47 }
48
49 @Test func resolveReportsAmbiguousWithAllCandidates() throws {
50 var v = Vault()
51 let a = Entry(id: idA(), name: "gh", password: "p")
52 let b = Entry(id: idB(), name: "gh", password: "p")
53 try v.add(a)
54 try v.add(b)
55 #expect(throws: KeycaskError.ambiguous(name: "gh", candidates: [a, b])) {
56 try v.resolve("gh")
57 }
58 }
59
60 @Test func resolveUnknownIsNotFound() {
61 #expect(throws: KeycaskError.notFound("nope")) { try Vault().resolve("nope") }
62 }
63
64 @Test func filterAndSearch() throws {
65 var v = Vault()
66 try v.add(Entry(id: idA(), name: "GitHub", password: "p", tags: ["dev"]))
67 try v.add(Entry(id: idB(), name: "bank", password: "p", url: "https://bank.example"))
68 #expect(v.filter(tag: "DEV").map(\.id) == [idA()])
69 #expect(v.search("example").map(\.id) == [idB()])
70 #expect(v.search("zzz").isEmpty)
71 }
72
73 @Test func sortedEntriesOrderByNameThenID() throws {
74 var v = Vault()
75 try v.add(Entry(id: idB(), name: "gh", password: "p"))
76 try v.add(Entry(id: idA(), name: "gh", password: "p"))
77 try v.add(Entry(id: EntryID("cccc4444")!, name: "Alpha", password: "p"))
78 #expect(v.sortedEntries.map(\.id.rawValue) == ["cccc4444", "aaaa2222", "bbbb3333"])
79 }
80}