Files

170 lines
8.3 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CryptoKit
import Darwin
import Foundation
/// A bounded byte source, independent of HTTP, local files, and agent workspaces.
public nonisolated enum SFTPStreamUploadOperation {
public typealias ReadChunk = @Sendable () async throws -> Data?
public static let chunkBytes = 64 * 1_024
public enum Failure: Error, Sendable {
case invalidLength, invalidChunk, targetConflict, missingParent, timedOut
case digestMismatch(actualSHA256: String)
}
public static func run(
client: any SFTPChannelProtocol, remotePath: String, byteCount: Int64, sha256: String,
overwrite: Bool, cancellation: TransferCancellationToken,
idleTimeout: Duration = .seconds(60), resume: SFTPResumeState? = nil,
read: @escaping ReadChunk, progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws -> String {
guard byteCount >= 0 else { throw Failure.invalidLength }
let parent = (remotePath as NSString).deletingLastPathComponent
guard try await client.attributes(at: parent)?.isDirectory == true else { throw Failure.missingParent }
if resume?.snapshot.committing != true,
let target = try await client.attributes(at: remotePath), target.isDirectory || !overwrite
{
throw Failure.targetConflict
}
let relay = try SFTPSocketRelay()
let observer = cancellation.observeCancellation { relay.stop() }
defer {
cancellation.removeObserver(observer)
relay.close()
}
let checkpoint = resume?.snapshot
let source = SourceState()
let watchdog = Task {
while !Task.isCancelled {
do { try await Task.sleep(for: min(.seconds(1), idleTimeout)) } catch { return }
if source.idleDuration >= idleTimeout {
source.record(Failure.timedOut)
cancellation.cancel()
return
}
}
}
defer { watchdog.cancel() }
let writerQueue = DispatchQueue(label: "termind.sftp.stream.source")
return try await withTaskCancellationHandler {
do {
return try await withThrowingTaskGroup(of: String?.self) { group in
group.addTask {
do {
var digest = checkpoint?.digest ?? SHA256()
var received: Int64 = checkpoint?.offset ?? 0
while let chunk = try await read() {
try Task.checkCancellation()
guard !cancellation.isCancelled else { throw SFTPError.cancelled }
guard !chunk.isEmpty, chunk.count <= chunkBytes else { throw Failure.invalidChunk }
guard Int64(chunk.count) <= byteCount - received else { throw Failure.invalidLength }
received += Int64(chunk.count)
source.touch()
digest.update(data: chunk)
try await writerQueue.perform {
try chunk.withUnsafeBytes { bytes in
var offset = 0
while offset < bytes.count {
guard !cancellation.isCancelled else { throw SFTPError.cancelled }
let count = Darwin.send(
relay.writer, bytes.baseAddress!.advanced(by: offset),
bytes.count - offset, 0
)
if count > 0 {
offset += count
} else if count < 0 && errno == EINTR {
continue
} else {
throw SFTPError.cancelled
}
}
}
}
}
guard received == byteCount else { throw Failure.invalidLength }
let actual = digest.finalize().map { String(format: "%02x", $0) }.joined()
guard actual == sha256.lowercased() else {
throw Failure.digestMismatch(actualSHA256: actual)
}
relay.finishWriting()
return actual
} catch {
if !cancellation.isCancelled { source.record(error) }
cancellation.cancel()
throw error
}
}
group.addTask {
do {
if let resume {
try await client.uploadStream(
fromSocket: relay.reader, to: remotePath, byteCount: byteCount,
overwrite: overwrite,
cancellation: cancellation, resume: resume,
progress: { value in
source.touch()
progress(value)
},
sourceSucceeded: { relay.sourceSucceeded }
)
} else {
try await client.uploadStream(
fromSocket: relay.reader, to: remotePath, byteCount: byteCount,
overwrite: overwrite,
cancellation: cancellation,
progress: { value in
source.touch()
progress(value)
},
sourceSucceeded: { relay.sourceSucceeded }
)
}
} catch {
if let cleanup = error as? SFTPTemporaryFileCleanupError {
source.recordCleanup(cleanup.path)
}
throw error
}
return nil
}
var actual = ""
do {
while let result = try await group.next() { if let result { actual = result } }
return actual
} catch {
cancellation.cancel()
group.cancelAll()
throw error
}
}
} catch {
if let path = source.cleanupPath {
let cleanup = error as? SFTPTemporaryFileCleanupError
throw SFTPTemporaryFileCleanupError(path: path, cause: source.failure ?? cleanup?.cause ?? error)
}
throw source.failure ?? error
}
} onCancel: {
cancellation.cancel()
}
}
private final class SourceState: @unchecked Sendable {
private let lock = NSLock()
private var error: (any Error)?
private var remainingPath: String?
var cleanupPath: String? { lock.withLock { remainingPath } }
func recordCleanup(_ path: String) { lock.withLock { remainingPath = path } }
private var lastProgress = ContinuousClock.now
var idleDuration: Duration {
lock.withLock { ContinuousClock.now - lastProgress }
}
func touch() { lock.withLock { lastProgress = .now } }
var failure: (any Error)? { lock.withLock { error } }
func record(_ error: any Error) { lock.withLock { if self.error == nil { self.error = error } } }
}
}