// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import CSSH2 import CryptoKit import Darwin import Foundation import TermindApplication import TermindDomain nonisolated enum SFTPChannelAttempt: Sendable { case value(Value) case again } nonisolated final class SFTPChannelValue: @unchecked Sendable { let value: Any init(_ value: Any) { self.value = value } } nonisolated enum SFTPChannelAnyAttempt: @unchecked Sendable { case value(SFTPChannelValue) case again } /// Type-erased access to a non-blocking libssh2 owner. Standalone, PTY and /// Agent sessions keep their own lifetimes; this driver lets one SFTP /// implementation take bounded turns on any of those owners. nonisolated final class SFTPChannelDriver: @unchecked Sendable { typealias RawOperation = @Sendable (OpaquePointer, OpaquePointer) throws -> SFTPChannelAnyAttempt let identity: UUID // External channels (including siblings and asynchronous deinit close tasks) keep // the transport owner alive. The owner never retains its drivers, so this is acyclic. private let connectionOwner: (any AnyObject & Sendable)? private let openAction: @Sendable () async throws -> UUID private let closeAction: @Sendable (UUID) async -> Void private let performAction: @Sendable (UUID, @escaping RawOperation) async throws -> SFTPChannelValue init( identity: UUID, retaining connectionOwner: (any AnyObject & Sendable)? = nil, open: @escaping @Sendable () async throws -> UUID, close: @escaping @Sendable (UUID) async -> Void, perform: @escaping @Sendable (UUID, @escaping RawOperation) async throws -> SFTPChannelValue ) { self.identity = identity self.connectionOwner = connectionOwner openAction = open closeAction = close performAction = perform } func open() async throws -> UUID { try await openAction() } func close(_ id: UUID) async { await closeAction(id) } func perform( _ id: UUID, _ operation: @escaping @Sendable (OpaquePointer, OpaquePointer) throws -> SFTPChannelAttempt ) async throws -> Value { let boxed = try await performAction(id) { libssh2Session, sftp in switch try operation(libssh2Session, sftp) { case .value(let value): .value(SFTPChannelValue(value)) case .again: .again } } guard let value = boxed.value as? Value else { preconditionFailure("SFTP channel driver returned an unexpected value type") } return value } } private nonisolated final class SFTPChannelHandle: @unchecked Sendable { let pointer: OpaquePointer init(_ pointer: OpaquePointer) { self.pointer = pointer } } private nonisolated final class SFTPChannelWriteContext: @unchecked Sendable { let handle: OpaquePointer let temporary: String let destination: String let overwrite: Bool var handleOpen = true init(handle: OpaquePointer, temporary: String, destination: String, overwrite: Bool) { self.handle = handle self.temporary = temporary self.destination = destination self.overwrite = overwrite } } private nonisolated struct SFTPProgressMeter { let total: Int64 let report: @Sendable (SFTPProgress) -> Void private(set) var completed: Int64 private var sampledBytes: Int64 = 0 private var sampledAt = ContinuousClock.now init(total: Int64, offset: Int64 = 0, report: @escaping @Sendable (SFTPProgress) -> Void) { self.total = total completed = offset self.report = report } mutating func add(_ bytes: Int) { completed += Int64(bytes) sampledBytes += Int64(bytes) let elapsed = sampledAt.duration(to: .now) guard elapsed >= .milliseconds(250) else { return } let parts = elapsed.components let seconds = max(0.001, Double(parts.seconds) + Double(parts.attoseconds) / 1e18) report( SFTPProgress( completedBytes: completed, totalBytes: total, bytesPerSecond: Int64(Double(sampledBytes) / seconds))) sampledBytes = 0 sampledAt = .now } func finish() { report(SFTPProgress(completedBytes: completed, totalBytes: total, bytesPerSecond: 0)) } } /// An opened SFTP subsystem channel on a multiplexed non-blocking SSH connection. actor SFTPChannel: SFTPChannelProtocol { private nonisolated let driver: SFTPChannelDriver private let id: UUID private var disconnected = false private let charset: RemoteCharset static func open( using driver: SFTPChannelDriver, charset: RemoteCharset ) async throws -> any SFTPChannelProtocol { let id = try await driver.open() do { try Task.checkCancellation() return SFTPChannel(driver: driver, id: id, charset: charset) } catch { await driver.close(id) throw error } } private init(driver: SFTPChannelDriver, id: UUID, charset: RemoteCharset) { self.driver = driver self.id = id self.charset = charset } deinit { let driver = driver let id = id Task { await driver.close(id) } } func openSiblingChannel() async throws -> any SFTPChannelProtocol { try await Self.open(using: driver, charset: charset) } func disconnect() async { guard !disconnected else { return } disconnected = true await driver.close(id) } func homeDirectory() async throws -> String { let source = try encodePath(".") let charset = charset let value: String = try await driver.perform(id) { libssh2Session, sftp in var target = [CChar](repeating: 0, count: 4 * 1024) let count = source.withUnsafeBytes { libssh2_sftp_symlink_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(source.count), &target, UInt32(target.count), Int32(LIBSSH2_SFTP_REALPATH)) } if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count > 0 else { throw Self.error(libssh2Session, sftp, "Unable to resolve the remote home directory") } let bytes = Data(target.prefix(Int(count)).map { UInt8(bitPattern: $0) }) return .value(RemotePath.normalized(try Self.decodePath(bytes, charset: charset))) } return value } func listDirectory(at path: String) async throws -> [RemoteFile] { let handle = try await openHandle(path: path, flags: 0, mode: 0, type: Int32(LIBSSH2_SFTP_OPENDIR)) let charset = charset var files: [RemoteFile] = [] do { while true { let next: RemoteFile? = try await driver.perform(id) { libssh2Session, sftp in var name = [CChar](repeating: 0, count: 4 * 1024) var attributes = LIBSSH2_SFTP_ATTRIBUTES() let count = libssh2_sftp_readdir_ex( handle.pointer, &name, name.count, nil, 0, &attributes) if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count >= 0 else { throw Self.error(libssh2Session, sftp, "Unable to read \(path)") } guard count > 0 else { return .value(nil) } let filename = try Self.decodePath( Data(name.prefix(Int(count)).map { UInt8(bitPattern: $0) }), charset: charset) return .value( Self.remoteFile( path: RemotePath.join(path, filename), name: filename, attributes: attributes)) } guard let next else { break } if next.name != "." && next.name != ".." { files.append(next) } } try await closeHandle(handle) } catch { try? await closeHandle(handle) throw error } return files.sorted { if $0.isDirectory != $1.isDirectory { return $0.isDirectory } return $0.name.localizedStandardCompare($1.name) == .orderedAscending } } func attributes(at path: String) async throws -> RemoteFile? { let pathBytes = try encodePath(path) let value: RemoteFile? = try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() let result = pathBytes.withUnsafeBytes { libssh2_sftp_stat_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes) } if result == LIBSSH2_ERROR_EAGAIN { return .again } if result == 0 { return .value(Self.remoteFile(path: path, attributes: attributes)) } if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return .value(nil) } throw Self.error(libssh2Session, sftp, "Unable to inspect \(path)") } return value } func readFile(at path: String, maximumBytes: Int) async throws -> Data { guard maximumBytes >= 0 else { throw SFTPError.operation("The inline read limit is invalid.") } let handle = try await openHandle( path: path, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) var data = Data() do { while data.count <= maximumBytes { let wanted = min(64 * 1024 - 1, maximumBytes - data.count) + 1 let chunk: Data? = try await read(handle, maximumBytes: wanted) guard let chunk else { break } data.append(chunk) } try await closeHandle(handle) } catch { try? await closeHandle(handle) throw error } guard data.count <= maximumBytes else { throw SFTPError.operation("\(path) exceeds the inline read limit of \(maximumBytes) bytes.") } return data } func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { let temporary = path + ".termind-write-" + UUID().uuidString.lowercased() let context = try await beginWrite( temporary: temporary, destination: path, overwrite: overwrite, continuing: false, snapshot: nil) do { try await write(data, to: context.handle, cancellation: nil, acknowledge: nil) try await closeHandle(SFTPChannelHandle(context.handle)) context.handleOpen = false if let existing = try await attributes(at: path), !overwrite || existing.isDirectory { throw SFTPStreamUploadOperation.Failure.targetConflict } try await rename(from: temporary, to: path, overwrite: overwrite) } catch { if context.handleOpen { try? await closeHandle(SFTPChannelHandle(context.handle)) } try? await removeFile(at: temporary) throw error } } func setPermissions(at path: String, to permissions: UInt32) async throws { let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to change permissions for \(path)") { sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) attributes.permissions = UInt(permissions & 0o7777) return pathBytes.withUnsafeBytes { libssh2_sftp_stat_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), Int32(LIBSSH2_SFTP_SETSTAT), &attributes) } } } func createDirectory(at path: String) async throws { let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to create \(path)") { sftp in pathBytes.withUnsafeBytes { libssh2_sftp_mkdir_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), 0o755) } } } func rename(from source: String, to destination: String, overwrite: Bool) async throws { let sourceBytes = try encodePath(source) let destinationBytes = try encodePath(destination) let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 let _: Void = try await driver.perform(id) { libssh2Session, sftp in let result = sourceBytes.withUnsafeBytes { sourceBuffer in return destinationBytes.withUnsafeBytes { destinationBuffer in let sourcePointer = sourceBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) let destinationPointer = destinationBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) return SFTPRenameOperation.perform( overwrite: overwrite, posixRename: { libssh2_sftp_posix_rename_ex( sftp, sourcePointer, sourceBytes.count, destinationPointer, destinationBytes.count) }, standardRename: { libssh2_sftp_rename_ex( sftp, sourcePointer, UInt32(sourceBytes.count), destinationPointer, UInt32(destinationBytes.count), flags) }, lastStatus: { libssh2_sftp_last_error(sftp) }) } } if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error(libssh2Session, sftp, "Unable to rename \(source)") } return .value(()) } } func removeFile(at path: String) async throws { let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to delete \(path)") { sftp in pathBytes.withUnsafeBytes { libssh2_sftp_unlink_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count)) } } } func removeDirectory(at path: String) async throws { let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to delete \(path)") { sftp in pathBytes.withUnsafeBytes { libssh2_sftp_rmdir_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count)) } } } func uploadFile( from localURL: URL, to remotePath: String, overwrite: Bool, cancellation: TransferCancellationToken, resume: SFTPResumeState, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { if resume.snapshot.committed { return } let values = try localURL.resourceValues( forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]) guard values.isRegularFile == true, let size = values.fileSize else { throw SFTPResumeFailure.sourceChanged } let identity = SFTPSourceIdentity(byteCount: Int64(size), modifiedAt: values.contentModificationDate) try resume.validateSource(identity) let snapshot = resume.snapshot if try await recoverCommittedWrite( destination: remotePath, resume: resume, cancellation: cancellation ) { return } let file = try FileHandle(forReadingFrom: localURL) defer { try? file.close() } try Self.verifyLocalPrefix(file, snapshot: snapshot, cancellation: cancellation) let context = try await beginWrite( temporary: resume.temporaryPath(for: remotePath), destination: remotePath, overwrite: overwrite, continuing: snapshot.hasTemporaryFile, snapshot: snapshot, cancellation: cancellation) if !snapshot.hasTemporaryFile { resume.createdTemporaryFile() } do { var meter = SFTPProgressMeter( total: identity.byteCount, offset: snapshot.offset, report: progress) while true { guard !cancellation.isCancelled else { throw SFTPError.cancelled } let data = try file.read(upToCount: 32_700) ?? Data() if data.isEmpty { break } guard Int64(data.count) <= identity.byteCount - meter.completed else { throw SFTPResumeFailure.sourceChanged } try await write(data, to: context.handle, cancellation: cancellation) { chunk in resume.acknowledge(chunk) meter.add(chunk.count) } } guard meter.completed == identity.byteCount else { throw SFTPResumeFailure.sourceChanged } guard try Self.localIdentity(localURL) == identity else { throw SFTPResumeFailure.sourceChanged } try await finishWrite(context, resume: resume) meter.finish() } catch { if context.handleOpen { try? await closeHandle(SFTPChannelHandle(context.handle)) } throw error } } func uploadFile( from localURL: URL, to remotePath: String, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { try await uploadFile( from: localURL, to: remotePath, overwrite: overwrite, cancellation: cancellation, resume: SFTPResumeState(), progress: progress) } func downloadFile( from remotePath: String, to localURL: URL, overwrite: Bool, cancellation: TransferCancellationToken, resume: SFTPResumeState, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { if resume.snapshot.committed { return } let handle = try await openHandle( path: remotePath, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) var handleOpen = true do { let identity = try await remoteIdentity(handle) try resume.validateSource(identity) let snapshot = resume.snapshot let temporary = URL(fileURLWithPath: resume.temporaryPath(for: localURL.path)) let fm = FileManager.default if !snapshot.hasTemporaryFile { guard fm.createFile(atPath: temporary.path, contents: nil) else { throw SFTPError.localFile("Unable to create \(temporary.path)") } resume.createdTemporaryFile() } let file = try FileHandle(forUpdating: temporary) defer { try? file.close() } guard try file.seekToEnd() == UInt64(snapshot.offset) else { throw SFTPResumeFailure.partialChanged } try file.seek(toOffset: 0) try Self.verifyLocalPrefix(file, snapshot: snapshot, cancellation: cancellation) try await verifyRemotePrefix(handle, snapshot: snapshot, cancellation: cancellation) try file.seek(toOffset: UInt64(snapshot.offset)) var meter = SFTPProgressMeter( total: identity.byteCount, offset: snapshot.offset, report: progress) while let data = try await read(handle, maximumBytes: 32_700) { guard !cancellation.isCancelled else { throw SFTPError.cancelled } guard Int64(data.count) <= identity.byteCount - meter.completed else { throw SFTPResumeFailure.sourceChanged } try file.write(contentsOf: data) resume.acknowledge(data) meter.add(data.count) } guard meter.completed == identity.byteCount, try await remoteIdentity(handle) == identity else { throw SFTPResumeFailure.sourceChanged } if resume.snapshot.capturesFileVersion { resume.recordCommittedVersion( SFTPFileVersion(try await remoteFile(at: remotePath, from: handle))) } guard !cancellation.isCancelled else { throw SFTPError.cancelled } try await closeHandle(handle) handleOpen = false try file.synchronize() meter.finish() resume.willCommit() if fm.fileExists(atPath: localURL.path) { guard overwrite else { throw SFTPError.localFile("\(localURL.lastPathComponent) already exists.") } _ = try fm.replaceItemAt(localURL, withItemAt: temporary) } else { try fm.moveItem(at: temporary, to: localURL) } resume.didCommit() } catch { if handleOpen { try? await closeHandle(handle) } throw error } } func downloadFile( from remotePath: String, to localURL: URL, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { try await downloadFile( from: remotePath, to: localURL, overwrite: overwrite, cancellation: cancellation, resume: SFTPResumeState(), progress: progress) } func uploadStream( fromSocket socket: Int32, to remotePath: String, byteCount: Int64, overwrite: Bool, cancellation: TransferCancellationToken, resume: SFTPResumeState, progress: @escaping @Sendable (SFTPProgress) -> Void, sourceSucceeded: @escaping @Sendable () -> Bool ) async throws { if resume.snapshot.committed { return } let snapshot = resume.snapshot if try await recoverCommittedWrite( destination: remotePath, resume: resume, cancellation: cancellation ) { return } let context = try await beginWrite( temporary: resume.temporaryPath(for: remotePath), destination: remotePath, overwrite: overwrite, continuing: snapshot.hasTemporaryFile, snapshot: snapshot, cancellation: cancellation) if !snapshot.hasTemporaryFile { resume.createdTemporaryFile() } do { var meter = SFTPProgressMeter(total: byteCount, offset: snapshot.offset, report: progress) while true { guard !cancellation.isCancelled else { throw SFTPError.cancelled } var buffer = [UInt8](repeating: 0, count: 32_700) let count = Darwin.recv(socket, &buffer, buffer.count, 0) if count < 0, errno == EINTR { continue } if count < 0 { throw SFTPError.operation("The SFTP upload stream was interrupted.") } if count == 0 { break } let data = Data(buffer.prefix(count)) guard Int64(data.count) <= byteCount - meter.completed else { throw SFTPError.operation("The SFTP upload source exceeded the declared length.") } try await write(data, to: context.handle, cancellation: cancellation) { chunk in resume.acknowledge(chunk) meter.add(chunk.count) } } guard !cancellation.isCancelled, sourceSucceeded() else { throw SFTPError.cancelled } guard meter.completed == byteCount else { throw SFTPError.operation( "The SFTP upload source ended before the declared length.") } try await finishWrite(context, resume: resume) meter.finish() } catch { if context.handleOpen { try? await closeHandle(SFTPChannelHandle(context.handle)) } throw error } } func uploadStream( fromSocket socket: Int32, to remotePath: String, byteCount: Int64, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void, sourceSucceeded: @escaping @Sendable () -> Bool ) async throws { try await uploadStream( fromSocket: socket, to: remotePath, byteCount: byteCount, overwrite: overwrite, cancellation: cancellation, resume: SFTPResumeState(), progress: progress, sourceSucceeded: sourceSucceeded) } func openDownloadStream( at path: String, maximumBytes: Int64, cancellation: TransferCancellationToken ) async throws -> any SFTPDownloadStream { let handle = try await openHandle( path: path, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) do { let identity = try await remoteIdentity(handle) guard identity.byteCount <= maximumBytes else { throw SFTPDownloadFailure.tooLarge } return SFTPChannelDownloadStream( client: self, handle: handle, identity: identity, cancellation: cancellation) } catch { try? await closeHandle(handle) throw error } } 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 { if let destination = destination as? SFTPChannel, destination.driver.identity == driver.identity { try await copyOnSharedTransport( from: sourcePath, to: destinationPath, destination: destination, byteCount: byteCount, overwrite: overwrite, cancellation: cancellation, progress: progress) return } let relay = try SFTPSocketRelay() let observer = cancellation.observeCancellation { relay.stop() } defer { cancellation.removeObserver(observer) relay.close() } try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { do { try await self.downloadToSocket( sourcePath, socket: relay.writer, cancellation: cancellation) relay.finishWriting() } catch { cancellation.cancel() throw error } } group.addTask { try await destination.uploadStream( fromSocket: relay.reader, to: destinationPath, byteCount: byteCount, overwrite: overwrite, cancellation: cancellation, progress: progress, sourceSucceeded: { relay.sourceSucceeded }) } do { while try await group.next() != nil {} } catch { cancellation.cancel() relay.stop() group.cancelAll() throw error } } } private func copyOnSharedTransport( from sourcePath: String, to destinationPath: String, destination: SFTPChannel, byteCount: Int64, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { let source = try await openHandle( path: sourcePath, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) var sourceOpen = true let identity = try await remoteIdentity(source) guard identity.byteCount == byteCount else { try? await closeHandle(source) throw SFTPResumeFailure.sourceChanged } let temporary = destinationPath + ".termind-copy-" + UUID().uuidString.lowercased() let output = try await destination.beginWrite( temporary: temporary, destination: destinationPath, overwrite: overwrite, continuing: false, snapshot: nil) do { var meter = SFTPProgressMeter(total: byteCount, report: progress) while let data = try await read(source, maximumBytes: 64 * 1024) { guard !cancellation.isCancelled else { throw SFTPError.cancelled } guard Int64(data.count) <= byteCount - meter.completed else { throw SFTPResumeFailure.sourceChanged } try await destination.write( data, to: output.handle, cancellation: cancellation, acknowledge: nil) meter.add(data.count) } guard meter.completed == byteCount, try await remoteIdentity(source) == identity else { throw SFTPResumeFailure.sourceChanged } try await closeHandle(source) sourceOpen = false try await destination.closeHandle(SFTPChannelHandle(output.handle)) output.handleOpen = false if let existing = try await destination.attributes(at: destinationPath), !overwrite || existing.isDirectory { throw SFTPStreamUploadOperation.Failure.targetConflict } try await destination.rename(from: temporary, to: destinationPath, overwrite: overwrite) meter.finish() } catch { if sourceOpen { try? await closeHandle(source) } if output.handleOpen { try? await destination.closeHandle(SFTPChannelHandle(output.handle)) } try? await destination.removeFile(at: temporary) throw error } } private func downloadToSocket( _ path: String, socket: Int32, cancellation: TransferCancellationToken ) async throws { let handle = try await openHandle( path: path, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) do { while let data = try await read(handle, maximumBytes: 32_700) { var offset = 0 while offset < data.count { guard !cancellation.isCancelled else { throw SFTPError.cancelled } let sent = data.withUnsafeBytes { Darwin.send(socket, $0.baseAddress!.advanced(by: offset), data.count - offset, 0) } if sent > 0 { offset += sent } else if sent < 0, errno == EINTR { continue } else { throw SFTPError.operation("The SFTP copy relay was interrupted.") } } } try await closeHandle(handle) } catch { try? await closeHandle(handle) throw error } } fileprivate func streamRead( _ handle: SFTPChannelHandle, cancellation: TransferCancellationToken ) async throws -> Data? { guard !cancellation.isCancelled else { throw SFTPError.cancelled } return try await read(handle, maximumBytes: 64 * 1024) } fileprivate func streamSeek(_ handle: SFTPChannelHandle, offset: Int64) async throws { guard offset >= 0 else { throw SFTPResumeFailure.partialChanged } try await seek(handle, offset: offset) } fileprivate func streamClose(_ handle: SFTPChannelHandle) async throws { try await closeHandle(handle) } fileprivate func streamIdentity(_ handle: SFTPChannelHandle) async throws -> SFTPSourceIdentity { try await remoteIdentity(handle) } private func openHandle( path: String, flags: UInt, mode: Int, type: Int32 ) async throws -> SFTPChannelHandle { let pathBytes = try encodePath(path) return try await driver.perform(id) { libssh2Session, sftp in let opened = pathBytes.withUnsafeBytes { libssh2_sftp_open_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), flags, mode, type) } if let opened { return .value(SFTPChannelHandle(opened)) } if libssh2_session_last_errno(libssh2Session) == LIBSSH2_ERROR_EAGAIN { return .again } throw Self.error(libssh2Session, sftp, "Unable to open \(path)") } } private func closeHandle(_ handle: SFTPChannelHandle) async throws { let _: Void = try await driver.perform(id) { libssh2Session, sftp in let result = libssh2_sftp_close_handle(handle.pointer) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error(libssh2Session, sftp, "Unable to close SFTP handle") } return .value(()) } } private func read(_ handle: SFTPChannelHandle, maximumBytes: Int) async throws -> Data? { try await driver.perform(id) { libssh2Session, sftp in var buffer = [UInt8](repeating: 0, count: maximumBytes) let count = libssh2_sftp_read(handle.pointer, &buffer, buffer.count) if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count >= 0 else { throw Self.error(libssh2Session, sftp, "Unable to read remote file") } return .value(count == 0 ? nil : Data(buffer.prefix(count))) } } private func seek(_ handle: SFTPChannelHandle, offset: Int64) async throws { let _: Void = try await driver.perform(id) { _, _ in libssh2_sftp_seek64(handle.pointer, UInt64(offset)) return .value(()) } } private func write( _ data: Data, to handle: OpaquePointer, cancellation: TransferCancellationToken?, acknowledge: ((Data) -> Void)? ) async throws { let stableHandle = SFTPChannelHandle(handle) var position = 0 while position < data.count { if cancellation?.isCancelled == true { throw SFTPError.cancelled } let start = position let written: Int = try await driver.perform(id) { libssh2Session, sftp in let count = data.withUnsafeBytes { libssh2_sftp_write( stableHandle.pointer, $0.baseAddress!.advanced(by: start).assumingMemoryBound(to: CChar.self), data.count - start) } if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count > 0 else { throw Self.error(libssh2Session, sftp, "Unable to write remote file") } return .value(count) } let chunk = data.subdata(in: position..<(position + written)) acknowledge?(chunk) position += written } } private func performStatus( prefix: String, _ operation: @escaping @Sendable (OpaquePointer) -> Int32 ) async throws { let _: Void = try await driver.perform(id) { libssh2Session, sftp in let result = operation(sftp) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error(libssh2Session, sftp, prefix) } return .value(()) } } private func recoverCommittedWrite( destination: String, resume: SFTPResumeState, cancellation: TransferCancellationToken ) async throws -> Bool { let snapshot = resume.snapshot guard snapshot.hasTemporaryFile, snapshot.committing, let temporary = snapshot.temporaryPath, try await attributes(at: temporary) == nil else { return false } let handle = try await openHandle( path: destination, flags: UInt(LIBSSH2_FXF_READ), mode: 0, type: Int32(LIBSSH2_SFTP_OPENFILE)) do { guard try await remoteSize(handle) == snapshot.offset else { throw SFTPResumeFailure.partialChanged } try await verifyRemotePrefix( handle, snapshot: snapshot, cancellation: cancellation) resume.recordCommittedVersion( SFTPFileVersion(try await remoteFile(at: destination, from: handle))) try await closeHandle(handle) resume.didCommit() return true } catch { try? await closeHandle(handle) throw error } } private func beginWrite( temporary: String, destination: String, overwrite: Bool, continuing: Bool, snapshot: SFTPResumeState.Snapshot?, cancellation: TransferCancellationToken = TransferCancellationToken() ) async throws -> SFTPChannelWriteContext { if let editPolicy = snapshot?.editPolicy { try editPolicy.validate(try await attributes(at: destination)) } if continuing, try await attributes(at: temporary) == nil { throw SFTPResumeFailure.partialChanged } let flags = UInt( LIBSSH2_FXF_READ | LIBSSH2_FXF_WRITE | (continuing ? 0 : (LIBSSH2_FXF_CREAT | LIBSSH2_FXF_EXCL))) let temporaryBytes = try encodePath(temporary) let context: SFTPChannelWriteContext = try await driver.perform(id) { libssh2Session, sftp in let handle = temporaryBytes.withUnsafeBytes { libssh2_sftp_open_ex( sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(temporaryBytes.count), flags, 0o600, Int32(LIBSSH2_SFTP_OPENFILE)) } if let handle { return .value( SFTPChannelWriteContext( handle: handle, temporary: temporary, destination: destination, overwrite: overwrite)) } if libssh2_session_last_errno(libssh2Session) == LIBSSH2_ERROR_EAGAIN { return .again } throw Self.error(libssh2Session, sftp, "Unable to create \(destination)") } if continuing, let snapshot { let handle = SFTPChannelHandle(context.handle) let size = try await remoteIdentity(handle).byteCount guard size >= snapshot.offset else { try? await closeHandle(SFTPChannelHandle(context.handle)) throw SFTPResumeFailure.partialChanged } try await verifyRemotePrefix( handle, snapshot: snapshot, cancellation: cancellation) if size != snapshot.offset { try await truncate(handle, to: snapshot.offset) } } return context } private func finishWrite(_ context: SFTPChannelWriteContext, resume: SFTPResumeState) async throws { let handle = SFTPChannelHandle(context.handle) let snapshot = resume.snapshot guard try await remoteSize(handle) == snapshot.offset else { throw SFTPResumeFailure.partialChanged } if let editPolicy = snapshot.editPolicy { try await setPermissions(on: handle, to: editPolicy.permissions) let committed = try await remoteFile(at: context.destination, from: handle) resume.recordCommittedVersion(SFTPFileVersion(committed)) } try await closeHandle(handle) context.handleOpen = false if let editPolicy = snapshot.editPolicy { try editPolicy.validate(try await attributes(at: context.destination)) } if let existing = try await attributes(at: context.destination), !context.overwrite || existing.isDirectory { throw SFTPStreamUploadOperation.Failure.targetConflict } resume.willCommit() try await rename( from: context.temporary, to: context.destination, overwrite: context.overwrite) resume.didCommit() } private func remoteSize(_ handle: SFTPChannelHandle) async throws -> Int64 { try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() let result = libssh2_sftp_fstat_ex(handle.pointer, &attributes, 0) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0, attributes.flags & UInt(LIBSSH2_SFTP_ATTR_SIZE) != 0, attributes.filesize <= UInt64(Int64.max) else { throw Self.error(libssh2Session, sftp, "Unable to inspect remote file") } return .value(Int64(attributes.filesize)) } } private func truncate(_ handle: SFTPChannelHandle, to size: Int64) async throws { let _: Void = try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() attributes.flags = UInt(LIBSSH2_SFTP_ATTR_SIZE) attributes.filesize = UInt64(size) let result = libssh2_sftp_fstat_ex(handle.pointer, &attributes, 1) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error(libssh2Session, sftp, "Unable to trim incomplete file") } return .value(()) } } private func setPermissions(on handle: SFTPChannelHandle, to permissions: UInt32) async throws { let _: Void = try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) attributes.permissions = UInt(permissions & 0o7777) let result = libssh2_sftp_fstat_ex(handle.pointer, &attributes, 1) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error( libssh2Session, sftp, String(localized: "Unable to preserve remote file permissions")) } return .value(()) } } private func remoteFile( at path: String, from handle: SFTPChannelHandle ) async throws -> RemoteFile { try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() let result = libssh2_sftp_fstat_ex(handle.pointer, &attributes, 0) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0 else { throw Self.error(libssh2Session, sftp, "Unable to inspect remote file") } return .value(Self.remoteFile(path: path, attributes: attributes)) } } private func remoteIdentity(_ handle: SFTPChannelHandle) async throws -> SFTPSourceIdentity { try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() let result = libssh2_sftp_fstat_ex(handle.pointer, &attributes, 0) if result == LIBSSH2_ERROR_EAGAIN { return .again } guard result == 0, attributes.flags & UInt(LIBSSH2_SFTP_ATTR_SIZE) != 0, attributes.filesize <= UInt64(Int64.max), attributes.flags & UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) != 0, attributes.permissions & 0o170000 == 0o100000 else { throw SFTPResumeFailure.sourceChanged } return .value( SFTPSourceIdentity( byteCount: Int64(attributes.filesize), modifiedAt: attributes.flags & UInt(LIBSSH2_SFTP_ATTR_ACMODTIME) != 0 ? Date(timeIntervalSince1970: Double(attributes.mtime)) : nil)) } } private func verifyRemotePrefix( _ handle: SFTPChannelHandle, snapshot: SFTPResumeState.Snapshot, cancellation: TransferCancellationToken ) async throws { try await seek(handle, offset: 0) var remaining = snapshot.offset var digest = SHA256() while remaining > 0 { guard !cancellation.isCancelled else { throw SFTPError.cancelled } guard let data = try await read(handle, maximumBytes: Int(min(remaining, 64 * 1024))), !data.isEmpty else { throw SFTPResumeFailure.partialChanged } digest.update(data: data) remaining -= Int64(data.count) } guard digest.finalize() == snapshot.digest.finalize() else { throw SFTPResumeFailure.partialChanged } try await seek(handle, offset: snapshot.offset) } private static func verifyLocalPrefix( _ file: FileHandle, snapshot: SFTPResumeState.Snapshot, cancellation: TransferCancellationToken ) throws { var remaining = snapshot.offset var digest = SHA256() while remaining > 0 { guard !cancellation.isCancelled else { throw SFTPError.cancelled } let data = try file.read(upToCount: Int(min(remaining, 64 * 1024))) ?? Data() guard !data.isEmpty else { throw SFTPResumeFailure.partialChanged } digest.update(data: data) remaining -= Int64(data.count) } guard digest.finalize() == snapshot.digest.finalize() else { throw SFTPResumeFailure.partialChanged } } private static func localIdentity(_ url: URL) throws -> SFTPSourceIdentity { let values = try url.resourceValues( forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]) guard values.isRegularFile == true, let size = values.fileSize else { throw SFTPResumeFailure.sourceChanged } return SFTPSourceIdentity(byteCount: Int64(size), modifiedAt: values.contentModificationDate) } private static func error( _ libssh2Session: OpaquePointer, _ sftp: OpaquePointer, _ prefix: String ) -> SFTPError { let sftpCode = libssh2_sftp_last_error(sftp) let sessionCode = libssh2_session_last_errno(libssh2Session) let message = "\(prefix): SFTP error \(sftpCode)" if [UInt(LIBSSH2_FX_NO_CONNECTION), UInt(LIBSSH2_FX_CONNECTION_LOST)].contains(sftpCode) || [ LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED, ].contains(sessionCode) { return .connectionLost(message) } return .operation(message) } private func encodePath(_ path: String) throws -> Data { guard !path.utf8.contains(0) else { throw SFTPError.operation(String(localized: "A remote path contains an invalid null character.")) } return try RemoteCharsetStreamConverter.encode(path, charset: charset) } private nonisolated static func decodePath(_ data: Data, charset: RemoteCharset) throws -> String { try RemoteCharsetStreamConverter.decode(data, charset: charset) } private static func remoteFile( path: String, name: String? = nil, attributes: LIBSSH2_SFTP_ATTRIBUTES ) -> RemoteFile { let permissions = UInt32(attributes.permissions) let kind: RemoteFile.Kind switch permissions & 0o170000 { case 0o040000: kind = .directory case 0o100000: kind = .file case 0o120000: kind = .symbolicLink case 0: kind = .unknown default: kind = .special } return RemoteFile( path: path, name: name ?? URL(fileURLWithPath: path).lastPathComponent, kind: kind, size: attributes.filesize <= UInt64(Int64.max) ? Int64(attributes.filesize) : 0, modifiedAt: attributes.flags & UInt(LIBSSH2_SFTP_ATTR_ACMODTIME) != 0 ? Date(timeIntervalSince1970: Double(attributes.mtime)) : nil, permissions: permissions, ownerUID: UInt32(attributes.uid), ownerGID: UInt32(attributes.gid)) } } private actor SFTPChannelDownloadStream: SFTPDownloadStream { private let client: SFTPChannel private let handle: SFTPChannelHandle let sourceIdentity: SFTPSourceIdentity? let byteCount: Int64 private let cancellation: TransferCancellationToken private var closed = false private var received: Int64 = 0 private var eof = false init( client: SFTPChannel, handle: SFTPChannelHandle, identity: SFTPSourceIdentity, cancellation: TransferCancellationToken ) { self.client = client self.handle = handle sourceIdentity = identity byteCount = identity.byteCount self.cancellation = cancellation } func seek(to offset: Int64) async throws { guard offset >= 0, offset <= byteCount else { throw SFTPResumeFailure.partialChanged } try await client.streamSeek(handle, offset: offset) received = offset eof = false } func readChunk() async throws -> Data? { guard !eof else { return nil } guard let data = try await client.streamRead(handle, cancellation: cancellation) else { guard received == byteCount, try await client.streamIdentity(handle) == sourceIdentity else { throw SFTPDownloadFailure.sourceChanged } eof = true return nil } guard Int64(data.count) <= byteCount - received else { throw SFTPDownloadFailure.sourceChanged } received += Int64(data.count) return data } func close() async throws { guard !closed else { return } closed = true try await client.streamClose(handle) } }