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:
Alan Wizemann
2026-04-23 00:56:34 +02:00
parent e76fbf9937
commit 385c3a2e4d
4 changed files with 1152 additions and 0 deletions
@@ -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
}
}
}
+402
View File
@@ -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)
}
}
}