Major update for stability

This commit is contained in:
Mark Gregory
2026-09-08 15:22:19 +01:00
parent 3b95f53a25
commit b2f3c5aef6
24 changed files with 1059 additions and 212 deletions
+19
View File
@@ -2,12 +2,18 @@ import SwiftUI
import SwiftData
struct ContentView: View {
let startupStorageError: String?
@EnvironmentObject private var automation: BackgroundSyncManager
@Environment(\.openWindow) private var openWindow
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@State private var showingNewJob = false
@State private var showingSplash = true
@State private var showingStorageAlert = false
init(startupStorageError: String? = nil) {
self.startupStorageError = startupStorageError
}
var body: some View {
ZStack {
@@ -21,6 +27,12 @@ struct ContentView: View {
}
}
.sheet(isPresented: $showingNewJob) { JobEditorView() }
.alert("Storage Recovery Mode", isPresented: $showingStorageAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(startupStorageError ?? "Rsync Manager is using temporary storage.")
}
.onAppear { showingStorageAlert = startupStorageError != nil }
.task {
guard showingSplash else { return }
try? await Task.sleep(for: .seconds(1.0))
@@ -124,6 +136,13 @@ struct ContentView: View {
.background(.thinMaterial, in: Capsule())
}
if let serviceError = automation.lastError {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.orange)
.help(serviceError)
.accessibilityLabel("Background service error")
}
Picker("Section", selection: $section) {
Label("Jobs", systemImage: "rectangle.stack").tag(AppSection.jobs)
Label("Calendar", systemImage: "calendar").tag(AppSection.calendar)
+2
View File
@@ -34,6 +34,7 @@ final class SyncJob {
var watchPath: String = ""
var watchDebounceSeconds: Int = 3
var lastWatchEventAt: Date? = nil
var allowUnattendedDelete: Bool = false
init(
name: String = "New Sync Job",
@@ -71,6 +72,7 @@ final class SyncJob {
self.watchPath = ""
self.watchDebounceSeconds = 3
self.lastWatchEventAt = nil
self.allowUnattendedDelete = false
}
var exclusions: [String] {
+21
View File
@@ -43,6 +43,27 @@ final class SyncRun {
RsyncSummary.parse(output + "\n" + errorOutput)
}
static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String {
let stripped = stripANSI(value)
let redacted = stripped
.replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression)
.replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression)
let data = Data(redacted.utf8)
guard data.count > maxBytes else { return redacted }
let prefix = data.prefix(maxBytes)
return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Manager for safety and storage limits.]\n"
}
private static func stripANSI(_ value: String) -> String {
value.replacingOccurrences(
of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#,
with: "",
options: .regularExpression
)
}
var exportText: String {
var text = "Rsync Manager Run Log\n"
text += "Job: \(jobName)\n"
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -511,3 +511,41 @@ For an rsync-specific option not documented here, run `man rsync` in Terminal fo
Use **Sync All** in the Jobs sidebar to run every enabled job in sequence. Rsync Manager queues the jobs rather than starting several rsync processes at once. Each job creates its own History entry, and the header shows the current batch progress. Disabled jobs are skipped.
# Security and unattended operation
Rsync Manager is designed for direct Developer ID distribution. It uses the macOS Hardened Runtime and intentionally does not use the App Sandbox because the utility must launch `rsync`/`ssh` and work with arbitrary user-selected paths.
## SSH security
SSH authentication uses a private key or your normal macOS SSH agent. Rsync Manager does not store SSH passwords and does not place passwords on a process command line. Automated SSH uses `BatchMode=yes`, a connection timeout, keepalive intervals and a finite keepalive failure count. Host-key verification is never disabled; connect to a new host once in Terminal first if SSH needs you to approve its host key.
Example:
```text
ssh -p 22 -i ~/.ssh/id_ed25519 mag@pegasus.local
```
## Protected advanced arguments
The Advanced field is for ordinary rsync tuning only. Rsync Manager rejects arguments that can replace the SSH transport, run a different remote command, read options/file lists from other files, write logs/batches to arbitrary paths, load a password file, enter daemon mode, remove source files or bypass the app's controlled delete switch. Examples that are rejected include `-e`, `-M`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files` and `--delete*`. Unmatched quotes and trailing escape characters are also rejected.
## Delete protection
Manual `--delete` runs always show a destructive-operation warning. If a scheduled or file-watch job uses Delete, you must also explicitly enable **Allow unattended delete** in the job editor. This prevents a job from becoming destructively automated merely because Schedule or File Watch was enabled later.
Run a Dry Run and review the destination before authorising unattended delete.
## Path overlap protection
For local jobs, source and destination must be separate directory trees. Rsync Manager rejects configurations where the destination is inside the source or the source is inside the destination. This prevents recursive backup trees and common file-watch loops.
## History and privacy
Rsync Manager does not include analytics or tracking. Run output is sanitised before persistence, terminal control sequences are removed, likely credential-shaped values are redacted, and each stdout/stderr log is capped at approximately 2 MB. History retention is configurable in **Settings > Automation > History & Privacy** and defaults to 90 days.
The bundled privacy manifest declares no tracking and no collected data.
## Release verification
The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
+16 -2
View File
@@ -5,6 +5,7 @@ import SwiftData
@MainActor
struct RsyncManagerApp: App {
let sharedModelContainer: ModelContainer
let startupStorageError: String?
@StateObject private var runner: RsyncRunner
@StateObject private var automation: BackgroundSyncManager
@@ -14,20 +15,33 @@ struct RsyncManagerApp: App {
SyncRun.self,
])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
KeychainStore.delete("defaultSSHPassword")
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
sharedModelContainer = container
startupStorageError = nil
let rsyncRunner = RsyncRunner()
_runner = StateObject(wrappedValue: rsyncRunner)
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: container.mainContext, runner: rsyncRunner))
} catch {
fatalError("Could not create ModelContainer: \(error)")
let fallbackConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
do {
let fallback = try ModelContainer(for: schema, configurations: [fallbackConfiguration])
sharedModelContainer = fallback
startupStorageError = "Rsync Manager could not open its persistent database and is running with temporary in-memory storage for this session. Jobs or history created now will not persist. Original error: \(error.localizedDescription)"
let rsyncRunner = RsyncRunner()
_runner = StateObject(wrappedValue: rsyncRunner)
_automation = StateObject(wrappedValue: BackgroundSyncManager(modelContext: fallback.mainContext, runner: rsyncRunner))
} catch {
fatalError("Rsync Manager could not initialise persistent or temporary storage: \(error)")
}
}
}
var body: some Scene {
WindowGroup {
ContentView()
ContentView(startupStorageError: startupStorageError)
.frame(minWidth: 1040, minHeight: 680)
.environmentObject(runner)
.environmentObject(automation)
@@ -23,6 +23,7 @@ final class BackgroundSyncManager: ObservableObject {
@Published private(set) var isSyncAllRunning = false
@Published private(set) var syncAllCompleted = 0
@Published private(set) var syncAllTotal = 0
@Published private(set) var lastError: String?
var activityDisplayTitle: String {
if isSyncAllRunning {
@@ -67,6 +68,7 @@ final class BackgroundSyncManager: ObservableObject {
guard !started else { return }
started = true
refreshConfiguration()
pruneHistoryIfNeeded(force: true)
checkSchedules()
timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
@@ -119,7 +121,7 @@ final class BackgroundSyncManager: ObservableObject {
for job in jobs where job.scheduleEnabled && job.scheduleKind != .manual && job.nextRunAt == nil {
job.nextRunAt = job.calculateNextRun(after: Date().addingTimeInterval(-1))
}
try? modelContext.save()
saveContext("refreshing automation configuration")
}
private func checkSchedules() {
@@ -136,7 +138,7 @@ final class BackgroundSyncManager: ObservableObject {
job.nextRunAt = job.calculateNextRun(after: now)
}
}
try? modelContext.save()
saveContext("updating scheduled jobs")
}
private func fileChanged(jobID: UUID, path: String) {
@@ -150,7 +152,7 @@ final class BackgroundSyncManager: ObservableObject {
guard !Task.isCancelled, let self else { return }
_ = self.enqueue(jobID: jobID, trigger: .watched)
}
try? modelContext.save()
saveContext("recording file-watch activity")
}
@discardableResult
@@ -209,12 +211,12 @@ final class BackgroundSyncManager: ObservableObject {
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: false, executable: executable)
let run = SyncRun(job: job, command: command, wasDryRun: false, trigger: trigger)
modelContext.insert(run)
try? modelContext.save()
saveContext("creating run history")
do {
let result = try await runner.run(job: job, dryRun: false)
run.output = result.stdout
run.errorOutput = result.stderr
let result = try await runner.run(job: job, dryRun: false, trigger: trigger)
run.output = SyncRun.sanitizedLog(result.stdout)
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
run.exitCode = Int(result.exitCode)
run.wasCancelled = result.wasCancelled
run.finishedAt = Date()
@@ -222,13 +224,42 @@ final class BackgroundSyncManager: ObservableObject {
job.lastExitCode = Int(result.exitCode)
job.updatedAt = Date()
} catch {
run.errorOutput = error.localizedDescription
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
run.exitCode = -1
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = -1
}
try? modelContext.save()
saveContext("saving run history")
pruneHistoryIfNeeded()
}
private func pruneHistoryIfNeeded(force: Bool = false) {
let now = Date()
if !force, let last = UserDefaults.standard.object(forKey: "lastHistoryPruneAt") as? Date, now.timeIntervalSince(last) < 3600 { return }
let configuredDays = UserDefaults.standard.integer(forKey: "historyRetentionDays")
let days = configuredDays == 0 ? 90 : max(7, min(configuredDays, 3650))
let cutoff = now.addingTimeInterval(-TimeInterval(days) * 86_400)
do {
let runs = try modelContext.fetch(FetchDescriptor<SyncRun>())
for run in runs where run.finishedAt != nil && run.startedAt < cutoff {
modelContext.delete(run)
}
try modelContext.save()
UserDefaults.standard.set(now, forKey: "lastHistoryPruneAt")
} catch {
lastError = "Could not prune run history: \(error.localizedDescription)"
}
}
private func saveContext(_ operation: String) {
do {
try modelContext.save()
} catch {
lastError = "Database error while \(operation): \(error.localizedDescription)"
}
}
private func fetchJobs() -> [SyncJob] {
+185 -25
View File
@@ -12,31 +12,16 @@ struct RsyncCommandBuilder {
if job.compress { args.append("-z") }
// Apple's bundled /usr/bin/rsync uses the Apple-specific -E flag for
// extended attributes. That flag is not compatible with a standard
// rsync server over SSH (for example Linux rsync 3.x), because Apple
// rsync expands it to --extended-attributes on the remote side.
//
// Therefore:
// • Apple rsync + local job -> -E
// • Apple rsync + SSH job -> omit xattr flag
// • Modern/Homebrew rsync -> -X (standard rsync xattrs)
if job.preserveExtendedAttributes {
if isAppleRsync(executable) {
if !job.isRemote {
args.append("-E")
}
if !job.isRemote { args.append("-E") }
} else {
args.append("-X")
}
}
if job.deleteExtraneous { args.append("--delete") }
// --info=progress2 is not supported by Apple's bundled rsync 2.6.9.
// --progress works on both Apple's rsync and modern Homebrew rsync.
if job.showProgress { args.append("--progress") }
if dryRun { args.append("--dry-run") }
args += ["--human-readable", "--stats"]
@@ -50,14 +35,24 @@ struct RsyncCommandBuilder {
}
if job.isRemote {
var sshParts = ["ssh", "-p", String(job.remotePort)]
var sshParts = [
"ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=15",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-p", String(job.remotePort)
]
let keyPath = normalizedPrivateKeyPath(job.sshKeyPath)
if !keyPath.isEmpty {
sshParts += ["-i", keyPath]
sshParts += ["-o", "IdentitiesOnly=yes", "-i", keyPath]
}
args += ["-e", sshParts.map(shellEscape).joined(separator: " ")]
}
// End option parsing before user-controlled filesystem operands. This prevents
// a local path beginning with '-' from being interpreted as another rsync option.
args.append("--")
args.append(normalizeSource(job.sourcePath))
args.append(job.destinationDisplay)
return args
@@ -71,22 +66,21 @@ struct RsyncCommandBuilder {
static func normalizedPrivateKeyPath(_ path: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasSuffix(".pub") else { return trimmed }
guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath }
let privatePath = String(trimmed.dropLast(4))
if FileManager.default.fileExists(atPath: privatePath) {
return privatePath
let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath
if FileManager.default.fileExists(atPath: expandedPrivatePath) {
return expandedPrivatePath
}
return trimmed
return (trimmed as NSString).expandingTildeInPath
}
private static func isAppleRsync(_ executable: String) -> Bool {
executable == "/usr/bin/rsync"
}
private static func normalizeSource(_ path: String) -> String {
path
}
private static func normalizeSource(_ path: String) -> String { path }
static func shellEscape(_ value: String) -> String {
if value.isEmpty { return "''" }
@@ -130,3 +124,169 @@ struct RsyncCommandBuilder {
return result
}
}
enum RsyncSecurityValidator {
static func validationMessage(for job: SyncJob, trigger: SyncRunTrigger = .manual) -> String? {
let source = job.sourcePath.trimmingCharacters(in: .whitespacesAndNewlines)
let destination = job.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !source.isEmpty, !destination.isEmpty else {
return "Source and destination are required."
}
if containsControlCharacters(source) || containsControlCharacters(destination) {
return "Paths may not contain control characters."
}
if job.watchEnabled && containsControlCharacters(job.effectiveWatchPath) {
return "The file-watch path may not contain control characters."
}
if job.exclusions.contains(where: containsControlCharacters) {
return "Exclusion patterns may not contain control characters."
}
if let advancedError = validateAdditionalArguments(job.additionalArguments) {
return advancedError
}
if job.isRemote {
let host = job.remoteHost.trimmingCharacters(in: .whitespacesAndNewlines)
let user = job.remoteUser.trimmingCharacters(in: .whitespacesAndNewlines)
guard isValidHost(host) else {
return "The SSH host is invalid. Use a hostname or IP address without spaces or command-line option prefixes."
}
if !user.isEmpty, !isValidUser(user) {
return "The SSH username is invalid. Use letters, numbers, '.', '_' or '-'."
}
guard (1...65535).contains(job.remotePort) else {
return "The SSH port must be between 1 and 65535."
}
if destination.hasPrefix("-") {
return "A remote destination may not begin with '-'."
}
if !isSafeRemotePath(destination) {
return "The remote destination contains characters that are unsafe for an SSH/rsync remote shell. Use a normal path containing letters, numbers, spaces, '/', '.', '_', '-', '~', '+', '@', '%' or '=' only."
}
if destination == host || destination == "\(user)@\(host)" {
return "Remote destination must be a path on the remote machine, not the host address."
}
let keyPath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
if !job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if keyPath.hasSuffix(".pub") {
return "Select the private SSH key, not the .pub public key."
}
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: keyPath, isDirectory: &isDirectory), !isDirectory.boolValue else {
return "The selected private SSH key does not exist or is not a regular file."
}
}
} else if pathsOverlap(source: source, destination: destination) {
return "Source and destination overlap. Choose separate directory trees to prevent recursive copies or file-watch loops."
}
if job.deleteExtraneous,
(trigger == .scheduled || trigger == .watched),
!job.allowUnattendedDelete {
return "This job uses --delete but unattended destructive sync is not authorised. Edit the job and explicitly enable unattended delete."
}
return nil
}
private static func validateAdditionalArguments(_ raw: String) -> String? {
if !hasBalancedShellSyntax(raw) {
return "Advanced rsync arguments contain an unmatched quote or trailing escape character."
}
let arguments = RsyncCommandBuilder.shellSplit(raw)
let blockedExact: Set<String> = [
"-e", "-M", "--rsh", "--rsync-path", "--remote-option",
"--password-file", "--daemon", "--config", "--port", "--address",
"--sockopts", "--remove-source-files", "--copy-as", "--files-from",
"--exclude-from", "--include-from", "--log-file", "--log-file-format",
"--backup-dir", "--temp-dir", "--partial-dir", "--chown", "--usermap",
"--groupmap", "--rsync-path", "--write-batch", "--only-write-batch",
"--read-batch"
]
let blockedPrefixes = [
"--rsh=", "--rsync-path=", "--remote-option=", "--password-file=",
"--config=", "--port=", "--address=", "--sockopts=", "--copy-as=",
"--delete", "--remove-source-files", "--files-from=", "--exclude-from=",
"--include-from=", "--log-file=", "--log-file-format=", "--backup-dir=",
"--temp-dir=", "--partial-dir=", "--chown=", "--usermap=", "--groupmap=",
"--write-batch=", "--only-write-batch=", "--read-batch="
]
for argument in arguments {
if blockedExact.contains(argument) || blockedPrefixes.contains(where: { argument.hasPrefix($0) }) {
return "Advanced argument '\(argument)' is security-sensitive or destructive and is managed by Rsync Manager. Remove it and use the dedicated job controls instead."
}
if containsControlCharacters(argument) {
return "Advanced rsync arguments may not contain control characters."
}
}
return nil
}
private static func isSafeRemotePath(_ value: String) -> Bool {
guard !value.isEmpty, !containsControlCharacters(value) else { return false }
// Rsync invokes the remote side through SSH. Keep remote paths out of shell
// metacharacter territory so a configured path can never become a remote command.
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " /._-~+@%="))
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
private static func hasBalancedShellSyntax(_ value: String) -> Bool {
var quote: Character?
var escaping = false
for ch in value {
if escaping {
escaping = false
continue
}
if ch == "\\" {
escaping = true
continue
}
if let activeQuote = quote {
if ch == activeQuote { quote = nil }
continue
}
if ch == "\"" || ch == "'" { quote = ch }
}
return quote == nil && !escaping
}
private static func isValidHost(_ value: String) -> Bool {
guard !value.isEmpty, !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_:[]"))
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
private static func isValidUser(_ value: String) -> Bool {
guard !value.hasPrefix("-"), !containsControlCharacters(value) else { return false }
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
}
private static func containsControlCharacters(_ value: String) -> Bool {
value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) }
}
private static func pathsOverlap(source: String, destination: String) -> Bool {
let src = canonicalLocalPath(source)
let dst = canonicalLocalPath(destination)
guard !src.isEmpty, !dst.isEmpty else { return false }
if src == dst { return true }
return dst.hasPrefix(src + "/") || src.hasPrefix(dst + "/")
}
private static func canonicalLocalPath(_ value: String) -> String {
let expanded = (value as NSString).expandingTildeInPath
let standardized = (expanded as NSString).standardizingPath
if standardized == "/" { return standardized }
return standardized.hasSuffix("/") ? String(standardized.dropLast()) : standardized
}
}
+23 -6
View File
@@ -1,12 +1,29 @@
import Foundation
enum RsyncLocator {
static let candidates = [
"/opt/homebrew/bin/rsync",
"/usr/local/bin/rsync",
"/usr/bin/rsync"
]
static func locate() -> String {
let candidates = [
"/opt/homebrew/bin/rsync",
"/usr/local/bin/rsync",
"/usr/bin/rsync"
]
return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) ?? "/usr/bin/rsync"
candidates.first(where: isTrustedExecutableCandidate) ?? "/usr/bin/rsync"
}
static func isTrustedExecutableCandidate(_ path: String) -> Bool {
let manager = FileManager.default
guard manager.isExecutableFile(atPath: path) else { return false }
let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
var isDirectory: ObjCBool = false
guard manager.fileExists(atPath: resolved, isDirectory: &isDirectory), !isDirectory.boolValue else { return false }
do {
let attributes = try manager.attributesOfItem(atPath: resolved)
return attributes[.type] as? FileAttributeType == .typeRegular
} catch {
return false
}
}
}
+15 -13
View File
@@ -14,17 +14,16 @@ final class RsyncRunner: ObservableObject {
let wasCancelled: Bool
}
func run(job: SyncJob, dryRun: Bool) async throws -> Result {
func run(job: SyncJob, dryRun: Bool, trigger: SyncRunTrigger = .manual) async throws -> Result {
guard !isRunning else { throw RunnerError.alreadyRunning }
guard !job.sourcePath.isEmpty, !job.destinationPath.isEmpty else { throw RunnerError.invalidPaths }
if job.isRemote && job.remoteHost.isEmpty { throw RunnerError.missingRemoteHost }
if job.isRemote && remoteDestinationLooksLikeHost(job) { throw RunnerError.invalidRemoteDestination }
if job.isRemote && job.sshKeyPath.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".pub") {
let privatePath = RsyncCommandBuilder.normalizedPrivateKeyPath(job.sshKeyPath)
if privatePath.hasSuffix(".pub") { throw RunnerError.publicSSHKeySelected }
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: trigger) {
throw RunnerError.securityValidation(message)
}
let executable = RsyncLocator.locate()
guard RsyncLocator.isTrustedExecutableCandidate(executable) else {
throw RunnerError.invalidExecutable(executable)
}
let arguments = RsyncCommandBuilder.build(job: job, dryRun: dryRun, executable: executable)
isRunning = true
@@ -106,15 +105,18 @@ final class RsyncRunner: ObservableObject {
}
enum RunnerError: LocalizedError {
case alreadyRunning, invalidPaths, missingRemoteHost, invalidRemoteDestination, publicSSHKeySelected
case alreadyRunning
case securityValidation(String)
case invalidExecutable(String)
var errorDescription: String? {
switch self {
case .alreadyRunning: return "Another rsync job is already running."
case .invalidPaths: return "Source and destination are required."
case .missingRemoteHost: return "Remote host is required for an SSH job."
case .invalidRemoteDestination: return "Remote destination must be a path on the remote machine, for example /home/mag/backup or ~/backup — not the host address."
case .publicSSHKeySelected: return "The SSH key must be the private key (for example ~/.ssh/id_rsa or ~/.ssh/id_ed25519), not the .pub file."
case .alreadyRunning:
return "Another rsync job is already running."
case .securityValidation(let message):
return message
case .invalidExecutable(let path):
return "The rsync executable at \(path) is unavailable or is not a trusted regular executable file."
}
}
}
+22 -18
View File
@@ -10,8 +10,7 @@ struct SettingsView: View {
@AppStorage("backgroundSchedulesEnabled") private var backgroundSchedulesEnabled = true
@AppStorage("fileWatchingEnabled") private var fileWatchingEnabled = true
@AppStorage("defaultWatchDebounceSeconds") private var defaultWatchDebounceSeconds = 3
@State private var defaultPassword = ""
@State private var passwordSaved = false
@AppStorage("historyRetentionDays") private var historyRetentionDays = 90
@State private var launchAtLogin = false
@State private var launchError: String?
@@ -30,18 +29,9 @@ struct SettingsView: View {
}
}
SecureField("Default password", text: $defaultPassword)
HStack {
Text("Stored securely in the macOS Keychain, never in SwiftData or UserDefaults.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button(passwordSaved ? "Saved" : "Save Password") {
KeychainStore.set(defaultPassword, for: "defaultSSHPassword")
passwordSaved = true
}
.disabled(defaultPassword.isEmpty)
}
Label("Authentication uses an SSH private key or your macOS SSH agent. Passwords are intentionally not stored or passed on the command line.", systemImage: "lock.shield")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("New Jobs") {
@@ -71,6 +61,13 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
Section("History & Privacy") {
Stepper("Keep run history for \(historyRetentionDays) days", value: $historyRetentionDays, in: 7...3650, step: 7)
Text("Run output is sanitised, ANSI control sequences are removed, and individual stdout/stderr logs are capped at approximately 2 MB before storage.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("File Watching") {
Toggle("Watch enabled jobs for changes", isOn: $fileWatchingEnabled)
Stepper("Default debounce: \(defaultWatchDebounceSeconds) seconds", value: $defaultWatchDebounceSeconds, in: 1...60)
@@ -91,7 +88,16 @@ struct SettingsView: View {
Form {
Section("Rsync") {
LabeledContent("Detected executable", value: RsyncLocator.locate())
Text("Rsync Manager prefers Homebrew rsync, then /usr/local/bin/rsync, and finally the version supplied with macOS.")
LabeledContent("Executable validation", value: RsyncLocator.isTrustedExecutableCandidate(RsyncLocator.locate()) ? "Passed" : "Failed")
Text("Only executable regular-file rsync candidates are accepted. Symlinks are resolved before validation.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Security") {
LabeledContent("SSH mode", value: "Batch / key or agent")
LabeledContent("Host-key verification", value: "System default")
Text("SSH connections use BatchMode, connection timeouts and keepalive limits. Rsync Manager never disables SSH host-key verification.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -107,11 +113,9 @@ struct SettingsView: View {
}
.frame(width: 650, height: 455)
.onAppear {
defaultPassword = KeychainStore.string(for: "defaultSSHPassword")
passwordSaved = !defaultPassword.isEmpty
KeychainStore.delete("defaultSSHPassword")
launchAtLogin = SMAppService.mainApp.status == .enabled
}
.onChange(of: defaultPassword) { _, _ in passwordSaved = false }
.onChange(of: backgroundSchedulesEnabled) { _, _ in automation.refreshConfiguration() }
.onChange(of: fileWatchingEnabled) { _, _ in automation.refreshConfiguration() }
.alert("Launch at Login", isPresented: Binding(
+26 -12
View File
@@ -19,23 +19,37 @@ enum KeychainStore {
return value
}
static func set(_ value: String, for account: String) {
@discardableResult
static func set(_ value: String, for account: String) -> OSStatus {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
if value.isEmpty {
SecItemDelete(base as CFDictionary)
return
}
if value.isEmpty { return delete(account) }
let data = Data(value.utf8)
let update: [String: Any] = [kSecValueData as String: data]
if SecItemUpdate(base as CFDictionary, update as CFDictionary) == errSecItemNotFound {
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
let update: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
let updateStatus = SecItemUpdate(base as CFDictionary, update as CFDictionary)
if updateStatus != errSecItemNotFound { return updateStatus }
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
return SecItemAdd(add as CFDictionary, nil)
}
@discardableResult
static func delete(_ account: String) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecItemNotFound ? errSecSuccess : status
}
}
+28 -9
View File
@@ -251,6 +251,10 @@ struct JobDetailView: View {
Binding(
get: { job.scheduleEnabled },
set: { enabled in
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling."
return
}
job.scheduleEnabled = enabled
if enabled {
if job.scheduleKind == .manual { job.scheduleKind = .daily }
@@ -268,6 +272,10 @@ struct JobDetailView: View {
Binding(
get: { job.watchEnabled },
set: { enabled in
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching."
return
}
job.watchEnabled = enabled
if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
job.watchPath = job.sourcePath
@@ -284,8 +292,12 @@ struct JobDetailView: View {
private func persistAutomationChange() {
job.updatedAt = Date()
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
do {
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
} catch {
errorMessage = "Could not save automation settings: \(error.localizedDescription)"
}
}
@ViewBuilder
@@ -316,7 +328,12 @@ struct JobDetailView: View {
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable)
let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual)
modelContext.insert(run)
try? modelContext.save()
do {
try modelContext.save()
} catch {
errorMessage = "Could not create run history: \(error.localizedDescription)"
return
}
if presentation.showsModal {
presentedRun = run
@@ -325,23 +342,25 @@ struct JobDetailView: View {
Task {
do {
let result = try await runner.run(job: job, dryRun: dryRun)
run.output = result.stdout
run.errorOutput = result.stderr
let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual)
run.output = SyncRun.sanitizedLog(result.stdout)
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
run.exitCode = Int(result.exitCode)
run.wasCancelled = result.wasCancelled
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = Int(result.exitCode)
job.updatedAt = Date()
try? modelContext.save()
do { try modelContext.save() }
catch { errorMessage = "Could not save run history: \(error.localizedDescription)" }
} catch {
run.errorOutput = error.localizedDescription
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
run.exitCode = -1
run.finishedAt = Date()
job.lastRunAt = run.finishedAt
job.lastExitCode = -1
try? modelContext.save()
do { try modelContext.save() }
catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" }
if !presentation.showsModal {
errorMessage = error.localizedDescription
+37 -4
View File
@@ -38,6 +38,8 @@ struct JobEditorView: View {
@State private var watchEnabled: Bool
@State private var watchPath: String
@State private var watchDebounceSeconds: Int
@State private var allowUnattendedDelete: Bool
@State private var validationMessage: String?
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
self.existingJob = job
@@ -68,6 +70,8 @@ struct JobEditorView: View {
_watchPath = State(initialValue: job?.watchPath ?? "")
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
_validationMessage = State(initialValue: nil)
}
var body: some View {
@@ -174,6 +178,12 @@ struct JobEditorView: View {
Toggle("Archive mode (-a)", isOn: $archive)
Toggle("Compress during transfer (-z)", isOn: $compress)
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
.font(.caption)
.foregroundStyle(.orange)
}
Toggle("Preserve permissions", isOn: $preservePermissions)
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
Toggle("Show transfer progress", isOn: $showProgress)
@@ -191,6 +201,9 @@ struct JobEditorView: View {
Section("Advanced") {
TextField("Additional rsync arguments", text: $additionalArguments)
.font(.system(.body, design: .monospaced))
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
@@ -213,6 +226,14 @@ struct JobEditorView: View {
}
}
}
.alert("Invalid Sync Job", isPresented: Binding(
get: { validationMessage != nil },
set: { if !$0 { validationMessage = nil } }
)) {
Button("OK", role: .cancel) { validationMessage = nil }
} message: {
Text(validationMessage ?? "The job configuration is invalid.")
}
.frame(width: 690, height: 820)
}
@@ -262,10 +283,22 @@ struct JobEditorView: View {
job.watchEnabled = watchEnabled
job.watchPath = watchPath
job.watchDebounceSeconds = watchDebounceSeconds
job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
job.updatedAt = Date()
if existingJob == nil { modelContext.insert(job) }
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
dismiss()
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
validationMessage = message
return
}
do {
if existingJob == nil { modelContext.insert(job) }
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
dismiss()
} catch {
validationMessage = "Could not save the job: \(error.localizedDescription)"
}
}
}
+16 -2
View File
@@ -9,6 +9,7 @@ struct JobListView: View {
@Binding var showingNewJob: Bool
@State private var searchText = ""
@State private var filter: JobFilter = .all
@State private var persistenceError: String?
var body: some View {
VStack(spacing: 0) {
@@ -50,6 +51,14 @@ struct JobListView: View {
.listStyle(.sidebar)
}
}
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to save job changes.")
}
}
private var dashboardHeader: some View {
@@ -198,6 +207,7 @@ struct JobListView: View {
copy.watchEnabled = job.watchEnabled
copy.watchPath = job.watchPath
copy.watchDebounceSeconds = job.watchDebounceSeconds
copy.allowUnattendedDelete = job.allowUnattendedDelete
modelContext.insert(copy)
saveAndRefresh()
selection = copy
@@ -210,8 +220,12 @@ struct JobListView: View {
}
private func saveAndRefresh() {
try? modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
do {
try modelContext.save()
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
} catch {
persistenceError = error.localizedDescription
}
}
}
+16 -2
View File
@@ -5,6 +5,7 @@ struct RunHistoryView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@State private var selection: SyncRun?
@State private var persistenceError: String?
let onBack: () -> Void
var body: some View {
@@ -24,7 +25,7 @@ struct RunHistoryView: View {
Button("Clear", role: .destructive) {
runs.forEach(modelContext.delete)
selection = nil
try? modelContext.save()
saveHistoryChanges()
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -79,7 +80,7 @@ struct RunHistoryView: View {
Button("Delete Log", role: .destructive) {
if selection?.id == run.id { selection = nil }
modelContext.delete(run)
try? modelContext.save()
saveHistoryChanges()
}
}
}
@@ -103,6 +104,19 @@ struct RunHistoryView: View {
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.alert("Database Error", isPresented: Binding(
get: { persistenceError != nil },
set: { if !$0 { persistenceError = nil } }
)) {
Button("OK", role: .cancel) { persistenceError = nil }
} message: {
Text(persistenceError ?? "Unable to update run history.")
}
}
private func saveHistoryChanges() {
do { try modelContext.save() }
catch { persistenceError = error.localizedDescription }
}
private func statusIcon(_ run: SyncRun) -> String {