import Foundation import NIO import Citadel final class SFTPService: NASTransferProtocol { private var client: SSHClient? private var sftp: SFTPClient? private(set) var isConnected: Bool = false func connect(to host: String, port: Int, username: String, password: String) async throws { do { let c = try await SSHClient.connect( host: host, port: port, authenticationMethod: .passwordBased(username: username, password: password), hostKeyValidator: .acceptAnything(), reconnect: .never ) let sftpClient = try await c.openSFTP() self.client = c self.sftp = sftpClient self.isConnected = true } catch { throw BackupError.connectionFailed(host) } } func disconnect() { Task { try? await sftp?.close() client = nil } sftp = nil isConnected = false } func listShares() async throws -> [String] { [] } func listDirectory(at path: String) async throws -> [NASItem] { guard let sftp else { throw BackupError.connectionFailed("Not connected") } do { let batches = try await sftp.listDirectory(atPath: path) // listDirectory returns [SFTPMessage.Name]; each Name contains multiple SFTPPathComponent let components = batches.flatMap { $0.components } return components.compactMap { component -> NASItem? in guard component.filename != "." && component.filename != ".." else { return nil } let fullPath = path.hasSuffix("/") ? "\(path)\(component.filename)" : "\(path)/\(component.filename)" let isDir = component.attributes.permissions.map { ($0 & 0o170000) == 0o040000 } ?? false return NASItem( name: component.filename, path: fullPath, isDirectory: isDir, size: Int64(component.attributes.size ?? 0), modifiedDate: nil ) } } catch { throw BackupError.directoryListFailed(path) } } func createDirectory(at path: String) async throws { guard let sftp else { throw BackupError.connectionFailed("Not connected") } do { try await sftp.createDirectory(atPath: path) } catch { throw BackupError.directoryCreateFailed(path) } } func fileExists(at remotePath: String) async throws -> Bool { guard let sftp else { throw BackupError.connectionFailed("Not connected") } do { _ = try await sftp.getAttributes(at: remotePath) return true } catch { return false } } func downloadData(at remotePath: String) async throws -> Data { guard let sftp else { throw BackupError.connectionFailed("Not connected") } return try await sftp.withFile(filePath: remotePath, flags: [.read]) { file in let buf = try await file.readAll() return Data(buf.readableBytesView) } } func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let sftp else { throw BackupError.connectionFailed("Not connected") } let filename = localURL.lastPathComponent do { let data = try Data(contentsOf: localURL) let total = Int64(data.count) progress(0, total) try await sftp.withFile( filePath: remotePath, flags: [.write, .create, .truncate] ) { file in let buffer = ByteBuffer(data: data) try await file.write(buffer, at: 0) } progress(total, total) } catch { throw BackupError.uploadFailed(filename, underlying: error) } } }