Files
Termind/TFITests/MobileCommandGeneratorAndUploadTests.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

1481 lines
67 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UniformTypeIdentifiers
import struct TermindDomain.Host
@testable import TermindIOS
@MainActor
private final class MobileCommandGeneratorStub: CommandGenerating {
var command: String
var delay: Duration?
var ignoresCancellation = false
private(set) var didFinish = false
private(set) var contexts: [CommandGeneratorContext] = []
init(command: String, delay: Duration? = nil) {
self.command = command
self.delay = delay
}
func generate(_ context: CommandGeneratorContext) async throws -> String {
contexts.append(context)
if let delay {
if ignoresCancellation {
try? await Task.sleep(for: delay)
} else {
try await Task.sleep(for: delay)
}
}
didFinish = true
return command
}
}
@MainActor
private final class MobileSSHCommandConnectionBox {
var draft = "list files"
private(set) var writes: [Data] = []
private(set) var shellIntegrationPreferences: [Bool] = []
private var exits: [SSHPTYChannel.ExitHandler] = []
private var shellIntegrations: [SSHPTYChannel.ShellIntegrationHandler] = []
var sessionCount: Int { shellIntegrations.count }
var services: MobileWorkspaceConnectionServices {
MobileWorkspaceConnectionServices(
makePTYChannel: { [weak self] output, exit, shellIntegration in
self?.exits.append(exit)
self?.shellIntegrations.append(shellIntegration)
return SSHPTYChannel(
output: output,
exit: exit,
shellIntegration: shellIntegration
)
},
connectSSH: { [weak self] _, _, shellIntegrationEnabled, _, _, _ in
self?.shellIntegrationPreferences.append(shellIntegrationEnabled)
},
connectSFTP: { _, _, _ in throw SFTPError.notConnected },
writeSSH: { [weak self] _, data in
guard let self else { return }
writes.append(data)
if data == Data("\u{1B}[777;1~".utf8) {
shellIntegrations.last?(.draft(draft))
}
}
)
}
func sendShellEvents(
_ events: [SSHShellIntegrationEvent],
session index: Int? = nil
) async {
let index = index ?? shellIntegrations.index(before: shellIntegrations.endIndex)
let handler = shellIntegrations[index]
await Task.detached {
for event in events { handler(event) }
}.value
}
func finishSession(
_ result: Result<Int32, TermindSSHError>,
session index: Int
) async {
let handler = exits[index]
await Task.detached { handler(result) }.value
}
}
@Suite @MainActor
struct MobileCommandGeneratorTests {
@Test func runtimePaneInvalidationClearsDirectoryAndIgnoresUnscopedEvents() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, connectionServices: connections.services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let context = TerminalShellContext(
identity: .init(backend: "test-multiplexer", server: "s", session: "s", pane: "a", shellInstance: "i"),
shell: "bash", directory: "/pane-a", home: "/home/test", promptReady: true, sequence: 1,
capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft])
workspace.receiveShellEvent(
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
#expect(!host.sessionPersistence.enabled)
#expect(workspace.canGenerateFromCurrentDraft)
#expect(workspace.workingDirectory == "/pane-a")
workspace.receiveShellEvent(.runtime(.init(status: .unavailable)))
workspace.receiveWorkingDirectory("/stale")
workspace.receiveShellEvent(.promptReady)
#expect(workspace.workingDirectory == nil)
#expect(workspace.sameDirectoryLaunch == nil)
#expect(!workspace.canGenerateFromCurrentDraft)
}
@Test(arguments: HostShellIntegrationMode.allCases, [false, true])
func integrationUsesConnectionSnapshotUntilManualOrAutomaticReconnect(
mode: HostShellIntegrationMode, global: Bool
) async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = mode
library.upsert(host)
let settings = TerminalSettingsModel()
#expect(settings.set(global, for: \.shellIntegration))
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, terminalSettings: settings, connectionServices: connections.services)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let expected = mode.isEnabled(globalSetting: global)
#expect(connections.shellIntegrationPreferences == [expected])
workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"))
workspace.receiveWorkingDirectory("/srv/app")
#expect(settings.set(!global, for: \.shellIntegration))
host.shellIntegrationMode = expected ? .disabled : .enabled
library.upsert(host)
#expect(workspace.isShellIntegrationEnabled == expected)
#expect(workspace.canGenerateFromCurrentDraft == expected)
#expect((workspace.sameDirectoryLaunch != nil) == expected)
workspace.receiveShellEvent(.commandStarted("pwd"))
workspace.receiveShellEvent(.commandFinished(0, duration: 0.1))
#expect(history.entries().count == (expected ? 1 : 0))
#expect(connections.sessionCount == 1)
workspace.disconnect()
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(connections.shellIntegrationPreferences == [expected, !expected])
host.shellIntegrationMode = .followGlobalSettings
library.replacePersistedSnapshot(VaultSnapshot(hosts: [host], identities: library.identities))
var synced = settings.preferences
synced.shellIntegration = expected
settings.applyRemoteSnapshot(synced)
#expect(workspace.isShellIntegrationEnabled == !expected)
await connections.finishSession(.failure(.socket("test disconnect")), session: 1)
#expect(await eventually(attempts: 500) { connections.sessionCount == 3 && workspace.connected })
#expect(connections.shellIntegrationPreferences == [expected, !expected, expected])
}
@Test func startupSnippetUsesExplicitLaunchOnceThenRefreshesOnReconnect() async throws {
var (host, library) = try commandFixture()
var startup = Snippet(title: "Startup", body: "echo first")
host.startupSnippetID = startup.id
library.upsert(startup)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services,
initialCommand: "echo explicit"
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(connections.writes.isEmpty)
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
startup.body = "echo latest"
library.upsert(startup)
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
await connections.sendShellEvents(
[.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")],
session: 1
)
#expect(
await eventually {
connections.writes == [
Data("echo explicit\r".utf8),
Data("echo latest\r".utf8),
]
}
)
}
@Test func startupSnippetRunsImmediatelyWhenShellIntegrationIsDisabled() async throws {
var (host, library) = try commandFixture()
let startup = Snippet(title: "Startup", body: "export TERMIND_TEST=1")
host.startupSnippetID = startup.id
host.shellIntegrationMode = .disabled
library.upsert(startup)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(
await eventually {
connections.writes == [Data("export TERMIND_TEST=1\r".utf8)]
}
)
}
@Test(arguments: [nil, "", "error: missing file\n 中文 😀\n\n"] as [String?])
func readsTheCurrentDraftAndInsertsWithoutExecuting(_ screen: String?) async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
connections.draft = "show hidden files"
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let systemInfo = RemoteSystemInfo(
distributionID: "debian",
distributionVersion: "13",
kernelName: "Linux",
kernelRelease: "6.12.0",
architecture: "arm64"
)
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: systemInfo, homeDirectory: "/home/test")
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let generator = MobileCommandGeneratorStub(command: "ls -la")
var viewportReads = 0
workspace.generateCommand(using: generator) { source in
#expect(source === workspace)
#expect(connections.writes.isEmpty)
viewportReads += 1
return screen
}
#expect(await eventually { workspace.commandGeneratorState == .inserted(targetCount: 1) })
#expect(
generator.contexts == [
CommandGeneratorContext(
draft: "show hidden files",
shell: "zsh",
cwd: "/srv/app",
remoteSystemInfo: systemInfo,
viewport: screen.map { .init(text: $0) }
)
]
)
#expect(viewportReads == 1)
#expect(connections.writes.first == Data("\u{1B}[777;1~".utf8))
#expect(
connections.writes.last
== Data("\u{1B}[777;2~\u{1B}[200~ls -la\u{1B}[201~".utf8)
)
#expect(connections.writes.last?.contains(0x0A) == false)
#expect(connections.writes.last?.contains(0x0D) == false)
}
@Test func unavailablePromptFailsWithoutReadingOrCallingTheModel() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let generator = MobileCommandGeneratorStub(command: "ls")
workspace.generateCommand(using: generator)
guard case .failed = workspace.commandGeneratorState else {
Issue.record("Expected an unavailable-prompt failure")
return
}
#expect(generator.contexts.isEmpty)
#expect(connections.writes.isEmpty)
}
@Test func sameDirectorySFTPLaunchRequiresLiveShellIntegrationAndWorkingDirectory() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/releases/")
#expect(workspace.sameDirectoryLaunch == nil)
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(
await eventually {
workspace.sameDirectoryLaunch
== MobileWorkspaceDirectoryLaunch(hostID: host.id, kind: .sftp, path: "/srv/releases")
})
workspace.receiveWorkingDirectory(nil)
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func directoryStartupCommandRunsOnceWithoutEnteringHistory() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let command = try #require(RemoteShellCommand.changeDirectory(to: "/srv/it's live"))
let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh, initialPath: "/srv/it's live"))
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.testWorkspaceSession.close(id) }
#expect(await eventually { workspace.connected })
#expect(!connections.writes.contains(Data((command + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.commandStarted(command),
.commandFinished(0, duration: 0.01),
.commandStarted("pwd"),
.commandFinished(0, duration: 0.02),
])
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
#expect(await eventually { workspace.commandHistory.entries().count == 1 })
#expect(workspace.commandHistory.entries().first?.command == "pwd")
#expect(workspace.pendingInitialCommand == nil)
}
@Test func snippetStartupCommandWritesOnceAfterSSHConnect() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let snippet = Snippet(title: "Status", body: "uptime")
let id = try #require(app.testWorkspaceSession.execute(snippet, on: [host.id]).first)
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.testWorkspaceSession.close(id) }
#expect(await eventually { workspace.connected })
#expect(!connections.writes.contains(Data((snippet.body + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(await eventually { connections.writes.contains(Data((snippet.body + "\r").utf8)) })
#expect(workspace.pendingInitialCommand == nil)
}
enum GenerationInterruption: CaseIterable, Sendable {
case input, disconnect, cancel
}
@Test(arguments: GenerationInterruption.allCases)
func interruptionDiscardsLateGeneration(_ interruption: GenerationInterruption) async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/tmp")
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let generator = MobileCommandGeneratorStub(command: "ls", delay: .seconds(30))
generator.ignoresCancellation = true
workspace.generateCommand(using: generator, readViewport: { _ in "original screen" })
#expect(await eventually { workspace.commandGeneratorState == .generating })
switch interruption {
case .input: await workspace.receiveInteractiveTerminalInput(Data("new draft".utf8))
case .disconnect: workspace.disconnect()
case .cancel: workspace.cancelCommandGeneration()
}
#expect(await eventually { generator.didFinish })
#expect(await eventually { workspace.commandGeneratorState == .idle })
#expect(!connections.writes.contains(Data("\u{1B}[777;2~\u{1B}[200~ls\u{1B}[201~".utf8)))
}
@Test func productionCallbacksStayOrderedAndIgnoreAnOldSession() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected && connections.sessionCount == 1 })
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
.commandStarted("pwd"),
])
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
await connections.sendShellEvents([
.commandFinished(0, duration: 0.25)
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
let entry = try #require(history.entries().first)
#expect(entry.command == "pwd")
#expect(entry.exitStatus == 0)
#expect(entry.duration == 0.25)
await connections.finishSession(.success(0), session: 0)
#expect(await eventually { !workspace.connected })
workspace.connect()
#expect(await eventually { workspace.connected && connections.sessionCount == 2 })
workspace.receiveWorkingDirectory("/srv/next")
await connections.sendShellEvents([.commandStarted("stale")], session: 0)
await connections.sendShellEvents(
[
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
], session: 1)
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
#expect(!history.entries().contains { $0.command == "stale" })
}
@Test func disabledIntegrationIsPassedToTheTransportAndRemainsUnavailable() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let settings = TerminalSettingsModel()
#expect(settings.set(false, for: \.shellIntegration))
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
terminalSettings: settings,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
])
#expect(await eventually { connections.shellIntegrationPreferences == [false] })
#expect(!workspace.canGenerateFromCurrentDraft)
workspace.receiveWorkingDirectory("/srv/app")
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func unsupportedShellRemainsUnavailableAfterOrderedPromptEvents() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
let history = workspace.commandHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/app")
await connections.sendShellEvents([
.available(shell: "tcsh", systemInfo: nil, homeDirectory: "/home/test"),
.promptReady,
.commandStarted("echo marker"),
.promptReady,
])
#expect(await eventually { history.entries().count == 1 })
#expect(!workspace.canGenerateFromCurrentDraft)
let generator = MobileCommandGeneratorStub(command: "echo generated")
workspace.generateCommand(using: generator)
guard case .failed = workspace.commandGeneratorState else {
Issue.record("Expected an unsupported-shell failure")
return
}
#expect(generator.contexts.isEmpty)
#expect(connections.writes.isEmpty)
}
private func commandFixture() throws -> (Host, HostLibraryModel) {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
return (host, library)
}
private func eventually(attempts: Int = 100, _ condition: @escaping @MainActor () -> Bool) async -> Bool {
for _ in 0..<attempts {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
}
@Suite(.serialized) @MainActor
struct MobileUploadBatchTests {
@Test func usernameOnlyTerminalUploadRejectsInteractionWithoutOpeningASheet() async throws {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let client = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
#expect(request.configuration.credentials.isEmpty)
let handler = try #require(request.enforcingInteractionPolicy().keyboardInteractive)
_ = try await handler(
SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: host.id, hostname: host.host, port: 22,
username: "user", round: 1, name: "OTP", instruction: "",
prompts: [.init(text: "Code", echo: false)]))
return client
}, writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library, transfers: transfers, connectionServices: services)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
#expect(workspace.uploadTargetProblem == nil)
#expect(workspace.prepareUpload([URL(fileURLWithPath: "/tmp/termind-test-upload")]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.keyboardAuthentication.current == nil)
#expect(transfers.sftpTransfers.isEmpty)
}
@Test(arguments: [UploadFailureStage.connection, .localFile, .preparation, .enqueue])
private func terminalUploadFailuresUseNoticesAndReleaseTheClient(_ stage: UploadFailureStage) async throws {
let client = MobileUploadSFTPClient(failureStage: stage)
let workspace = uploadWorkspace(client: client) {
if stage == .connection { throw URLError(.cannotConnectToHost) }
return client
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: source) }
if stage == .enqueue {
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
} else if stage != .localFile {
try Data("upload".utf8).write(to: source)
}
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
#expect(!workspace.hasPendingTransferConflict)
if stage != .connection { #expect(await client.disconnectCount == 1) }
}
@Test(arguments: [false, true])
func conflictDismissalCancelsOnlyAnUnconsumedRequest(_ confirms: Bool) async throws {
let remote = RemoteFile(
path: "/home/test/report.txt", name: "report.txt", kind: .file, size: 0,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let client = MobileUploadSFTPClient(attributes: [remote.path: remote])
let workspace = uploadWorkspace(client: client, kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
#expect(await eventually { workspace.hasPendingTransferConflict })
let request = try #require(workspace.transferConflictID)
workspace.dismissTransferConflict(request)
if confirms { workspace.enqueuePendingTransfer(.replace) }
#expect(await eventually { !workspace.hasPendingTransferConflict })
if confirms {
#expect(await eventually { workspace.transfers?.sftpTransfers.first?.state == .completed })
#expect(await client.uploadedData[remote.path] == Data("report".utf8))
} else {
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
}
#expect(workspace.prepareUpload([file]))
#expect(await eventually { workspace.hasPendingTransferConflict })
let replacement = try #require(workspace.transferConflictID)
#expect(replacement != request)
workspace.dismissTransferConflict(request)
await withCheckedContinuation { continuation in
DispatchQueue.main.async { continuation.resume() }
}
#expect(workspace.transferConflictID == replacement)
workspace.cancelPendingUpload()
}
@Test func browserUploadFailuresKeepTheirOperationAlert() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client, kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let missing = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
#expect(workspace.prepareUpload([missing]))
#expect(await eventually { workspace.operationError != nil })
#expect(workspace.uploadNotice == nil)
#expect(await client.disconnectCount == 0)
workspace.reportUploadFailure("Provider failed")
#expect(workspace.operationError == "Provider failed")
#expect(workspace.uploadNotice == nil)
}
@Test func partialFileProviderFailureStaysVisibleWhileReadableFilesAreQueued() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data("readable".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let good = NSItemProvider()
good.suggestedName = "good.txt"
good.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(source, false, nil)
return nil
}
let bad = NSItemProvider()
bad.suggestedName = "bad.txt"
bad.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(nil, false, CocoaError(.fileReadNoPermission))
return nil
}
let payload = await MobileDropPayloadLoader.load([good, bad], kinds: [.files], fileMode: .staged)
defer {
if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) }
}
let error = try #require(payload.failures.first)
#expect(workspace.prepareUpload(payload.fileURLs))
workspace.reportUploadFailure(error)
#expect(await eventuallyAsync { await client.disconnectCount == 1 })
#expect(workspace.transfers?.sftpTransfers.count == 1)
#expect(workspace.uploadNotice == .rejected(error))
#expect(workspace.operationError == nil)
// A new batch replaces the partial-failure notice and restores the ordinary success timer.
#expect(workspace.prepareUpload([source]))
#expect(workspace.uploadNotice == .preparing("/srv/current"))
#expect(await eventually { workspace.uploadNotice == .queued(1, "/srv/current") })
try await Task.sleep(for: .milliseconds(2300))
#expect(workspace.uploadNotice == nil)
}
@Test func repeatedFailuresRestartTheThreeSecondDismissalTimer() async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient())
defer { workspace.disconnect() }
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
#expect(workspace.uploadNotice == .rejected("Unreadable file"))
try await Task.sleep(for: .milliseconds(1700))
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
}
@Test(arguments: [false, true])
func cancelledOpeningCannotReplaceANewerDropFailure(_ lateFailure: Bool) async throws {
let client = MobileUploadSFTPClient()
let opening = MobileUploadOpeningGate()
let workspace = uploadWorkspace(client: client) {
if opening.hasStarted { throw URLError(.timedOut) }
return try await opening.open()
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = URL(fileURLWithPath: "/tmp/unused-upload")
#expect(workspace.prepareUpload([source]))
#expect(await eventually { opening.hasStarted })
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
let notice = workspace.uploadNotice
opening.finish(lateFailure ? .failure(SSHInteractionRequired()) : .success(client))
#expect(await eventually { opening.hasFinished })
if !lateFailure { #expect(await eventuallyAsync { await client.disconnectCount == 1 }) }
#expect(workspace.uploadNotice == notice)
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
}
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp])
func transferRoutesRetainTheRemoteHomeAfterWorkspaceClosure(_ kind: ConnectionHistoryEntry.Kind) async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: kind)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
if kind == .ssh { workspace.receiveWorkingDirectory("/home/test") }
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
let transfers = try #require(workspace.transfers)
#expect(await eventually { transfers.sftpTransfers.first?.state == .completed })
workspace.disconnect()
let transfer = try #require(transfers.sftpTransfers.first)
#expect(transfer.destinationHomeDirectory == "/home/test")
#expect(transfer.routeLabel { _ in "Upload" } == "Upload · ~/report.txt")
#expect(workspace.sftpChannel.homeDirectory == nil)
}
@Test func downloadsAndRemoteCopiesRetainResolvedHomeDirectories() async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient(), kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let transfers = try #require(workspace.transfers)
let file = RemoteFile(
path: "/home/test/report.txt", name: "report.txt", kind: .file, size: 0,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: destination) }
workspace.prepareDownload(file, to: destination)
#expect(await eventually { transfers.sftpTransfers.first?.state == .completed })
let download = try #require(transfers.sftpTransfers.first)
#expect(download.sourceHomeDirectory == "/home/test")
#expect(download.routeLabel { _ in "Upload" } == "Upload · ~/report.txt")
let source = try #require(workspace.remoteCopySource(for: file))
let payload = transfers.registerRemoteCopySource(source)
await workspace.sftpChannel.openDirectory("/home/test/archive")
workspace.prepareRemoteCopy(payload)
#expect(await eventually { transfers.sftpTransfers.contains { $0.direction == .copy } })
let copy = try #require(transfers.sftpTransfers.first { $0.direction == .copy })
#expect(copy.sourceHomeDirectory == "/home/test")
#expect(copy.destinationHomeDirectory == "/home/test")
#expect(copy.routeLabel { _ in "Upload" } == "Upload · ~/report.txt → Upload · ~/archive/report.txt")
transfers.cancelAll()
await transfers.waitForCancellation()
}
@Test func unavailableHomeDoesNotPreventConnectingToAnExplicitDirectory() async throws {
let workspace = uploadWorkspace(
client: MobileUploadSFTPClient(homeDirectory: nil), kind: .sftp, initialPath: "/srv")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(workspace.sftpChannel.path == "/srv")
#expect(workspace.sftpChannel.homeDirectory == nil)
}
private func uploadWorkspace(
client: MobileUploadSFTPClient,
kind: ConnectionHistoryEntry.Kind = .ssh,
initialPath: String = "",
connectionOrigin: RemoteConnectionOrigin = .library,
open: (() async throws -> any SFTPClientProtocol)? = nil
) -> MobileWorkspace {
let identity = Identity(name: "Upload", username: "user")
let host = Host(
name: "Upload", host: "example.invalid", identityID: identity.id, sftpChannelInitialPath: initialPath)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in
if let open { return try await open() }
return client
}, writeSSH: { _, _ in })
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, connectionServices: services, connectionOrigin: connectionOrigin)
}
@Test func serialUploadSurvivesClosingTheSFTPWorkspace() async throws {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let primary = MobileUploadSFTPClient(blocksUpload: true)
let extra = MobileUploadSFTPClient(requiresInteraction: true)
let transfers = SFTPTransferCoordinator(store: library) { extra }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in }, connectSFTP: { _, _, _ in primary }, writeSSH: { _, _ in })
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library, transfers: transfers, connectionServices: services)
workspace.connect()
#expect(await eventually { workspace.connected })
let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data("test".utf8).write(to: file)
defer { try? FileManager.default.removeItem(at: file) }
#expect(workspace.prepareUpload([file]))
#expect(await eventuallyAsync { await primary.startedUploads == 1 })
workspace.disconnect()
#expect(await primary.disconnectCount == 0)
await primary.finishUploads()
#expect(await eventually { transfers.sftpTransfers.allSatisfy { $0.state == .completed } })
#expect(await eventuallyAsync { await primary.disconnectCount == 1 })
}
@Test func sshUploadUsesTheReportedWorkingDirectoryAndReleasesPreflightClient() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let preflightClient = MobileUploadSFTPClient()
let transferClient = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { transferClient }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
return preflightClient
},
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
transfers: transfers,
connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "report.txt")
try Data("report".utf8).write(to: file)
#expect(workspace.prepareUpload([file]))
#expect(await eventually { transfers.sftpTransfers.count == 1 })
#expect(transfers.sftpTransfers.first?.remotePath == "/srv/current/report.txt")
#expect(await eventuallyAsync { await preflightClient.disconnectCount == 1 })
}
@Test func terminalBatchUploadsFiveFilesConcurrentlyAndKeepsQueuedFilesAfterClosure() async throws {
let identity = Identity(name: "Upload", username: "user")
let host = Host(name: "Concurrent", host: "parallel.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let clients = MobileParallelUploadClients()
let primary = clients.makeClient()
let transfers = SFTPTransferCoordinator(
store: library, clientFactory: { clients.makeClient() }, globalLimit: 15, perHostLimit: 5,
strategy: .connectionsPreferred)
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { request, _, _ in
#expect(request.interactionPolicy == .forbidden)
return primary
}, writeSSH: { _, _ in })
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
transfers: transfers, connectionServices: services)
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let files = try (0..<7).map { index in
let file = directory.appending(path: "file-\(index)")
try Data([1]).write(to: file)
return file
}
#expect(workspace.prepareUpload(files))
#expect(await eventuallyAsync { await clients.startedUploads() == 5 })
#expect(transfers.activeCount == 5)
#expect(clients.snapshot.count == 5)
#expect(await primary.startedUploads == 1)
workspace.disconnect()
#expect(await primary.disconnectCount == 0)
await primary.finishUploads()
#expect(await eventuallyAsync { await clients.startedUploads() == 6 })
#expect(transfers.activeCount == 5)
for _ in 0..<100 {
await clients.finishUploads()
if transfers.activeCount == 0 { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(transfers.sftpTransfers.count == 7)
#expect(transfers.sftpTransfers.allSatisfy { $0.state == .completed })
#expect(await primary.disconnectCount == 1)
}
@Test func sftpFolderUploadUsesTheCurrentDirectoryAndOneBatch() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let client = MobileUploadSFTPClient()
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in client },
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
connectionServices: services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await workspace.sftpChannel.openDirectory("/srv/incoming")
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let folder = root.appending(path: "bundle", directoryHint: .isDirectory)
let nested = folder.appending(path: "nested", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("payload".utf8).write(to: nested.appending(path: "payload.txt"))
let looseFile = root.appending(path: "README.md")
try Data("readme".utf8).write(to: looseFile)
let providers = [(folder, UTType.folder), (looseFile, UTType.plainText)].map { url, type in
let provider = NSItemProvider()
provider.suggestedName = url.lastPathComponent
provider.registerFileRepresentation(forTypeIdentifier: type.identifier, fileOptions: [], visibility: .all) {
@Sendable completion in
completion(url, false, nil)
return nil
}
return provider
}
let payload = await MobileDropPayloadLoader.load(providers, kinds: [.files], fileMode: .staged)
defer { if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) } }
#expect(payload.failures.isEmpty)
try FileManager.default.removeItem(at: root)
#expect(workspace.prepareUpload(payload.fileURLs))
#expect(await eventually { transfers.sftpTransfers.count == 2 })
#expect(
Set(transfers.sftpTransfers.map(\.remotePath)) == [
"/srv/incoming/README.md",
"/srv/incoming/bundle/nested/payload.txt",
]
)
#expect(Set(transfers.sftpTransfers.map(\.batchID)).count == 1)
#expect(await eventuallyAsync { await client.uploadedData.count == 2 })
#expect(
await client.uploadedData == [
"/srv/incoming/README.md": Data("readme".utf8),
"/srv/incoming/bundle/nested/payload.txt": Data("payload".utf8),
])
#expect(workspace.uploadNotice == .queued(2, "/srv/incoming"))
#expect(
await client.createdDirectories == [
"/srv/incoming/bundle",
"/srv/incoming/bundle/nested",
]
)
}
@Test(arguments: [false, true])
func successfulFileDownloadsRefreshHistoryWhileInvalidAndFailedFilesDoNot(external: Bool) async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
let library = HostLibraryModel(
hosts: [host],
identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
)
]
)
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let report = RemoteFile(
path: "/srv/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let broken = RemoteFile(
path: "/srv/broken.txt", name: "broken.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let changedDirectory = RemoteFile(
path: "/srv/changed", name: "changed", kind: .directory, size: 0,
permissions: 0o755, ownerUID: 501, ownerGID: 20)
let sessionClient = MobileUploadSFTPClient(
attributes: [
report.path: report,
broken.path: broken,
changedDirectory.path: changedDirectory,
],
failedDownloadPaths: [broken.path])
let transferClient = MobileUploadSFTPClient(failedDownloadPaths: [broken.path])
let transfers = SFTPTransferCoordinator(store: library) { transferClient }
let services = MobileWorkspaceConnectionServices(
makePTYChannel: { SSHPTYChannel(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in sessionClient },
writeSSH: { _, _ in }
)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .sftp),
hosts: library,
transfers: transfers,
connectionServices: services,
connectionOrigin: external ? .externalLink(hostKeyConstraints: [:]) : .library
)
let history = workspace.sftpHistory
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
#expect(await workspace.reopenHistoryFile(at: report.path))
#expect(
await eventually {
history.entries().contains { $0.kind == .file && $0.path == report.path }
})
#expect(await workspace.reopenHistoryFile(at: broken.path))
#expect(
await eventually {
guard let state = transfers.sftpTransfers.first(where: { $0.remotePath == broken.path })?.state
else { return false }
if case .failed = state { return true }
return false
})
#expect(!history.entries().contains { $0.path == broken.path })
#expect(transfers.sftpTransfers.allSatisfy { $0.sourceHomeDirectory == "/home/test" })
#expect(!(await workspace.reopenHistoryFile(at: "/srv/missing.txt")))
#expect(!(await workspace.reopenHistoryFile(at: changedDirectory.path)))
#expect(!history.entries().contains { $0.path == changedDirectory.path })
}
@Test func closingTemporaryWorkspacePreventsLatePreviewFromRestoringHistory() async throws {
let client = MobileUploadSFTPClient(blocksDownload: true)
let workspace = uploadWorkspace(
client: client, kind: .sftp, connectionOrigin: .externalLink(hostKeyConstraints: [:]))
workspace.connect()
#expect(await eventually { workspace.connected })
let file = RemoteFile(
path: "/report.txt", name: "report.txt", kind: .file, size: 4,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
var completed = false
#expect(workspace.preview(file) { _ in completed = true })
#expect(await eventuallyAsync { await client.startedDownloads == 1 })
workspace.close()
await client.finishDownloads()
#expect(await eventually { completed })
#expect(workspace.sftpHistory.entries().isEmpty)
}
@Test func sharedUploadPlannerDoesNotFollowASymlinkOutsideTheSelectedFolder() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let upload = root.appending(path: "upload", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: upload, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("inside".utf8).write(to: upload.appending(path: "inside.txt"))
let outside = root.appending(path: "outside.txt")
try Data("outside".utf8).write(to: outside)
try FileManager.default.createSymbolicLink(
at: upload.appending(path: "escape.txt"),
withDestinationURL: outside
)
let client = MobileUploadSFTPClient()
let batch = try await SFTPLocalUploadPlanner.prepare(
[upload],
destination: "/incoming",
client: client
)
#expect(batch.transferCount == 1)
#expect(batch.rootCount == 1)
}
@Test func sharedUploadPlannerCreatesAnEmptyFolderWithoutInventingATransfer() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let emptyFolder = root.appending(path: "empty", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: emptyFolder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let client = MobileUploadSFTPClient()
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID,
host: "example.invalid",
port: 22,
username: "deploy",
credentials: [],
expectedHostKeys: []
)
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let batch = try await SFTPLocalUploadPlanner.prepare(
[emptyFolder],
destination: "/incoming",
client: client
)
let result = try await SFTPLocalUploadPlanner.enqueue(
batch,
policy: .replace,
hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client,
coordinator: coordinator
)
#expect(batch.transferCount == 0)
#expect(!batch.isEmpty)
#expect(result.transferCount == 0)
#expect(result.changedRemoteDirectories)
#expect(await client.createdDirectories == ["/incoming/empty"])
#expect(coordinator.sftpTransfers.isEmpty)
}
@Test func sharedUploadReplaceRejectsAChildOutsideTheConflictingDirectory() 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 local = root.appending(path: "payload.txt")
try Data("replacement".utf8).write(to: local)
let target = RemoteFile(
path: "/incoming/payload.txt", name: "payload.txt", kind: .directory, size: 0,
permissions: 0o755, ownerUID: 501, ownerGID: 20)
let escaped = RemoteFile(
path: "/outside/untouched.txt", name: "untouched.txt", kind: .file, size: 1,
permissions: 0o644, ownerUID: 501, ownerGID: 20)
let client = MobileUploadSFTPClient(attributes: [target.path: target], children: [target.path: [escaped]])
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID, host: "example.invalid", port: 22, username: "deploy",
credentials: [], expectedHostKeys: [])
let batch = try await SFTPLocalUploadPlanner.prepare([local], destination: "/incoming", client: client)
#expect(batch.conflictCount == 1)
await #expect(throws: (any Error).self) {
try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
#expect(await client.listedDirectories == [target.path])
#expect(await client.removedPaths.isEmpty)
#expect(coordinator.sftpTransfers.isEmpty)
}
@Test func cancelledUploadDoesNotCreateRemoteDirectories() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
let folder = root.appending(path: "empty", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let client = MobileUploadSFTPClient()
let coordinator = SFTPTransferCoordinator(store: HostLibraryModel()) { client }
let hostID = UUID()
let configuration = SSHConnectionConfiguration(
hostID: hostID, host: "example.invalid", port: 22, username: "deploy",
credentials: [], expectedHostKeys: [])
let batch = try await SFTPLocalUploadPlanner.prepare([folder], destination: "/incoming", client: client)
let task = Task { @MainActor in
withUnsafeCurrentTask { $0?.cancel() }
return try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
await #expect(throws: CancellationError.self) { try await task.value }
#expect(await client.createdDirectories.isEmpty)
#expect(coordinator.sftpTransfers.isEmpty)
}
private func eventually(_ condition: @escaping @MainActor () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
private func eventuallyAsync(_ condition: @escaping () async -> Bool) async -> Bool {
for _ in 0..<100 {
if await condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
}
private nonisolated enum UploadFailureStage: Sendable {
case connection, localFile, preparation, enqueue
}
@MainActor
private final class MobileUploadOpeningGate {
private var continuation: CheckedContinuation<any SFTPClientProtocol, any Error>?
private(set) var hasStarted = false
private(set) var hasFinished = false
func open() async throws -> any SFTPClientProtocol {
defer { hasFinished = true }
return try await withCheckedThrowingContinuation {
continuation = $0
hasStarted = true
}
}
func finish(_ result: Result<any SFTPClientProtocol, any Error>) {
continuation?.resume(with: result)
continuation = nil
}
}
private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var uploadedData: [String: Data] = [:]
private(set) var createdDirectories: [String] = []
private(set) var listedDirectories: [String] = []
private(set) var removedPaths: [String] = []
private(set) var disconnectCount = 0
private(set) var startedUploads = 0
private(set) var startedDownloads = 0
private let resolvedHomeDirectory: String?
private let blocksUpload: Bool
private let blocksDownload: Bool
private let requiresInteraction: Bool
private let failureStage: UploadFailureStage?
private var uploadWaiters: [CheckedContinuation<Void, Never>] = []
private var downloadWaiters: [CheckedContinuation<Void, Never>] = []
private let attributes: [String: RemoteFile]
private let failedDownloadPaths: Set<String>
private let children: [String: [RemoteFile]]
init(
homeDirectory: String? = "/home/test",
attributes: [String: RemoteFile] = [:],
failedDownloadPaths: Set<String> = [],
children: [String: [RemoteFile]] = [:],
blocksUpload: Bool = false, blocksDownload: Bool = false, requiresInteraction: Bool = false,
failureStage: UploadFailureStage? = nil
) {
self.resolvedHomeDirectory = homeDirectory
self.blocksUpload = blocksUpload
self.blocksDownload = blocksDownload
self.requiresInteraction = requiresInteraction
self.failureStage = failureStage
self.attributes = attributes
self.failedDownloadPaths = failedDownloadPaths
self.children = children
}
func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {
if requiresInteraction { throw SSHInteractionRequired() }
}
func finishUploads() {
let pending = uploadWaiters
uploadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func finishDownloads() {
let pending = downloadWaiters
downloadWaiters.removeAll()
for waiter in pending { waiter.resume() }
}
func disconnect() async { disconnectCount += 1 }
func homeDirectory() async throws -> String {
guard let resolvedHomeDirectory else { throw SFTPError.operation("Home unavailable") }
return resolvedHomeDirectory
}
func listDirectory(at path: String) async throws -> [RemoteFile] {
listedDirectories.append(path)
return children[path, default: []]
}
func attributes(at path: String) async throws -> RemoteFile? {
if failureStage == .preparation { throw SFTPError.operation("Cannot inspect upload destination") }
return attributes[path]
}
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {
if failureStage == .enqueue { throw SFTPError.operation("Cannot create upload directory") }
createdDirectories.append(path)
}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws { removedPaths.append(path) }
func removeDirectory(at path: String) async throws { removedPaths.append(path) }
func uploadFile(
from localURL: URL,
to remotePath: String,
overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {
startedUploads += 1
if blocksUpload { await withCheckedContinuation { uploadWaiters.append($0) } }
uploadedData[remotePath] = try Data(contentsOf: localURL)
}
func downloadFile(
from remotePath: String,
to localURL: URL,
overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {
startedDownloads += 1
if blocksDownload { await withCheckedContinuation { downloadWaiters.append($0) } }
if failedDownloadPaths.contains(remotePath) {
throw SFTPError.operation("Download failed")
}
}
}
private nonisolated final class MobileParallelUploadClients: @unchecked Sendable {
private let lock = NSLock()
private var clients: [MobileUploadSFTPClient] = []
var snapshot: [MobileUploadSFTPClient] { lock.withLock { clients } }
func makeClient() -> MobileUploadSFTPClient {
let client = MobileUploadSFTPClient(blocksUpload: true)
lock.withLock { clients.append(client) }
return client
}
func startedUploads() async -> Int {
var count = 0
for client in snapshot { count += await client.startedUploads }
return count
}
func finishUploads() async {
for client in snapshot { await client.finishUploads() }
}
}