Files
Rsync-Manager/RsyncManager/Services/RsyncCommandBuilder.swift
T
2026-09-08 14:12:59 +01:00

133 lines
4.3 KiB
Swift

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
}
}