mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
feat(config): template-config models + Keychain wrapper + ProjectConfigService
Groundwork for v2.3 template configuration. No user-visible behaviour yet — this commit adds the data structures, storage layer, and validation rules that the installer/uninstaller/UI will integrate with in the next two commits. Models (Core/Models/TemplateConfig.swift): - TemplateConfigSchema + TemplateConfigField for the author-declared manifest.config block. 7 field types: string, text, number, bool, enum, list, secret. Type-specific constraints (pattern, min/max, min/maxLength, min/maxItems, enum options) are all optional and the validator enforces only those applicable to the field's type. - TemplateModelRecommendation for the author's model-of-choice hint (preferred + rationale + alternatives). Purely advisory — Scarf never auto-switches the active model. - TemplateConfigValue enum: string / number / bool / list / keychainRef. Custom Codable preserves keychain:// refs on round-trip — a round through save/load never demotes a secret ref to plaintext. - ProjectConfigFile is the on-disk shape at <project>/.scarf/config.json. - TemplateKeychainRef: derives (service, account) from templateSlug + fieldKey + project-path hash. The 32-bit FNV-1a suffix prevents two installs of the same template in different dirs from colliding in the login Keychain. uri <-> parse round-trips losslessly. Keychain layer (Core/Services/ProjectConfigKeychain.swift): - Thin wrapper over kSecClassGenericPassword. set() tries update-first then add-if-missing so we don't trip "already exists" on a race. - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: no iCloud sync, but cron triggers can still read after the user's first unlock. - testServiceSuffix lets unit tests route items under a distinct service prefix so nothing leaks into the user's real Keychain. Service layer (Core/Services/ProjectConfigService.swift): - load/save for <project>/.scarf/config.json through the ServerContext transport (so remote-ready for when installer goes remote). - cacheManifest/loadCachedManifest: the installer copies template.json into <project>/.scarf/manifest.json so the post-install "Configuration" button can render the form offline. - resolveSecret / storeSecret / deleteSecrets: the three Keychain paths any caller needs. Non-secret values never pass through these. - validateSchema: author-facing invariants (unique keys, known types, enum opts present/unique, no defaults on secrets, non-empty model preferred). Called by ProjectTemplateService during inspect. - validateValues: user-facing invariants (required, pattern, numeric range, list bounds, enum membership). Returns one error per problem so the UI can surface them inline with the offending field. Tests (scarfTests/TemplateConfigTests.swift): 23 tests in 5 suites. - Schema validation: happy path + every rejection rule. - Value validation: required, pattern, numeric range, list bounds, enum membership, secret-via-keychain-ref acceptance. - Keychain ref: uri round-trip, parse rejection of malformed input, path-hash differs across project dirs but is stable for same path. - ProjectConfigFile round-trips non-secret values cleanly AND preserves keychain:// refs (the bug that would silently demote secrets to plaintext if the Codable were wrong). - Real Keychain integration: store+resolve+delete, set overwrites, delete of missing item is a no-op, bulk delete clears all. Tests use unique testServiceSuffix per run so no cross-contamination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||
|
||||
/// Author-declared configuration schema for a template. Published as the
|
||||
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||
/// macOS Keychain.
|
||||
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||
let fields: [TemplateConfigField]
|
||||
let modelRecommendation: TemplateModelRecommendation?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fields = "schema"
|
||||
case modelRecommendation
|
||||
}
|
||||
|
||||
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||
|
||||
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||
/// schema at manifest-parse time, so this is safe.
|
||||
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||
fields.first { $0.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable field the user fills in. Discriminated by `type`.
|
||||
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||
/// without fighting Swift's `"case"` discriminator syntax.
|
||||
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { key }
|
||||
|
||||
let key: String
|
||||
let type: FieldType
|
||||
let label: String
|
||||
let description: String?
|
||||
let required: Bool
|
||||
let placeholder: String?
|
||||
|
||||
// Type-specific constraints — all optional. The validator enforces
|
||||
// only the ones that apply to `type`; extras are ignored.
|
||||
let defaultValue: TemplateConfigValue?
|
||||
let options: [EnumOption]? // type == .enum
|
||||
let minLength: Int? // type == .string / .text
|
||||
let maxLength: Int?
|
||||
let pattern: String? // type == .string (regex)
|
||||
let minNumber: Double? // type == .number
|
||||
let maxNumber: Double?
|
||||
let step: Double?
|
||||
let itemType: String? // type == .list — only "string" supported in v1
|
||||
let minItems: Int?
|
||||
let maxItems: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case key, type, label, description, required, placeholder
|
||||
case defaultValue = "default"
|
||||
case options
|
||||
case minLength, maxLength, pattern
|
||||
case minNumber = "min"
|
||||
case maxNumber = "max"
|
||||
case step
|
||||
case itemType, minItems, maxItems
|
||||
}
|
||||
|
||||
enum FieldType: String, Codable, Sendable, Equatable {
|
||||
case string
|
||||
case text
|
||||
case number
|
||||
case bool
|
||||
case `enum`
|
||||
case list
|
||||
case secret
|
||||
}
|
||||
|
||||
/// One option of an `enum` field. `value` is what ends up in
|
||||
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { value }
|
||||
let value: String
|
||||
let label: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||
/// active model. Individual cron jobs can override via
|
||||
/// `HermesCronJob.model` if the author wants enforcement.
|
||||
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||
let preferred: String
|
||||
let rationale: String?
|
||||
let alternatives: [String]?
|
||||
}
|
||||
|
||||
// MARK: - Values (what lands in config.json and the Keychain)
|
||||
|
||||
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||
/// serialising config.json to disk never leaks the secret into git or
|
||||
/// into backups.
|
||||
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case list([String])
|
||||
case keychainRef(String)
|
||||
|
||||
/// Convenience: the string representation suitable for display or
|
||||
/// for writing into a placeholder that the agent reads. Keychain
|
||||
/// refs return the ref string, not the resolved secret — callers
|
||||
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||
/// actually need the plaintext.
|
||||
nonisolated var displayString: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(n)
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .list(let items): return items.joined(separator: ", ")
|
||||
case .keychainRef(let ref): return ref
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let s = try? container.decode(String.self) {
|
||||
// Preserve the keychain:// scheme so secrets round-trip as
|
||||
// references, not as plaintext.
|
||||
if s.hasPrefix("keychain://") {
|
||||
self = .keychainRef(s)
|
||||
} else {
|
||||
self = .string(s)
|
||||
}
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let arr = try? container.decode([String].self) {
|
||||
self = .list(arr)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
TemplateConfigValue.self,
|
||||
.init(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .list(let items): try container.encode(items)
|
||||
case .keychainRef(let ref): try container.encode(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||
|
||||
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||
/// references that `ProjectConfigService` resolves through the Keychain
|
||||
/// on demand.
|
||||
struct ProjectConfigFile: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let templateId: String
|
||||
var values: [String: TemplateConfigValue]
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templateId
|
||||
case values
|
||||
case updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain reference helpers
|
||||
|
||||
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||
/// (service + account) from the template slug + project-path hash so two
|
||||
/// installs of the same template in different dirs don't collide in the
|
||||
/// login Keychain.
|
||||
struct TemplateKeychainRef: Sendable, Equatable {
|
||||
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||
let service: String
|
||||
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||
/// guarantees uniqueness across multiple installs of the same template.
|
||||
let account: String
|
||||
|
||||
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||
|
||||
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||
/// input isn't well-formed so callers can distinguish a missing ref
|
||||
/// from a malformed one.
|
||||
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||
guard uri.hasPrefix("keychain://") else { return nil }
|
||||
let rest = String(uri.dropFirst("keychain://".count))
|
||||
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||
let service = String(rest[..<slash])
|
||||
let account = String(rest[rest.index(after: slash)...])
|
||||
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||
return TemplateKeychainRef(service: service, account: account)
|
||||
}
|
||||
|
||||
/// Build a ref from a template slug + field key + project path.
|
||||
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||
/// of the absolute project path. Stable across launches, different
|
||||
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||
nonisolated static func make(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
projectPath: String
|
||||
) -> TemplateKeychainRef {
|
||||
TemplateKeychainRef(
|
||||
service: "com.scarf.template.\(templateSlug)",
|
||||
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func shortHash(of string: String) -> String {
|
||||
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||
// distinguishing a handful of project dirs per template install.
|
||||
let data = Data(string.utf8)
|
||||
var hash: UInt32 = 0x811c9dc5
|
||||
for byte in data {
|
||||
hash ^= UInt32(byte)
|
||||
hash &*= 0x01000193
|
||||
}
|
||||
return String(format: "%08x", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||
/// UI can surface the error inline with the field rather than at the
|
||||
/// top of the form.
|
||||
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||
let fieldKey: String?
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||
case duplicateKey(String)
|
||||
case unsupportedType(String)
|
||||
case emptyEnumOptions(String)
|
||||
case duplicateEnumValue(key: String, value: String)
|
||||
case unsupportedListItemType(key: String, itemType: String)
|
||||
case secretFieldHasDefault(String)
|
||||
case emptyModelPreferred
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateKey(let k):
|
||||
return "Config schema has duplicate key: \(k)"
|
||||
case .unsupportedType(let t):
|
||||
return "Config schema uses unsupported field type: \(t)"
|
||||
case .emptyEnumOptions(let k):
|
||||
return "Enum field '\(k)' must declare at least one option"
|
||||
case .duplicateEnumValue(let k, let v):
|
||||
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||
case .unsupportedListItemType(let k, let t):
|
||||
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||
case .secretFieldHasDefault(let k):
|
||||
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||
case .emptyModelPreferred:
|
||||
return "modelRecommendation.preferred must be a non-empty model id."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
/// Thin wrapper around the macOS Keychain for template-config secrets.
|
||||
/// Scarf doesn't have other Keychain users yet so this file is the one
|
||||
/// place that touches the `Security` framework; keep it small and
|
||||
/// auditable so a reader can tell at a glance what we store, under what
|
||||
/// identifiers, and when items are removed.
|
||||
///
|
||||
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
|
||||
/// the login Keychain. Each item is identified by a (service, account)
|
||||
/// pair derived from the template slug + field key + project-path hash
|
||||
/// — see `TemplateKeychainRef.make`. The stored Data is the user's
|
||||
/// raw secret bytes; we never transform or encode them.
|
||||
///
|
||||
/// **When items are written.** By `ProjectTemplateInstaller` after the
|
||||
/// install preview is confirmed and the user has filled in the
|
||||
/// configure sheet. By `TemplateConfigSheet` when the user edits a
|
||||
/// secret field post-install.
|
||||
///
|
||||
/// **When items are removed.** By `ProjectTemplateUninstaller`,
|
||||
/// iterating the lock file's `configKeychainItems` list. The login
|
||||
/// Keychain is never swept for stray entries — if the lock is out of
|
||||
/// sync we log + skip rather than guess which items are ours.
|
||||
///
|
||||
/// **What shows to the user.** macOS prompts "Scarf wants to access
|
||||
/// the Keychain" the first time we read a secret in a given session.
|
||||
/// User approves; subsequent reads in that session are silent. We
|
||||
/// never bypass this — the prompt is the user's trust boundary.
|
||||
struct ProjectConfigKeychain: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
|
||||
|
||||
/// Which Keychain to target. The default is the login Keychain
|
||||
/// (`nil` uses the user's default chain). Tests pass an explicit
|
||||
/// namespace suffix via `testServiceSuffix` — see `TemplateConfigTests` —
|
||||
/// so integration tests can roundtrip without polluting real
|
||||
/// user state.
|
||||
let testServiceSuffix: String?
|
||||
|
||||
nonisolated init(testServiceSuffix: String? = nil) {
|
||||
self.testServiceSuffix = testServiceSuffix
|
||||
}
|
||||
|
||||
/// Write or overwrite the secret for (service, account). Tests
|
||||
/// route their items through a distinct service prefix via
|
||||
/// `testServiceSuffix` so they can't leak into the user's real
|
||||
/// Keychain.
|
||||
nonisolated func set(service: String, account: String, secret: Data) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Try update first — cheaper than delete-then-add and doesn't
|
||||
// trip macOS's "item already exists" if another thread raced us.
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: secret,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if updateStatus == errSecSuccess { return }
|
||||
if updateStatus != errSecItemNotFound {
|
||||
throw Self.error(status: updateStatus, op: "update")
|
||||
}
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = secret
|
||||
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — stays in
|
||||
// this device's Keychain, not synced via iCloud, usable after
|
||||
// first unlock (so background cron triggers can read).
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw Self.error(status: addStatus, op: "add")
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the secret for (service, account). Returns `nil` when
|
||||
/// the item simply doesn't exist (user never set it, or an
|
||||
/// uninstall already removed it). Throws on every other Keychain
|
||||
/// error so callers don't silently treat "access denied" or
|
||||
/// "corrupt keychain" as "no value."
|
||||
nonisolated func get(service: String, account: String) throws -> Data? {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
if status != errSecSuccess {
|
||||
throw Self.error(status: status, op: "get")
|
||||
}
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Delete the secret for (service, account). Absent item is a
|
||||
/// no-op; any other failure throws. Called by
|
||||
/// `ProjectTemplateUninstaller` for every item in
|
||||
/// `TemplateLock.configKeychainItems`.
|
||||
nonisolated func delete(service: String, account: String) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status == errSecItemNotFound || status == errSecSuccess { return }
|
||||
throw Self.error(status: status, op: "delete")
|
||||
}
|
||||
|
||||
/// Convenience: apply the test suffix when in test mode.
|
||||
nonisolated private func resolved(service: String) -> String {
|
||||
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
|
||||
return "\(service).\(suffix)"
|
||||
}
|
||||
|
||||
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
|
||||
/// — callers decide whether the failure is fatal.
|
||||
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
|
||||
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
|
||||
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
|
||||
return NSError(
|
||||
domain: "com.scarf.keychain",
|
||||
code: Int(status),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ref-shaped convenience layer
|
||||
|
||||
extension ProjectConfigKeychain {
|
||||
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
|
||||
/// service/account plumbing every caller would otherwise repeat.
|
||||
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
|
||||
try set(service: ref.service, account: ref.account, secret: secret)
|
||||
}
|
||||
|
||||
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
|
||||
try get(service: ref.service, account: ref.account)
|
||||
}
|
||||
|
||||
nonisolated func delete(ref: TemplateKeychainRef) throws {
|
||||
try delete(service: ref.service, account: ref.account)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
|
||||
/// into typed values, writes them back, resolves Keychain-backed secrets
|
||||
/// on demand, and validates user-entered values against the schema.
|
||||
///
|
||||
/// Separation of concerns:
|
||||
///
|
||||
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
|
||||
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
|
||||
/// at install time so the post-install editor works offline. This
|
||||
/// service treats the schema as read-only input; `validateSchema`
|
||||
/// checks structural invariants and is called by
|
||||
/// `ProjectTemplateService` during install-plan building.
|
||||
/// - **Value storage.** Non-secret values live inline in `config.json`;
|
||||
/// secret values are Keychain references of the form
|
||||
/// `"keychain://<service>/<account>"`. The service owns both halves
|
||||
/// of that storage — callers never open `config.json` or touch the
|
||||
/// Keychain directly.
|
||||
/// - **Remote readiness.** All file I/O goes through
|
||||
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
|
||||
/// eventually supports remote contexts, the config store comes along
|
||||
/// for the ride. Keychain access stays local (it's a macOS-side thing
|
||||
/// by definition — agents on remote Hermes installs would fetch
|
||||
/// values via Scarf's channel, same as today).
|
||||
struct ProjectConfigService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
|
||||
|
||||
let context: ServerContext
|
||||
let keychain: ProjectConfigKeychain
|
||||
|
||||
nonisolated init(
|
||||
context: ServerContext = .local,
|
||||
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
|
||||
) {
|
||||
self.context = context
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
nonisolated static func configPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/config.json"
|
||||
}
|
||||
|
||||
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/manifest.json"
|
||||
}
|
||||
|
||||
// MARK: - Load / save on-disk config
|
||||
|
||||
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
|
||||
/// cleanly when the file is absent (e.g. a project installed from
|
||||
/// a schema-less template, or a hand-added project). Throws on
|
||||
/// malformed JSON so the caller can surface a concrete error
|
||||
/// rather than silently treating a corrupt file as missing.
|
||||
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.configPath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `<project>/.scarf/config.json`. Secrets should already be
|
||||
/// represented as `TemplateConfigValue.keychainRef` references here
|
||||
/// — this service never inspects their plaintext.
|
||||
nonisolated func save(
|
||||
project: ProjectEntry,
|
||||
templateId: String,
|
||||
values: [String: TemplateConfigValue]
|
||||
) throws {
|
||||
let transport = context.makeTransport()
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: templateId,
|
||||
values: values,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(Self.configPath(for: project), data: data)
|
||||
}
|
||||
|
||||
// MARK: - Manifest cache (schema used by post-install editor)
|
||||
|
||||
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
|
||||
/// so the post-install "Configuration" button can render the form
|
||||
/// offline. Called once by the installer after unpack + validate.
|
||||
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(path, data: manifestData)
|
||||
}
|
||||
|
||||
/// Load the cached manifest into a `ProjectTemplateManifest` so the
|
||||
/// editor can look up field types + labels. Returns `nil` when the
|
||||
/// project wasn't installed from a schemaful template.
|
||||
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Secrets
|
||||
|
||||
/// Resolve a `keychainRef` value into the actual secret bytes.
|
||||
/// Returns `nil` if the Keychain entry has been removed (e.g.
|
||||
/// external user cleanup, a previous uninstall that didn't finish).
|
||||
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
|
||||
guard case .keychainRef(let uri) = value,
|
||||
let ref = TemplateKeychainRef.parse(uri) else {
|
||||
return nil
|
||||
}
|
||||
return try keychain.get(ref: ref)
|
||||
}
|
||||
|
||||
/// Store a freshly-entered secret. Returns the `keychainRef` value
|
||||
/// suitable for writing into `config.json`.
|
||||
nonisolated func storeSecret(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
project: ProjectEntry,
|
||||
secret: Data
|
||||
) throws -> TemplateConfigValue {
|
||||
let ref = TemplateKeychainRef.make(
|
||||
templateSlug: templateSlug,
|
||||
fieldKey: fieldKey,
|
||||
projectPath: project.path
|
||||
)
|
||||
try keychain.set(ref: ref, secret: secret)
|
||||
return .keychainRef(ref.uri)
|
||||
}
|
||||
|
||||
/// Delete every Keychain item tracked in `refs`. Absent items are
|
||||
/// fine (uninstall may run after the user manually cleaned an
|
||||
/// entry). Any other failure is logged and re-thrown so the
|
||||
/// uninstaller can surface it.
|
||||
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
|
||||
for ref in refs {
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema validation (author-facing; called at bundle inspect time)
|
||||
|
||||
/// Verify structural invariants on a schema: unique keys, known
|
||||
/// types, enum options, secret-without-default rule, model
|
||||
/// recommendation non-empty when present. Called by
|
||||
/// `ProjectTemplateService.inspect` before buildPlan runs.
|
||||
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
|
||||
var seen = Set<String>()
|
||||
for field in schema.fields {
|
||||
if !seen.insert(field.key).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateKey(field.key)
|
||||
}
|
||||
switch field.type {
|
||||
case .enum:
|
||||
let opts = field.options ?? []
|
||||
guard !opts.isEmpty else {
|
||||
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
|
||||
}
|
||||
var seenValues = Set<String>()
|
||||
for opt in opts {
|
||||
if !seenValues.insert(opt.value).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
|
||||
}
|
||||
}
|
||||
case .list:
|
||||
let item = field.itemType ?? "string"
|
||||
if item != "string" {
|
||||
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
|
||||
}
|
||||
case .secret:
|
||||
if field.defaultValue != nil {
|
||||
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
|
||||
}
|
||||
case .string, .text, .number, .bool:
|
||||
break
|
||||
}
|
||||
}
|
||||
if let rec = schema.modelRecommendation {
|
||||
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw TemplateConfigSchemaError.emptyModelPreferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation (runs on user input in the configure sheet)
|
||||
|
||||
/// Validate user-entered values against the schema. Returns one
|
||||
/// `TemplateConfigValidationError` per problem. Empty array means
|
||||
/// the form is submittable.
|
||||
nonisolated static func validateValues(
|
||||
_ values: [String: TemplateConfigValue],
|
||||
against schema: TemplateConfigSchema
|
||||
) -> [TemplateConfigValidationError] {
|
||||
var errors: [TemplateConfigValidationError] = []
|
||||
for field in schema.fields {
|
||||
let value = values[field.key]
|
||||
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
|
||||
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
|
||||
continue
|
||||
}
|
||||
guard let value else { continue }
|
||||
switch field.type {
|
||||
case .string, .text:
|
||||
if case .string(let s) = value {
|
||||
if let min = field.minLength, s.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at least \(min) characters."))
|
||||
}
|
||||
if let max = field.maxLength, s.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at most \(max) characters."))
|
||||
}
|
||||
if let pattern = field.pattern,
|
||||
s.range(of: pattern, options: .regularExpression) == nil {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) doesn't match the expected format."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a string."))
|
||||
}
|
||||
|
||||
case .number:
|
||||
if case .number(let n) = value {
|
||||
if let min = field.minNumber, n < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≥ \(min)."))
|
||||
}
|
||||
if let max = field.maxNumber, n > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≤ \(max)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a number."))
|
||||
}
|
||||
|
||||
case .bool:
|
||||
if case .bool = value { /* ok */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be true or false."))
|
||||
}
|
||||
|
||||
case .enum:
|
||||
if case .string(let s) = value {
|
||||
let options = (field.options ?? []).map(\.value)
|
||||
if !options.contains(s) {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of the predefined options."))
|
||||
}
|
||||
|
||||
case .list:
|
||||
if case .list(let items) = value {
|
||||
if let min = field.minItems, items.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) needs at least \(min) item(s)."))
|
||||
}
|
||||
if let max = field.maxItems, items.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) accepts at most \(max) item(s)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a list."))
|
||||
}
|
||||
|
||||
case .secret:
|
||||
if case .keychainRef = value { /* opaque — trust it */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be supplied (Keychain entry missing)."))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
nonisolated private static func hasMeaningfulValue(
|
||||
_ value: TemplateConfigValue?,
|
||||
type: TemplateConfigField.FieldType
|
||||
) -> Bool {
|
||||
guard let value else { return false }
|
||||
switch (type, value) {
|
||||
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
|
||||
return !s.isEmpty
|
||||
case (.number, .number):
|
||||
return true
|
||||
case (.bool, .bool):
|
||||
return true
|
||||
case (.list, .list(let arr)):
|
||||
return !arr.isEmpty
|
||||
case (.secret, .keychainRef):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
// MARK: - Schema validation
|
||||
|
||||
@Suite struct TemplateConfigSchemaValidationTests {
|
||||
|
||||
@Test func acceptsMinimalValidSchema() throws {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
|
||||
@Test func rejectsDuplicateKeys() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "same", type: .string, label: "A", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil),
|
||||
.init(key: "same", type: .bool, label: "B", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsSecretWithDefault() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "api_key", type: .secret, label: "API Key",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: .string("leaked-by-accident"),
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithoutOptions() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: [],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithDuplicateValues() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "a", label: "A"),
|
||||
.init(value: "a", label: "Another A")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsUnsupportedListItemType() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "items", type: .list, label: "Items",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil,
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: "number", minItems: 1, maxItems: 10)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEmptyModelPreferred() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [],
|
||||
modelRecommendation: .init(preferred: " ", rationale: nil, alternatives: nil)
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation
|
||||
|
||||
@Suite struct TemplateConfigValueValidationTests {
|
||||
|
||||
@Test func requiredFieldRejectsEmptyString() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["name": .string("")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.fieldKey == "name")
|
||||
}
|
||||
|
||||
@Test func patternRejectsBadInput() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "email", type: .string, label: "Email",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: "^[^@]+@[^@]+$",
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["email": .string("not-an-email")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func numberRangeEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "port", type: .number, label: "Port",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: 1024,
|
||||
maxNumber: 65535, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["port": .number(80)], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func enumRejectsUnknownValue() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "mode", type: .enum, label: "Mode",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "fast", label: "Fast"),
|
||||
.init(value: "slow", label: "Slow")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["mode": .string("medium")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func listItemBoundsEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "urls", type: .list, label: "URLs",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: "string",
|
||||
minItems: 1, maxItems: 3)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let tooFew = ProjectConfigService.validateValues(
|
||||
["urls": .list([])], against: schema
|
||||
)
|
||||
let tooMany = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b", "c", "d"])], against: schema
|
||||
)
|
||||
let justRight = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b"])], against: schema
|
||||
)
|
||||
#expect(tooFew.count == 1)
|
||||
#expect(tooMany.count == 1)
|
||||
#expect(justRight.isEmpty)
|
||||
}
|
||||
|
||||
@Test func secretFieldAcceptsKeychainRef() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "tok", type: .secret, label: "Token",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["tok": .keychainRef("keychain://test/tok:abc")],
|
||||
against: schema
|
||||
)
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain ref helpers
|
||||
|
||||
@Suite struct TemplateKeychainRefTests {
|
||||
|
||||
@Test func uriRoundTrips() {
|
||||
let ref = TemplateKeychainRef(
|
||||
service: "com.scarf.template.alice-foo",
|
||||
account: "api_key:deadbeef"
|
||||
)
|
||||
#expect(ref.uri == "keychain://com.scarf.template.alice-foo/api_key:deadbeef")
|
||||
let parsed = TemplateKeychainRef.parse(ref.uri)
|
||||
#expect(parsed == ref)
|
||||
}
|
||||
|
||||
@Test func parseRejectsMalformedUris() {
|
||||
#expect(TemplateKeychainRef.parse("") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain:///account-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://service-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("https://example.com/foo") == nil)
|
||||
}
|
||||
|
||||
@Test func hashDiffersByProjectPath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p2")
|
||||
#expect(a.service == b.service) // same template
|
||||
#expect(a.account != b.account) // different project → different hash suffix
|
||||
}
|
||||
|
||||
@Test func hashStableForSamePath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
#expect(a == b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk config round-trip
|
||||
|
||||
@Suite struct ProjectConfigFileTests {
|
||||
|
||||
@Test func roundTripsNonSecretValues() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: [
|
||||
"name": .string("Alice"),
|
||||
"enabled": .bool(true),
|
||||
"count": .number(42),
|
||||
"tags": .list(["a", "b", "c"]),
|
||||
],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.templateId == "alice/example")
|
||||
#expect(decoded.values["name"] == .string("Alice"))
|
||||
#expect(decoded.values["enabled"] == .bool(true))
|
||||
#expect(decoded.values["count"] == .number(42))
|
||||
#expect(decoded.values["tags"] == .list(["a", "b", "c"]))
|
||||
}
|
||||
|
||||
@Test func preservesKeychainRefsOnRoundTrip() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: ["tok": .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef")],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
// Keychain refs must NOT demote to plain strings on round-trip
|
||||
// — otherwise a post-install editor would lose the secret
|
||||
// binding when saving unchanged values.
|
||||
#expect(decoded.values["tok"] == .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProjectConfigService + Keychain integration
|
||||
|
||||
/// Exercises the full secret-storage path through a real macOS Keychain
|
||||
/// with a test-only service suffix so nothing leaks into the user's
|
||||
/// login Keychain. Every test sets + reads + deletes within a unique
|
||||
/// service name so parallel runs don't collide.
|
||||
@Suite struct ProjectConfigSecretsTests {
|
||||
|
||||
@Test func storeAndResolveSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
let project = ProjectEntry(name: "Scratch", path: NSTemporaryDirectory() + UUID().uuidString)
|
||||
|
||||
let stored = try service.storeSecret(
|
||||
templateSlug: "alice-example",
|
||||
fieldKey: "api_key",
|
||||
project: project,
|
||||
secret: Data("hunter2".utf8)
|
||||
)
|
||||
|
||||
// What goes into config.json is a keychainRef, not the bytes.
|
||||
guard case .keychainRef(let uri) = stored else {
|
||||
Issue.record("expected keychainRef, got \(stored)")
|
||||
return
|
||||
}
|
||||
#expect(uri.hasPrefix("keychain://"))
|
||||
|
||||
// Resolve brings the bytes back.
|
||||
let resolved = try service.resolveSecret(ref: stored)
|
||||
#expect(resolved == Data("hunter2".utf8))
|
||||
|
||||
// Clean up so we don't leave a test item in the Keychain.
|
||||
if let ref = TemplateKeychainRef.parse(uri) {
|
||||
try keychain.delete(ref: ref)
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func setOverwritesExistingSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.overwrite", account: "k:1")
|
||||
try keychain.set(ref: ref, secret: Data("first".utf8))
|
||||
try keychain.set(ref: ref, secret: Data("second".utf8))
|
||||
#expect((try keychain.get(ref: ref)) == Data("second".utf8))
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteOfMissingItemSucceeds() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.absent", account: "never:set")
|
||||
// Deleting a non-existent item is a no-op — must not throw.
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteMultipleSecretsClearsAll() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
|
||||
let refs = (0..<3).map { i in
|
||||
TemplateKeychainRef(service: "com.scarf.template.bulk", account: "k:\(i)")
|
||||
}
|
||||
for ref in refs {
|
||||
try keychain.set(ref: ref, secret: Data("v".utf8))
|
||||
}
|
||||
try service.deleteSecrets(refs: refs)
|
||||
for ref in refs {
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user