import Foundation /// A restic repository. Credentials never live here — the password and any /// secret environment values are held in the Keychain and resolved at spawn time. public struct Repository: Codable, Sendable, Equatable { /// Passed to `restic --repo` verbatim, minus any basic-auth credentials. public var url: String /// `rest:` only. When set, the password is read from the Keychain and, with /// the username, handed to restic in its environment at launch. public var usesBasicAuth: Bool /// Basic-auth username. Not a secret, so it stays here where it can be /// seen and edited; only the matching password goes to the Keychain. public var basicAuthUsername: String /// `restic --host`. Snapshots are looked up by host, so this must be stable /// across machines that share a repository. public var host: String /// Declares that the backend rejects deletes. The engine then refuses every /// destructive operation regardless of what the UI asks for. public var appendOnly: Bool /// Freeform flags applied to every restic invocation. public var extraFlags: [ArgumentLine] /// Freeform, non-secret environment, e.g. `HTTPS_PROXY` or `RESTIC_CACHE_DIR`. public var extraEnvironment: [EnvironmentEntry] public init( url: String, usesBasicAuth: Bool = false, basicAuthUsername: String = "", host: String = Repository.defaultHost, appendOnly: Bool = false, extraFlags: [ArgumentLine] = [], extraEnvironment: [EnvironmentEntry] = [] ) { self.url = url self.usesBasicAuth = usesBasicAuth self.basicAuthUsername = basicAuthUsername self.host = host self.appendOnly = appendOnly self.extraFlags = extraFlags self.extraEnvironment = extraEnvironment } /// The local hostname with the Bonjour suffix removed, which is what restic /// would default to anyway. public static var defaultHost: String { var name = ProcessInfo.processInfo.hostName for suffix in [".local", ".lan"] where name.hasSuffix(suffix) { name.removeLast(suffix.count) } return name } public var backend: RepositoryBackend? { RepositoryBackend.detect(url) } } /// A name/value pair added to restic's environment. /// /// Plain text, and stored as such in `config.json`. Snapshot deliberately has no /// "secret" variant: the only credentials v1 needs are the repository password /// and rest: basic auth, both of which have dedicated Keychain entries. A /// half-used secret mechanism would be worse than none, because it would look /// like somewhere safe to put a key. public struct EnvironmentEntry: Codable, Sendable, Equatable, Identifiable { public var id: UUID public var name: String public var value: String public init(id: UUID = UUID(), name: String, value: String = "") { self.id = id self.name = name self.value = value } public var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty } } /// The backends v1 supports. /// /// `sftp:` is deliberately absent: restic implements it by shelling out to `ssh`, /// which can block on a host-key or passphrase prompt that a menu bar app has no /// way to answer, turning a backup into an indefinite hang rather than an error. /// Object stores would only need environment variables and are cheap to add later. public enum RepositoryBackend: String, Codable, Sendable, CaseIterable { case rest case local public static let supportedDescription = "a rest: URL or a local path" public static func detect(_ url: String) -> RepositoryBackend? { let trimmed = url.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } if trimmed.hasPrefix("rest:") { return .rest } if trimmed.hasPrefix("local:") { return .local } // A bare path is restic's local-repository syntax. if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") || trimmed.hasPrefix(".") { return .local } return nil } /// Whether a run should wait for `NWPathMonitor` to report a usable path. /// A repository on an external disk must not be blocked while offline. public var requiresNetwork: Bool { switch self { case .rest: true case .local: false } } } public enum RepositoryValidationError: Error, Equatable, CustomStringConvertible { case empty case unsupportedScheme(String) public var description: String { switch self { case .empty: "Enter a repository location." case .unsupportedScheme(let scheme): """ Snapshot doesn't support \(scheme) repositories — it supports \ \(RepositoryBackend.supportedDescription). \(Self.sftpNote(for: scheme)) """ } } private static func sftpNote(for scheme: String) -> String { scheme == "sftp:" ? "sftp needs ssh, which can block on a key passphrase or host-key prompt " + "that a background app can't answer." : "" } } extension Repository { /// Rejects unsupported backends up front rather than letting a run hang later. public static func validate(url: String) -> RepositoryValidationError? { let trimmed = url.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return .empty } guard RepositoryBackend.detect(trimmed) == nil else { return nil } let scheme = trimmed.prefix { $0 != ":" } return .unsupportedScheme(trimmed.contains(":") ? "\(scheme):" : "that kind of") } }