SFTP
Open
An SFTPClient is opened on a dedicated SSHConnection. While the client is alive that connection is the SFTP subsystem — no other high-level job can run on it.
let connection = try await SSHClient.connect(configuration: configuration)
let sftp = try await connection.openSFTP()
defer { Task { try? await sftp.close() } }
Listing & stat
for entry in try await sftp.listDirectory("/var/log") {
print(entry.filename, entry.attributes?.size ?? 0)
}
let attributes = try await sftp.stat("/etc/hosts")
print(attributes.size, attributes.permissions, attributes.modifiedAt as Any)
let resolved = try await sftp.realpath("./relative/path")
print(resolved)
Whole-file I/O
let data = try await sftp.readFile("/etc/hostname")
try await sftp.writeFile(Data("hello\n".utf8), to: "/tmp/hello.txt")
try await sftp.setPermissions(0o644, at: "/tmp/hello.txt")
File handles
For chunked I/O or seeking, open a handle.
let handle = try await sftp.openFile(
"/tmp/big.bin",
flags: [.read],
permissions: 0o600
)
defer { Task { try? await handle.close() } }
while true {
let chunk = try await handle.readData(maximumLength: 64 * 1024)
if chunk.isEmpty { break }
// process chunk
}
Flags are an option set: .read, .write, .create, .truncate, .append. Combine them as needed ([.write, .create, .truncate]).
Directories & symlinks
try await sftp.createDirectory("/tmp/staging", permissions: 0o755)
try await sftp.removeDirectory("/tmp/staging")
try await sftp.removeFile("/tmp/old.zip")
try await sftp.rename("/tmp/new.zip", to: "/tmp/old.zip")
try await sftp.createSymbolicLink("/tmp/link", targetPath: "/srv/payload")
let target = try await sftp.readLink("/tmp/link")
Transfers
try await sftp.upload(
localURL: URL(fileURLWithPath: "/Users/me/release.zip"),
to: "/srv/release.zip"
) { sent, total in
print("\(sent) / \(total)")
}
try await sftp.download(
remotePath: "/srv/release.zip",
to: URL(fileURLWithPath: "/Users/me/release.zip")
)
Progress callbacks deliver (completedBytes, totalBytes). totalBytes is the source size (local for uploads, remote for downloads).
Resumable transfers
resumeUpload and resumeDownload continue where a previous attempt stopped:
- resumeUpload reads the remote file’s current size from the server, seeks both sides to that offset, and writes the rest of the local file. It fails if the remote file is larger than the local one.
- resumeDownload reads the local file’s current size, seeks the remote file to that offset, and appends to the local file with
O_APPEND. It fails if the local file is larger than the remote one.
try await sftp.resumeUpload(
localURL: URL(fileURLWithPath: "/Users/me/release.zip"),
to: "/srv/release.zip"
)
try await sftp.resumeDownload(
remotePath: "/srv/release.zip",
to: URL(fileURLWithPath: "/Users/me/release.zip")
)
Task cancellation tears the underlying connection down — any pending operations on the same SFTPClient fail with SSHKitError(.cancelled) and the client is unusable afterwards.