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
@@ -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."
}
}
}