97 lines
3.6 KiB
Swift
97 lines
3.6 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
/// Process-local state only. The digest covers acknowledged destination writes,
|
|
/// never bytes merely read from the source or queued on a socket.
|
|
public nonisolated final class SFTPResumeState: @unchecked Sendable {
|
|
public struct Snapshot: Sendable {
|
|
public var capturesFileVersion = false
|
|
public var editPolicy: SFTPEditUploadPolicy?
|
|
public var committedVersion: SFTPFileVersion?
|
|
public var temporaryPath: String?
|
|
public var offset: Int64 = 0
|
|
public var digest = SHA256()
|
|
public var source: SFTPSourceIdentity?
|
|
public var hasTemporaryFile = false
|
|
public var committed = false
|
|
public var committing = false
|
|
public var sha256: String { digest.finalize().map { String(format: "%02x", $0) }.joined() }
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var value = Snapshot()
|
|
public init(editPolicy: SFTPEditUploadPolicy? = nil, capturesFileVersion: Bool = false) {
|
|
value.editPolicy = editPolicy
|
|
value.capturesFileVersion = capturesFileVersion
|
|
}
|
|
public var snapshot: Snapshot { lock.withLock { value } }
|
|
|
|
func temporaryPath(for destination: String) -> String {
|
|
lock.withLock {
|
|
if let path = value.temporaryPath { return path }
|
|
let path = destination + ".termind-part-" + UUID().uuidString
|
|
value.temporaryPath = path
|
|
return path
|
|
}
|
|
}
|
|
|
|
public func validateSource(_ source: SFTPSourceIdentity) throws {
|
|
try lock.withLock {
|
|
if let previous = value.source, previous != source { throw SFTPResumeFailure.sourceChanged }
|
|
value.source = source
|
|
}
|
|
}
|
|
|
|
public func acknowledge(_ data: Data) {
|
|
lock.withLock {
|
|
value.digest.update(data: data)
|
|
value.offset += Int64(data.count)
|
|
}
|
|
}
|
|
|
|
func createdTemporaryFile() { lock.withLock { value.hasTemporaryFile = true } }
|
|
public func recordPrefix(digest: SHA256, offset: Int64) {
|
|
lock.withLock {
|
|
if offset >= value.offset {
|
|
value.digest = digest
|
|
value.offset = offset
|
|
}
|
|
}
|
|
}
|
|
public func cleanup(using channel: any SFTPChannelProtocol) async throws {
|
|
let current = snapshot
|
|
guard !current.committed, let path = current.temporaryPath else { return }
|
|
do {
|
|
if try await channel.attributes(at: path) != nil { try await channel.removeFile(at: path) }
|
|
} catch { throw SFTPTemporaryFileCleanupError(path: path, cause: error) }
|
|
}
|
|
|
|
func recordCommittedVersion(_ version: SFTPFileVersion) {
|
|
lock.withLock { value.committedVersion = version }
|
|
}
|
|
|
|
func willCommit() { lock.withLock { value.committing = true } }
|
|
func didCommit() { lock.withLock { value.committed = true } }
|
|
}
|
|
|
|
public nonisolated struct SFTPSourceIdentity: Equatable, Sendable {
|
|
public var byteCount: Int64
|
|
public var modifiedAt: Date?
|
|
public init(byteCount: Int64, modifiedAt: Date?) {
|
|
self.byteCount = byteCount
|
|
self.modifiedAt = modifiedAt
|
|
}
|
|
}
|
|
|
|
public nonisolated enum SFTPResumeFailure: LocalizedError, Sendable {
|
|
case sourceChanged, partialChanged, unsupported
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .sourceChanged: String(localized: "The source file changed. Start a new transfer.")
|
|
case .partialChanged: String(localized: "The incomplete file changed. Start a new transfer.")
|
|
case .unsupported: String(localized: "This connection does not support resuming transfers.")
|
|
}
|
|
}
|
|
}
|