1643 lines
72 KiB
Swift
1643 lines
72 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
import UniformTypeIdentifiers
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct SFTPFeatureTests {
|
|
@Test
|
|
func contextClickSelectsBeforeBuildingTargetsAndPreservesAnExistingMultiSelection() async {
|
|
let first = remoteFile(path: "/home/a.txt", kind: .file)
|
|
let second = remoteFile(path: "/home/b.txt", kind: .file)
|
|
let third = remoteFile(path: "/home/c.txt", kind: .file)
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home",
|
|
client: SnapshotSFTPClient(responses: [.success([first, second, third])]))
|
|
await channel.reload(silently: true)
|
|
|
|
#expect(channel.selectForContextMenu(first))
|
|
#expect(channel.selection == [first.id])
|
|
#expect(channel.menuTargets(for: first) == [first])
|
|
|
|
channel.selection = [first.id, second.id]
|
|
#expect(!channel.selectForContextMenu(second))
|
|
#expect(channel.selection == [first.id, second.id])
|
|
let targets = channel.menuTargets(for: second)
|
|
#expect(targets == [first, second])
|
|
|
|
#expect(channel.selectForContextMenu(third))
|
|
#expect(channel.selection == [third.id])
|
|
#expect(channel.menuTargets(for: third) == [third])
|
|
#expect(targets == [first, second])
|
|
#expect(!channel.selectForContextMenu(third))
|
|
}
|
|
|
|
@Test
|
|
func transferSummaryWeightsProgressByBytesAndCountsFailures() {
|
|
let host = Host(name: "Host", host: "host.example")
|
|
let first = SFTPTransfer(
|
|
hostID: host.id, direction: .upload,
|
|
localPath: "/tmp/first", remotePath: "/srv/first",
|
|
byteCount: 100, transferredBytes: 25, state: .running
|
|
)
|
|
let second = SFTPTransfer(
|
|
hostID: host.id, direction: .upload,
|
|
localPath: "/tmp/second", remotePath: "/srv/second",
|
|
byteCount: 300, transferredBytes: 0, state: .queued
|
|
)
|
|
let paused = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/paused", remotePath: "/paused",
|
|
byteCount: 1_000, transferredBytes: 500, state: .paused
|
|
)
|
|
let finished = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/finished", remotePath: "/finished",
|
|
byteCount: 1_000, transferredBytes: 1_000, state: .completed
|
|
)
|
|
let failed = SFTPTransfer(
|
|
hostID: host.id, direction: .download,
|
|
localPath: "/tmp/failed", remotePath: "/srv/failed",
|
|
byteCount: 10, transferredBytes: 4, state: .failed("Network lost")
|
|
)
|
|
let store = HostLibraryModel(
|
|
hosts: [host], sftpTransfers: [first, second, paused, finished, failed]
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: store)
|
|
|
|
#expect(coordinator.summary.activeCount == 2)
|
|
#expect(coordinator.summary.failedCount == 1)
|
|
#expect(coordinator.summary.aggregateProgress == 0.0625)
|
|
}
|
|
|
|
@Test
|
|
func clearingTransferCenterHistoryPreservesUnfinishedWork() {
|
|
let host = Host(name: "Host", host: "host.example")
|
|
let queued = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/queued", remotePath: "/queued",
|
|
state: .queued
|
|
)
|
|
let running = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/running", remotePath: "/running",
|
|
state: .running
|
|
)
|
|
let paused = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/paused", remotePath: "/paused",
|
|
state: .paused
|
|
)
|
|
let completed = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/completed", remotePath: "/completed",
|
|
state: .completed
|
|
)
|
|
let cancelled = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/cancelled", remotePath: "/cancelled",
|
|
state: .cancelled
|
|
)
|
|
let failed = SFTPTransfer(
|
|
hostID: host.id, localPath: "/tmp/failed", remotePath: "/failed",
|
|
state: .failed("Permission denied")
|
|
)
|
|
let store = HostLibraryModel(
|
|
hosts: [host],
|
|
sftpTransfers: [queued, running, paused, completed, cancelled, failed]
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: store)
|
|
|
|
coordinator.clearFinished()
|
|
|
|
#expect(store.sftpTransfers.map(\.id) == [queued.id, running.id, paused.id])
|
|
}
|
|
|
|
@Test
|
|
func removingOneFinishedTransferPreservesActiveWorkAndDownloadedFile() throws {
|
|
let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try Data("downloaded".utf8).write(to: localURL)
|
|
defer { try? FileManager.default.removeItem(at: localURL) }
|
|
|
|
let queued = SFTPTransfer(localPath: "/tmp/queued", remotePath: "/queued", state: .queued)
|
|
let running = SFTPTransfer(localPath: "/tmp/running", remotePath: "/running", state: .running)
|
|
let paused = SFTPTransfer(localPath: "/tmp/paused", remotePath: "/paused", state: .paused)
|
|
let completed = SFTPTransfer(
|
|
direction: .download, localPath: localURL.path, remotePath: "/completed", state: .completed)
|
|
let cancelled = SFTPTransfer(localPath: "/tmp/cancelled", remotePath: "/cancelled", state: .cancelled)
|
|
let failed = SFTPTransfer(localPath: "/tmp/failed", remotePath: "/failed", state: .failed("lost"))
|
|
let store = HostLibraryModel(sftpTransfers: [queued, running, paused, completed, cancelled, failed])
|
|
let coordinator = SFTPTransferCoordinator(store: store)
|
|
|
|
for id in [queued.id, running.id, paused.id, UUID()] { coordinator.removeFinished(id) }
|
|
#expect(store.sftpTransfers.count == 6)
|
|
|
|
coordinator.removeFinished(completed.id)
|
|
#expect(store.sftpTransfers.map(\.id) == [queued.id, running.id, paused.id, cancelled.id, failed.id])
|
|
#expect(FileManager.default.fileExists(atPath: localURL.path))
|
|
|
|
coordinator.removeFinished(cancelled.id)
|
|
coordinator.removeFinished(failed.id)
|
|
#expect(store.sftpTransfers.map(\.id) == [queued.id, running.id, paused.id])
|
|
}
|
|
|
|
@Test
|
|
func remotePathsNormalizeWithoutEscapingRoot() {
|
|
#expect(RemotePath.normalized(" /srv//releases/../current/ ") == "/srv/current")
|
|
#expect(RemotePath.normalized("../../etc") == "/etc")
|
|
#expect(RemotePath.parent(of: "/") == "/")
|
|
#expect(RemotePath.parent(of: "/var/log") == "/var")
|
|
#expect(RemotePath.join("/", "tmp") == "/tmp")
|
|
#expect(
|
|
RemotePath.components(of: "/var/log/nginx").map(\.path) == [
|
|
"/", "/var", "/var/log", "/var/log/nginx",
|
|
])
|
|
#expect(RemotePath.displayed("/home/deploy", home: .resolved("/home/deploy")) == "~")
|
|
#expect(RemotePath.displayed("/home/deploy/app", home: .resolved("/home/deploy")) == "~/app")
|
|
#expect(RemotePath.displayed("/home/deployer", home: .resolved("/home/deploy")) == "/home/deployer")
|
|
#expect(RemotePath.displayed("/var/log", home: .unavailable) == "/var/log")
|
|
#expect(RemotePath.displayed("/var/log", home: .resolved("/")) == "~/var/log")
|
|
#expect(
|
|
RemotePath.displayedComponents(
|
|
of: "/home/deploy/app/logs", home: .resolved("/home/deploy")
|
|
).map(\.name) == ["~", "app", "logs"]
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func sftpDirectorySortingKeepsFoldersFirstAndUsesStableNames() async {
|
|
let older = Date(timeIntervalSinceReferenceDate: 100)
|
|
let newer = Date(timeIntervalSinceReferenceDate: 200)
|
|
let folderZ = remoteFile(path: "/home/z-folder", kind: .directory, modifiedAt: older)
|
|
let folderA = remoteFile(path: "/home/a-folder", kind: .directory, modifiedAt: newer)
|
|
let missingDate = remoteFile(path: "/home/undated.log", kind: .file, size: 50)
|
|
let small = remoteFile(
|
|
path: "/home/small.txt", kind: .file, size: 10, modifiedAt: older
|
|
)
|
|
let large = remoteFile(
|
|
path: "/home/large.txt", kind: .file, size: 100, modifiedAt: newer
|
|
)
|
|
let client = SnapshotSFTPClient(
|
|
responses: [.success([folderZ, missingDate, large, folderA, small])]
|
|
)
|
|
let channel = SFTPChannelModel(hostID: UUID(), initialPath: "/home", client: client)
|
|
|
|
await channel.reload(silently: true)
|
|
#expect(
|
|
channel.filteredFiles.map(\.name) == [
|
|
"a-folder", "z-folder", "large.txt", "small.txt", "undated.log",
|
|
])
|
|
|
|
channel.sort(by: .modified)
|
|
#expect(channel.sortDirection == .descending)
|
|
#expect(
|
|
channel.filteredFiles.map(\.name) == [
|
|
"a-folder", "z-folder", "large.txt", "small.txt", "undated.log",
|
|
])
|
|
|
|
channel.sort(by: .size)
|
|
#expect(channel.sortDirection == .descending)
|
|
#expect(
|
|
channel.filteredFiles.map(\.name) == [
|
|
"a-folder", "z-folder", "large.txt", "undated.log", "small.txt",
|
|
])
|
|
|
|
channel.sort(by: .kind)
|
|
#expect(channel.sortDirection == .ascending)
|
|
#expect(
|
|
channel.filteredFiles.map(\.name) == [
|
|
"a-folder", "z-folder", "undated.log", "large.txt", "small.txt",
|
|
])
|
|
}
|
|
|
|
/// The visible list is cached rather than recomputed on every read, so the
|
|
/// thing worth pinning is that each of its three inputs invalidates it —
|
|
/// and that selection, which is not one of them, does not.
|
|
@Test
|
|
func sftpVisibleRowsFollowFilterAndSortButNotSelection() async {
|
|
let notes = remoteFile(path: "/home/notes.txt", kind: .file, size: 10)
|
|
let report = remoteFile(path: "/home/report.txt", kind: .file, size: 100)
|
|
let archive = remoteFile(path: "/home/archive", kind: .directory)
|
|
let client = SnapshotSFTPClient(responses: [.success([report, notes, archive])])
|
|
let channel = SFTPChannelModel(hostID: UUID(), initialPath: "/home", client: client)
|
|
|
|
await channel.reload(silently: true)
|
|
#expect(channel.visibleRows.map(\.file.name) == ["archive", "notes.txt", "report.txt"])
|
|
#expect(channel.visibleRows.map(\.sizeText) == ["", "10 bytes", "100 bytes"])
|
|
|
|
channel.selection = [notes.id, report.id]
|
|
#expect(channel.visibleRows.map(\.file.name) == ["archive", "notes.txt", "report.txt"])
|
|
#expect(channel.selectedFiles == [notes, report])
|
|
|
|
channel.query = "report"
|
|
#expect(channel.visibleRows.map(\.file.name) == ["report.txt"])
|
|
// The filter dropped a selected row, so the selection follows it out.
|
|
#expect(channel.selectedFiles == [report])
|
|
|
|
channel.sort(by: .size)
|
|
#expect(channel.sortDirection == .descending)
|
|
#expect(channel.visibleRows.map(\.file.name) == ["report.txt"])
|
|
|
|
channel.query = ""
|
|
#expect(channel.visibleRows.map(\.file.name) == ["archive", "report.txt", "notes.txt"])
|
|
}
|
|
|
|
@Test
|
|
func sftpHiddenFilesAreExcludedByDefaultAndReconciledWithSelection() async {
|
|
let hiddenFolder = remoteFile(path: "/home/.git", kind: .directory)
|
|
let hiddenFile = remoteFile(path: "/home/.env", kind: .file)
|
|
let visibleFile = remoteFile(path: "/home/readme.md", kind: .file)
|
|
let client = SnapshotSFTPClient(
|
|
responses: [.success([visibleFile, hiddenFile, hiddenFolder])]
|
|
)
|
|
let channel = SFTPChannelModel(hostID: UUID(), initialPath: "/home", client: client)
|
|
|
|
await channel.reload(silently: true)
|
|
#expect(channel.visibleRows.map(\.file.name) == ["readme.md"])
|
|
|
|
channel.showsHiddenFiles = true
|
|
#expect(channel.visibleRows.map(\.file.name) == [".git", ".env", "readme.md"])
|
|
|
|
channel.selection = [hiddenFile.id]
|
|
channel.showsHiddenFiles = false
|
|
#expect(channel.visibleRows.map(\.file.name) == ["readme.md"])
|
|
#expect(channel.selection.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func crossHostCopyBelongsToBothHostsAndDrivesBothStatusDots() {
|
|
let source = Host(name: "Source", host: "source.example")
|
|
let destination = Host(name: "Destination", host: "destination.example")
|
|
let sftpTransfer = SFTPTransfer(
|
|
hostID: destination.id,
|
|
sourceHostID: source.id,
|
|
destinationHostID: destination.id,
|
|
direction: .copy,
|
|
localPath: "/source/README.md",
|
|
remotePath: "/destination/README.md",
|
|
state: .running
|
|
)
|
|
let store = HostLibraryModel(
|
|
hosts: [source, destination], sftpTransfers: [sftpTransfer]
|
|
)
|
|
|
|
#expect(store.sftpTransfers(for: source.id).map(\.id) == [sftpTransfer.id])
|
|
#expect(store.sftpTransfers(for: destination.id).map(\.id) == [sftpTransfer.id])
|
|
#expect(store.status(of: source.id) == .connected)
|
|
#expect(store.status(of: destination.id) == .connected)
|
|
}
|
|
|
|
@Test
|
|
func keepBothNamesPreserveExtensions() {
|
|
#expect(RemotePath.keepBothCandidate(for: "/tmp/archive.tar", index: 2) == "/tmp/archive.2.tar")
|
|
#expect(RemotePath.keepBothCandidate(for: "/tmp/folder", index: 3) == "/tmp/folder.3")
|
|
}
|
|
|
|
@Test
|
|
func remoteFileExposesUnixPermissions() {
|
|
let file = RemoteFile(
|
|
path: "/tmp/tool", name: "tool", kind: .file, size: 12,
|
|
modifiedAt: nil, permissions: 0o100754, ownerUID: 501, ownerGID: 20
|
|
)
|
|
#expect(file.permissionDescription == "-rwxr-xr--")
|
|
#expect(file.permissionMode == 0o754)
|
|
#expect(file.octalPermissionDescription == "0754")
|
|
#expect(file.id == "/tmp/tool")
|
|
}
|
|
|
|
@Test
|
|
func permissionDraftValidatesAndNormalizesOctalInput() {
|
|
var draft = SFTPPermissionDraft(permissions: 0o1004755)
|
|
#expect(draft.text == "4755")
|
|
#expect(draft.mode == 0o4755)
|
|
#expect(!draft.canSave)
|
|
|
|
let rejectedInvalidDigits = draft.accept("4888")
|
|
#expect(!rejectedInvalidDigits)
|
|
#expect(draft.text == "4755")
|
|
let acceptedEmptyDraft = draft.accept("")
|
|
#expect(acceptedEmptyDraft)
|
|
#expect(draft.parsedMode == nil)
|
|
#expect(!draft.canSave)
|
|
let acceptedShortMode = draft.accept("640")
|
|
#expect(acceptedShortMode)
|
|
#expect(draft.parsedMode == 0o640)
|
|
draft.normalizeText()
|
|
#expect(draft.text == "0640")
|
|
#expect(draft.canSave)
|
|
}
|
|
|
|
@Test
|
|
func permissionDraftSwitchesPreserveSpecialBits() {
|
|
var draft = SFTPPermissionDraft(permissions: 0o4755)
|
|
draft.set(0o020, enabled: true)
|
|
#expect(draft.text == "4775")
|
|
draft.set(0o100, enabled: false)
|
|
#expect(draft.text == "4675")
|
|
#expect(draft.mode & 0o7000 == 0o4000)
|
|
}
|
|
|
|
@Test
|
|
func permissionUpdateWritesModeAndRefreshesDirectory() async {
|
|
let file = remoteFile(path: "/home/tool", kind: .file)
|
|
let client = PermissionSFTPClient(file: file)
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
let saved = await sftpChannel.updatePermissions(for: file, to: 0o104755)
|
|
|
|
#expect(saved)
|
|
#expect(await client.lastPermissionUpdate == .init(path: "/home/tool", mode: 0o4755))
|
|
#expect(sftpChannel.files.first?.permissionMode == 0o4755)
|
|
#expect(sftpChannel.operationError == nil)
|
|
}
|
|
|
|
@Test
|
|
func permissionUpdateFailureKeepsEditorOpenThroughFalseResult() async {
|
|
let file = remoteFile(path: "/home/tool", kind: .file)
|
|
let client = PermissionSFTPClient(file: file, rejectsUpdate: true)
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
let saved = await sftpChannel.updatePermissions(for: file, to: 0o755)
|
|
|
|
#expect(!saved)
|
|
#expect(sftpChannel.files.isEmpty)
|
|
#expect(sftpChannel.operationError == "Permission denied")
|
|
}
|
|
|
|
@Test
|
|
func hostDefaultsToRemoteHomeForSFTPChannel() {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
#expect(host.sftpChannelInitialPath.isEmpty)
|
|
let pane = Pane.sftpChannel(host: host)
|
|
guard case .sftpChannel(let sftpChannel) = pane.content else {
|
|
Issue.record("Expected SFTP channel pane")
|
|
return
|
|
}
|
|
#expect(sftpChannel.path == "/")
|
|
}
|
|
|
|
@Test
|
|
func applicationTerminationClosesRegisteredWindows() {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let window = WindowModel()
|
|
app.registerWindow(window)
|
|
|
|
app.shutdownForTermination()
|
|
|
|
#expect(window.workspaces.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func emptyInitialPathResolvesRemoteHomeOnlyOnce() async throws {
|
|
let client = ManifestSFTPClient(entries: [:], home: "/home/deploy/")
|
|
let sftpChannel = SFTPChannelModel(hostID: UUID(), initialPath: "")
|
|
|
|
try await sftpChannel.resolveInitialPathIfNeeded(using: client)
|
|
try await sftpChannel.resolveInitialPathIfNeeded(using: client)
|
|
|
|
#expect(sftpChannel.path == "/home/deploy")
|
|
#expect(sftpChannel.pathText == "/home/deploy")
|
|
#expect(await client.homeDirectoryRequestCount == 1)
|
|
#expect(sftpChannel.displayedPath == "~")
|
|
}
|
|
|
|
@Test
|
|
func configuredInitialPathStillResolvesHomeWithoutChangingNavigation() async throws {
|
|
let client = ManifestSFTPClient(entries: [:], home: "/home/deploy")
|
|
let channel = SFTPChannelModel(hostID: UUID(), initialPath: "/home/deploy/project")
|
|
|
|
try await channel.resolveInitialPathIfNeeded(using: client)
|
|
|
|
#expect(channel.path == "/home/deploy/project")
|
|
#expect(channel.displayedPath == "~/project")
|
|
#expect(await client.homeDirectoryRequestCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func configuredInitialPathFallsBackToAbsoluteWhenHomeIsUnavailable() async throws {
|
|
let client = ManifestSFTPClient(entries: [:], rejectsHome: true)
|
|
let channel = SFTPChannelModel(hostID: UUID(), initialPath: "/srv/project")
|
|
|
|
try await channel.resolveInitialPathIfNeeded(using: client)
|
|
|
|
#expect(channel.homeResolution == .unavailable)
|
|
#expect(channel.displayedPath == "/srv/project")
|
|
}
|
|
|
|
@Test
|
|
func silentRefreshCommitsOnlySuccessfulSnapshots() async {
|
|
let initial = remoteFile(path: "/home/old.txt", kind: .file)
|
|
let updated = remoteFile(path: "/home/new.txt", kind: .file)
|
|
let client = SnapshotSFTPClient(responses: [.success([initial]), .failure, .success([updated])])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
await sftpChannel.reload(silently: true)
|
|
#expect(sftpChannel.files == [initial])
|
|
await sftpChannel.reload(silently: true)
|
|
#expect(sftpChannel.files == [initial])
|
|
#expect(sftpChannel.operationError == nil)
|
|
await sftpChannel.reload(silently: true)
|
|
#expect(sftpChannel.files == [updated])
|
|
}
|
|
|
|
@Test
|
|
func directoryRefreshReconcilesRowsAndSelection() async {
|
|
let retained = remoteFile(path: "/home/retained.txt", kind: .file)
|
|
let removed = remoteFile(path: "/home/removed.txt", kind: .file)
|
|
let client = SnapshotSFTPClient(
|
|
responses: [.success([retained, removed]), .success([retained])]
|
|
)
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
await sftpChannel.reload(silently: true)
|
|
sftpChannel.selection = [retained.id, removed.id]
|
|
await sftpChannel.reload(silently: true)
|
|
|
|
#expect(sftpChannel.files == [retained])
|
|
#expect(sftpChannel.selection == [retained.id])
|
|
}
|
|
|
|
@Test
|
|
func filteringPrunesSelectionThatIsNotRepresentedByTheTable() async {
|
|
let visible = remoteFile(path: "/home/visible.txt", kind: .file)
|
|
let hidden = remoteFile(path: "/home/hidden.log", kind: .file)
|
|
let client = SnapshotSFTPClient(responses: [.success([visible, hidden])])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
await sftpChannel.reload(silently: true)
|
|
sftpChannel.selection = [hidden.id]
|
|
sftpChannel.query = "visible"
|
|
|
|
#expect(sftpChannel.filteredFiles == [visible])
|
|
#expect(sftpChannel.selection.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func automaticRefreshRepeatsUntilDisconnect() async {
|
|
let client = SnapshotSFTPClient(responses: [.success([])])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration,
|
|
automaticRefreshInterval: .milliseconds(10)
|
|
)
|
|
|
|
sftpChannel.startAutomaticRefresh()
|
|
while await client.listRequestCount < 2 { await Task.yield() }
|
|
sftpChannel.disconnect()
|
|
let countAfterDisconnect = await client.listRequestCount
|
|
try? await Task.sleep(for: .milliseconds(30))
|
|
|
|
#expect(await client.listRequestCount == countAfterDisconnect)
|
|
}
|
|
|
|
@Test
|
|
func connectionLossStartsReconnectWithoutClearingDirectorySnapshot() async {
|
|
let host = Host(name: "Files", host: "files.example")
|
|
let existing = remoteFile(path: "/home/existing.txt", kind: .file)
|
|
let client = SnapshotSFTPClient(
|
|
responses: [.success([existing]), .connectionLost]
|
|
)
|
|
let store = HostLibraryModel(hosts: [host])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: host.id, initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
sftpChannel.bindStatus(to: store)
|
|
|
|
await sftpChannel.reload()
|
|
await sftpChannel.reload(silently: true)
|
|
|
|
#expect(sftpChannel.status == .reconnecting)
|
|
#expect(sftpChannel.reconnectState?.attempt == 1)
|
|
#expect(sftpChannel.connectionJourney.mode == .reconnection)
|
|
#expect(sftpChannel.connectionJourney.isPresented)
|
|
#expect(sftpChannel.files == [existing])
|
|
#expect(sftpChannel.path == "/home")
|
|
sftpChannel.disconnect()
|
|
}
|
|
|
|
@Test
|
|
func ordinarySFTPFailureDoesNotStartReconnect() async {
|
|
let host = Host(name: "Files", host: "files.example")
|
|
let client = SnapshotSFTPClient(responses: [.failure])
|
|
let store = HostLibraryModel(hosts: [host])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: host.id, initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
sftpChannel.bindStatus(to: store)
|
|
|
|
await sftpChannel.reload()
|
|
|
|
#expect(sftpChannel.status == .connected)
|
|
#expect(sftpChannel.reconnectState == nil)
|
|
#expect(sftpChannel.operationError == "Refresh failed")
|
|
sftpChannel.disconnect()
|
|
}
|
|
|
|
@Test
|
|
func successfulUploadRefreshesTheVisibleDirectory() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let localFile = root.appendingPathComponent("report.txt")
|
|
try Data("termind".utf8).write(to: localFile)
|
|
let uploaded = remoteFile(path: "/home/report.txt", kind: .file)
|
|
let client = SnapshotSFTPClient(responses: [.success([uploaded])])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await sftpChannel.prepareUpload([localFile])
|
|
await sftpChannel.enqueuePending(policy: .replace, coordinator: coordinator)
|
|
for _ in 0..<100 where sftpChannel.files.isEmpty {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(sftpChannel.files == [uploaded])
|
|
}
|
|
|
|
@Test
|
|
func coordinatorHonorsGlobalAndPerHostLimits() async {
|
|
let hostA = UUID()
|
|
let hostB = UUID()
|
|
let store = HostLibraryModel()
|
|
let gate = SFTPTransferClientGate()
|
|
let coordinator = SFTPTransferCoordinator(
|
|
store: store, clientFactory: { FakeSFTPTransferClient(gate: gate) }, globalLimit: 3, perHostLimit: 2)
|
|
let root = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
for index in 0..<5 {
|
|
let isA = index < 4
|
|
let configuration = SSHConnectionConfiguration(
|
|
hostID: isA ? hostA : hostB, host: isA ? "a" : "b",
|
|
port: 22, username: "user", credentials: [],
|
|
expectedHostKeys: [
|
|
SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "fingerprint")
|
|
]
|
|
)
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(),
|
|
localURL: root.appendingPathComponent("file-\(index)"),
|
|
remotePath: "/file-\(index)", byteCount: 1,
|
|
hostID: isA ? hostA : hostB, configuration: configuration,
|
|
scopedRoot: root, overwrite: true
|
|
)
|
|
}
|
|
await gate.waitForStarts(3)
|
|
#expect(await gate.maximumActive == 3)
|
|
#expect(await gate.maximumActive(for: "a") == 2)
|
|
await gate.releaseAll()
|
|
}
|
|
|
|
@Test(arguments: [(15, 5), (100, 50)])
|
|
func configuredLimitsBoundConcurrentFilesAcrossHosts(_ limits: (Int, Int)) async throws {
|
|
let gate = SFTPTransferClientGate()
|
|
let coordinator = SFTPTransferCoordinator(
|
|
store: HostLibraryModel(), clientFactory: { FakeSFTPTransferClient(gate: gate) },
|
|
globalLimit: limits.0, perHostLimit: limits.1)
|
|
let root = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
for index in 0..<4 {
|
|
let hostID = UUID()
|
|
for file in 0...limits.1 {
|
|
coordinator.enqueue(
|
|
direction: .upload, batchID: UUID(), localURL: root.appending(path: "file-\(index)-\(file)"),
|
|
remotePath: "/file-\(file)", byteCount: 1, hostID: hostID,
|
|
configuration: makeTestConfiguration(hostID: hostID, host: "host-\(index)"),
|
|
scopedRoot: root, overwrite: true)
|
|
}
|
|
}
|
|
await gate.waitForStarts(limits.0)
|
|
#expect(await gate.maximumActive == limits.0)
|
|
#expect(await gate.maximumActive(for: "host-0") == limits.1)
|
|
for _ in 0..<500 {
|
|
await gate.releaseAll()
|
|
// Admission can still be pending between the last active task and its successor.
|
|
if coordinator.sftpTransfers.allSatisfy({ $0.state.isFinished }) { break }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
#expect(coordinator.sftpTransfers.allSatisfy { $0.state == .completed })
|
|
#expect(await gate.maximumActive == limits.0)
|
|
for index in 0..<4 { #expect(await gate.maximumActive(for: "host-\(index)") <= limits.1) }
|
|
}
|
|
|
|
@Test
|
|
func contextSerializesPrimaryChannelAndClosesItAfterOwnerRelease() async {
|
|
let hostID = UUID()
|
|
let store = HostLibraryModel()
|
|
let gate = SFTPTransferClientGate()
|
|
let client = PrimarySFTPTransferClient(gate: gate)
|
|
let context = SFTPTransferContext(
|
|
snapshot: SSHAdditionalConnectionSnapshot(
|
|
configuration: makeTestConfiguration(hostID: hostID, host: "fixture.invalid")),
|
|
channel: client, primaryRole: .ptyConnectionFallback)
|
|
let coordinator = SFTPTransferCoordinator(
|
|
store: store, globalLimit: 4, perHostLimit: 4
|
|
)
|
|
let root = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
|
|
for index in 0..<2 {
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(),
|
|
localURL: root.appendingPathComponent("primary-\(index)"),
|
|
remotePath: "/primary-\(index)", byteCount: 1,
|
|
hostID: hostID, connection: .context(context),
|
|
scopedRoot: root, overwrite: true
|
|
)
|
|
}
|
|
|
|
await gate.waitForStarts(1)
|
|
#expect(await gate.maximumActive == 1)
|
|
await gate.releaseNext()
|
|
await gate.waitForStarts(2)
|
|
#expect(await gate.maximumActive == 1)
|
|
await gate.releaseAll()
|
|
|
|
while coordinator.activeCount > 0 { await Task.yield() }
|
|
#expect(await client.connectCount == 0)
|
|
#expect(await client.disconnectCount == 0)
|
|
#expect(coordinator.sftpTransfers.allSatisfy { $0.state == .completed })
|
|
await context.releaseOwner()
|
|
#expect(await client.disconnectCount == 1)
|
|
}
|
|
|
|
/// Raising a limit starts queued work immediately; lowering it never stops
|
|
/// running transfers and only constrains what starts next.
|
|
@Test(arguments: [(1, 2, 2, 2), (3, 1, 4, 3)] as [(Int, Int, Int, Int)])
|
|
func limitChangesStartOrConstrainTheQueueWithoutStoppingActiveWork(
|
|
initialLimit: Int, updatedLimit: Int, transferCount: Int, maximumActive: Int
|
|
) async {
|
|
let gate = SFTPTransferClientGate()
|
|
let coordinator = SFTPTransferCoordinator(
|
|
store: HostLibraryModel(), clientFactory: { FakeSFTPTransferClient(gate: gate) },
|
|
globalLimit: initialLimit, perHostLimit: initialLimit
|
|
)
|
|
let root = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
for index in 0..<transferCount {
|
|
let hostID = UUID()
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(),
|
|
localURL: root.appendingPathComponent("limit-\(index)"),
|
|
remotePath: "/limit-\(index)", byteCount: 1, hostID: hostID,
|
|
configuration: makeTestConfiguration(hostID: hostID, host: "host-\(index)"),
|
|
scopedRoot: root, overwrite: true
|
|
)
|
|
}
|
|
let running = min(initialLimit, transferCount)
|
|
await gate.waitForStarts(running)
|
|
|
|
coordinator.updateLimits(global: updatedLimit, perHost: updatedLimit)
|
|
|
|
if updatedLimit > initialLimit {
|
|
await gate.waitForStarts(maximumActive)
|
|
} else {
|
|
#expect(coordinator.activeCount == running)
|
|
#expect(coordinator.sftpTransfers.filter { $0.state == .queued }.count == transferCount - running)
|
|
await gate.releaseAll()
|
|
await gate.waitForStarts(transferCount)
|
|
}
|
|
#expect(await gate.maximumActive == maximumActive)
|
|
await gate.releaseAll()
|
|
}
|
|
|
|
@Test
|
|
func coordinatorReportsCompletionForOpenAfterDownload() async {
|
|
let store = HostLibraryModel()
|
|
let client = ManifestSFTPClient(entries: [:])
|
|
let coordinator = SFTPTransferCoordinator(store: store) { client }
|
|
let root = FileManager.default.temporaryDirectory
|
|
var completionState: SFTPTransfer.State?
|
|
|
|
coordinator.enqueue(
|
|
direction: .download, batchID: UUID(),
|
|
localURL: root.appendingPathComponent(UUID().uuidString),
|
|
remotePath: "/document.txt", byteCount: 4, hostID: UUID(),
|
|
configuration: testConfiguration, scopedRoot: root, overwrite: false
|
|
) { state in
|
|
completionState = state
|
|
}
|
|
|
|
while coordinator.sftpTransfers.first?.state == .queued
|
|
|| coordinator.sftpTransfers.first?.state == .running
|
|
{
|
|
await Task.yield()
|
|
}
|
|
#expect(completionState == .completed)
|
|
}
|
|
|
|
@Test
|
|
func sshAndSFTPChannelStatusesAggregateForHostDots() {
|
|
let hostID = UUID()
|
|
let sshSessionID = UUID()
|
|
let sftpChannelID = UUID()
|
|
let store = HostLibraryModel()
|
|
|
|
store.setConnectionStatus(.connected, connectionID: sshSessionID, hostID: hostID)
|
|
store.setConnectionStatus(.connecting, connectionID: sftpChannelID, hostID: hostID)
|
|
#expect(store.status(of: hostID) == .connected)
|
|
|
|
store.setConnectionStatus(.disconnected, connectionID: sshSessionID, hostID: hostID)
|
|
#expect(store.status(of: hostID) == .connecting)
|
|
|
|
store.setConnectionStatus(.reconnecting, connectionID: sftpChannelID, hostID: hostID)
|
|
#expect(store.status(of: hostID) == .reconnecting)
|
|
|
|
store.setConnectionStatus(.connected, connectionID: sftpChannelID, hostID: hostID)
|
|
#expect(store.status(of: hostID) == .connected)
|
|
|
|
store.setConnectionStatus(.disconnected, connectionID: sftpChannelID, hostID: hostID)
|
|
#expect(store.status(of: hostID) == .disconnected)
|
|
}
|
|
|
|
@Test
|
|
func sftpChannelPublishesItsStatusToHostStore() {
|
|
let hostID = UUID()
|
|
let store = HostLibraryModel()
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: hostID, initialPath: "/home",
|
|
client: SnapshotSFTPClient(responses: [.success([])]),
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
sftpChannel.bindStatus(to: store)
|
|
#expect(store.status(of: hostID) == .connected)
|
|
|
|
sftpChannel.disconnect()
|
|
#expect(store.status(of: hostID) == .disconnected)
|
|
}
|
|
|
|
@Test
|
|
func multipleSelectionPreservesVisibleOrder() async {
|
|
let first = remoteFile(path: "/home/a.txt", kind: .file)
|
|
let second = remoteFile(path: "/home/b.txt", kind: .file)
|
|
let client = SnapshotSFTPClient(responses: [.success([first, second])])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
await sftpChannel.reload(silently: true)
|
|
sftpChannel.selection = [second.id, first.id]
|
|
|
|
#expect(sftpChannel.selectedFiles == [first, second])
|
|
}
|
|
|
|
@Test(arguments: [".bashrc", "README.md"])
|
|
func finderDragRepresentationPreservesExactBasename(_ name: String) async {
|
|
let client = WritingSFTPClient()
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/home", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
let file = remoteFile(path: "/home/\(name)", kind: .file)
|
|
let provider = SFTPBrowserView.promisedFileProvider(
|
|
for: file, sftpChannel: sftpChannel, coordinator: coordinator
|
|
)
|
|
|
|
let representedName = await withCheckedContinuation { continuation in
|
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, _ in
|
|
continuation.resume(returning: url?.lastPathComponent)
|
|
}
|
|
}
|
|
|
|
#expect(representedName != nil)
|
|
#expect(provider.suggestedName == name)
|
|
}
|
|
|
|
@Test
|
|
func remoteCopyManifestPreservesHierarchyAndEmptyDirectories() async throws {
|
|
let sourceHostID = UUID()
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let empty = remoteFile(path: "/source/bundle/empty", kind: .directory)
|
|
let readme = remoteFile(path: "/source/bundle/README.md", kind: .file, size: 4)
|
|
let client = TreeSFTPClient(listings: [
|
|
bundle.path: [empty, readme],
|
|
empty.path: [],
|
|
])
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
let source = SFTPRemoteCopySource(
|
|
hostID: sourceHostID,
|
|
configuration: makeTestConfiguration(hostID: sourceHostID, host: "source"),
|
|
files: [bundle]
|
|
)
|
|
let payload = coordinator.registerRemoteCopySource(source)
|
|
|
|
let manifest = try await coordinator.remoteManifest(for: payload)
|
|
|
|
#expect(
|
|
manifest.entries.map(\.relativePath) == [
|
|
"bundle", "bundle/empty", "bundle/README.md",
|
|
])
|
|
#expect(
|
|
manifest.entries.filter(\.isDirectory).map(\.relativePath) == [
|
|
"bundle", "bundle/empty",
|
|
])
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func sameHostRemoteCopyRejectsItsSourceAndDescendants(_ intoDescendant: Bool) async throws {
|
|
let hostID = UUID()
|
|
let sourcePath = "/source/bundle"
|
|
let bundle = remoteFile(path: sourcePath, kind: .directory)
|
|
let source = TreeSFTPClient(listings: ["/source": [bundle], sourcePath: []])
|
|
let destinationPath = intoDescendant ? "/source/bundle/nested" : "/source"
|
|
let destination = ManifestSFTPClient(entries: [
|
|
destinationPath: remoteFile(path: destinationPath, kind: .directory)
|
|
])
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { source }
|
|
let payload = coordinator.registerRemoteCopySource(
|
|
SFTPRemoteCopySource(
|
|
hostID: hostID,
|
|
configuration: makeTestConfiguration(hostID: hostID, host: "same-host"),
|
|
files: [bundle]
|
|
))
|
|
let channel = SFTPChannelModel(
|
|
hostID: hostID, initialPath: destinationPath, client: destination,
|
|
configuration: makeTestConfiguration(hostID: hostID, host: "same-host")
|
|
)
|
|
|
|
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
|
|
#expect(channel.operationError == String(localized: "A path cannot be copied into itself."))
|
|
#expect(!channel.hasPendingBatch)
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
#expect(try await source.listDirectory(at: "/source").map(\.path) == [sourcePath])
|
|
}
|
|
|
|
@Test
|
|
func remoteDownloadPlannerPreservesHierarchyAndEmptyDirectories() async throws {
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let empty = remoteFile(path: "/source/bundle/empty", kind: .directory)
|
|
let readme = remoteFile(path: "/source/bundle/README.md", kind: .file, size: 4)
|
|
let client = TreeSFTPClient(listings: [
|
|
bundle.path: [empty, readme],
|
|
empty.path: [],
|
|
])
|
|
let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
|
|
let batch = try await SFTPRemoteDownloadPlanner.prepare(
|
|
[bundle], destination: destination, client: client
|
|
)
|
|
|
|
#expect(batch.transferCount == 1)
|
|
#expect(batch.directoryCount == 2)
|
|
#expect(batch.conflictCount == 0)
|
|
}
|
|
|
|
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth], [false, true])
|
|
func downloadingAFileResolvesExistingFilesAndDirectories(
|
|
policy: TransferConflictPolicy, existingIsDirectory: Bool
|
|
) async throws {
|
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let original = root.appending(path: "report.txt")
|
|
if existingIsDirectory {
|
|
try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true)
|
|
}
|
|
let preserved = existingIsDirectory ? original.appending(path: "keep.txt") : original
|
|
try Data("original".utf8).write(to: preserved)
|
|
let client = WritingSFTPClient()
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/source", client: client, configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await channel.prepareDownload([remoteFile(path: "/source/report.txt", kind: .file)], to: root)
|
|
#expect(channel.conflictCount == 1)
|
|
await channel.enqueuePending(policy: policy, coordinator: coordinator)
|
|
for _ in 0..<200 {
|
|
guard coordinator.sftpTransfers.contains(where: { $0.state == .queued || $0.state == .running }) else {
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(channel.operationError == nil)
|
|
#expect(!channel.hasPendingBatch)
|
|
if policy != .replace { #expect(try Data(contentsOf: preserved) == Data("original".utf8)) }
|
|
if policy == .skip {
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
} else {
|
|
let destination = policy == .keepBoth ? root.appending(path: "report.2.txt") : original
|
|
#expect(coordinator.sftpTransfers.map(\.state) == [.completed])
|
|
#expect(try Data(contentsOf: destination) == Data("termind".utf8))
|
|
}
|
|
}
|
|
|
|
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth])
|
|
func downloadingAConflictingDirectoryAppliesThePolicyToItsWholeSubtree(
|
|
policy: TransferConflictPolicy
|
|
) async throws {
|
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let original = root.appending(path: "bundle")
|
|
try Data("original".utf8).write(to: original)
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let nested = remoteFile(path: "/source/bundle/nested", kind: .directory)
|
|
let file = remoteFile(path: "/source/bundle/nested/payload.txt", kind: .file)
|
|
let sibling = remoteFile(path: "/source/bundle-other", kind: .directory)
|
|
let client = TreeSFTPClient(listings: [bundle.path: [nested], nested.path: [file]])
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/source", client: client, configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await channel.prepareDownload([bundle, sibling], to: root)
|
|
#expect(channel.conflictCount == 1)
|
|
await channel.enqueuePending(policy: policy, coordinator: coordinator)
|
|
|
|
#expect(channel.operationError == nil)
|
|
#expect(!channel.hasPendingBatch)
|
|
#expect(try root.appending(path: "bundle-other").resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true)
|
|
if policy != .replace { #expect(try Data(contentsOf: original) == Data("original".utf8)) }
|
|
if policy == .skip {
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
} else {
|
|
let directory = policy == .keepBoth ? root.appending(path: "bundle.2") : original
|
|
let destination = directory.appending(path: "nested/payload.txt")
|
|
#expect(coordinator.sftpTransfers.map(\.localPath) == [destination.path])
|
|
#expect(coordinator.sftpTransfers.map(\.remotePath) == [file.path])
|
|
#expect(coordinator.sftpTransfers.map(\.direction) == [.download])
|
|
#expect(
|
|
try directory.appending(path: "nested").resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func downloadingOnlyEmptyDirectoriesCreatesThemUnlessTheBatchWasCancelled() async throws {
|
|
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let empty = remoteFile(path: "/source/bundle/empty", kind: .directory)
|
|
let client = TreeSFTPClient(listings: [bundle.path: [empty]])
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/source", client: client, configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await channel.prepareDownload([bundle], to: root)
|
|
#expect(channel.hasPendingBatch)
|
|
channel.cancelPendingBatch()
|
|
await channel.enqueuePending(policy: .skip, coordinator: coordinator)
|
|
#expect(!FileManager.default.fileExists(atPath: root.path))
|
|
|
|
await channel.prepareDownload([bundle], to: root)
|
|
await channel.enqueuePending(policy: .skip, coordinator: coordinator)
|
|
|
|
#expect(channel.operationError == nil)
|
|
#expect(!channel.hasPendingBatch)
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
#expect(try root.appending(path: "bundle/empty").resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true)
|
|
}
|
|
|
|
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth], [RemoteFile.Kind.file, .directory])
|
|
func remoteCopyResolvesExistingFilesAndDirectories(
|
|
policy: TransferConflictPolicy, kind: RemoteFile.Kind
|
|
) async throws {
|
|
let sourceHostID = UUID()
|
|
let targetHostID = UUID()
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let readme = remoteFile(path: "/source/bundle/README.md", kind: .file, size: 4)
|
|
let sourceClient = TreeSFTPClient(listings: [bundle.path: [readme]])
|
|
let targetClient = ManifestSFTPClient(entries: [
|
|
"/incoming/bundle/README.md": remoteFile(
|
|
path: "/incoming/bundle/README.md", kind: kind, size: 3
|
|
)
|
|
])
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { sourceClient }
|
|
let payload = coordinator.registerRemoteCopySource(
|
|
SFTPRemoteCopySource(
|
|
hostID: sourceHostID,
|
|
configuration: makeTestConfiguration(hostID: sourceHostID, host: "source"),
|
|
files: [bundle]
|
|
))
|
|
let target = SFTPChannelModel(
|
|
hostID: targetHostID, initialPath: "/incoming", client: targetClient,
|
|
configuration: makeTestConfiguration(hostID: targetHostID, host: "target")
|
|
)
|
|
|
|
await target.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
|
|
#expect(target.pendingRemoteCopyBatch?.directoryCount == 1)
|
|
#expect(target.pendingRemoteCopyBatch?.transferCount == 1)
|
|
#expect(target.conflictCount == 1)
|
|
await target.enqueuePendingRemoteCopy(policy: policy, coordinator: coordinator)
|
|
#expect(!target.hasPendingBatch)
|
|
#expect(target.operationError == nil)
|
|
let name = policy == .keepBoth ? "README.2.md" : "README.md"
|
|
#expect(coordinator.sftpTransfers.map(\.remotePath) == (policy == .skip ? [] : ["/incoming/bundle/\(name)"]))
|
|
#expect(coordinator.sftpTransfers.allSatisfy { $0.direction == .copy && $0.localPath == readme.path })
|
|
let existing = try await targetClient.attributes(at: "/incoming/bundle/README.md")
|
|
#expect(existing?.kind == (policy == .replace && kind == .directory ? nil : kind))
|
|
}
|
|
|
|
@Test(arguments: [TransferConflictPolicy.skip, .replace, .keepBoth])
|
|
func remoteCopyDirectoryConflictsApplyToTheWholeSubtree(policy: TransferConflictPolicy) async throws {
|
|
let bundle = remoteFile(path: "/source/bundle", kind: .directory)
|
|
let nested = remoteFile(path: "/source/bundle/nested", kind: .directory)
|
|
let file = remoteFile(path: "/source/bundle/nested/payload.txt", kind: .file)
|
|
let sibling = remoteFile(path: "/source/bundle-other", kind: .directory)
|
|
let source = TreeSFTPClient(listings: [bundle.path: [nested], nested.path: [file]])
|
|
let destination = ManifestSFTPClient(
|
|
entries: ["/incoming/bundle": remoteFile(path: "/incoming/bundle", kind: .file)],
|
|
rejectedAttributePrefix: policy == .replace ? nil : "/incoming/bundle/"
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { source }
|
|
let payload = coordinator.registerRemoteCopySource(
|
|
SFTPRemoteCopySource(hostID: UUID(), configuration: testConfiguration, files: [bundle, sibling]))
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/incoming", client: destination, configuration: testConfiguration
|
|
)
|
|
|
|
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
#expect(channel.conflictCount == 1)
|
|
await channel.enqueuePendingRemoteCopy(policy: policy, coordinator: coordinator)
|
|
|
|
#expect(channel.operationError == nil)
|
|
#expect(!channel.hasPendingBatch)
|
|
#expect(await destination.rejectedAttributeRequestCount == 0)
|
|
#expect(try await destination.attributes(at: "/incoming/bundle-other")?.isDirectory == true)
|
|
let directory = policy == .keepBoth ? "/incoming/bundle.2" : "/incoming/bundle"
|
|
#expect(
|
|
coordinator.sftpTransfers.map(\.remotePath) == (policy == .skip ? [] : [directory + "/nested/payload.txt"]))
|
|
if policy != .replace { #expect(try await destination.attributes(at: "/incoming/bundle")?.kind == .file) }
|
|
}
|
|
|
|
@Test
|
|
func remoteCopyOfEmptyDirectoriesRefreshesTheBrowserAndHonorsPendingCancellation() async throws {
|
|
let empty = remoteFile(path: "/source/empty", kind: .directory)
|
|
let source = TreeSFTPClient(listings: [:])
|
|
let destination = ManifestSFTPClient(entries: [:])
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { source }
|
|
let payload = coordinator.registerRemoteCopySource(
|
|
SFTPRemoteCopySource(hostID: UUID(), configuration: testConfiguration, files: [empty]))
|
|
let channel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/incoming", client: destination, configuration: testConfiguration
|
|
)
|
|
|
|
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
channel.cancelPendingBatch()
|
|
await channel.enqueuePendingRemoteCopy(policy: .replace, coordinator: coordinator)
|
|
#expect(await destination.createdDirectories.isEmpty)
|
|
|
|
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
|
|
await channel.enqueuePendingRemoteCopy(policy: .replace, coordinator: coordinator)
|
|
for _ in 0..<200 {
|
|
if !channel.files.isEmpty { break }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
#expect(channel.files.map(\.path) == ["/incoming/empty"])
|
|
#expect(channel.operationError == nil)
|
|
#expect(!channel.hasPendingBatch)
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func remoteCopyStreamsThroughSocketWithoutTemporaryFile() async throws {
|
|
let sourceHostID = UUID()
|
|
let targetHostID = UUID()
|
|
let state = RemoteCopyTrackingState()
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) {
|
|
RemoteCopyTrackingClient(state: state)
|
|
}
|
|
coordinator.enqueueRemoteCopy(
|
|
batchID: UUID(),
|
|
sourcePath: "/source/README.md", sourceHostID: sourceHostID,
|
|
sourceConfiguration: makeTestConfiguration(hostID: sourceHostID, host: "source"),
|
|
destinationPath: "/target/README.md", destinationHostID: targetHostID,
|
|
destinationConfiguration: makeTestConfiguration(hostID: targetHostID, host: "target"),
|
|
byteCount: 4, overwrite: false
|
|
)
|
|
|
|
for _ in 0..<200 {
|
|
if coordinator.sftpTransfers.count == 1,
|
|
coordinator.sftpTransfers.allSatisfy({ $0.state == .completed })
|
|
{
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(await state.events == ["copy:source:/source/README.md->target:/target/README.md"])
|
|
#expect(coordinator.sftpTransfers.map(\.direction) == [.copy])
|
|
}
|
|
|
|
@Test
|
|
func uploadPreflightCountsATopLevelFileConflictOnce() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let localFile = root.appendingPathComponent("report.txt")
|
|
try Data("termind".utf8).write(to: localFile)
|
|
|
|
let client = ManifestSFTPClient(entries: [
|
|
"/incoming/report.txt": remoteFile(path: "/incoming/report.txt", kind: .file)
|
|
])
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/incoming", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
|
|
await sftpChannel.prepareUpload([localFile])
|
|
|
|
#expect(sftpChannel.pendingUploadBatch?.conflictCount == 1)
|
|
#expect(sftpChannel.pendingUploadBatch?.transferCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func skippingAConflictingUploadDirectorySkipsItsWholeSubtree() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let upload = root.appendingPathComponent("bundle", isDirectory: true)
|
|
let nested = upload.appendingPathComponent("nested", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try Data("termind".utf8).write(to: nested.appendingPathComponent("payload.txt"))
|
|
|
|
let client = ManifestSFTPClient(
|
|
entries: ["/incoming/bundle": remoteFile(path: "/incoming/bundle", kind: .file)],
|
|
rejectedAttributePrefix: "/incoming/bundle/"
|
|
)
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/incoming", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await sftpChannel.prepareUpload([upload])
|
|
#expect(sftpChannel.pendingUploadBatch?.conflictCount == 1)
|
|
#expect(sftpChannel.operationError == nil)
|
|
|
|
await sftpChannel.enqueuePending(policy: .skip, coordinator: coordinator)
|
|
|
|
#expect(coordinator.sftpTransfers.isEmpty)
|
|
#expect(await client.createdDirectories.isEmpty)
|
|
#expect(await client.rejectedAttributeRequestCount == 0)
|
|
}
|
|
|
|
@Test
|
|
func keepingBothForAConflictingUploadDirectoryRemapsItsWholeSubtree() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let upload = root.appendingPathComponent("bundle", isDirectory: true)
|
|
let nested = upload.appendingPathComponent("nested", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try Data("termind".utf8).write(to: nested.appendingPathComponent("payload.txt"))
|
|
|
|
let client = ManifestSFTPClient(
|
|
entries: ["/incoming/bundle": remoteFile(path: "/incoming/bundle", kind: .file)],
|
|
rejectedAttributePrefix: "/incoming/bundle/"
|
|
)
|
|
let sftpChannel = SFTPChannelModel(
|
|
hostID: UUID(), initialPath: "/incoming", client: client,
|
|
configuration: testConfiguration
|
|
)
|
|
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
|
|
|
|
await sftpChannel.prepareUpload([upload])
|
|
var uploadedPaths: [[String]] = []
|
|
await sftpChannel.enqueuePending(
|
|
policy: .keepBoth, coordinator: coordinator,
|
|
uploadedPaths: { uploadedPaths.append($0) })
|
|
for _ in 0..<200 {
|
|
if !uploadedPaths.isEmpty { break }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
#expect(uploadedPaths == [["/incoming/bundle.2"]])
|
|
|
|
#expect(coordinator.sftpTransfers.map(\.remotePath) == ["/incoming/bundle.2/nested/payload.txt"])
|
|
#expect(
|
|
await client.createdDirectories == [
|
|
"/incoming/bundle.2", "/incoming/bundle.2/nested",
|
|
])
|
|
#expect(await client.rejectedAttributeRequestCount == 0)
|
|
}
|
|
}
|
|
|
|
private let testConfiguration = makeTestConfiguration(hostID: UUID(), host: "example.test")
|
|
|
|
private func makeTestConfiguration(hostID: UUID, host: String) -> SSHConnectionConfiguration {
|
|
SSHConnectionConfiguration(
|
|
hostID: hostID, host: host, port: 22, username: "user", credentials: [],
|
|
expectedHostKeys: [
|
|
SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "fingerprint")
|
|
]
|
|
)
|
|
}
|
|
|
|
private nonisolated func remoteFile(
|
|
path: String, kind: RemoteFile.Kind, size: Int64 = 0, modifiedAt: Date? = nil
|
|
) -> RemoteFile {
|
|
RemoteFile(
|
|
path: path, name: (path as NSString).lastPathComponent, kind: kind,
|
|
size: size, modifiedAt: modifiedAt, permissions: 0o644, ownerUID: 501, ownerGID: 20
|
|
)
|
|
}
|
|
|
|
private actor SFTPTransferClientGate {
|
|
private var active = 0
|
|
private var maximum = 0
|
|
private var starts = 0
|
|
private var activeByHost: [String: Int] = [:]
|
|
private var maximumByHost: [String: Int] = [:]
|
|
private var continuations: [CheckedContinuation<Void, Never>] = []
|
|
private var startWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
|
|
|
var maximumActive: Int { maximum }
|
|
func maximumActive(for host: String) -> Int { maximumByHost[host, default: 0] }
|
|
|
|
func enter(host: String) async {
|
|
active += 1
|
|
activeByHost[host, default: 0] += 1
|
|
starts += 1
|
|
let ready = startWaiters.filter { starts >= $0.0 }
|
|
startWaiters.removeAll { starts >= $0.0 }
|
|
for (_, waiter) in ready { waiter.resume() }
|
|
maximum = max(maximum, active)
|
|
maximumByHost[host] = max(maximumByHost[host, default: 0], activeByHost[host, default: 0])
|
|
await withCheckedContinuation { continuations.append($0) }
|
|
active -= 1
|
|
activeByHost[host, default: 1] -= 1
|
|
}
|
|
|
|
func waitForStarts(_ count: Int) async {
|
|
guard starts < count else { return }
|
|
await withCheckedContinuation { startWaiters.append((count, $0)) }
|
|
}
|
|
|
|
func releaseAll() {
|
|
let waiting = continuations
|
|
continuations.removeAll()
|
|
for continuation in waiting { continuation.resume() }
|
|
}
|
|
|
|
func releaseNext() {
|
|
guard !continuations.isEmpty else { return }
|
|
continuations.removeFirst().resume()
|
|
}
|
|
}
|
|
|
|
private actor PermissionSFTPClient: SFTPClientProtocol {
|
|
struct PermissionUpdate: Equatable, Sendable {
|
|
var path: String
|
|
var mode: UInt32
|
|
}
|
|
|
|
private var file: RemoteFile
|
|
private let rejectsUpdate: Bool
|
|
private(set) var lastPermissionUpdate: PermissionUpdate?
|
|
|
|
init(file: RemoteFile, rejectsUpdate: Bool = false) {
|
|
self.file = file
|
|
self.rejectsUpdate = rejectsUpdate
|
|
}
|
|
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws {}
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { [file] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { file }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {
|
|
guard !rejectsUpdate else { throw SFTPError.operation("Permission denied") }
|
|
let mode = permissions & 0o7777
|
|
lastPermissionUpdate = PermissionUpdate(path: path, mode: mode)
|
|
file.permissions = (file.permissions & ~UInt32(0o7777)) | mode
|
|
}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
}
|
|
|
|
private actor FakeSFTPTransferClient: SFTPClientProtocol {
|
|
let gate: SFTPTransferClientGate
|
|
private var host = ""
|
|
init(gate: SFTPTransferClientGate) { self.gate = gate }
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws { host = configuration.host }
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String { "/Users/test" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws { await gate.enter(host: host) }
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws { await gate.enter(host: host) }
|
|
}
|
|
|
|
private actor PrimarySFTPTransferClient: SFTPClientProtocol {
|
|
let gate: SFTPTransferClientGate
|
|
private(set) var connectCount = 0
|
|
private(set) var disconnectCount = 0
|
|
|
|
init(gate: SFTPTransferClientGate) { self.gate = gate }
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws { connectCount += 1 }
|
|
func disconnect() async { disconnectCount += 1 }
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws { await gate.enter(host: "primary") }
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws { await gate.enter(host: "primary") }
|
|
}
|
|
|
|
private actor ManifestSFTPClient: SFTPClientProtocol {
|
|
private var entries: [String: RemoteFile]
|
|
private let rejectedAttributePrefix: String?
|
|
private let home: String
|
|
private let rejectsHome: Bool
|
|
private(set) var createdDirectories: [String] = []
|
|
private(set) var rejectedAttributeRequestCount = 0
|
|
private(set) var homeDirectoryRequestCount = 0
|
|
|
|
init(
|
|
entries: [String: RemoteFile], rejectedAttributePrefix: String? = nil,
|
|
home: String = "/Users/test", rejectsHome: Bool = false
|
|
) {
|
|
self.entries = entries
|
|
self.rejectedAttributePrefix = rejectedAttributePrefix
|
|
self.home = home
|
|
self.rejectsHome = rejectsHome
|
|
}
|
|
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws {}
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String {
|
|
homeDirectoryRequestCount += 1
|
|
if rejectsHome { throw SFTPError.operation("Home unavailable") }
|
|
return home
|
|
}
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] {
|
|
entries.values.filter { ($0.path as NSString).deletingLastPathComponent == path }
|
|
}
|
|
func attributes(at path: String) async throws -> RemoteFile? {
|
|
if let rejectedAttributePrefix, path.hasPrefix(rejectedAttributePrefix) {
|
|
rejectedAttributeRequestCount += 1
|
|
throw SFTPError.operation("Unexpected descendant stat: \(path)")
|
|
}
|
|
return entries[path]
|
|
}
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {
|
|
createdDirectories.append(path)
|
|
entries[path] = remoteFile(path: path, kind: .directory)
|
|
}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws { entries[path] = nil }
|
|
func removeDirectory(at path: String) async throws { entries[path] = nil }
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
}
|
|
|
|
private actor SnapshotSFTPClient: SFTPClientProtocol {
|
|
enum Response: Sendable {
|
|
case success([RemoteFile])
|
|
case failure
|
|
case connectionLost
|
|
}
|
|
|
|
private var responses: [Response]
|
|
private(set) var listRequestCount = 0
|
|
|
|
init(responses: [Response]) { self.responses = responses }
|
|
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws {}
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] {
|
|
listRequestCount += 1
|
|
let response = responses.count > 1 ? responses.removeFirst() : responses[0]
|
|
switch response {
|
|
case .success(let files): return files
|
|
case .failure: throw SFTPError.operation("Refresh failed")
|
|
case .connectionLost: throw SFTPError.connectionLost("Network lost")
|
|
}
|
|
}
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
}
|
|
|
|
private actor WritingSFTPClient: SFTPClientProtocol {
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws {}
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {
|
|
try Data("termind".utf8).write(to: localURL)
|
|
}
|
|
}
|
|
|
|
private actor TreeSFTPClient: SFTPClientProtocol {
|
|
let listings: [String: [RemoteFile]]
|
|
|
|
init(listings: [String: [RemoteFile]]) {
|
|
self.listings = listings
|
|
}
|
|
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws {}
|
|
func disconnect() async {}
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { listings[path] ?? [] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {}
|
|
}
|
|
|
|
private actor RemoteCopyTrackingState {
|
|
private(set) var events: [String] = []
|
|
func append(_ event: String) { events.append(event) }
|
|
}
|
|
|
|
private actor RemoteCopyTrackingClient: SFTPClientProtocol {
|
|
let state: RemoteCopyTrackingState
|
|
private var host = ""
|
|
|
|
init(state: RemoteCopyTrackingState) { self.state = state }
|
|
|
|
func connect(
|
|
using configuration: SSHConnectionConfiguration,
|
|
progress: SSHRouteProgressHandler?
|
|
) async throws { host = configuration.host }
|
|
func disconnect() async {}
|
|
func connectedHost() -> String { host }
|
|
func homeDirectory() async throws -> String { "/home" }
|
|
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
|
|
func attributes(at path: String) async throws -> RemoteFile? { nil }
|
|
func setPermissions(at path: String, to permissions: UInt32) async throws {}
|
|
func createDirectory(at path: String) async throws {}
|
|
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
|
|
func removeFile(at path: String) async throws {}
|
|
func removeDirectory(at path: String) async throws {}
|
|
func uploadFile(
|
|
from localURL: URL, to remotePath: String, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {
|
|
_ = try Data(contentsOf: localURL)
|
|
await state.append("upload:\(host):\(remotePath)")
|
|
}
|
|
func downloadFile(
|
|
from remotePath: String, to localURL: URL, overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {
|
|
try FileManager.default.createDirectory(
|
|
at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true
|
|
)
|
|
try Data("termind".utf8).write(to: localURL)
|
|
await state.append("download:\(host):\(remotePath)")
|
|
}
|
|
func copyFile(
|
|
from sourcePath: String,
|
|
to destinationPath: String,
|
|
on destination: any SFTPChannelProtocol,
|
|
byteCount: Int64,
|
|
overwrite: Bool,
|
|
cancellation: TransferCancellationToken,
|
|
progress: @escaping @Sendable (SFTPProgress) -> Void
|
|
) async throws {
|
|
guard let destination = destination as? RemoteCopyTrackingClient else {
|
|
throw SFTPError.operation("Unexpected test destination")
|
|
}
|
|
let destinationHost = await destination.connectedHost()
|
|
await state.append(
|
|
"copy:\(host):\(sourcePath)->\(destinationHost):\(destinationPath)"
|
|
)
|
|
progress(
|
|
SFTPProgress(
|
|
completedBytes: byteCount, totalBytes: byteCount, bytesPerSecond: 0
|
|
))
|
|
}
|
|
}
|