Traversio

Transfers

Read whole files, use bounded concurrent read or write requests, observe transfer progress, and understand the current transfer limits.

Reading Files

Use readFile(_:,chunkSize:maxConcurrentReads:progress:) to fetch an entire file into memory:

let data = try await sftp.readFile("/etc/hostname")
let text = String(decoding: data, as: UTF8.self)
print(text)

The default chunk size is 32 * 1024 bytes. You can adjust it:

let data = try await sftp.readFile("/var/log/system.log", chunkSize: 8 * 1024)

Read behavior:

  • Traversio opens the remote file handle for you
  • reads sequentially until EOF by default
  • closes the handle before returning

For a small amount of read-ahead on one handle, increase maxConcurrentReads:

let data = try await sftp.readFile(
    "/var/log/system.log",
    chunkSize: 32 * 1024,
    maxConcurrentReads: 4
)

That keeps at most four SSH_FXP_READ requests in flight on one handle and still reassembles the final file contents in order.

Use downloadFile(_:,to:expectedSize:chunkSize:maxConcurrentReads:progress:shouldContinue:) when the destination is a local file URL and you want Traversio to own the streaming loop:

let localURL = FileManager.default.temporaryDirectory
    .appendingPathComponent("hostname.txt")

try await sftp.downloadFile(
    "/etc/hostname",
    to: localURL,
    expectedSize: 32
)

Current local-file download behavior:

  • Traversio creates parent directories for the destination URL
  • replaces an existing file at that exact path
  • streams chunks from one remote handle into the local file
  • uses a bounded read window when expectedSize is available, with maxConcurrentReads defaulting to 64
  • reports cumulative SSHSFTPTransferProgress values when you pass progress:
  • checks task cancellation and shouldContinue before remote work and between chunks

Use downloadDirectory(_:,to:chunkSize:maxConcurrentReads:progress:shouldContinue:) when you want Traversio to recurse through a remote directory tree:

let localDirectory = FileManager.default.temporaryDirectory
    .appendingPathComponent("logs", isDirectory: true)

let summary = try await sftp.downloadDirectory(
    "/var/log/archive",
    to: localDirectory
)

print(summary.filesTransferred)
print(summary.skippedEntries)

Current recursive download behavior:

  • Traversio creates the destination directory when it does not exist
  • recursively walks regular files and directories
  • streams each file through the existing downloadFile(...) helper
  • forwards maxConcurrentReads to each file download
  • skips symbolic links and other unsupported entry kinds
  • reports cumulative transferred bytes through SSHSFTPTransferProgress, with totalBytes currently left as nil
  • returns SSHSFTPDirectoryTransferSummary with file, directory, and skipped-entry counts
  • checks task cancellation and shouldContinue before listing directories and between entries

Use resumeDownloadFile(_:,existingData:,chunkSize:maxConcurrentReads:progress:) when you already have a trusted local prefix of the remote file and want Traversio to fetch only the remaining suffix:

let partial = Array("hello ".utf8)

let result = try await sftp.resumeDownloadFile(
    "/var/log/traversio.log",
    existingData: partial,
    chunkSize: 32 * 1024,
    maxConcurrentReads: 4
)

print(result.startingOffset)
print(result.bytesDownloaded)
print(String(decoding: result.data, as: UTF8.self))

Current resume behavior:

  • Traversio first runs STAT on the remote path
  • the helper compares the reported remote size with existingData.count
  • if the remote file is larger, Traversio opens the file and starts reading at that offset
  • if the local prefix already covers the full remote size, the helper returns immediately without opening a file handle
  • if the remote size is smaller than the local prefix, the helper throws SSHSFTPResumeError.remoteFileIsSmallerThanLocalData
  • if the server returns attributes without a file size, the helper throws SSHSFTPResumeError.remoteFileSizeUnavailable

Writing Files

Use writeFile(_:,data:,chunkSize:maxConcurrentWrites:syncAfterWrite:progress:) for whole-file uploads:

let payload = Array("hello from traversio\n".utf8)
try await sftp.writeFile("/tmp/traversio-demo.txt", data: payload)

Default write behavior:

  • sequential chunked writes unless you raise maxConcurrentWrites
  • chunkSize defaults to 32 * 1024
  • the convenience path opens the file with .write, .create, and .truncate

writeFile replaces existing contents unless you switch to a lower-level open-file workflow.

Use uploadFile(from:to:attributes:chunkSize:maxConcurrentWrites:syncAfterWrite:progress:shouldContinue:) when the source already lives at a local file URL and you want Traversio to own the chunked read/write loop:

let localURL = URL(fileURLWithPath: "/tmp/traversio-demo.txt")

try await sftp.uploadFile(
    from: localURL,
    to: "/tmp/traversio-demo.txt"
)

Current local-file upload behavior:

  • Traversio reads the local file incrementally instead of loading it all into memory first
  • opens the remote file with .write, .create, and .truncate
  • keeps a bounded write window by default, with maxConcurrentWrites defaulting to 16
  • reports cumulative SSHSFTPTransferProgress values when you pass progress:
  • leaves security-scoped resource ownership to the caller
  • checks task cancellation and shouldContinue before remote work and between chunks

Use uploadDirectory(from:to:fileAttributes:directoryAttributes:chunkSize:maxConcurrentWrites:syncAfterWrite:progress:shouldContinue:) when you want Traversio to recurse through a local directory tree:

let localDirectory = URL(fileURLWithPath: "/tmp/project-cache", isDirectory: true)

let summary = try await sftp.uploadDirectory(
    from: localDirectory,
    to: "/tmp/project-cache"
)

print(summary.bytesTransferred)
print(summary.directoriesTraversed)

Current recursive upload behavior:

  • Traversio ensures each remote directory exists before uploading children
  • if mkdir fails because the destination already exists, Traversio confirms that with stat(...) and continues
  • recursively walks local regular files and directories
  • streams each file through the existing uploadFile(...) helper
  • forwards maxConcurrentWrites to each file upload
  • skips symbolic links and other unsupported entry kinds
  • reports cumulative transferred bytes through SSHSFTPTransferProgress, with totalBytes currently left as nil
  • leaves security-scoped resource ownership and cancellation cleanup policy to the caller
  • checks task cancellation and shouldContinue before remote mutations and between entries

Use resumeUploadFile(_:,data:,chunkSize:maxConcurrentWrites:syncAfterWrite:progress:) when the remote path may already contain a prefix of the payload and you want Traversio to continue from the server-reported file size:

let payload = Array("hello from traversio\n".utf8)

let result = try await sftp.resumeUploadFile(
    "/tmp/traversio-demo.txt",
    data: payload
)

print(result.startingOffset)
print(result.bytesUploaded)
print(result.didResume)

Current resume behavior:

  • Traversio first runs STAT on the target path
  • SSH_FX_NO_SUCH_FILE starts the upload from offset 0
  • an existing remote file resumes from its reported size
  • Traversio opens the file with .write and .create, without .truncate
  • if the remote size is larger than the local payload, the helper throws SSHSFTPResumeError.remoteFileIsLargerThanLocalData
  • if the server returns attributes without a file size, the helper throws SSHSFTPResumeError.remoteFileSizeUnavailable

For bounded write pipelining on one handle, increase maxConcurrentWrites:

try await sftp.writeFile(
    "/tmp/traversio-demo.txt",
    data: payload,
    chunkSize: 32 * 1024,
    maxConcurrentWrites: 4
)

That keeps at most four SSH_FXP_WRITE requests in flight at a time while still waiting for every status reply before the upload finishes.

If the server advertises OpenSSH [email protected] version 1, you can also ask Traversio to issue an explicit post-write durability request before the handle closes:

try await sftp.writeFile(
    "/tmp/traversio-demo.txt",
    data: payload,
    syncAfterWrite: true
)

If the extension is not advertised, Traversio fails the call instead of pretending the data was synced.

Working With File Handles

Use openFile(_:,flags:,attributes:) when you need offset-based reads or writes, handle-scoped metadata, or an explicit fsync step under your own control:

let handle = try await sftp.openFile(
    "/tmp/traversio-demo.txt",
    flags: [.read, .write, .create]
)

let prefix = try await handle.read(at: 0, length: 64)
print(prefix.map { String(decoding: $0, as: UTF8.self) } as Any)

try await handle.write(Array("tail\n".utf8), at: 5)
try await handle.seek(to: 0)
let cursorPrefix = try await handle.read(length: 64)
print(cursorPrefix.map { String(decoding: $0, as: UTF8.self) } as Any)
try await handle.synchronize()

let attributes = try await handle.stat()
print(attributes.permissions as Any)

try await handle.close()

The public handle surface exposes:

  • tell()
  • seek(to:)
  • rewind()
  • read(length:)
  • read(at:length:)
  • readAll(chunkSize:maxConcurrentReads:progress:)
  • readChunks(startingAt:chunkSize:)
  • write(_:)
  • write(_:at:)
  • write(contentsOf:startingAt:progress:)
  • stat()
  • setAttributes(_:)
  • fileSystemAttributes()
  • synchronize()
  • close()

Use the cursor methods when you want file-descriptor-style position tracking on one handle. Use the offset methods when the caller owns random-access positions or a transfer loop that schedules requests independently.

readFile(...) and writeFile(...) remain the whole-file convenience wrappers. They are the best default when you do not need explicit handle control.

If you already have a handle and want the same bounded whole-file read helper there, use readAll(...):

let handle = try await sftp.openFile("/var/log/system.log")
let data = try await handle.readAll(
    chunkSize: 32 * 1024,
    maxConcurrentReads: 4
)
try await handle.close()

Handle-Level Streaming

Use readChunks(startingAt:chunkSize:) when you want a caller-controlled chunk stream instead of collecting the whole file in memory:

let handle = try await sftp.openFile("/var/log/system.log")

for try await chunk in handle.readChunks(chunkSize: 8 * 1024) {
    print(chunk.offset)
    print(chunk.bytes.count)
}

try await handle.close()

Each yielded SSHSFTPFileChunk carries:

  • the remote offset used for that read request
  • the bytes returned for that chunk
  • convenience accessors like count and endOffset

Use write(contentsOf:startingAt:progress:) when your upload source is already an AsyncSequence of byte chunks:

let handle = try await sftp.openFile(
    "/tmp/traversio-stream.txt",
    flags: [.write, .create, .truncate]
)

let stream = AsyncStream<[UInt8]> { continuation in
    continuation.yield(Array("hello ".utf8))
    continuation.yield(Array("world\n".utf8))
    continuation.finish()
}

try await handle.write(contentsOf: stream)
try await handle.close()

Streaming write behavior:

  • chunks are written sequentially in the order your AsyncSequence yields them
  • startingAt: lets you resume from a caller-chosen offset
  • progress is cumulative for the current streaming write call and leaves totalBytes empty
  • handle-level streaming keeps ownership of open/close with the caller

Remote-To-Remote Copy

Use SFTPRemoteTransfer.copyFile(...) when both endpoints are SFTP clients and the data should stream through the client process without first writing a persistent local file:

let bytesCopied = try await SFTPRemoteTransfer.copyFile(
    from: sourceSFTP,
    sourcePath: "/var/log/app.log",
    to: destinationSFTP,
    destinationPath: "/backup/app.log",
    progress: { progress in
        print(progress.bytesTransferred)
    }
)

This is a client-mediated transfer:

source server -> Traversio client process -> destination server

It is not a server-side copy between two unrelated servers. Traversio opens the source handle for read, opens the destination handle with .write, .create, and .truncate, streams chunks in order, reports cumulative progress, observes cancellation and shouldContinue, and closes both handles on success or failure.

Use SFTPRemoteTransfer.copyDirectory(...) for a conservative recursive directory copy:

let summary = try await SFTPRemoteTransfer.copyDirectory(
    from: sourceSFTP,
    sourcePath: "/var/log/app",
    to: destinationSFTP,
    destinationPath: "/backup/app"
)

print(summary.filesTransferred)
print(summary.skippedEntries)

Current recursive remote-copy behavior:

  • creates destination directories before copying children
  • recursively copies regular files and directories
  • skips symbolic links and other unsupported entry kinds
  • returns SSHSFTPDirectoryTransferSummary
  • reports cumulative bytes through SSHSFTPTransferProgress

Progress Callbacks

The whole-file convenience APIs can now report cumulative transfer progress through SSHSFTPTransferProgress.

Example:

let payload = Array(repeating: UInt8(ascii: "a"), count: 128 * 1024)

try await sftp.writeFile(
    "/tmp/traversio-demo.txt",
    data: payload,
    chunkSize: 32 * 1024,
    maxConcurrentWrites: 4,
    progress: { progress in
        print(progress.bytesTransferred)
        print(progress.totalBytes as Any)
        print(progress.fractionCompleted as Any)
    }
)

Current progress behavior:

  • readFile(...) and SFTPFileHandle.readAll(...) report cumulative bytesTransferred
  • resumeDownloadFile(...) reports cumulative read progress against the full remote length, starting from the already-present local prefix when a resume actually happens
  • writeFile(...) reports cumulative bytesTransferred plus totalBytes
  • resumeUploadFile(...) reports cumulative write progress against the full payload length, starting from the already-present remote prefix when a resume actually happens
  • write(contentsOf:startingAt:progress:) reports cumulative bytesTransferred for the streamed portion and leaves totalBytes empty
  • the callback runs on the transfer task, so expensive work inside the callback also becomes part of the transfer's pacing
  • progress is reported after Traversio has successfully appended one read chunk or received one write status reply

Transfer Continuation

Local-file and recursive directory helpers accept an optional shouldContinue callback:

try await sftp.downloadFile(
    "/var/log/system.log",
    to: localURL,
    progress: { progress in
        print(progress.bytesTransferred)
    },
    shouldContinue: {
        await taskStore.isTransferActive(id: taskID)
    }
)

Current continuation behavior:

  • returning false stops the helper with CancellationError
  • Task.cancel() is still observed through Swift task cancellation
  • file handles opened by the helper are closed during cancellation cleanup
  • recursive helpers pass the same callback into each child file transfer
  • caller-owned cleanup such as partial-file deletion and security-scoped resource release stays with the app

A Small Round Trip

let file = "/tmp/traversio-demo.txt"

try await sftp.writeFile(file, data: Array("hello\n".utf8))
let roundTrip = try await sftp.readFile(file)

print(String(decoding: roundTrip, as: UTF8.self))

Transfer Limits

Important limits:

  • whole-file reads and uploads support a bounded number of concurrent SFTP requests on one handle
  • handle-level streaming download and upload APIs are now part of the current release
  • resumable whole-file upload and download are now part of the current release
  • remote-to-remote helpers stream through the client process; they are not server-side copies
  • handle-scoped reads and writes use request/response operations with explicit offsets
  • syncAfterWrite depends on OpenSSH [email protected]
  • local-file and recursive directory helpers observe task cancellation plus SSHSFTPTransferContinuationHandler

On this page