From e8484333102a6f886644d7b4a226eb8f02ccedd6 Mon Sep 17 00:00:00 2001 From: Newton Date: Wed, 15 Jul 2026 12:59:49 -0500 Subject: [PATCH 1/2] feat(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fulcrum's dev builds are re-signed on every rebuild (ad-hoc identity on hosts without a team certificate). The legacy macOS keychain binds item ACLs to the code signature, so every rebuild made KeychainEstateIdentityKeyStore trigger a blocking "allow keychain access" prompt during estate-host construction — a hang at app bootstrap on every single dev run, hit by every consumer of a durable on-disk estate, not just Fulcrum. Moves FileEstateIdentityKeyStore from the fulcrum consumer into LocusKit beside EstateIdentityKeyStore.swift, so any moot consumer carrying the same dev-signing problem can reach the fix without duplicating it. Entire type is behind #if DEBUG (verified: present in the Debug .o, absent from the Release .o via nm) — the production default (Estate.defaultIdentityKeyStore(for:) → KeychainEstateIdentityKeyStore for durable backends) is untouched by this file; production is not wireable to this type. Doc comments carry the Perkins-reviewed security posture verbatim (0600 key file, 0700 directory, backup-excluded, per-estate-UUID filename, dev-machine-local low-value threat model). One adaptation from the fulcrum original: the Application Support subdirectory is now a caller-supplied parameter (appSupportSubdirectory:) instead of a hardcoded "Fulcrum" literal, since this type now lives in a kit multiple products can consume. Parity ruling: no Rust counterpart, and none needed. This is a host-side developer-experience workaround for a macOS/iOS code-signing artifact, not a substrate algorithm or on-disk data format — the EstateIdentityKeyStore protocol itself, and both its existing conformers (Keychain, InMemory), are Swift-only. The four-way conformance gate applies to bitmap/vector algorithms; it does not apply here. Tests (5, DEBUG-gated, Swift Testing): identity persists across a fresh store instance reading the same directory (the "reopen from file" property this type exists for), unknown estate ID loads nil, two estates don't collide, file/directory permissions are 0600/0700, second store overwrites first. Release-unreachability is a compile-config property (confirmed via nm on the .o), not something a runtime test can additionally assert, so no test attempts to. swift test: 832/832 passed (827 baseline + 5 new), exit 0. swift build -c release: succeeds, symbol confirmed absent via nm. Fulcrum-side follow-up (M-R3.3 Parts 2-3, gated on merge + a future pin advance per D23): delete FulcrumKit/Sources/MootEstate/ FileEstateIdentityKeyStore.swift and repoint GLKEstateSession.swift's explicit injection at LocusKit's copy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hr1uCLfesRfCHxTwyYgBiu --- .../LocusKit/FileEstateIdentityKeyStore.swift | 135 +++++++++++++++ .../FileEstateIdentityKeyStoreTests.swift | 162 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift create mode 100644 packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift diff --git a/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift new file mode 100644 index 00000000..daf7198c --- /dev/null +++ b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift @@ -0,0 +1,135 @@ +// FileEstateIdentityKeyStore.swift +// +// DEBUG-ONLY estate identity key store backed by plain files. +// +// WHY THIS EXISTS (2026-07-11, upstreamed from the fulcrum consumer — +// dev runs were hanging): development builds are re-signed on every +// rebuild (ad-hoc identity on hosts without a team certificate), and +// the legacy macOS keychain binds item ACLs to the code signature. +// Every rebuild therefore made `KeychainEstateIdentityKeyStore` trigger +// a blocking "allow keychain access" prompt during estate-host +// construction — a hang at app bootstrap on every single dev run, and +// every consumer app hosting a durable on-disk estate hit it. +// +// SECURITY BOUNDARY (Perkins posture, carried verbatim from the +// consumer-side review that ratified this bypass): the Keychain store +// remains the production default (`Estate.defaultIdentityKeyStore(for:)` +// is UNCHANGED by this file — it still resolves durable backends to +// `KeychainEstateIdentityKeyStore`), and the release binary CANNOT +// reach this type — the entire file is compiled out of non-DEBUG +// builds. This preserves the ratified "production must never be +// wireable to a non-Keychain key store" posture while making DEBUG +// runs promptless. Key material in DEBUG lands in a 0600 file under +// Application Support; acceptable for development machines, never for +// release (which never compiles this path). +// +// Unlike `InMemoryEstateIdentityKeyStore` (whose keys vanish per-process +// and would re-mint the estate's federation identity on every launch), +// this store is durable across runs — the dev estate keeps ONE stable +// identity, matching the Keychain store's observable behavior minus the +// prompts. +// +// SWIFT/RUST PARITY RULING: this type intentionally has NO Rust +// counterpart. It is a host-side developer-experience workaround for a +// macOS/iOS code-signing artifact (ad-hoc re-signing invalidating +// Keychain ACLs), not a substrate algorithm or on-disk format — nothing +// a Rust-side consumer of LocusKit's data files would ever need to read +// or produce. The four-way conformance gate (Swift scalar, Swift Metal, +// Rust scalar, Rust BLAS/NEON) applies to algorithms operating on +// bitmap/vector data; it does not apply to key-store plumbing, and +// `EstateIdentityKeyStore` itself (the protocol this type conforms to) +// is a Swift-only seam — `KeychainEstateIdentityKeyStore` and +// `InMemoryEstateIdentityKeyStore` are likewise Swift-only. Parity is +// therefore N/A by construction, not waived. +// +// CALLER RESPONSIBILITY: this type is not wired into +// `Estate.defaultIdentityKeyStore(for:)` — that resolver's Keychain +// default for durable backends is untouched by this file. A DEBUG-only +// consumer that wants the promptless dev path must inject it +// explicitly: +// +// #if DEBUG +// let identityKeyStore = try FileEstateIdentityKeyStore(appSupportSubdirectory: "Fulcrum") +// #else +// let identityKeyStore: (any EstateIdentityKeyStore)? = nil // Keychain default +// #endif +// let estate = try await Estate.open(storage: storage, owner: owner, +// identityKeyStore: identityKeyStore) +// +// ADAPTATION NOTE (upstreaming deviation from the fulcrum original): the +// consumer-side type hardcoded its Application Support subdirectory to +// the literal "Fulcrum". Landing in a shared kit consumed by more than +// one product, that literal is parameterized here +// (`appSupportSubdirectory`) so a second consumer app doesn't inherit a +// misleading path. This does not change the Perkins-reviewed security +// posture (DEBUG-only gate, 0600 file, 0700 directory, backup-excluded, +// per-estate-UUID filename) — only which folder name the caller chooses. + +#if DEBUG + +import Foundation + +/// File-backed `EstateIdentityKeyStore` for DEBUG builds: one raw key file +/// per estate UUID, 0600 permissions, under +/// `Application Support//estate-identity-keys/`. +public struct FileEstateIdentityKeyStore: EstateIdentityKeyStore { + + /// Directory holding one `.key` file per estate. + private let directory: URL + + /// Creates the store rooted at the default Application Support + /// location. The directory is created lazily on first write. + /// + /// - Parameter appSupportSubdirectory: the consumer-owned folder name + /// under Application Support (e.g. an app's product name). Callers + /// from different apps sharing this DEBUG path on the same machine + /// must pass distinct values to avoid colliding on the same + /// `estate-identity-keys/` directory. + public init(appSupportSubdirectory: String) throws { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + self.directory = base + .appendingPathComponent(appSupportSubdirectory, isDirectory: true) + .appendingPathComponent("estate-identity-keys", isDirectory: true) + } + + private func keyURL(forEstateID estateID: UUID) -> URL { + directory.appendingPathComponent("\(estateID.uuidString).key") + } + + public func loadPrivateKey(forEstateID estateID: UUID) throws -> Data? { + let url = keyURL(forEstateID: estateID) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } + + public func storePrivateKey(_ keyData: Data, forEstateID estateID: UUID) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + // 0700 on the directory: owner-only traversal, matching the + // 0600 files inside. + attributes: [.posixPermissions: 0o700] + ) + // Backup exclusion (Perkins hardening condition 1, carried forward + // from the consumer-side review): key material must never ride + // into Time Machine / backup tools — unlike Keychain items, plain + // files carry no backup encryption of their own. + var backupExcluded = directory + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? backupExcluded.setResourceValues(values) + let url = keyURL(forEstateID: estateID) + try keyData.write(to: url, options: [.atomic]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } +} + +#endif diff --git a/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift new file mode 100644 index 00000000..d56b47fc --- /dev/null +++ b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift @@ -0,0 +1,162 @@ +// FileEstateIdentityKeyStoreTests.swift +// +// Tests for `FileEstateIdentityKeyStore` (DEBUG-only; see the doc +// comment on the type for the Perkins posture and the ad-hoc-signing +// keychain-hang story this exists to fix). +// +// The whole file is gated `#if DEBUG` because the type under test does +// not exist outside DEBUG builds — there is nothing to compile, let +// alone run, in a release configuration. `swift test` builds Debug by +// default (SwiftPM convention), so this suite runs on every normal +// `swift test` invocation; a release-configuration build never sees +// this file's contents at all, which is itself the compile-time proof +// of release-unreachability (see the type's own doc comment) — that +// property is a compile-config assertion, not something a runtime test +// can additionally demonstrate, so no test attempts to. +#if DEBUG + +import Foundation +import Testing +@testable import LocusKit + +@Suite("FileEstateIdentityKeyStoreTests") +struct FileEstateIdentityKeyStoreTests { + + // Distinct from any real product's Application Support subdirectory + // so this suite can never collide with a developer's actual dev-run + // key files, and cleans up after itself in each test. + private static let testSubdirectory = "LocusKitTests-FileEstateIdentityKeyStore" + + private func makeStore() throws -> FileEstateIdentityKeyStore { + try FileEstateIdentityKeyStore(appSupportSubdirectory: Self.testSubdirectory) + } + + private func keyFileURL(forEstateID estateID: UUID) throws -> URL { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return base + .appendingPathComponent(Self.testSubdirectory, isDirectory: true) + .appendingPathComponent("estate-identity-keys", isDirectory: true) + .appendingPathComponent("\(estateID.uuidString).key") + } + + // MARK: - 1. Identity persists across reopen from file + + /// Storing a key then constructing a FRESH `FileEstateIdentityKeyStore` + /// (simulating a process relaunch reading the same on-disk directory) + /// must load back the identical bytes — this is the whole point of the + /// type: a stable dev-run identity across rebuilds, unlike + /// `InMemoryEstateIdentityKeyStore` which re-mints per process. + @Test("private key persists across a fresh store instance reading the same directory") + func identityPersistsAcrossReopenFromFile() throws { + let estateID = UUID() + let keyURL = try keyFileURL(forEstateID: estateID) + defer { try? FileManager.default.removeItem(at: keyURL) } + + let original = Data((0..<32).map { UInt8($0) }) // 32-byte stand-in key + let writer = try makeStore() + try writer.storePrivateKey(original, forEstateID: estateID) + + // Fresh instance — no shared in-memory state with `writer`. Only + // the file on disk connects the two. + let reader = try makeStore() + let reloaded = try reader.loadPrivateKey(forEstateID: estateID) + + #expect(reloaded == original) + } + + // MARK: - 2. Unknown estate ID returns nil, not an error + + /// A fresh estate UUID with no prior `storePrivateKey` call must load + /// as `nil`, matching `EstateIdentityKeyStore`'s documented contract + /// (nil means "no key stored yet", not a thrown error) — the same + /// contract `KeychainEstateIdentityKeyStore` honors for + /// `errSecItemNotFound`. + @Test("loadPrivateKey returns nil for an estate ID with no stored key") + func loadReturnsNilForUnknownEstateID() throws { + let store = try makeStore() + let neverStored = UUID() + let result = try store.loadPrivateKey(forEstateID: neverStored) + #expect(result == nil) + } + + // MARK: - 3. Independent estates do not collide + + /// Two distinct estate UUIDs must resolve to two distinct key files + /// and never overwrite one another's key material — the filename is + /// keyed by estate UUID specifically so this holds. + @Test("two distinct estate IDs store and load independent key material") + func distinctEstatesDoNotCollide() throws { + let estateA = UUID() + let estateB = UUID() + let urlA = try keyFileURL(forEstateID: estateA) + let urlB = try keyFileURL(forEstateID: estateB) + defer { + try? FileManager.default.removeItem(at: urlA) + try? FileManager.default.removeItem(at: urlB) + } + + let keyA = Data((0..<32).map { UInt8($0) }) + let keyB = Data((0..<32).map { UInt8(255 - $0) }) + let store = try makeStore() + try store.storePrivateKey(keyA, forEstateID: estateA) + try store.storePrivateKey(keyB, forEstateID: estateB) + + #expect(try store.loadPrivateKey(forEstateID: estateA) == keyA) + #expect(try store.loadPrivateKey(forEstateID: estateB) == keyB) + } + + // MARK: - 4. File and directory permissions + + /// The key file is written 0600 (owner read/write only) and the + /// containing directory 0700 (owner traversal only) — the Perkins + /// posture this type exists under depends on both, since a + /// world-readable directory or file would defeat the "developer-local, + /// low-value" threat-model reasoning the security review relied on. + @Test("stored key file is 0600 and its directory is 0700") + func filePermissionsAreOwnerOnly() throws { + let estateID = UUID() + let keyURL = try keyFileURL(forEstateID: estateID) + defer { try? FileManager.default.removeItem(at: keyURL) } + + let store = try makeStore() + try store.storePrivateKey(Data([0x01]), forEstateID: estateID) + + let fileAttrs = try FileManager.default.attributesOfItem(atPath: keyURL.path) + let filePerms = (fileAttrs[.posixPermissions] as? NSNumber)?.uint16Value + #expect(filePerms == 0o600) + + let dirAttrs = try FileManager.default.attributesOfItem( + atPath: keyURL.deletingLastPathComponent().path + ) + let dirPerms = (dirAttrs[.posixPermissions] as? NSNumber)?.uint16Value + #expect(dirPerms == 0o700) + } + + // MARK: - 5. Overwrite replaces rather than appends + + /// A second `storePrivateKey` call for the same estate UUID overwrites + /// the previous value in place — matching the protocol's documented + /// contract ("a second call for the same UUID overwrites the previous + /// value") and `KeychainEstateIdentityKeyStore`'s update-in-place + /// behavior on `errSecDuplicateItem`. + @Test("storing a second key for the same estate ID overwrites the first") + func secondStoreOverwritesFirst() throws { + let estateID = UUID() + let keyURL = try keyFileURL(forEstateID: estateID) + defer { try? FileManager.default.removeItem(at: keyURL) } + + let store = try makeStore() + try store.storePrivateKey(Data([0x01, 0x02]), forEstateID: estateID) + try store.storePrivateKey(Data([0x03, 0x04, 0x05]), forEstateID: estateID) + + let reloaded = try store.loadPrivateKey(forEstateID: estateID) + #expect(reloaded == Data([0x03, 0x04, 0x05])) + } +} + +#endif From 17f0788ff9bcc604ef53d8f155731bbadc585892 Mon Sep 17 00:00:00 2001 From: Newton Date: Wed, 15 Jul 2026 13:08:12 -0500 Subject: [PATCH 2/2] fix(locuskit): reject unsafe FileEstateIdentityKeyStore subdirectory values (Perkins CHANGES-REQUESTED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init(appSupportSubdirectory:) joined the caller-supplied string directly onto the Application Support root via URL.appendingPathComponent, which does not sanitize ".." segments — they resolve at the filesystem layer. An unvalidated value (e.g. "../../Library/LaunchAgents") could steer DEBUG-only key-material reads/writes outside Application Support entirely. The fulcrum original this type was upstreamed from made this structurally unreachable by hardcoding the subdirectory literal; generalizing it to a caller-supplied parameter for multi-consumer use (this PR's own prior commit) reopened the path unless validated. Adds EstateError.invalidIdentityKeyStoreSubdirectory(String), thrown at construction (before any FileManager call) when the subdirectory is empty, contains "/", or is a "."/".." traversal segment. Additive enum case only — grepped EstateError across the whole worktree, every existing use is single-case `catch let EstateError.foo(...)` pattern matching, no exhaustive switch to update. Tests: 2 new (one parameterized over 6 unsafe shapes: "", "..", ".", "../../etc", "a/b", "/etc" — each must throw invalidIdentityKeyStoreSubdirectory with the rejected value attached; one confirming a clean single-component name still constructs and round-trips a key normally). swift test: 834/834 passed (827 original baseline + 7 new across both commits), exit 0. swift build -c release: still succeeds, DEBUG gate unaffected by this change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hr1uCLfesRfCHxTwyYgBiu --- .../Sources/LocusKit/EstateTypes.swift | 18 ++++++++ .../LocusKit/FileEstateIdentityKeyStore.swift | 34 ++++++++++++++ .../FileEstateIdentityKeyStoreTests.swift | 44 +++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift b/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift index 874c1e83..10eaf6aa 100644 --- a/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift +++ b/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift @@ -139,4 +139,22 @@ public enum EstateError: Error, Sendable, Equatable { /// `status` is the raw `OSStatus` value (Int32) from the failing call, /// readable as a human-readable string via `SecCopyErrorMessageString`. case keychainError(status: Int32) + + /// `FileEstateIdentityKeyStore.init(appSupportSubdirectory:)` (DEBUG + /// builds only) received a subdirectory name that is empty, contains a + /// path separator, or is a `.`/`..` traversal segment. The associated + /// string is the rejected value, verbatim, for caller diagnostics. + /// + /// Rejected at construction, before any `FileManager` call, because + /// the subdirectory is joined onto the Application Support root via + /// `URL.appendingPathComponent` — a value like `".."` or + /// `"../../Library"` resolves at the filesystem layer and would let a + /// caller-supplied string steer key-material reads/writes outside + /// Application Support entirely. The original fulcrum-local + /// implementation this type was upstreamed from made this + /// structurally unreachable by hardcoding the subdirectory literal; + /// generalizing it to a caller-supplied parameter for multi-consumer + /// use (see `FileEstateIdentityKeyStore`'s doc comment) reopened that + /// path unless validated here. + case invalidIdentityKeyStoreSubdirectory(String) } diff --git a/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift index daf7198c..b6e86635 100644 --- a/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift +++ b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift @@ -64,6 +64,21 @@ // misleading path. This does not change the Perkins-reviewed security // posture (DEBUG-only gate, 0600 file, 0700 directory, backup-excluded, // per-estate-UUID filename) — only which folder name the caller chooses. +// +// PATH-TRAVERSAL FINDING (Perkins, PR review, fixed same PR): the +// fulcrum original's hardcoded "Fulcrum" literal made the subdirectory +// structurally unreachable to a caller — generalizing it to a +// caller-supplied `String` reopened a path-traversal surface, because +// `appSupportSubdirectory` is joined onto the Application Support root +// with `URL.appendingPathComponent`, which does not sanitize `..` +// segments (they resolve at the filesystem layer, not the URL layer). A +// value like `"../../Library/LaunchAgents"` would write key material +// outside Application Support entirely — in a shared-kit surface now +// consumed by 4+ apps, that's a real arbitrary-path-write risk in DEBUG +// builds, not a theoretical one. `init(appSupportSubdirectory:)` +// rejects empty, `/`-containing, `.`, and `..` values at construction +// (throws `EstateError.invalidIdentityKeyStoreSubdirectory`) before any +// `FileManager` call — see the initializer's own doc comment. #if DEBUG @@ -85,7 +100,26 @@ public struct FileEstateIdentityKeyStore: EstateIdentityKeyStore { /// from different apps sharing this DEBUG path on the same machine /// must pass distinct values to avoid colliding on the same /// `estate-identity-keys/` directory. + /// - Throws: `EstateError.invalidIdentityKeyStoreSubdirectory(_:)` if + /// `appSupportSubdirectory` is empty, contains a path separator + /// (`/`), or is a `.`/`..` traversal segment. This value is joined + /// directly onto the Application Support root via + /// `URL.appendingPathComponent`, which does not sanitize `..` + /// segments — the filesystem resolves them — so an unvalidated + /// caller string could steer key-material reads/writes outside + /// Application Support entirely (Perkins finding, upstream PR + /// review). Validated here, at construction, before any + /// `FileManager` call, rather than deferred to `storePrivateKey`/ + /// `loadPrivateKey`, so a bad value fails fast and can never + /// partially write. public init(appSupportSubdirectory: String) throws { + guard !appSupportSubdirectory.isEmpty, + !appSupportSubdirectory.contains("/"), + appSupportSubdirectory != ".", + appSupportSubdirectory != ".." + else { + throw EstateError.invalidIdentityKeyStoreSubdirectory(appSupportSubdirectory) + } let base = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, diff --git a/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift index d56b47fc..e3b83f4c 100644 --- a/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift +++ b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift @@ -157,6 +157,50 @@ struct FileEstateIdentityKeyStoreTests { let reloaded = try store.loadPrivateKey(forEstateID: estateID) #expect(reloaded == Data([0x03, 0x04, 0x05])) } + + // MARK: - 6. Subdirectory validation (Perkins path-traversal finding) + + /// `appSupportSubdirectory` is joined directly onto the Application + /// Support root via `URL.appendingPathComponent`, which does not + /// sanitize `..` segments — the filesystem resolves them. Every shape + /// that could steer key-material reads/writes outside Application + /// Support must be rejected at construction, before any + /// `FileManager` call. + @Test( + "init throws invalidIdentityKeyStoreSubdirectory for each unsafe subdirectory shape", + arguments: [ + "", // empty + "..", // parent-directory traversal + ".", // current-directory no-op, still not a real name + "../../etc", // multi-segment traversal + "a/b", // embedded path separator — appendingPathComponent + // would create an extra directory level, not + // traverse, but still isn't a single component + "/etc", // leading separator + ] + ) + func rejectsUnsafeSubdirectoryShapes(_ unsafeValue: String) throws { + do { + _ = try FileEstateIdentityKeyStore(appSupportSubdirectory: unsafeValue) + Issue.record("expected invalidIdentityKeyStoreSubdirectory for \(unsafeValue.debugDescription)") + } catch let EstateError.invalidIdentityKeyStoreSubdirectory(rejected) { + #expect(rejected == unsafeValue) + } + } + + /// A clean, single-component subdirectory name (no separators, not a + /// `.`/`..` traversal segment) must still construct successfully — + /// the guard must reject only the unsafe shapes, not everything. + @Test("init succeeds for a clean single-component subdirectory name") + func acceptsCleanSubdirectoryName() throws { + let store = try FileEstateIdentityKeyStore(appSupportSubdirectory: Self.testSubdirectory) + let estateID = UUID() + let keyURL = try keyFileURL(forEstateID: estateID) + defer { try? FileManager.default.removeItem(at: keyURL) } + + try store.storePrivateKey(Data([0xAA]), forEstateID: estateID) + #expect(try store.loadPrivateKey(forEstateID: estateID) == Data([0xAA])) + } } #endif