Files
RSync-Studio/Rsync Studio/Services/RsyncCommandBuilder.swift
2026-09-08 20:06:58 +01:00

305 lines
13 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") }
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 let volume = SyncVolumeInspector.info(forPath: destination) {
if job.requireExpectedVolume && !job.expectedVolumeIdentifier.isEmpty && volume.identifier != job.expectedVolumeIdentifier {
return "The configured destination volume is not mounted. Rsync Studio will not use a different volume at the same path."
}
if job.minimumFreeSpaceMB > 0 && volume.freeBytes < Int64(job.minimumFreeSpaceMB) * 1_048_576 {
return "The destination does not have the configured minimum free space of \(job.minimumFreeSpaceMB) MB."
}
} else {
return "The destination volume cannot be identified."
}
}
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<String> = [
"-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 Studio. 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
}
}