docs(research): add russh-sftp deep-dive reference

This commit is contained in:
2026-06-10 14:45:08 +00:00
parent f2a25f5bc1
commit f10dc23d13
7 changed files with 1927 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
# 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
```rust
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
```rust
pub struct StatusReply {
pub status_code: StatusCode,
pub error_message: Option<String>,
pub language_tag: Option<String>,
}
```
Convenience constructors:
```rust
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:
```rust
// 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:
```rust
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:
1. Calls the handler method with the packet fields
2. On `Ok(packet)` — converts the result to a `Packet` via `.into()`
3. On `Err(err)` — converts error to `StatusReply`, then constructs a `Packet::Status` with defaults for missing fields
### The `process_request` Function
```rust
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
```rust
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:
1. Reads a packet from the stream (`read_packet`)
2. Deserializes to `Packet`
3. On deserialization error, sends `Packet::error(0, StatusCode::BadMessage)`
4. Calls `process_request()` to dispatch to the handler
5. Serializes the response and writes it back to the stream
6. Flushes the stream
7. Repeats until `UnexpectedEof` or 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:
```rust
#[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.