Initial Commit
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user