Files

161 lines
7.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
/// An awaited copy independent of browser payloads and transfer UI lifetimes.
public nonisolated enum SFTPRemoteCopyOperation {
public struct Outcome: Sendable {
public var actualDestinationPath: String
public var copiedFiles = 0
public var createdDirectories = 0
public var skippedItems = 0
public var bytes: Int64 = 0
public var totalBytes: Int64 = 0
public var failedPath: String?
public var error: String?
}
public static func run(
sourcePath: String, destinationPath: String, sameHost: Bool,
source: any SFTPChannelProtocol, destination: any SFTPChannelProtocol,
policy: TransferConflictPolicy, cancellation: TransferCancellationToken,
report: @escaping @Sendable (Outcome, SFTPProgress?) -> Void
) async -> Outcome {
var outcome = Outcome(actualDestinationPath: destinationPath)
var currentPath = sourcePath
func checkCancellation() throws {
try Task.checkCancellation()
guard !cancellation.isCancelled else { throw SFTPError.cancelled }
}
func contains(_ path: String, in prefix: String) -> Bool {
path == prefix || prefix == "/" || path.hasPrefix(prefix + "/")
}
func join(_ base: String, _ relative: String) -> String {
base == "/" ? "/\(relative)" : base + "/" + relative
}
do {
try checkCancellation()
guard try AgentToolSupport.normalizeRemotePath(sourcePath) == sourcePath,
try AgentToolSupport.normalizeRemotePath(destinationPath) == destinationPath
else { throw SFTPError.operation(String(localized: "Invalid SFTP copy source path.")) }
// LSTAT each ancestor so a link cannot alias a source tree into the destination.
for (path, client) in [(sourcePath, source), (destinationPath, destination)] {
var ancestor = ""
for component in path.split(separator: "/").dropLast() {
try checkCancellation()
ancestor += "/" + component
currentPath = ancestor
guard try await client.attributes(at: ancestor)?.kind == .directory else {
throw SFTPRemoteCopySourceError(path: ancestor)
}
}
}
currentPath = sourcePath
guard let root = try await source.attributes(at: sourcePath) else {
throw SFTPError.operation(String(localized: "The SFTP copy source does not exist: \(sourcePath)"))
}
guard root.path == sourcePath, sourcePath != "/",
root.name == (sourcePath as NSString).lastPathComponent
else {
throw SFTPError.operation(String(localized: "Invalid SFTP copy source path."))
}
let rootTarget = join(destinationPath, root.name)
outcome.actualDestinationPath = rootTarget
guard
!sameHost || (rootTarget != sourcePath && (!root.isDirectory || !contains(rootTarget, in: sourcePath)))
else { throw SFTPError.operation(String(localized: "A path cannot be copied into itself.")) }
currentPath = destinationPath
guard try await destination.attributes(at: destinationPath)?.isDirectory == true else {
throw SFTPError.operation(String(localized: "The SFTP copy destination must be an existing directory."))
}
currentPath = sourcePath
let entries = try await SFTPRemoteCopyPlanner.manifest(
files: [root], client: source, regularItemsOnly: true, cancellation: cancellation
)
for entry in entries where !entry.isDirectory {
let (total, overflow) = outcome.totalBytes.addingReportingOverflow(entry.byteCount)
guard !overflow else {
throw SFTPError.operation(String(localized: "The SFTP copy is too large to count."))
}
outcome.totalBytes = total
}
report(outcome, nil)
var skipped: [String] = []
var mappings: [(String, String)] = []
for entry in entries {
try checkCancellation()
let original = join(destinationPath, entry.relativePath)
if skipped.contains(where: { contains(original, in: $0) }) {
outcome.skippedItems += 1
continue
}
currentPath = entry.sourcePath
guard let fresh = try await source.attributes(at: entry.sourcePath), fresh.kind == entry.kind,
entry.isDirectory || fresh.size == entry.byteCount
else {
throw SFTPError.operation(String(localized: "The SFTP copy source changed: \(entry.sourcePath)"))
}
var target = original
for mapping in mappings where contains(original, in: mapping.0) {
target = mapping.1 + original.dropFirst(mapping.0.count)
break
}
currentPath = target
try checkCancellation()
let existing = try await destination.attributes(at: target)
if entry.isDirectory {
let resolution = try await SFTPRemoteConflictResolver.resolveDirectories(
[target], conflicts: existing != nil && existing?.isDirectory != true ? [target] : [],
policy: policy, client: destination
)
outcome.createdDirectories += resolution.created.count
if !resolution.skipped.isEmpty {
skipped.append(original)
outcome.skippedItems += 1
}
if let mapping = resolution.mappings.first {
// Most specific mapping wins, including remapped nested directories.
mappings.insert((original, mapping.1), at: 0)
if original == rootTarget { outcome.actualDestinationPath = mapping.1 }
}
} else {
var overwrite = false
if let existing {
guard
let resolution = try await SFTPRemoteConflictResolver.resolveConflict(
path: target, conflictingTargetIsDirectory: existing.isDirectory,
policy: policy, client: destination
)
else {
outcome.skippedItems += 1
report(outcome, nil)
continue
}
target = resolution.path
overwrite = resolution.overwrite
}
currentPath = target
if original == rootTarget { outcome.actualDestinationPath = target }
let snapshot = outcome
try checkCancellation()
try await source.copyFile(
from: entry.sourcePath, to: target, on: destination,
byteCount: entry.byteCount, overwrite: overwrite, cancellation: cancellation
) { progress in report(snapshot, progress) }
outcome.copiedFiles += 1
outcome.bytes += entry.byteCount
}
report(outcome, nil)
}
} catch {
outcome.failedPath = (error as? SFTPRemoteCopySourceError)?.path ?? currentPath
outcome.error = error.localizedDescription
}
return outcome
}
}