import Foundation import SwiftData import TermindApplication import TermindDomain import TermindInfrastructure import TermindPersistence 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 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, 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 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, completed, cancelled, failed] ) let coordinator = SFTPTransferCoordinator(store: store) coordinator.clearFinished() #expect(store.sftpTransfers.map(\.id) == [queued.id, running.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() if coordinator.activeCount == 0 { 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 borrowedConnectionRunsSeriallyWithoutReconnect() async { let hostID = UUID() let connectionID = UUID() let store = HostLibraryModel() let gate = SFTPTransferClientGate() let client = BorrowedSFTPTransferClient(gate: gate) 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("borrowed-\(index)"), remotePath: "/borrowed-\(index)", byteCount: 1, hostID: hostID, connection: .borrowed(id: connectionID, channel: client), scopedRoot: root, overwrite: true ) } await gate.waitForStarts(1) #expect(await gate.maximumActive == 1) #expect(coordinator.activeCount == 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 }) } @Test func increasingLimitsImmediatelyStartsQueuedTransfers() async { let gate = SFTPTransferClientGate() let coordinator = SFTPTransferCoordinator( store: HostLibraryModel(), clientFactory: { FakeSFTPTransferClient(gate: gate) }, globalLimit: 1, perHostLimit: 1 ) let root = URL(fileURLWithPath: NSTemporaryDirectory()) for index in 0..<2 { 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 ) } await gate.waitForStarts(1) coordinator.updateLimits(global: 2, perHost: 2) await gate.waitForStarts(2) #expect(await gate.maximumActive == 2) await gate.releaseAll() } @Test func decreasingLimitsLeavesActiveTransfersRunningAndConstrainsTheQueue() async { let gate = SFTPTransferClientGate() let coordinator = SFTPTransferCoordinator( store: HostLibraryModel(), clientFactory: { FakeSFTPTransferClient(gate: gate) }, globalLimit: 3, perHostLimit: 3 ) let root = URL(fileURLWithPath: NSTemporaryDirectory()) for index in 0..<4 { let hostID = UUID() coordinator.enqueue( direction: .download, batchID: UUID(), localURL: root.appendingPathComponent("decrease-limit-\(index)"), remotePath: "/decrease-limit-\(index)", byteCount: 1, hostID: hostID, configuration: makeTestConfiguration(hostID: hostID, host: "host-\(index)"), scopedRoot: root, overwrite: true ) } await gate.waitForStarts(3) coordinator.updateLimits(global: 1, perHost: 1) #expect(coordinator.activeCount == 3) #expect(coordinator.sftpTransfers.filter { $0.state == .queued }.count == 1) await gate.releaseAll() await gate.waitForStarts(4) #expect(await gate.maximumActive == 3) 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]) await sftpChannel.enqueuePending(policy: .keepBoth, coordinator: coordinator) #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] = [] private var startWaiters: [(Int, CheckedContinuation)] = [] 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 BorrowedSFTPTransferClient: 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: "borrowed") } func downloadFile( from remotePath: String, to localURL: URL, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { await gate.enter(host: "borrowed") } } 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 )) } }