9.8 KiB
russh-sftp: Server Implementation
Server Architecture
The server side of russh-sftp follows a handler trait pattern: users implement the server::Handler trait, and the framework calls their methods in response to incoming SFTP requests. Each request is processed and a response packet is sent back on the same stream.
The server processes requests sequentially — one at a time, in order. There is no concurrent request handling within a single SFTP session.
Server Handler Trait
pub trait Handler: Sized {
type Error: Into<StatusReply> + Send;
fn unimplemented(&self) -> Self::Error;
// --- Lifecycle ---
fn init(&mut self, version: u32, extensions: HashMap<String, String>)
-> impl Future<Output = Result<Version, Self::Error>> + Send;
// --- File operations ---
fn open(&mut self, id: u32, filename: String, pflags: OpenFlags, attrs: FileAttributes)
-> impl Future<Output = Result<Handle, Self::Error>> + Send;
fn close(&mut self, id: u32, handle: String)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn read(&mut self, id: u32, handle: String, offset: u64, len: u32)
-> impl Future<Output = Result<Data, Self::Error>> + Send;
fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec<u8>)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
// --- Metadata ---
fn lstat(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Attrs, Self::Error>> + Send;
fn fstat(&mut self, id: u32, handle: String)
-> impl Future<Output = Result<Attrs, Self::Error>> + Send;
fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn fsetstat(&mut self, id: u32, handle: String, attrs: FileAttributes)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
// --- Directory operations ---
fn opendir(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Handle, Self::Error>> + Send;
fn readdir(&mut self, id: u32, handle: String)
-> impl Future<Output = Result<Name, Self::Error>> + Send;
// --- Filesystem operations ---
fn remove(&mut self, id: u32, filename: String)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn mkdir(&mut self, id: u32, path: String, attrs: FileAttributes)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn rmdir(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn realpath(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Name, Self::Error>> + Send;
fn stat(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Attrs, Self::Error>> + Send;
fn rename(&mut self, id: u32, oldpath: String, newpath: String)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
fn readlink(&mut self, id: u32, path: String)
-> impl Future<Output = Result<Name, Self::Error>> + Send;
fn symlink(&mut self, id: u32, linkpath: String, targetpath: String)
-> impl Future<Output = Result<Status, Self::Error>> + Send;
// --- Extensions ---
fn extended(&mut self, id: u32, request: String, data: Vec<u8>)
-> impl Future<Output = Result<Packet, Self::Error>> + Send;
}
Every method has a default implementation that calls self.unimplemented() and returns it as Err, making it safe to only implement the methods you need.
Handler Error Type
The associated Error type must implement Into<StatusReply>. When a handler method returns Err(e), the framework converts the error to a StatusReply and sends an SSH_FXP_STATUS packet with the appropriate StatusCode, error message, and language tag.
StatusReply
pub struct StatusReply {
pub status_code: StatusCode,
pub error_message: Option<String>,
pub language_tag: Option<String>,
}
Convenience constructors:
impl StatusReply {
pub fn new(status_code: StatusCode) -> Self;
pub fn with_message(self, message: impl Into<String>) -> Self;
pub fn with_language_tag(self, tag: impl Into<String>) -> Self;
}
impl StatusCode {
pub fn with_message(self, message: impl Into<String>) -> StatusReply;
}
Example:
// Using StatusCode directly — minimal allocation
Err(StatusCode::NoSuchFile)
// With custom message
Err(StatusCode::PermissionDenied.with_message("access denied"))
// Full control
Err(StatusReply::new(StatusCode::Failure)
.with_message("disk full")
.with_language_tag("en-US"))
All of these produce StatusReply, which then maps to:
SSH_FXP_STATUS { id, status_code, error_message, language_tag }
Request Processing Pipeline
The into_wrap! Macro
Each request is processed via a macro that extracts fields from the packet and calls the handler:
macro_rules! into_wrap {
($id:expr, $handler:expr, $var:ident; $($arg:ident),*) => {
match $handler.$var($($var.$arg),*).await {
Err(err) => {
let StatusReply { status_code, error_message, language_tag } = err.into();
Packet::Status(Status {
id: $id,
status_code,
error_message: error_message.unwrap_or_else(|| status_code.to_string()),
language_tag: language_tag.unwrap_or_else(|| "en-US".to_string()),
})
},
Ok(packet) => packet.into(),
}
};
}
This macro:
- Calls the handler method with the packet fields
- On
Ok(packet)— converts the result to aPacketvia.into() - On
Err(err)— converts error toStatusReply, then constructs aPacket::Statuswith defaults for missing fields
The process_request Function
async fn process_request<H>(packet: Packet, handler: &mut H) -> Packet
where H: Handler + Send
{
let id = packet.get_request_id();
match packet {
Packet::Init(init) => into_wrap!(id, handler, init; version, extensions),
Packet::Open(open) => into_wrap!(id, handler, open; id, filename, pflags, attrs),
Packet::Close(close) => into_wrap!(id, handler, close; id, handle),
Packet::Read(read) => into_wrap!(id, handler, read; id, handle, offset, len),
// ... all other variants
Packet::Extended(extended) => into_wrap!(id, handler, extended; id, request, data),
_ => Packet::error(0, StatusCode::BadMessage),
}
}
The run Functions
pub async fn run<S, H>(stream: S, handler: H)
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
H: Handler + Send + 'static;
pub async fn run_with_config<S, H>(mut stream: S, mut handler: H, cfg: Config)
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
H: Handler + Send + 'static;
The server loop:
- Reads a packet from the stream (
read_packet) - Deserializes to
Packet - On deserialization error, sends
Packet::error(0, StatusCode::BadMessage) - Calls
process_request()to dispatch to the handler - Serializes the response and writes it back to the stream
- Flushes the stream
- Repeats until
UnexpectedEofor cancellation
The server is spawned on tokio::spawn (or wasm_bindgen_futures::spawn_local on WASM) and runs independently.
Response Types
Each handler method return type maps to a specific response packet:
| Handler Method | Success Return | Packet Type |
|---|---|---|
init |
Version |
SSH_FXP_VERSION |
open |
Handle |
SSH_FXP_HANDLE |
close |
Status |
SSH_FXP_STATUS |
read |
Data |
SSH_FXP_DATA |
write |
Status |
SSH_FXP_STATUS |
lstat / stat / fstat |
Attrs |
SSH_FXP_ATTRS |
setstat / fsetstat |
Status |
SSH_FXP_STATUS |
opendir |
Handle |
SSH_FXP_HANDLE |
readdir |
Name |
SSH_FXP_NAME |
remove / mkdir / rmdir / rename / symlink |
Status |
SSH_FXP_STATUS |
realpath / readlink |
Name |
SSH_FXP_NAME |
extended |
Packet |
Any response type |
Server Example
A minimal SFTP server with russh integration:
#[derive(Default)]
struct SftpSession {
version: Option<u32>,
}
impl russh_sftp::server::Handler for SftpSession {
type Error = StatusCode;
fn unimplemented(&self) -> Self::Error {
StatusCode::OpUnsupported
}
async fn init(&mut self, version: u32, _ext: HashMap<String, String>)
-> Result<Version, Self::Error>
{
self.version = Some(version);
Ok(Version::new())
}
async fn close(&mut self, id: u32, _handle: String) -> Result<Status, Self::Error> {
Ok(Status { id, status_code: StatusCode::Ok, error_message: "Ok".into(), language_tag: "en-US".into() })
}
async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
Ok(Handle { id, handle: path })
}
async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
// Return EOF when done
Err(StatusCode::Eof)
}
async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
Ok(Name { id, files: vec![File::dummy("/")] })
}
}
// In the russh Handler:
async fn subsystem_request(&mut self, channel_id: ChannelId, name: &str, session: &mut Session)
-> Result<(), Self::Error>
{
if name == "sftp" {
let channel = self.get_channel(channel_id).await;
let sftp = SftpSession::default();
session.channel_success(channel_id)?;
russh_sftp::server::run(channel.into_stream(), sftp).await;
} else {
session.channel_failure(channel_id)?;
}
Ok(())
}
WASM Considerations
The server module is gated behind #[cfg(not(target_arch = "wasm32"))] and is not available on WASM targets. The client module is available on both native and WASM via the runtime.rs abstraction.