Files

320 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
public nonisolated struct SFTPRemoteDownloadBatch: Sendable {
public struct Job: Sendable {
var remotePath: String
var localURL: URL
var byteCount: Int64
var hasConflict: Bool
var conflictingTargetIsDirectory: Bool
}
public var id = UUID()
var jobs: [Job]
var directories: [URL]
var directoryConflicts: Set<URL>
public var destination: URL
public var conflictCount: Int
public var transferCount: Int { jobs.count }
public var directoryCount: Int { directories.count }
}
public nonisolated enum SFTPRemoteDownloadPlanner {
public static func prepare(
_ files: [RemoteFile], destination: URL,
client: any SFTPChannelProtocol
) async throws -> SFTPRemoteDownloadBatch {
let scoped = destination.startAccessingSecurityScopedResource()
defer { if scoped { destination.stopAccessingSecurityScopedResource() } }
var jobs: [SFTPRemoteDownloadBatch.Job] = []
var directories: [URL] = []
var directoryConflicts: Set<URL> = []
var conflicts = 0
func collect(_ remote: RemoteFile, local: URL) async throws {
if remote.isDirectory {
directories.append(local)
for child in try await client.listDirectory(at: remote.path) {
try await collect(child, local: local.appending(path: child.name))
}
return
}
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: local.path, isDirectory: &isDirectory)
if exists { conflicts += 1 }
jobs.append(
.init(
remotePath: remote.path, localURL: local, byteCount: remote.size,
hasConflict: exists,
conflictingTargetIsDirectory: exists && isDirectory.boolValue
))
}
for file in files {
try await collect(file, local: destination.appending(path: file.name))
}
for directory in directories {
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory),
!isDirectory.boolValue
{
directoryConflicts.insert(directory)
conflicts += 1
}
}
return SFTPRemoteDownloadBatch(
jobs: jobs, directories: directories,
directoryConflicts: directoryConflicts,
destination: destination, conflictCount: conflicts
)
}
@MainActor public static func enqueue(
_ batch: SFTPRemoteDownloadBatch,
policy: TransferConflictPolicy,
hostID: Host.ID,
connection: SFTPTransferConnection,
homeDirectory: String? = nil,
coordinator: SFTPTransferCoordinator,
completion: ((String, SFTPTransfer.State) -> Void)? = nil
) throws {
let scoped = batch.destination.startAccessingSecurityScopedResource()
defer { if scoped { batch.destination.stopAccessingSecurityScopedResource() } }
var skippedPrefixes: [URL] = []
var mappings: [(URL, URL)] = []
for original in batch.directories {
if skippedPrefixes.contains(where: { contains(original, in: $0) }) { continue }
var directory = mapped(original, using: mappings)
if batch.directoryConflicts.contains(original) {
switch policy {
case .skip:
skippedPrefixes.append(original)
continue
case .replace:
try FileManager.default.removeItem(at: original)
case .keepBoth:
let candidate = keepBothURL(original)
mappings.append((original, candidate))
directory = candidate
}
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
for var job in batch.jobs {
if skippedPrefixes.contains(where: { contains(job.localURL, in: $0) }) { continue }
job.localURL = mapped(job.localURL, using: mappings)
if job.hasConflict {
switch policy {
case .skip: continue
case .replace:
if job.conflictingTargetIsDirectory {
try FileManager.default.removeItem(at: job.localURL)
}
case .keepBoth:
job.localURL = keepBothURL(job.localURL)
}
}
try FileManager.default.createDirectory(
at: job.localURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
coordinator.enqueue(
direction: .download, batchID: batch.id,
localURL: job.localURL, remotePath: job.remotePath,
byteCount: job.byteCount, hostID: hostID,
connection: connection, scopedRoot: batch.destination,
overwrite: job.hasConflict && policy == .replace,
homeDirectory: homeDirectory
) { state in completion?(job.remotePath, state) }
}
}
private static func contains(_ url: URL, in prefix: URL) -> Bool {
url.path == prefix.path || url.path.hasPrefix(prefix.path + "/")
}
private static func mapped(_ url: URL, using mappings: [(URL, URL)]) -> URL {
for mapping in mappings where contains(url, in: mapping.0) {
return URL(fileURLWithPath: mapping.1.path + url.path.dropFirst(mapping.0.path.count))
}
return url
}
private static func keepBothURL(_ url: URL) -> URL {
let ext = url.pathExtension
let base = url.deletingPathExtension()
var index = 2
while true {
let name =
ext.isEmpty
? "\(base.lastPathComponent).\(index)"
: "\(base.lastPathComponent).\(index).\(ext)"
let candidate = base.deletingLastPathComponent().appending(path: name)
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
index += 1
}
}
}
public nonisolated struct SFTPRemoteCopyBatch: Sendable {
public struct Job: Sendable {
var sourcePath: String
var destinationPath: String
var byteCount: Int64
var hasConflict: Bool
var conflictingTargetIsDirectory: Bool
}
public var id = UUID()
var source: SFTPRemoteCopySource
var jobs: [Job]
var directories: [String]
var directoryConflicts: Set<String>
public var conflictCount: Int
public var transferCount: Int { jobs.count }
public var directoryCount: Int { directories.count }
}
public nonisolated enum SFTPRemoteCopyPlanner {
static func manifest(
files: [RemoteFile], client: any SFTPChannelProtocol, regularItemsOnly: Bool = false,
cancellation: TransferCancellationToken? = nil
) async throws -> [SFTPRemoteManifestEntry] {
var entries: [SFTPRemoteManifestEntry] = []
func collect(_ file: RemoteFile, relativePath: String) async throws {
try Task.checkCancellation()
if cancellation?.isCancelled == true { throw SFTPError.cancelled }
if regularItemsOnly {
guard file.kind == .file || file.kind == .directory,
RemoteFileName.isValid(file.name), file.size >= 0
else { throw SFTPRemoteCopySourceError(path: file.path) }
}
entries.append(
SFTPRemoteManifestEntry(
sourcePath: file.path, relativePath: relativePath,
kind: file.kind, byteCount: file.size
))
guard file.isDirectory else { return }
for child in try await client.listDirectory(at: file.path) {
if regularItemsOnly {
let expected = file.path == "/" ? "/\(child.name)" : "\(file.path)/\(child.name)"
guard child.path == expected else { throw SFTPRemoteCopySourceError(path: child.path) }
}
try await collect(child, relativePath: relativePath + "/" + child.name)
}
}
for file in files { try await collect(file, relativePath: file.name) }
return entries
}
public static func prepare(
_ payload: SFTPRemoteItemsPayload,
destination: String,
destinationHostID: Host.ID,
client: any SFTPChannelProtocol,
coordinator: SFTPTransferCoordinator
) async throws -> SFTPRemoteCopyBatch {
let manifest = try await coordinator.remoteManifest(for: payload)
if manifest.source.hostID == destinationHostID {
for file in manifest.source.files {
let target = RemotePath.join(destination, file.name)
guard target != file.path,
!file.isDirectory || !contains(target, in: file.path)
else {
throw SFTPError.operation(String(localized: "A path cannot be copied into itself."))
}
}
}
var jobs: [SFTPRemoteCopyBatch.Job] = []
var directories: [String] = []
for entry in manifest.entries {
let target = RemotePath.join(destination, entry.relativePath)
if entry.isDirectory {
directories.append(target)
} else {
jobs.append(
.init(
sourcePath: entry.sourcePath, destinationPath: target,
byteCount: entry.byteCount, hasConflict: false,
conflictingTargetIsDirectory: false
))
}
}
let preflight = try await SFTPRemoteConflictResolver.preflight(
directories: directories, targets: jobs.map(\.destinationPath),
includeConflictingRoot: true, client: client)
for (index, kind) in zip(jobs.indices, preflight.targetKinds) {
guard let kind else { continue }
jobs[index].hasConflict = true
jobs[index].conflictingTargetIsDirectory = kind
}
return SFTPRemoteCopyBatch(
source: manifest.source, jobs: jobs,
directories: preflight.directories,
directoryConflicts: preflight.directoryConflicts,
conflictCount: preflight.conflictCount
)
}
private static func contains(_ path: String, in directory: String) -> Bool {
directory == "/" || path.hasPrefix(directory + "/")
}
/// Returns whether resolving the batch changed remote directories, even without file jobs.
@discardableResult
@MainActor public static func enqueue(
_ batch: SFTPRemoteCopyBatch,
policy: TransferConflictPolicy,
destinationHostID: Host.ID,
destinationConnection: SFTPTransferConnection,
destinationHomeDirectory: String? = nil,
client: any SFTPChannelProtocol,
coordinator: SFTPTransferCoordinator,
completion: ((SFTPTransfer.State) -> Void)? = nil
) async throws -> Bool {
let resolution = try await SFTPRemoteConflictResolver.resolveDirectories(
batch.directories, conflicts: batch.directoryConflicts,
policy: policy, client: client
)
for var job in batch.jobs {
guard let destination = resolution.destination(for: job.destinationPath) else { continue }
if destination != job.destinationPath { job.hasConflict = false }
job.destinationPath = destination
if job.hasConflict {
guard
let resolved = try await SFTPRemoteConflictResolver.resolveConflict(
path: job.destinationPath,
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
policy: policy, client: client
)
else { continue }
job.destinationPath = resolved.path
job.hasConflict = resolved.overwrite
}
coordinator.enqueueRemoteCopy(
batchID: batch.id,
sourcePath: job.sourcePath,
sourceHostID: batch.source.hostID,
sourceConnection: batch.source.connection,
destinationPath: job.destinationPath,
destinationHostID: destinationHostID,
destinationConnection: destinationConnection,
byteCount: job.byteCount,
overwrite: job.hasConflict && policy == .replace,
sourceHomeDirectory: batch.source.homeDirectory,
destinationHomeDirectory: destinationHomeDirectory,
completion: completion
)
}
return resolution.changed
}
}