133 lines
4.3 KiB
Swift
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
|
|
}
|
|
}
|