15 KiB
russh-sftp: Client Implementation
Client Architecture Overview
The client side provides two tiers of API:
-
RawSftpSession— Low-level request-response client that sends individual SFTP packets and awaits their responses. Suitable for custom or non-standard SFTP interactions. -
SftpSession— High-level client modeled afterstd::fs. Provides ergonomic methods for file operations and createsFileobjects implementingAsyncRead/AsyncWrite/AsyncSeek.
Both operate on a stream type S: AsyncRead + AsyncWrite + Unpin + Send + 'static.
Client Handler Trait
The raw client infrastructure uses an internal Handler trait to dispatch incoming response packets:
pub trait Handler: Sized {
type Error: Into<Error>;
fn version(&mut self, version: Version) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn status(&mut self, status: Status) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn handle(&mut self, handle: Handle) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn data(&mut self, data: Data) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn name(&mut self, name: Name) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn attrs(&mut self, attrs: Attrs) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn extended_reply(&mut self, reply: ExtendedReply) -> impl Future<Output = Result<(), Self::Error>> + Send;
}
With the async-trait feature enabled, this becomes #[async_trait].
The run() function spawns two tasks:
- Reader task: reads packets from the stream, dispatches to the handler
- Writer task: sends serialized packets from an
mpsc::UnboundedSender<Bytes>
pub fn run<S, H>(stream: S, handler: H) -> mpsc::UnboundedSender<Bytes>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
H: Handler + Send + 'static,
The returned sender is the write handle — callers push Bytes (serialized packets) into it.
RawSftpSession
RawSftpSession is the core request-response client. It implements Handler internally via SessionInner to route response packets back to awaiting request futures.
Construction
impl RawSftpSession {
pub fn new<S>(stream: S) -> Self
where S: AsyncRead + AsyncWrite + Unpin + Send + 'static;
pub fn new_with_config<S>(stream: S, cfg: Config) -> Self
where S: AsyncRead + AsyncWrite + Unpin + Send + 'static;
}
Internal Architecture
┌─────────────────────────────────────────────────────┐
│ RawSftpSession │
│ │
│ tx: UnboundedSender<Bytes> ←── write to stream │
│ requests: Arc<DashMap<Option<u32>, oneshot::Sender>>│
│ next_req_id: AtomicU32 │
│ handles: AtomicU64 │
│ timeout: AtomicU64 │
│ limits: Limits │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ SessionInner (Handler impl) │ │
│ │ │ │
│ │ version() → stores version, replies to init │ │
│ │ status() → replies by request ID │ │
│ │ handle() → replies by request ID │ │
│ │ data() → replies by request ID │ │
│ │ name() → replies by request ID │ │
│ │ attrs() → replies by request ID │ │
│ │ extended_reply() → replies by request ID │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Request-Response Mechanism
- Caller invokes a method like
raw.open(filename, flags, attrs).await RawSftpSessiongenerates a uniqueidviaAtomicU32::fetch_add- Creates a
oneshot::channeland inserts(Some(id), sender)into theDashMap - Serializes the packet to
Bytesand sends it via thetxchannel - Awaits the
oneshot::Receiverwith a timeout - When the reader task receives a response packet,
SessionInner::reply()removes the entry from theDashMapand sends thePacketthrough theoneshotsender - The original request future receives the
Packetand pattern-matches it
The init() method is special: it uses id: None (no request ID) and the version handler stores the negotiated version.
Limits Tracking
pub struct Limits {
pub packet_len: Option<u64>,
pub read_len: Option<u64>,
pub write_len: Option<u64>,
pub open_handles: Option<u64>,
}
- Fetched from the server via the
limits@openssh.comextension duringSftpSession::new_with_config() - Enforced client-side:
open()andopendir()checkopen_handles,read()checksread_len,write()andwrite_nowait()checkwrite_len,send()checkspacket_len
API Methods
| Method | SFTP Packet | Response | Notes |
|---|---|---|---|
init() |
Init |
Version |
Handshake; version negotiation |
open(filename, flags, attrs) |
Open |
Handle |
Opens a file; increments handle count |
close(handle) |
Close |
Status |
Closes a handle; decrements handle count |
close_nowait(handle) |
Close |
— | Fire-and-forget close (returns oneshot receiver) |
read(handle, offset, len) |
Read |
Data |
Reads data; respects Limits.read_len |
write(handle, offset, data) |
Write |
Status |
Writes data; respects Limits.write_len |
write_nowait(handle, offset, data) |
Write |
— | Fire-and-forget write |
lstat(path) |
Lstat |
Attrs |
Stat without following symlinks |
fstat(handle) |
Fstat |
Attrs |
Stat an open handle |
setstat(path, attrs) |
SetStat |
Status |
Set file attributes by path |
fsetstat(handle, attrs) |
FSetStat |
Status |
Set file attributes by handle |
opendir(path) |
OpenDir |
Handle |
Opens a directory; increments handle count |
readdir(handle) |
ReadDir |
Name |
Reads directory entries |
remove(filename) |
Remove |
Status |
Deletes a file |
mkdir(path, attrs) |
MkDir |
Status |
Creates a directory |
rmdir(path) |
RmDir |
Status |
Removes a directory |
realpath(path) |
RealPath |
Name |
Canonicalizes a path |
stat(path) |
Stat |
Status |
Stat following symlinks |
rename(oldpath, newpath) |
Rename |
Status |
Renames a file |
readlink(path) |
ReadLink |
Name |
Reads symlink target |
symlink(path, target) |
Symlink |
Status |
Creates a symbolic link |
extended(request, data) |
Extended |
Packet |
Generic extension mechanism |
limits() |
Extended |
LimitsExtension |
limits@openssh.com |
hardlink(oldpath, newpath) |
Extended |
Status |
hardlink@openssh.com |
fsync(handle) |
Extended |
Status |
fsync@openssh.com |
statvfs(path) |
Extended |
Statvfs |
statvfs@openssh.com v2 |
Response Classification
Two macros handle the common response patterns:
// For responses that return a specific packet type or error status
macro_rules! into_with_status {
($result:ident, $packet:ident) => {
match $result {
Packet::$packet(p) => Ok(p),
Packet::Status(p) => Err(p.into()),
_ => Err(Error::UnexpectedPacket),
}
};
}
// For responses that only return success/failure status
macro_rules! into_status {
($result:ident) => {
match $result {
Packet::Status(status) if status.status_code == StatusCode::Ok => Ok(status),
Packet::Status(status) => Err(status.into()),
_ => Err(Error::UnexpectedPacket),
}
};
}
Drop Behavior
RawSftpSession implements Drop to call close_session(), which sends an empty Bytes to signal the writer task to shut down the stream.
SftpSession (High-Level Client)
SftpSession wraps RawSftpSession in an Arc and provides std::fs-like methods:
pub struct SftpSession {
session: Arc<RawSftpSession>,
features: Features,
}
Construction and Version Negotiation
impl SftpSession {
pub async fn new<S>(stream: S) -> SftpResult<Self>;
pub async fn new_with_config<S>(stream: S, cfg: Config) -> SftpResult<Self>;
}
Construction performs:
- Creates
RawSftpSessionwith config - Sends
SSH_FXP_INITviasession.init().await - Checks for supported extensions in the version response
- If
limits@openssh.comextension is available, fetches and applies limits - Stores detected feature flags in
Features
Features Detection
pub(crate) struct Features {
pub hardlink: bool, // hardlink@openssh.com v1
pub fsync: bool, // fsync@openssh.com v1
pub statvfs: bool, // statvfs@openssh.com v2
pub limits: Option<Limits>, // from limits@openssh.com
pub max_concurrent_writes: usize,
pub max_packet_len: u32,
}
High-Level API
| Method | Description |
|---|---|
open(filename) |
Opens file read-only |
create(filename) |
Creates/truncates file write-only |
open_with_flags(filename, flags) |
Opens with specified OpenFlags |
open_with_flags_and_attributes(filename, flags, attrs) |
Opens with flags and initial attrs |
canonicalize(path) |
Resolves to absolute path via realpath |
create_dir(path) |
Creates directory |
read(path) |
Reads entire file to Vec<u8> |
write(path, data) |
Writes data to file |
try_exists(path) |
Checks existence (returns Ok(false) on NoSuchFile) |
read_dir(path) |
Returns ReadDir iterator over directory entries |
read_link(path) |
Reads symlink target |
remove_dir(path) |
Removes directory |
remove_file(filename) |
Removes file |
rename(oldpath, newpath) |
Renames file/directory |
symlink(path, target) |
Creates symbolic link |
metadata(path) |
Gets FileAttributes via stat |
set_metadata(path, metadata) |
Sets attributes via setstat |
symlink_metadata(path) |
Gets FileAttributes via lstat |
hardlink(oldpath, newpath) |
Creates hard link (returns Ok(false) if unsupported) |
fs_info(path) |
Gets filesystem stats via statvfs (returns Ok(None) if unsupported) |
set_timeout(secs) |
Sets request timeout |
close() |
Closes the SFTP session stream |
File (Async I/O)
client::fs::File implements AsyncRead, AsyncWrite, and AsyncSeek for remote file I/O:
pub struct File {
session: Arc<RawSftpSession>,
handle: String,
state: FileState,
pos: u64,
closed: bool,
features: Features,
}
struct FileState {
f_read: Option<Pin<Box<dyn Future<Output = io::Result<Option<Vec<u8>>>>>>>,
f_seek: Option<Pin<Box<dyn Future<Output = io::Result<u64>>>>>,
f_flush: Option<Pin<Box<dyn Future<Output = io::Result<()>>>>>,
f_shutdown: Option<Pin<Box<dyn Future<Output = io::Result<()>>>>>,
write_acks: VecDeque<oneshot::Receiver<SftpResult<Packet>>>,
}
AsyncRead Implementation
- Uses a state-machine pattern:
f_readstores the in-progress read future - Calculates read length as
min(remaining_buffer, max_read_len)wheremax_read_lencomes fromLimits.read_lenormax_packet_len - 9(read overhead) - On
StatusCode::Eof, returnsOk(None)→ signals clean EOF to the caller - Advances
posby the data length on successful read
AsyncWrite Implementation
- Implements pipelined writes with configurable concurrency (
max_concurrent_writes) - Uses
write_nowait()to fire off write requests without awaiting each ACK - Stores
oneshot::Receivers inwrite_acks: VecDeque - When
write_acks.len() >= max_concurrent_writes, polls the oldest pending ACK before accepting new writes - Write chunk size:
min(data.len(), max_write_len)wheremax_write_lencomes fromLimits.write_lenormax_packet_len - 21 - handle_length
Overhead constants:
const READ_OVERHEAD_LENGTH: u32 = 9; // type(1) + id(4) + data_len(4)
const WRITE_OVERHEAD_LENGTH: u32 = 21; // type(1) + id(4) + handle_len(4) + offset(8) + data_len(4)
AsyncSeek Implementation
SeekFrom::Start(pos)— direct position setSeekFrom::Current(delta)— arithmetic on currentposSeekFrom::End(delta)— requires anfstat()round-trip to get file size, then computes position
Flush and Shutdown
poll_flush(): Drains all pending write ACKs, then optionally callsfsyncif the server supports itpoll_shutdown(): Drains pending ACKs, then sendsclose()on the handle and marksclosed = true
Drop Behavior
If closed is not yet true, File::drop() sends a close_nowait() (fire-and-forget) to avoid blocking in a destructor.
File Metadata Methods
impl File {
pub async fn metadata(&self) -> SftpResult<Metadata>; // fstat
pub async fn set_metadata(&self, metadata: Metadata) -> SftpResult<()>; // fsetstat
pub async fn sync_all(&self) -> SftpResult<()>; // fsync (no-op if unsupported)
}
ReadDir and DirEntry
pub struct ReadDir {
parent: Arc<str>,
entries: VecDeque<(String, Metadata)>,
}
pub struct DirEntry {
parent: Arc<str>,
file: String,
metadata: Metadata,
}
ReadDirimplementsIterator<Item = DirEntry>- Automatically filters out
.and..entries DirEntry::path()constructs full paths using POSIX-style/separatorDirEntry::file_name(),file_type(),metadata()provide accessors
Runtime Abstraction (client/runtime.rs)
The client runtime abstracts over native tokio and WASM environments:
- Native (
not(target_arch = "wasm32")): Usestokio::spawnandtokio::time::timeout - WASM (
target_arch = "wasm32"): Useswasm_bindgen_futures::spawn_localandgloo_timers::future::TimeoutFuture
The spawn() function returns a JoinHandle<T> that wraps a oneshot::Receiver, providing a unified API for both platforms.
Timeout Behavior
Each request in RawSftpSession has a configurable timeout (default 10 seconds, set via Config::request_timeout_secs):
async fn request(&self, id: Option<u32>, packet: Packet) -> SftpResult<Packet> {
let rx = self.send(id, packet)?;
let timeout = self.timeout.load(Ordering::Relaxed);
match runtime::timeout(Duration::from_secs(timeout), rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(Error::UnexpectedBehavior("sender dropped".into())),
Err(error) => {
self.requests.remove(&id);
Err(error) // Error::Timeout
}
}
}
On timeout, the pending request entry is cleaned up from the DashMap.