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") } // 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") } } 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"] 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", "-p", String(job.remotePort)] let keyPath = normalizedPrivateKeyPath(job.sshKeyPath) if !keyPath.isEmpty { sshParts += ["-i", keyPath] } args += ["-e", sshParts.map(shellEscape).joined(separator: " ")] } 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 } let privatePath = String(trimmed.dropLast(4)) if FileManager.default.fileExists(atPath: privatePath) { return privatePath } return trimmed } 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 } }