Commit bcbfb8db53
Verified · cmc
Layout: unified · split
Sources/KeycaskCore/Vault.swift added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | } | |