diff --git a/scarf/scarf/Core/Models/TemplateConfig.swift b/scarf/scarf/Core/Models/TemplateConfig.swift new file mode 100644 index 0000000..458ce8d --- /dev/null +++ b/scarf/scarf/Core/Models/TemplateConfig.swift @@ -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 +/// `/.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:///"` 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 /.scarf/config.json) + +/// The JSON file the installer writes + the editor reads. Non-secret +/// values appear inline; secrets are `"keychain:///"` +/// 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: `:`. The hash suffix + /// guarantees uniqueness across multiple installs of the same template. + let account: String + + /// `"keychain:///"` — 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[.. 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." + } + } +} diff --git a/scarf/scarf/Core/Services/ProjectConfigKeychain.swift b/scarf/scarf/Core/Services/ProjectConfigKeychain.swift new file mode 100644 index 0000000..36f5590 --- /dev/null +++ b/scarf/scarf/Core/Services/ProjectConfigKeychain.swift @@ -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) + } +} diff --git a/scarf/scarf/Core/Services/ProjectConfigService.swift b/scarf/scarf/Core/Services/ProjectConfigService.swift new file mode 100644 index 0000000..091acda --- /dev/null +++ b/scarf/scarf/Core/Services/ProjectConfigService.swift @@ -0,0 +1,318 @@ +import Foundation +import os + +/// Per-project configuration I/O: reads `/.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 `/.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:///"`. 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 `/.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 `/.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 `/.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() + 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() + 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 + } + } +} diff --git a/scarf/scarfTests/TemplateConfigTests.swift b/scarf/scarfTests/TemplateConfigTests.swift new file mode 100644 index 0000000..57d529f --- /dev/null +++ b/scarf/scarfTests/TemplateConfigTests.swift @@ -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) + } + } +}