krz/keycask

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

Commit 6957cdf5cc

6957cdf5cc508d89941c26e159b1b8b3ba1a9ce8

parent: 48eb4d9dbf

Verified · cmc

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

Add EntryID

Layout: unified · split

Sources/KeycaskCore/EntryID.swift added +45
@@ -0,0 +1,45 @@
1public struct EntryID: Hashable, Sendable, CustomStringConvertible {
2 public static let alphabet: [Character] = Array("abcdefghijkmnpqrstuvwxyz23456789")
3 public static let length = 8
4
5 public let rawValue: String
6
7 public init?(_ raw: String) {
8 guard raw.count == Self.length else { return nil }
9 let allowed = Set(Self.alphabet)
10 guard raw.allSatisfy({ allowed.contains($0) }) else { return nil }
11 rawValue = raw
12 }
13
14 public static func random() -> EntryID {
15 var rng = SystemRandomNumberGenerator()
16 return random(using: &rng)
17 }
18
19 public static func random(using rng: inout some RandomNumberGenerator) -> EntryID {
20 var chars: [Character] = []
21 chars.reserveCapacity(length)
22 for _ in 0..<length {
23 chars.append(alphabet[Int(rng.next(upperBound: UInt32(alphabet.count)))])
24 }
25 return EntryID(String(chars))!
26 }
27
28 public var description: String { rawValue }
29}
30
31extension EntryID: Codable {
32 public init(from decoder: any Decoder) throws {
33 let raw = try decoder.singleValueContainer().decode(String.self)
34 guard let id = EntryID(raw) else {
35 throw DecodingError.dataCorrupted(
36 .init(codingPath: decoder.codingPath, debugDescription: "invalid entry id \(raw)"))
37 }
38 self = id
39 }
40
41 public func encode(to encoder: any Encoder) throws {
42 var container = encoder.singleValueContainer()
43 try container.encode(rawValue)
44 }
45}
Sources/KeycaskCore/KeycaskError.swift +2 −2
@@ -5,7 +5,7 @@ public enum KeycaskError: Error, Equatable, Sendable {
5 case corrupt(String) 5 case corrupt(String)
6 case vaultExists(String) 6 case vaultExists(String)
7 case noVault(String) 7 case noVault(String)
8 case duplicateID(String) 8 case duplicateID(EntryID)
9 case io(String) 9 case io(String)
10 case usage(String) 10 case usage(String)
11 case failure(String) 11 case failure(String)
@@ -29,7 +29,7 @@ public enum KeycaskError: Error, Equatable, Sendable {
29 case .corrupt(let why): "vault is corrupt: \(why)" 29 case .corrupt(let why): "vault is corrupt: \(why)"
30 case .vaultExists(let path): "vault \(path) already exists" 30 case .vaultExists(let path): "vault \(path) already exists"
31 case .noVault(let path): "vault \(path) not found (run `keycask init`)" 31 case .noVault(let path): "vault \(path) not found (run `keycask init`)"
32 case .duplicateID(let id): "duplicate id \(id)" 32 case .duplicateID(let id): "duplicate id \(id.rawValue)"
33 case .io(let why): why 33 case .io(let why): why
34 case .usage(let why): why 34 case .usage(let why): why
35 case .failure(let why): why 35 case .failure(let why): why
Tests/KeycaskCoreTests/EntryIDTests.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import Testing
3
4@testable import KeycaskCore
5
6@Suite struct EntryIDTests {
7 @Test func randomIDsHaveLengthEightFromTheAlphabet() {
8 let allowed = Set(EntryID.alphabet)
9 for _ in 0..<200 {
10 let id = EntryID.random()
11 #expect(id.rawValue.count == 8)
12 #expect(id.rawValue.allSatisfy { allowed.contains($0) })
13 }
14 }
15
16 @Test func alphabetExcludesAmbiguousCharacters() {
17 let alphabet = Set(EntryID.alphabet)
18 #expect(alphabet.count == 32)
19 for bad in ["l", "o", "0", "1"] {
20 #expect(!alphabet.contains(Character(bad)))
21 }
22 }
23
24 @Test func parsingValidatesLengthAndAlphabet() {
25 #expect(EntryID("abcd2345") != nil)
26 #expect(EntryID("abcd234") == nil)
27 #expect(EntryID("abcd23456") == nil)
28 #expect(EntryID("abcd234l") == nil)
29 #expect(EntryID("ABCD2345") == nil)
30 }
31
32 @Test func codableIsABareString() throws {
33 let id = EntryID("abcd2345")!
34 let data = try JSONEncoder().encode([id])
35 #expect(String(decoding: data, as: UTF8.self) == "[\"abcd2345\"]")
36 let back = try JSONDecoder().decode([EntryID].self, from: data)
37 #expect(back == [id])
38 #expect(throws: DecodingError.self) {
39 try JSONDecoder().decode([EntryID].self, from: Data("[\"bad\"]".utf8))
40 }
41 }
42
43 @Test func seededGeneratorIsDeterministic() {
44 struct Counter: RandomNumberGenerator {
45 var n: UInt64 = 0
46 mutating func next() -> UInt64 {
47 n += 1
48 return n
49 }
50 }
51 var a = Counter()
52 var b = Counter()
53 #expect(EntryID.random(using: &a) == EntryID.random(using: &b))
54 }
55}
Tests/KeycaskCoreTests/KeycaskErrorTests.swift +1 −1
@@ -8,7 +8,7 @@ import Testing
8 #expect(KeycaskError.io("x").exitCode == 1) 8 #expect(KeycaskError.io("x").exitCode == 1)
9 #expect(KeycaskError.corrupt("x").exitCode == 1) 9 #expect(KeycaskError.corrupt("x").exitCode == 1)
10 #expect(KeycaskError.vaultExists("x").exitCode == 1) 10 #expect(KeycaskError.vaultExists("x").exitCode == 1)
11 #expect(KeycaskError.duplicateID("abcd2345").exitCode == 1) 11 #expect(KeycaskError.duplicateID(EntryID("abcd2345")!).exitCode == 1)
12 #expect(KeycaskError.usage("x").exitCode == 2) 12 #expect(KeycaskError.usage("x").exitCode == 2)
13 #expect(KeycaskError.notFound("x").exitCode == 3) 13 #expect(KeycaskError.notFound("x").exitCode == 3)
14 #expect(KeycaskError.noVault("/p").exitCode == 3) 14 #expect(KeycaskError.noVault("/p").exitCode == 3)