import Foundation struct RsyncCommandBuilder { static func build(job: SyncJob, dryRun: Bool, executable: String) -> [String] { var args: [String] = [] if job.archive { args.append("-a") } else if job.preservePermissions { args.append("-p") } if job.compress { args.append("-z") } if job.preserveExtendedAttributes { if isAppleRsync(executable) { if !job.isRemote { args.append("-E") } } else { args.append("-X") } } if job.deleteExtraneous { args.append("--delete") } if job.showProgress { args.append("--progress") } if dryRun { args.append("--dry-run") } args += ["--human-readable", "--stats"] for exclusion in job.exclusions { args.append("--exclude=\(exclusion)") } if !job.additionalArguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args += shellSplit(job.additionalArguments) } if job.isRemote { 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 += ["-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 } static func displayCommand(job: SyncJob, dryRun: Bool, executable: String) -> String { ([executable] + build(job: job, dryRun: dryRun, executable: executable)) .map(shellEscape) .joined(separator: " ") } static func normalizedPrivateKeyPath(_ path: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasSuffix(".pub") else { return (trimmed as NSString).expandingTildeInPath } let privatePath = String(trimmed.dropLast(4)) let expandedPrivatePath = (privatePath as NSString).expandingTildeInPath if FileManager.default.fileExists(atPath: expandedPrivatePath) { return expandedPrivatePath } return (trimmed as NSString).expandingTildeInPath } private static func isAppleRsync(_ executable: String) -> Bool { executable == "/usr/bin/rsync" } private static func normalizeSource(_ path: String) -> String { path } static func shellEscape(_ value: String) -> String { if value.isEmpty { return "''" } let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./:@%+=,")) if value.unicodeScalars.allSatisfy({ allowed.contains($0) }) { return value } return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } static func shellSplit(_ string: String) -> [String] { var result: [String] = [] var current = "" var quote: Character? var escaping = false for ch in string { if escaping { current.append(ch) escaping = false continue } if ch == "\\" { escaping = true continue } if let q = quote { if ch == q { quote = nil } else { current.append(ch) } continue } if ch == "\"" || ch == "'" { quote = ch } else if ch.isWhitespace { if !current.isEmpty { result.append(current) current = "" } } else { current.append(ch) } } if !current.isEmpty { result.append(current) } 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 = [ "-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 } }