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 | } | ||