Files
Termind/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditLocalFiles.swift
T
2026-09-17 13:50:06 +08:00

72 lines
2.8 KiB
Swift

import CryptoKit
import Foundation
import TermindApplication
/// Streams arbitrary regular files; never decodes content or filters filename extensions.
public actor SFTPEditLocalFiles {
public struct Snapshot: Sendable {
public let url: URL
public let digest: String
public let size: Int64
}
private struct Signature: Equatable {
let size: UInt64
let modified: Date?
let inode: UInt64
}
private var cache: [URL: (Signature, String)] = [:]
public init() {}
public func digest(_ url: URL, force: Bool = true) throws -> String {
let before = try signature(url)
if !force, let cached = cache[url], cached.0 == before { return cached.1 }
let file = try FileHandle(forReadingFrom: url)
defer { try? file.close() }
var hash = SHA256()
while let data = try file.read(upToCount: 64 * 1024), !data.isEmpty {
try Task.checkCancellation()
hash.update(data: data)
}
guard try signature(url) == before else { throw SFTPEditFailure.localChanged }
let digest = hash.finalize().map { String(format: "%02x", $0) }.joined()
cache[url] = (before, digest)
return digest
}
public func snapshot(_ url: URL) throws -> Snapshot {
let original = try digest(url)
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("TermindEditUploads", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let copy = directory.appendingPathComponent(url.lastPathComponent)
do {
try FileManager.default.copyItem(at: url, to: copy)
let copied = try digest(copy)
guard copied == original, try digest(url) == original else { throw SFTPEditFailure.localChanged }
return Snapshot(url: copy, digest: copied, size: Int64(try signature(copy).size))
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
}
public func removeSnapshot(_ snapshot: Snapshot) {
cache[snapshot.url] = nil
try? FileManager.default.removeItem(at: snapshot.url.deletingLastPathComponent())
}
public func forget(_ url: URL) { cache[url] = nil }
private func signature(_ url: URL) throws -> Signature {
let values = try FileManager.default.attributesOfItem(atPath: url.path)
guard values[.type] as? FileAttributeType == .typeRegular else { throw SFTPEditFailure.unsupported }
return Signature(
size: (values[.size] as? NSNumber)?.uint64Value ?? 0,
modified: values[.modificationDate] as? Date,
inode: (values[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0)
}
}