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:

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.