docs(research): add russh and sftp-rs deep-dive references

This commit is contained in:
2026-06-10 13:41:17 +00:00
parent 5bb5e1064c
commit f2a25f5bc1
15 changed files with 3908 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
# sftp-rs: Overview and Architecture
**Version**: 0.3.0
**Repository**: https://github.com/jelmer/sftp-rs
**License**: Apache-2.0
**Rust Edition**: 2021
## What It Is
`sftp-rs` is a Rust crate implementing an **SFTP client** — the SSH File Transfer Protocol as defined in [draft-ietf-secsh-filexfer-02](https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-02). It provides a pure wire-protocol codec plus both synchronous and asynchronous client implementations that layer on top of any transport providing `Read + Write` (sync) or `AsyncRead + AsyncWrite` (async) byte streams.
Core design decisions:
- **Transport-agnostic** — does not include an SSH implementation itself; operates on top of an already-established SSH channel or any byte stream
- **Protocol v3** — targets SFTP protocol version 3, following the published draft but deviating where other servers and clients ignore the RFC
- **Pure codec** — the `protocol` module contains zero I/O; it builds and parses raw bytes, shared by both sync and async clients
- **Two concurrency models** — a synchronous `SftpClient<C>` and an async `AsyncSftpClient<W>` with a background reader task for concurrent pipelining
## Source Layout
```
sftp-rs/src/
├── lib.rs # Crate root, re-exports, feature gating
├── protocol.rs # Pure wire-protocol codec: types, builders, parsers
├── sync.rs # Synchronous SftpClient<C: Read + Write>
├── async.rs # AsyncSftpClient<W: AsyncWrite + Unpin> with background reader
├── russh.rs # russh transport glue (optional, feature-gated)
└── bin/
└── sftp.rs # CLI interactive sftp client binary
```
## Feature Flags
| Feature | Default | Dependencies | Description |
|---------|---------|-------------|-------------|
| `default` | ✅ | `bin` | Includes the CLI binary |
| `bin` | ✅ (via default) | `rustyline`, `shell-words` | Interactive CLI binary |
| `ssh2` | ❌ | `ssh2` | Integration with the `ssh2` crate (libssh2 bindings) |
| `async` | ❌ | `tokio` | Async client (`AsyncSftpClient`) |
| `russh` | ❌ | `russh`, `async`, `tokio` | russh transport integration |
The `russh` feature implies `async` (it requires `tokio` and the async client).
## Key Dependencies
| Dependency | Version | Purpose |
|------------|---------|---------|
| `byteorder` | 1 | Big-endian binary read/write for wire protocol |
| `russh` | 0.61 (optional) | Pure-Rust SSH implementation, provides channel + stream |
| `ssh2` | 0.9 (optional) | libssh2 bindings, provides `Channel` |
| `tokio` | 1 (optional) | Async runtime, `AsyncRead`/`AsyncWrite`, `io::split` |
| `rustyline` | 18 (optional) | Readline library for CLI binary |
| `shell-words` | 1 (optional) | Shell-style token parsing for CLI |
## Architecture Diagram
```
┌───────────────────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ CLI Binary │ │ SftpClient<C> │ │ AsyncSftp- │ │
│ │ (bin/sftp) │ │ (sync.rs) │ │ Client<W> │ │
│ │ │ │ │ │ (async.rs) │ │
│ │ SshChannel → │ │ C: Read+Write │ │ W: AsyncW │ │
│ │ SftpClient │ │ │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────────────────┴────────────────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ protocol.rs (codec) │ │
│ │ │ │
│ │ • Error, Result │ │
│ │ • Attributes (serde) │ │
│ │ • OpenOptions │ │
│ │ • build_*() builders │ │
│ │ • parse_*() / expect_*() │ │
│ │ • read/write_raw_packet │ │
│ │ • with/split_request_id │ │
│ └────────────────────────────┘ │
│ │ │
└──────────────────────────────────┼──────────────────────────────┘
┌────────────────────┼────────────────────┐
│ │ │
┌─────────┴──────┐ ┌─────────┴───────┐ ┌─────────┴──────┐
│ ssh subprocess│ │ russh Channel │ │ ssh2 Channel │
│ (stdin/stdout)│ │ (via Channel- │ │ (libssh2) │
│ │ │ Stream) │ │ │
└────────────────┘ └────────────────┘ └────────────────┘
```
## Protocol Version Negotiation
Both clients perform the same handshake on construction:
1. **Client sends `SSH_FXP_INIT`** with version `3`
2. **Server responds `SSH_FXP_VERSION`** with its version and optional extensions
3. If server version ≠ 3, the constructor returns an error
```rust
// From protocol.rs
pub fn build_init() -> Vec<u8> {
let mut buf = Vec::with_capacity(4);
buf.write_u32::<BigEndian>(3).unwrap(); // version = 3
buf
}
```
The handshake is the only unnumbered (no request-id) exchange. All subsequent requests include a 4-byte request-id used for demultiplexing responses.
## Re-exports from `lib.rs`
The crate root re-exports the most commonly used types:
```rust
pub use protocol::{
Attributes, Directory, Error, File, Kind, OpenOptions, Result, TextHint,
SSH_FILEXFER_ATTR_ACCESSTIME, SSH_FILEXFER_ATTR_ACL, /* ... all flag constants */
};
pub use sync::SftpClient;
#[cfg(feature = "async")]
pub use r#async::AsyncSftpClient;
```

View File

@@ -0,0 +1,276 @@
# sftp-rs: Wire Protocol Codec (`protocol.rs`)
The `protocol` module is the heart of the crate — a pure, I/O-free codec that encodes and decodes SFTP wire messages. Both the synchronous and asynchronous clients delegate all serialization and parsing to these functions.
## Packet Framing
Every SFTP message on the wire has this layout:
```
┌────────────┬──────────┬──────────────────┐
│ length │ type │ data │
│ (4 bytes │ (1 byte │ (length-1 bytes) │
│ BE u32) │ │ │
└────────────┴──────────┴──────────────────┘
```
For all numbered requests/responses (everything after INIT/VERSION), the `data` field begins with a 4-byte big-endian `request-id`:
```
data = [request_id (4 bytes BE u32)] [payload]
```
### Raw Packet I/O
```rust
// Sync I/O (used by SftpClient)
pub fn read_raw_packet<C: Read>(channel: &mut C) -> std::io::Result<(u8, Vec<u8>)>
pub fn write_raw_packet<C: Write>(channel: &mut C, kind: u8, buf: &[u8]) -> std::io::Result<()>
// Async I/O (used by AsyncSftpClient)
async fn read_packet_async<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<(u8, Vec<u8>)>
async fn write_packet_async<W: AsyncWrite + Unpin>(w: &mut W, kind: u8, body: &[u8]) -> std::io::Result<()>
```
Both return/accept `(kind: u8, body: Vec<u8>)` where `kind` is the SFTP message type byte and `body` is everything after it (including the request-id for numbered messages).
### Request-ID Helpers
```rust
// Prepend a 4-byte request-id to a request body
pub fn with_request_id(request_id: u32, body: &[u8]) -> Vec<u8>
// Strip the 4-byte request-id prefix from a response body
pub fn split_request_id(buf: &[u8]) -> std::io::Result<(u32, &[u8])>
```
## Message Type Constants
### Client → Server (Requests)
| Constant | Value | Description |
|----------|-------|-------------|
| `SSH_FXP_INIT` | 1 | Protocol version negotiation (unnumbered) |
| `SSH_FXP_OPEN` | 3 | Open a file |
| `SSH_FXP_CLOSE` | 4 | Close a handle |
| `SSH_FXP_READ` | 5 | Read from a file |
| `SSH_FXP_WRITE` | 6 | Write to a file |
| `SSH_FXP_LSTAT` | 7 | Get file attributes (don't follow symlinks) |
| `SSH_FXP_FSTAT` | 8 | Get file attributes by handle |
| `SSH_FXP_SETSTAT` | 9 | Set file attributes by path |
| `SSH_FXP_FSETSTAT` | 10 | Set file attributes by handle |
| `SSH_FXP_OPENDIR` | 11 | Open a directory for listing |
| `SSH_FXP_READDIR` | 12 | Read directory entries |
| `SSH_FXP_REMOVE` | 13 | Remove a file |
| `SSH_FXP_MKDIR` | 14 | Create a directory |
| `SSH_FXP_RMDIR` | 15 | Remove a directory |
| `SSH_FXP_REALPATH` | 16 | Canonicalize a path |
| `SSH_FXP_STAT` | 17 | Get file attributes (follow symlinks) |
| `SSH_FXP_RENAME` | 18 | Rename a file/directory |
| `SSH_FXP_READLINK` | 19 | Read the target of a symlink |
| `SSH_FXP_SYMLINK` | 20 | Create a symbolic link |
| `SSH_FXP_LINK` | 21 | Create a hard link |
| `SSH_FXP_BLOCK` | 22 | Byte-range lock |
| `SSH_FXP_UNBLOCK` | 23 | Byte-range unlock |
| `SSH_FXP_EXTENDED` | 200 | Vendor-specific extension request |
### Server → Client (Responses)
| Constant | Value | Description |
|----------|-------|-------------|
| `SSH_FXP_VERSION` | 2 | Version reply (unnumbered) |
| `SSH_FXP_STATUS` | 101 | Status response (success or error) |
| `SSH_FXP_HANDLE` | 102 | Returns a file/directory handle |
| `SSH_FXP_DATA` | 103 | Returns file data |
| `SSH_FXP_NAME` | 104 | Returns filename entries |
| `SSH_FXP_ATTRS` | 105 | Returns file attributes |
| `SSH_FXP_EXTENDED_REPLY` | 201 | Extension response data |
## Status Codes
| Constant | Value | Error Variant |
|----------|-------|---------------|
| `SSH_FX_OK` | 0 | `Ok(())` |
| `SSH_FX_EOF` | 1 | `Eof` |
| `SSH_FX_NO_SUCH_FILE` | 2 | `NoSuchFile` |
| `SSH_FX_PERMISSION_DENIED` | 3 | `PermissionDenied` |
| `SSH_FX_FAILURE` | 4 | `Failure` |
| `SSH_FX_BAD_MESSAGE` | 5 | `BadMessage` |
| `SSH_FX_NO_CONNECTION` | 6 | `NoConnection` |
| `SSH_FX_CONNECTION_LOST` | 7 | `ConnectionLost` |
| `SSH_FX_OP_UNSUPPORTED` | 8 | `OpUnsupported` |
| `SSH_FX_INVALID_HANDLE` | 9 | `InvalidHandle` |
| `SSH_FX_NO_SUCH_PATH` | 10 | `NoSuchPath` |
| `SSH_FX_FILE_ALREADY_EXISTS` | 11 | `FileAlreadyExists` |
| `SSH_FX_WRITE_PROTECT` | 12 | `WriteProtect` |
| `SSH_FX_NO_MEDIA` | 13 | `NoMedia` |
| `SSH_FX_NO_SPACE_ON_FILESYSTEM` | 14 | `NoSpaceOnFilesystem` |
| `SSH_FX_QUOTA_EXCEEDED` | 15 | `QuotaExceeded` |
| `SSH_FX_UNKNOWN_PRINCIPAL` | 16 | `UnknownPrincipal` |
| `SSH_FX_LOCK_CONFLICT` | 17 | `LockConflict` |
| `SSH_FX_DIR_NOT_EMPTY` | 18 | `DirNotEmpty` |
| `SSH_FX_NOT_A_DIRECTORY` | 19 | `NotADirectory` |
| `SSH_FX_INVALID_FILENAME` | 20 | `InvalidFilename` |
| `SSH_FX_LINK_LOOP` | 21 | `LinkLoop` |
| `SSH_FX_CANNOT_DELETE` | 22 | `CannotDelete` |
| `SSH_FX_INVALID_PARAMETER` | 23 | `InvalidParameter` |
| `SSH_FX_FILE_IS_A_DIRECTORY` | 24 | `FileIsADirectory` |
| `SSH_FX_BYTE_RANGE_LOCK_CONFLICT` | 25 | `ByteRangeLockConflict` |
| `SSH_FX_BYTE_RANGE_LOCK_REFUSED` | 26 | `ByteRangeLockRefused` |
| `SSH_FX_DELETE_PENDING` | 27 | `DeletePending` |
| `SSH_FX_FILE_CORRUPT` | 28 | `FileCorrupt` |
| `SSH_FX_OWNER_INVALID` | 29 | `OwnerInvalid` |
| `SSH_FX_GROUP_INVALID` | 30 | `GroupInvalid` |
| `SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK` | 31 | `NoMatchingByteRangeLock` |
Any unrecognized status code maps to `Error::Other(status, message, lang_tag)`.
## Request Body Builders
Each builder produces the `data` portion (without type byte or request-id) for a specific SFTP request:
### Handshake
```rust
// INIT: version 3 (no request-id)
pub fn build_init() -> Vec<u8>
// VERSION: parse server response
pub fn parse_version(body: &[u8]) -> std::io::Result<(u32, Vec<(String, String)>)>
```
### Single-Field Bodies
```rust
// Path-only requests: LSTAT, STAT, OPENDIR, REMOVE, MKDIR, RMDIR, READLINK
pub fn build_path_only(path: &str) -> Vec<u8>
// Handle-only requests: CLOSE, READDIR
pub fn build_handle_only(handle: &[u8]) -> Vec<u8>
```
### Composite Bodies
```rust
// OPEN: path + flags + attributes
pub fn build_open(path: &str, options: u32, attr: &Attributes) -> std::io::Result<Vec<u8>>
// READ: handle + offset + length
pub fn build_pread(handle: &[u8], offset: u64, length: u32) -> Vec<u8>
// WRITE: handle + offset + data
pub fn build_pwrite(handle: &[u8], offset: u64, data: &[u8]) -> Vec<u8>
// RENAME: oldpath + newpath + flags
pub fn build_rename(oldpath: &str, newpath: &str, flags: Option<u32>) -> Vec<u8>
// Default flags: OVERWRITE | ATOMIC | NATIVE = 0x07
// SYMLINK: path + target
pub fn build_two_paths(a: &str, b: &str) -> Vec<u8>
// LINK: path + target + symlink_flag
pub fn build_link(path: &str, target: &str, symlink: bool) -> Vec<u8>
// Path + attributes: SETSTAT, MKDIR
pub fn build_path_and_attrs(path: &str, attr: &Attributes) -> std::io::Result<Vec<u8>>
// Handle + attributes: FSETSTAT
pub fn build_handle_and_attrs(handle: &[u8], attr: &Attributes) -> std::io::Result<Vec<u8>>
// Path + flags: STAT, LSTAT
pub fn build_path_and_flags(path: &str, flags: u32) -> Vec<u8>
// Handle + flags: FSTAT
pub fn build_handle_and_flags(handle: &[u8], flags: u32) -> Vec<u8>
// REALPATH: path + optional control byte + optional compose path
pub fn build_realpath(path: &str, control_byte: Option<u8>, compose: Option<&str>) -> Vec<u8>
// BLOCK: handle + offset + length + lockmask
pub fn build_block(handle: &[u8], offset: u64, length: u64, lockmask: u32) -> Vec<u8>
// UNBLOCK: handle + offset + length
pub fn build_unblock(handle: &[u8], offset: u64, length: u64) -> Vec<u8>
// EXTENDED: request name + data
pub fn build_extended(request: &str, data: &[u8]) -> Vec<u8>
```
### Wire Encoding Helpers
```rust
// Write a length-prefixed UTF-8 string (4-byte BE length + bytes)
fn put_str(buf: &mut Vec<u8>, s: &str)
// Write a length-prefixed byte string (4-byte BE length + bytes)
fn put_bytes(buf: &mut Vec<u8>, b: &[u8])
// Read a length-prefixed UTF-8 string from a cursor
fn read_string(reader: &mut Cursor<&[u8]>, what: &str) -> std::io::Result<String>
```
## Response Parsers
Each parser takes the raw `data` portion (after stripping type byte and request-id) and returns a `Result`:
```rust
// Parse SSH_FXP_STATUS body → Ok(()) or typed Error
pub fn parse_status(respdata: &[u8]) -> Result<()>
// Parse SSH_FXP_HANDLE body → raw handle bytes
pub fn parse_handle(respdata: &[u8]) -> Result<Vec<u8>>
// Parse SSH_FXP_DATA body → raw data bytes
pub fn parse_data(respdata: &[u8]) -> Result<Vec<u8>>
// Parse SSH_FXP_ATTRS body → Attributes
pub fn parse_attrs(respdata: &[u8]) -> Result<Attributes>
// Parse SSH_FXP_NAME body (for READLINK, REALPATH) → (name, attrs) pairs
pub fn parse_name(respdata: &[u8]) -> Result<Vec<(String, Attributes)>>
// Parse SSH_FXP_NAME body (for READDIR) → (name, longname, attrs) triples
pub fn parse_readdir(respdata: &[u8]) -> Result<Vec<(String, String, Attributes)>>
```
## Response Expectation Functions
These are the primary entry points used by the client implementations. They take `(cmd, data)` — the type byte and payload from the server — and dispatch to the correct parser, or convert an unexpected `SSH_FXP_STATUS` into the appropriate typed error:
```rust
// Expect SSH_FXP_STATUS (for operations that return nothing on success)
pub fn expect_status(cmd: u8, data: &[u8]) -> Result<()>
// Expect SSH_FXP_HANDLE (for OPEN, OPENDIR)
pub fn expect_handle(cmd: u8, data: &[u8]) -> Result<Vec<u8>>
// Expect SSH_FXP_ATTRS (for STAT, LSTAT, FSTAT)
pub fn expect_attrs(cmd: u8, data: &[u8]) -> Result<Attributes>
// Expect SSH_FXP_DATA (for READ)
pub fn expect_data(cmd: u8, data: &[u8]) -> Result<Vec<u8>>
// Expect SSH_FXP_NAME with name+attrs (for READLINK, REALPATH)
pub fn expect_name(cmd: u8, data: &[u8]) -> Result<Vec<(String, Attributes)>>
// Expect SSH_FXP_NAME with name+longname+attrs (for READDIR)
pub fn expect_readdir(cmd: u8, data: &[u8]) -> Result<Vec<(String, String, Attributes)>>
// Expect SSH_FXP_EXTENDED_REPLY or SSH_FXP_STATUS
pub fn expect_extended(cmd: u8, data: Vec<u8>) -> Result<Option<Vec<u8>>>
```
If the server returns a different message type than expected, these functions produce `Error::Io("Unexpected response: ...")`. If the server returns `SSH_FXP_STATUS` where a data-bearing response was expected (even `SSH_FX_OK`), it is treated as a protocol violation and converted to the appropriate typed error.
## String Encoding
All strings in SFTP are length-prefixed with a 4-byte big-endian length followed by raw UTF-8 bytes:
```
┌───────────────┬──────────────────┐
│ length (4B) │ UTF-8 bytes │
│ BE u32 │ │
└───────────────┴──────────────────┘
```
Byte arrays (handles, ACLs) use the same length-prefix format but are not required to be valid UTF-8.

View File

@@ -0,0 +1,308 @@
# sftp-rs: Key Types
## `Error` Enum
The universal error type for all SFTP operations, covering both I/O failures and SFTP status codes:
```rust
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Utf8(std::str::Utf8Error),
Other(u32, String, String), // (status_code, message, lang_tag)
Eof(String, String), // End of file/directory
NoSuchFile(String, String),
PermissionDenied(String, String),
Failure(String, String),
BadMessage(String, String),
NoConnection(String, String),
ConnectionLost(String, String),
OpUnsupported(String, String),
InvalidHandle(String, String),
NoSuchPath(String, String),
FileAlreadyExists(String, String),
WriteProtect(String, String),
NoMedia(String, String),
NoSpaceOnFilesystem(String, String),
QuotaExceeded(String, String),
UnknownPrincipal(String, String),
LockConflict(String, String),
DirNotEmpty(String, String),
NotADirectory(String, String),
InvalidFilename(String, String),
LinkLoop(String, String),
CannotDelete(String, String),
InvalidParameter(String, String),
FileIsADirectory(String, String),
ByteRangeLockConflict(String, String),
ByteRangeLockRefused(String, String),
DeletePending(String, String),
FileCorrupt(String, String),
OwnerInvalid(String, String),
GroupInvalid(String, String),
NoMatchingByteRangeLock(String, String),
}
```
The `String` pairs in each variant are `(error_message, language_tag)` as returned by the server. The `Error` type implements `From<std::io::Error>` and `From<std::str::Utf8Error>`, and there is a `From<Error> for std::io::Error` conversion that maps SFTP error codes to appropriate `std::io::ErrorKind` values:
| Error Variant | io::ErrorKind |
|---------------|---------------|
| `Eof` | `UnexpectedEof` |
| `NoSuchFile` | `NotFound` |
| `NoSuchPath` | `NotFound` |
| `PermissionDenied` | `PermissionDenied` |
| `WriteProtect` | `PermissionDenied` |
| `QuotaExceeded` | `PermissionDenied` |
| `LockConflict` | `PermissionDenied` |
| `NoConnection` | `NotConnected` |
| `ConnectionLost` | `ConnectionReset` |
| `InvalidHandle` | `InvalidInput` |
| `FileAlreadyExists` | `AlreadyExists` |
| `InvalidFilename` | `InvalidInput` |
| All others | formatted via `Error::other()` |
```rust
pub type Result<R> = std::result::Result<R, Error>;
```
## `Attributes`
Represents SFTP file attributes — a flag-driven, extensible structure where only fields present in the `valid_attribute_flags` mask are serialized:
```rust
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct Attributes {
pub size: Option<u64>,
pub uid: Option<u32>,
pub gid: Option<u32>,
pub allocation_size: Option<u64>,
pub owner: Option<String>,
pub group: Option<String>,
pub permissions: Option<u32>,
pub access_time: Option<(u64, Option<u32>)>, // (seconds, nanoseconds)
pub create_time: Option<(u64, Option<u32>)>,
pub modify_time: Option<(u64, Option<u32>)>,
pub ctime: Option<(u64, Option<u32>)>,
pub acl: Option<Vec<u8>>,
pub attrib_bits: Option<u32>,
pub attrib_bits_valid: Option<u32>,
pub text_hint: Option<TextHint>,
pub mime_type: Option<String>,
pub link_count: Option<u32>,
pub untranslated_name: Option<Vec<u8>>,
pub extended: Option<Vec<(String, String)>>,
}
```
### Serialization
The `serialize()` method builds the wire format by writing a 4-byte `valid_attribute_flags` placeholder, then appending fields conditionally, then back-patching the flags:
```
┌──────────────────────────┐
│ valid_attribute_flags │ 4 bytes, BE u32
│ (back-patched at end) │
├──────────────────────────┤
│ size (if flag set) │ 8 bytes, BE u64
│ uid (if flag set) │ 4 bytes, BE u32
│ gid (if flag set) │ 4 bytes, BE u32
│ allocation_size │ 8 bytes, BE u64
│ owner (length-prefixed) │
│ group (length-prefixed) │
│ permissions │ 4 bytes, BE u32
│ access_time │ 8 bytes + opt 4 bytes ns
│ create_time │ 8 bytes + opt 4 bytes ns
│ modify_time │ 8 bytes + opt 4 bytes ns
│ ctime │ 8 bytes + opt 4 bytes ns
│ acl (length-prefixed) │
│ attrib_bits │ 4 bytes
│ attrib_bits_valid │ 4 bytes
│ text_hint │ 1 byte
│ mime_type (len-prefixed)│
│ link_count │ 4 bytes
│ untranslated_name │ length-prefixed bytes
│ extended │ count + key/value pairs
└──────────────────────────┘
```
Constraints enforced by serialization:
- `uid` and `gid` must both be present or both absent (same `SSH_FILEXFER_ATTR_UIDGID` flag)
- `owner` and `group` share the `SSH_FILEXFER_ATTR_OWNERGROUP` flag
- `attrib_bits` and `attrib_bits_valid` share the `SSH_FILEXFER_ATTR_BITS` flag
- `SSH_FILEXFER_ATTR_SUBSECOND_TIMES` is a shared flag — if set, all time fields include a 4-byte nanosecond component; if not, none do
### Deserialization
`deserialize(reader: &mut Cursor<&[u8]>)` reads the flags first, then conditionally reads each field based on flag bits. Subsecond nanoseconds are read for all time fields when `SSH_FILEXFER_ATTR_SUBSECOND_TIMES` is set.
## Attribute Flag Constants
| Constant | Value | Field(s) |
|----------|-------|----------|
| `SSH_FILEXFER_ATTR_SIZE` | 0x00000001 | `size` |
| `SSH_FILEXFER_ATTR_UIDGID` | 0x00000002 | `uid`, `gid` |
| `SSH_FILEXFER_ATTR_PERMISSIONS` | 0x00000004 | `permissions` |
| `SSH_FILEXFER_ATTR_ACCESSTIME` | 0x00000008 | `access_time` |
| `SSH_FILEXFER_ATTR_CREATETIME` | 0x00000010 | `create_time` |
| `SSH_FILEXFER_ATTR_MODIFYTIME` | 0x00000020 | `modify_time` |
| `SSH_FILEXFER_ATTR_ACL` | 0x00000040 | `acl` |
| `SSH_FILEXFER_ATTR_OWNERGROUP` | 0x00000080 | `owner`, `group` |
| `SSH_FILEXFER_ATTR_SUBSECOND_TIMES` | 0x00000100 | nanoseconds for all times |
| `SSH_FILEXFER_ATTR_BITS` | 0x00000200 | `attrib_bits`, `attrib_bits_valid` |
| `SSH_FILEXFER_ATTR_ALLOCATION_SIZE` | 0x00000400 | `allocation_size` |
| `SSH_FILEXFER_ATTR_TEXT_HINT` | 0x00000800 | `text_hint` |
| `SSH_FILEXFER_ATTR_MIME_TYPE` | 0x00001000 | `mime_type` |
| `SSH_FILEXFER_ATTR_LINK_COUNT` | 0x00002000 | `link_count` |
| `SSH_FILEXFER_ATTR_UNTRANSLATED_NAME` | 0x00004000 | `untranslated_name` |
| `SSH_FILEXFER_ATTR_CTIME` | 0x00008000 | `ctime` |
| `SSH_FILEXFER_ATTR_EXTENDED` | 0x80000000 | `extended` |
## `Kind` — File Type
Represents the type of a filesystem entry, encoded as a `u8` in the attributes:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Kind {
Regular, // 1
Directory, // 2
Symlink, // 3
Special, // 4
#[default]
Unknown, // 5
Socket, // 6
CharDevice, // 7
BlockDevice, // 8
Fifo, // 9
}
```
Implements `From<Kind> for u8` and `From<u8> for Kind` (panics on unknown values).
## `TextHint`
Indicates whether a file is text or binary, and whether that classification is known or guessed:
```rust
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TextHint {
KnownText, // 0x00
GuessedText, // 0x01
KnownBinary, // 0x02
GuessedBinary, // 0x03
}
```
## `OpenOptions`
Builder-style type for controlling file open behavior:
```rust
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct OpenOptions(u32);
impl OpenOptions {
pub fn new() -> OpenOptions;
pub fn read(mut self, read: bool) -> OpenOptions;
pub fn write(mut self, write: bool) -> OpenOptions;
pub fn append(mut self, append: bool) -> OpenOptions;
pub fn create(mut self, create: bool) -> OpenOptions;
pub fn truncate(mut self, truncate: bool) -> OpenOptions;
pub fn excl(mut self, excl: bool) -> OpenOptions;
pub fn mode(&mut self, mode: u32) -> &mut OpenOptions;
pub fn get(&self) -> u32;
}
```
### Open Flag Constants
| Constant | Value | Description |
|----------|-------|-------------|
| `SFTP_FLAG_READ` | 0x01 | Read access |
| `SFTP_FLAG_WRITE` | 0x02 | Write access |
| `SFTP_FLAG_APPEND` | 0x04 | Append data |
| `SFTP_FLAG_CREAT` | 0x08 | Create if not exists |
| `SFTP_FLAG_TRUNC` | 0x10 | Truncate to zero length |
| `SFTP_FLAG_EXCL` | 0x20 | Fail if file exists (exclusive create) |
Usage example:
```rust
let opts = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true);
// opts.get() == 0x1B (READ | WRITE | CREAT | TRUNC)
```
## `File` and `Directory` — Handle Wrappers
Opaque wrappers around the raw handle bytes returned by the server:
```rust
#[derive(Debug, Clone)]
pub struct File(pub Vec<u8>);
#[derive(Debug, Clone)]
pub struct Directory(pub Vec<u8>);
```
These are newtype wrappers that distinguish file handles from directory handles at the type level. The inner `Vec<u8>` is the server-assigned handle value (obtained from `SSH_FXP_HANDLE` responses), used in subsequent operations like `pread`, `pwrite`, `fclose`, `readdir`, `closedir`, etc.
## Rename Flags
Used with `SSH_FXP_RENAME`:
| Constant | Value | Description |
|----------|-------|-------------|
| `SSH_FXF_RENAME_OVERWRITE` | 0x01 | Overwrite existing target |
| `SSH_FXF_RENAME_ATOMIC` | 0x02 | Atomic rename |
| `SSH_FXF_RENAME_NATIVE` | 0x04 | Use native OS rename semantics |
Default for `build_rename()` when `flags` is `None`: `OVERWRITE | ATOMIC | NATIVE` = 0x07.
## Attribute Bits Flags
Used in `Attributes::attrib_bits`:
| Constant | Value |
|----------|-------|
| `SSH_FILEXFER_ATTR_FLAGS_READONLY` | 0x00000001 |
| `SSH_FILEXFER_ATTR_FLAGS_SYSTEM` | 0x00000002 |
| `SSH_FILEXFER_ATTR_FLAGS_HIDDEN` | 0x00000004 |
| `SSH_FILEXFER_ATTR_FLAGS_CASE_INSENSITIVE` | 0x00000008 |
| `SSH_FILEXFER_ATTR_FLAGS_ARCHIVE` | 0x00000010 |
| `SSH_FILEXFER_ATTR_FLAGS_ENCRYPTED` | 0x00000020 |
| `SSH_FILEXFER_ATTR_FLAGS_COMPRESSED` | 0x00000040 |
| `SSH_FILEXFER_ATTR_FLAGS_SPARSE` | 0x00000080 |
| `SSH_FILEXFER_ATTR_FLAGS_APPEND_ONLY` | 0x00000100 |
| `SSH_FILEXFER_ATTR_FLAGS_IMMUTABLE` | 0x00000200 |
| `SSH_FILEXFER_ATTR_FLAGS_SYNC` | 0x00000400 |
| `SSH_FILEXFER_ATTR_FLAGS_TRANSLATION_ERR` | 0x00000800 |
## ACE/MISC Open Flags (v5+ extensions)
These are defined for completeness but the crate targets v3:
| Constant | Value |
|----------|-------|
| `SSH_FXF_ACCESS_DISPOSITION` | 0x00000007 |
| `SSH_FXF_CREATE_NEW` | 0x00000000 |
| `SSH_FXF_CREATE_TRUNCATE` | 0x00000001 |
| `SSH_FXF_OPEN_EXISTING` | 0x00000002 |
| `SSH_FXF_OPEN_OR_CREATE` | 0x00000003 |
| `SSH_FXF_TRUNCATE_EXISTING` | 0x00000004 |
| `SSH_FXF_APPEND_DATA` | 0x00000008 |
| `SSH_FXF_APPEND_DATA_ATOMIC` | 0x00000010 |
| `SSH_FXF_TEXT_MODE` | 0x00000020 |
| `SSH_FXF_BLOCK_READ` | 0x00000040 |
| `SSH_FXF_BLOCK_WRITE` | 0x00000080 |
| `SSH_FXF_BLOCK_DELETE` | 0x00000100 |
| `SSH_FXF_BLOCK_ADVISORY` | 0x00000200 |
| `SSH_FXF_NOFOLLOW` | 0x00000400 |
| `SSH_FXF_DELETE_ON_CLOSE` | 0x00000800 |
| `SSH_FXF_ACCESS_AUDIT_ALARM_INFO` | 0x00001000 |
| `SSH_FXF_ACCESS_BACKUP` | 0x00002000 |
| `SSH_FXF_BACKUP_STREAM` | 0x00004000 |
| `SSH_FXF_OVERRIDE_OWNER` | 0x00008000 |

View File

@@ -0,0 +1,185 @@
# sftp-rs: Synchronous Client (`sync.rs`)
## `SftpClient<C>`
A synchronous SFTP client parameterized over any `Read + Write` channel:
```rust
pub struct SftpClient<C> {
channel: Mutex<C>,
last_request_id: std::sync::atomic::AtomicU32,
version: u32,
extensions: Vec<(String, String)>,
}
```
The `Mutex<C>` ensures the channel is accessed by one thread at a time — each `process()` call writes a request, then reads the response, atomically. The `AtomicU32` for request IDs allows the client to be shared across threads via `&self` (all methods take `&self`, not `&mut self`).
## Construction
```rust
impl<C: Read + Write> SftpClient<C> {
pub fn new(mut channel: C) -> std::io::Result<Self>
}
```
The constructor performs the SFTP handshake inline:
1. Writes `SSH_FXP_INIT` with version 3
2. Flushes the channel
3. Reads the response, expecting `SSH_FXP_VERSION`
4. Parses version and extensions
5. Returns the client if version == 3
### Platform-Specific Construction
```rust
impl SftpClient<std::fs::File> {
#[cfg(unix)]
pub fn from_fd(fd: i32) -> std::io::Result<Self>
#[cfg(windows)]
pub fn from_handle(handle: RawHandle) -> std::io::Result<Self>
}
```
These wrap an OS file descriptor or handle into a `std::fs::File`, then delegate to `new()`. This is the typical way to connect to an SSH subprocess that has the SFTP subsystem active on its stdin/stdout.
### ssh2 Integration
```rust
#[cfg(feature = "ssh2")]
impl TryFrom<ssh2::Channel> for SftpClient<ssh2::Channel> {
type Error = std::io::Error;
fn try_from(mut channel: ssh2::Channel) -> std::result::Result<Self, Self::Error>
}
```
Requests the `sftp` subsystem on the libssh2 channel, then delegates to `SftpClient::new()`.
## Request-Response Cycle: `process()`
The core internal method that drives all operations:
```rust
fn process(&self, cmd: u8, body: &[u8]) -> std::io::Result<(u8, Vec<u8>)>
```
1. Allocate a new `request_id` via `AtomicU32::fetch_add(1, SeqCst)`
2. Prepend the request-id to the body: `with_request_id(request_id, body)`
3. Lock the channel mutex
4. Write the packet: `write_raw_packet(channel, cmd, &body_with_id)`
5. Read the response: `read_raw_packet(channel)`
6. Strip the request-id from the response: `split_request_id(&buf)`
7. Assert the response request-id matches the sent one
8. Return `(response_cmd, payload)`
**Important limitation**: Because `process()` sends then immediately reads, the sync client cannot pipeline requests. Each request blocks until its response arrives. Multiple concurrent operations require separate connections.
## Public API
All methods take `&self` and return `Result<T>`:
### Directory Operations
```rust
pub fn mkdir(&self, path: &str, attr: &Attributes) -> Result<()>
pub fn rmdir(&self, path: &str) -> Result<()>
pub fn opendir(&self, path: &str) -> Result<Directory>
pub fn readdir(&self, dir: &Directory) -> Result<Vec<(String, String, Attributes)>>
pub fn closedir(&self, dir: &Directory) -> Result<()>
```
`readdir()` returns `(filename, longname, attributes)` triples. The `longname` is a human-readable string (like `ls -l` output) provided by the server in v3. Callers should loop on `readdir()` until it returns `Error::Eof` to exhaust all directory entries.
### File Operations
```rust
pub fn open(&self, path: &str, options: OpenOptions, attr: &Attributes) -> Result<File>
pub fn pread(&self, file: &File, offset: u64, length: u32) -> Result<Vec<u8>>
pub fn pwrite(&self, file: &File, offset: u64, data: &[u8]) -> Result<()>
pub fn fclose(&self, file: &File) -> Result<()>
```
File I/O is positional (`pread`/`pwrite`) — there is no implicit cursor. The caller tracks the offset.
### Attribute Operations
```rust
pub fn stat(&self, path: &str, flags: Option<u32>) -> Result<Attributes> // follows symlinks
pub fn lstat(&self, path: &str, flags: Option<u32>) -> Result<Attributes> // doesn't follow
pub fn fstat(&self, file: &File, flags: Option<u32>) -> Result<Attributes>
pub fn setstat(&self, path: &str, attr: &Attributes) -> Result<()>
pub fn fsetstat(&self, file: &File, attr: &Attributes) -> Result<()>
```
### Path Operations
```rust
pub fn realpath(&self, path: &str, control_byte: Option<u8>, compose_path: Option<&str>) -> Result<String>
pub fn readlink(&self, path: &str) -> Result<String>
pub fn remove(&self, path: &str) -> Result<()>
pub fn rename(&self, oldpath: &str, newpath: &str, flags: Option<u32>) -> Result<()>
```
### Link Operations
```rust
pub fn symlink(&self, path: &str, target: &str) -> Result<()>
pub fn hardlink(&self, path: &str, target: &str) -> Result<()>
pub fn link(&self, path: &str, target: &str, symlink: bool) -> Result<()>
```
`symlink()` uses `SSH_FXP_SYMLINK` (v3). `link()` and `hardlink()` use `SSH_FXP_LINK` (v5+ extension).
### Lock Operations
```rust
pub fn block(&self, file: &File, offset: u64, length: u64, lockmask: u32) -> Result<()>
pub fn unblock(&self, file: &File, offset: u64, length: u64) -> Result<()>
```
### Extended Operations
```rust
pub fn extended(&self, request: &str, data: &[u8]) -> Result<Option<Vec<u8>>>
pub fn flineseek(&self, file: &File, lineno: u64) -> Result<()>
```
`flineseek()` sends the `text-seek` extended request with the handle and line number. `extended()` returns `Some(data)` if the server sends `SSH_FXP_EXTENDED_REPLY`, or `None` if it sends `SSH_FXP_STATUS` with `SSH_FX_OK`.
## Introspection
```rust
pub fn version(&self) -> u32
pub fn extensions(&self) -> &[(String, String)]
```
Returns the negotiated protocol version and any server-advertised extensions from the handshake.
## Request-Response Mapping
| Method | Request Type | Expected Response |
|--------|-------------|-------------------|
| `open` | `SSH_FXP_OPEN` | `SSH_FXP_HANDLE` |
| `opendir` | `SSH_FXP_OPENDIR` | `SSH_FXP_HANDLE` |
| `pread` | `SSH_FXP_READ` | `SSH_FXP_DATA` |
| `pwrite` | `SSH_FXP_WRITE` | `SSH_FXP_STATUS` |
| `fclose` | `SSH_FXP_CLOSE` | `SSH_FXP_STATUS` |
| `stat` | `SSH_FXP_STAT` | `SSH_FXP_ATTRS` |
| `lstat` | `SSH_FXP_LSTAT` | `SSH_FXP_ATTRS` |
| `fstat` | `SSH_FXP_FSTAT` | `SSH_FXP_ATTRS` |
| `setstat` | `SSH_FXP_SETSTAT` | `SSH_FXP_STATUS` |
| `fsetstat` | `SSH_FXP_FSETSTAT` | `SSH_FXP_STATUS` |
| `mkdir` | `SSH_FXP_MKDIR` | `SSH_FXP_STATUS` |
| `rmdir` | `SSH_FXP_RMDIR` | `SSH_FXP_STATUS` |
| `remove` | `SSH_FXP_REMOVE` | `SSH_FXP_STATUS` |
| `rename` | `SSH_FXP_RENAME` | `SSH_FXP_STATUS` |
| `readdir` | `SSH_FXP_READDIR` | `SSH_FXP_NAME` |
| `closedir` | `SSH_FXP_CLOSE` | `SSH_FXP_STATUS` |
| `realpath` | `SSH_FXP_REALPATH` | `SSH_FXP_NAME` |
| `readlink` | `SSH_FXP_READLINK` | `SSH_FXP_NAME` |
| `symlink` | `SSH_FXP_SYMLINK` | `SSH_FXP_STATUS` |
| `link` | `SSH_FXP_LINK` | `SSH_FXP_STATUS` |
| `block` | `SSH_FXP_BLOCK` | `SSH_FXP_STATUS` |
| `unblock` | `SSH_FXP_UNBLOCK` | `SSH_FXP_STATUS` |
| `extended` | `SSH_FXP_EXTENDED` | `SSH_FXP_EXTENDED_REPLY` or `SSH_FXP_STATUS` |

View File

@@ -0,0 +1,211 @@
# sftp-rs: Asynchronous Client (`async.rs`)
## `AsyncSftpClient<W>`
An async SFTP client that supports **concurrent pipelined requests** over a single connection via a background reader task:
```rust
pub struct AsyncSftpClient<W> {
writer: TokioMutex<W>,
pending: Pending,
last_request_id: AtomicU32,
version: u32,
extensions: Vec<(String, String)>,
reader_task: TokioMutex<Option<tokio::task::JoinHandle<()>>>,
}
```
Where:
```rust
type Pending = Arc<StdMutex<HashMap<u32, oneshot::Sender<(u8, Vec<u8>)>>>>;
```
## Architecture: Background Reader + Oneshot Channels
Unlike the sync client (which does send-then-receive per request), the async client decouples writing from reading:
1. **Writer side**: Each call to `process()` writes a request packet (with a unique request-id) to the `writer`, protected by a `TokioMutex`
2. **Reader side**: A spawned tokio task (`run_reader`) continuously reads packets from the reader half, strips the request-id from each response, and routes it to the matching `oneshot::Sender` in the `pending` map
3. **Caller**: Awaits on the `oneshot::Receiver`, which resolves when the reader task delivers the matching response
This allows multiple requests to be in flight simultaneously — the client can send requests 1, 2, and 3, and the reader will route each response to the correct waiter regardless of arrival order.
```
┌─────────────────┐ write ┌──────────────┐
│ calling task │──────────────→│ writer (W) │
│ (await rx) │ └──────────────┘
└────────┬────────┘
│ oneshot channel
│ (tx inserted into pending map)
┌────────┴────────┐ read ┌──────────────┐
│ reader task │←──────────────│ reader (R) │
│ (run_reader) │ └──────────────┘
│ │
│ 1. read packet │
│ 2. split req_id│
│ 3. lookup pending[req_id]
│ 4. send via tx │
└─────────────────┘
```
## Construction
```rust
impl<W: AsyncWrite + Unpin + Send + 'static> AsyncSftpClient<W> {
pub async fn new<R>(mut reader: R, mut writer: W) -> std::io::Result<Self>
where
R: AsyncRead + Unpin + Send + 'static,
}
```
The constructor:
1. Sends `SSH_FXP_INIT` with version 3
2. Reads the response, expects `SSH_FXP_VERSION`
3. Parses version and extensions
4. Spawns the background reader task (`run_reader`)
5. Returns the client
The reader and writer are provided as separate halves — typically obtained via `tokio::io::split()` on a duplex stream.
## Drop Implementation
```rust
impl<W> Drop for AsyncSftpClient<W> {
fn drop(&mut self) {
if let Ok(mut guard) = self.reader_task.try_lock() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
}
}
```
When the client is dropped, the background reader task is aborted. This prevents the task from running after the client's channels are gone. The `try_lock()` avoids blocking in the drop handler.
## Request-Response Cycle: `process()`
```rust
async fn process(&self, cmd: u8, body: &[u8]) -> std::io::Result<(u8, Vec<u8>)>
```
1. Allocate `request_id` via `AtomicU32::fetch_add(1, SeqCst)`
2. Create a `oneshot::channel()`
3. Insert `tx` into `pending[request_id]`
4. Prepend request-id: `with_request_id(request_id, body)`
5. Lock `writer` and send the packet via `write_packet_async`
6. If the write fails, remove the pending entry and return the error
7. Await on `rx` — resolves with `(cmd, payload)` when the reader task delivers the response
## Background Reader: `run_reader()`
```rust
async fn run_reader<R: AsyncRead + Unpin>(mut reader: R, pending: Pending)
```
Runs in a loop:
1. Read a packet via `read_packet_async`
2. Split the request-id from the body
3. Look up `pending[request_id]` and remove it
4. Send `(cmd, payload)` via the oneshot channel
5. If the read fails (EOF, connection error), clear the entire pending map so all waiting tasks get a `RecvError` and return errors
## Async Packet I/O
```rust
async fn read_packet_async<R: AsyncRead + Unpin>(r: &mut R) -> std::io::Result<(u8, Vec<u8>)>
async fn write_packet_async<W: AsyncWrite + Unpin>(w: &mut W, kind: u8, body: &[u8]) -> std::io::Result<()>
```
These mirror the sync `read_raw_packet` / `write_raw_packet` but use `AsyncReadExt` / `AsyncWriteExt`. The write function builds the header inline:
```rust
let mut hdr = Vec::with_capacity(5);
hdr.extend_from_slice(&(body.len() as u32 + 1).to_be_bytes()); // length (includes type byte)
hdr.push(kind); // type
w.write_all(&hdr).await?;
w.write_all(body).await?;
w.flush().await?;
```
## Public API
The async client exposes the same operations as the sync client, but all methods are `async`:
```rust
// Directory operations
pub async fn mkdir(&self, path: &str, attr: &Attributes) -> Result<()>
pub async fn rmdir(&self, path: &str) -> Result<()>
pub async fn opendir(&self, path: &str) -> Result<Directory>
pub async fn readdir(&self, dir: &Directory) -> Result<Vec<(String, String, Attributes)>>
pub async fn closedir(&self, dir: &Directory) -> Result<()>
// File operations
pub async fn open(&self, path: &str, options: OpenOptions, attr: &Attributes) -> Result<File>
pub async fn pread(&self, file: &File, offset: u64, length: u32) -> Result<Vec<u8>>
pub async fn pwrite(&self, file: &File, offset: u64, data: &[u8]) -> Result<()>
pub async fn fclose(&self, file: &File) -> Result<()>
// Attribute operations
pub async fn stat(&self, path: &str, flags: Option<u32>) -> Result<Attributes>
pub async fn lstat(&self, path: &str, flags: Option<u32>) -> Result<Attributes>
pub async fn fstat(&self, file: &File, flags: Option<u32>) -> Result<Attributes>
pub async fn setstat(&self, path: &str, attr: &Attributes) -> Result<()>
pub async fn fsetstat(&self, file: &File, attr: &Attributes) -> Result<()>
// Path operations
pub async fn realpath(&self, path: &str, control_byte: Option<u8>, compose_path: Option<&str>) -> Result<String>
pub async fn readlink(&self, path: &str) -> Result<String>
pub async fn remove(&self, path: &str) -> Result<()>
pub async fn rename(&self, oldpath: &str, newpath: &str, flags: Option<u32>) -> Result<()>
// Link operations
pub async fn symlink(&self, path: &str, target: &str) -> Result<()>
pub async fn hardlink(&self, path: &str, target: &str) -> Result<()>
pub async fn link(&self, path: &str, target: &str, symlink: bool) -> Result<()>
// Lock operations
pub async fn block(&self, file: &File, offset: u64, length: u64, lockmask: u32) -> Result<()>
pub async fn unblock(&self, file: &File, offset: u64, length: u64) -> Result<()>
// Extended operations
pub async fn extended(&self, request: &str, data: &[u8]) -> Result<Option<Vec<u8>>>
pub async fn flineseek(&self, file: &File, lineno: u64) -> Result<()>
// Introspection
pub fn extensions(&self) -> &[(String, String)]
pub fn version(&self) -> u32
```
## Concurrency Benefits
Because the reader task decouples receiving from sending, multiple async operations can run concurrently:
```rust
// Three concurrent mkdir requests — all three are sent before any
// response arrives, and the reader task routes each response correctly
let (r1, r2, r3) = tokio::join!(
client.mkdir("/a", &attrs),
client.mkdir("/b", &attrs),
client.rmdir("/c"),
);
```
The sync client cannot do this — each `process()` call blocks on its response before the next request can be sent.
## Error Propagation on Disconnect
When the reader task encounters a read error (connection closed), it:
1. Clears the entire `pending` map
2. All `oneshot::Receiver`s in waiting tasks receive `Err(RecvError)`
3. The `process()` method converts this to `std::io::Error("reader task closed before response arrived")`
This ensures that pending operations fail promptly rather than hanging indefinitely when the connection drops.
## Pending Map: `StdMutex` vs `TokioMutex`
The `pending` map uses `std::sync::Mutex` rather than `tokio::sync::Mutex` because:
- The critical section is tiny (insert/remove from a HashMap)
- The reader task and writer are on different async tasks but need shared access
- `StdMutex` avoids holding a lock across `.await` points (the oneshot `rx.await` is outside the lock)

View File

@@ -0,0 +1,132 @@
# sftp-rs: russh Integration (`russh.rs`)
The `russh` module provides transport glue for connecting an `AsyncSftpClient` to a russh SSH session. It handles the SSH-level work of requesting the SFTP subsystem and converting the russh channel into a split read/write stream.
**Feature gate**: `russh` (implies `async` + `tokio`)
## Core Type Alias
```rust
pub type RusshSftpClient = AsyncSftpClient<WriteHalf<ChannelStream<Msg>>>;
```
The concrete client type when operating over a russh channel. The write half of a `ChannelStream` serves as the `W` type parameter for `AsyncSftpClient`.
## `from_channel()`
```rust
pub async fn from_channel(channel: Channel<Msg>) -> std::io::Result<RusshSftpClient>
```
The primary entry point. Takes an already-open russh session channel and:
1. **Requests the `sftp` subsystem** via `channel.request_subsystem(true, "sftp")`
- The `true` parameter means "want reply" — the server must acknowledge the subsystem request
- If the subsystem request fails, returns an IO error
2. **Delegates to `from_subsystem_channel()`** to wrap the channel into a client
The caller is responsible for establishing the SSH session (host-key verification, authentication, proxy jumps, etc.) and opening the channel, e.g.:
```rust
let session = /* established russh client session */;
let channel = session.channel_open_session().await?;
let sftp = sftp::russh::from_channel(channel).await?;
```
## `from_subsystem_channel()`
```rust
pub async fn from_subsystem_channel(channel: Channel<Msg>) -> std::io::Result<RusshSftpClient>
```
For use when the subsystem request has already been made (or the caller wants to manage it differently):
1. **Converts the channel to a stream**: `channel.into_stream()``ChannelStream<Msg>`
2. **Splits the stream**: `tokio::io::split(stream)``(read_half, write_half)`
3. **Constructs the client**: `AsyncSftpClient::new(read_half, write_half).await`
- This performs the SFTP handshake (INIT/VERSION)
Use cases:
- Custom subsystem names (not "sftp")
- Passing environment variables before the subsystem request
- Managing the subsystem request lifecycle externally
## Data Flow: russh → AsyncSftpClient
```
┌──────────────────────────────────────────────────────────────┐
│ Application Code │
│ │
│ let sftp = from_channel(channel).await?; │
│ sftp.open("/path", opts, &attrs).await? │
│ │
└───────────────┬──────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ AsyncSftpClient<WriteHalf<ChannelStream<Msg>>> │
│ │
│ ┌──────────────┐ ┌────────────────────────────────┐ │
│ │ writer │ │ reader task │ │
│ │ (WriteHalf) │ │ (ReadHalf) │ │
│ └──────┬───────┘ └────────────┬───────────────────┘ │
│ │ │ │
│ │ SFTP packets │ SFTP packets │
│ ▼ ▼ │
└─────────┼───────────────────────────┼─────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ tokio::io::split(ChannelStream<Msg>) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ChannelStream<Msg> │ │
│ │ (implements AsyncRead + AsyncWrite) │ │
│ └───────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴───────────────────────────────┐ │
│ │ Channel<Msg> (russh) │ │
│ │ • request_subsystem(true, "sftp") │ │
│ │ • into_stream() → ChannelStream │ │
│ └───────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴───────────────────────────────┐ │
│ │ SSH Session (russh client) │ │
│ │ • Authentication, host key verification │ │
│ │ • channel_open_session() │ │
│ └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
## Key russh Types Used
| Type | Source | Purpose |
|------|--------|---------|
| `Channel<Msg>` | `russh` | An open SSH channel, used to request subsystems |
| `ChannelStream<Msg>` | `russh` | Adapter from `Channel` to `AsyncRead + AsyncWrite` |
| `Msg` | `russh::client` | Message type parameter for russh channels |
| `WriteHalf<ChannelStream<Msg>>` | `tokio::io` | Write half after splitting the stream |
## Error Handling
The `from_channel()` function converts russh errors to `std::io::Error`:
```rust
channel
.request_subsystem(true, "sftp")
.await
.map_err(|e| std::io::Error::other(format!("sftp subsystem request failed: {:?}", e)))?;
```
This means the caller only needs to handle `std::io::Error`, not russh-specific error types.
## Responsibility Split
| Layer | Responsibility |
|-------|---------------|
| **Caller** | SSH session creation, host-key verification, user authentication, channel opening |
| **`from_channel()`** | Subsystem request, stream creation, SFTP handshake |
| **`from_subsystem_channel()`** | Stream creation, SFTP handshake (no subsystem request) |
| **`AsyncSftpClient`** | All SFTP protocol operations (open, read, write, etc.) |
The russh module is intentionally thin — it does the minimal work to bridge from a russh channel to an `AsyncSftpClient`, keeping all SFTP logic in the shared `async.rs` and `protocol.rs` modules.

View File

@@ -0,0 +1,243 @@
# sftp-rs: CLI Binary (`bin/sftp.rs`)
The crate includes an interactive command-line SFTP client binary, similar in spirit to OpenSSH's `sftp(1)`. It connects to a remote host via an `ssh -s <host> sftp` subprocess and provides a readline-based shell for file operations.
**Feature gate**: `bin` (enabled by default)
## Dependencies
- `rustyline` — readline library with history support
- `shell-words` — shell-style token parsing (handles quoting)
## Connection: `SshChannel`
The binary doesn't use russh or ssh2 — it spawns an `ssh` subprocess and talks to its stdin/stdout:
```rust
struct SshChannel {
child: Child,
stdin: Option<ChildStdin>,
stdout: Option<ChildStdout>,
}
```
### Spawning
```rust
fn spawn(destination: &str) -> std::io::Result<Self>
```
Runs:
```
ssh -s <destination> sftp
```
The `-s` flag tells ssh to request a subsystem (rather than execute a command). The `sftp` argument is the subsystem name.
### `Read` / `Write` Implementation
```rust
impl Read for SshChannel {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>
// Reads from child stdout
}
impl Write for SshChannel {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize>
fn flush(&mut self) -> std::io::Result<()>
// Writes to child stdin
}
```
### Drop Behavior
```rust
impl Drop for SshChannel {
fn drop(&mut self) {
drop(self.stdin.take()); // Close stdin → ssh sees EOF
drop(self.stdout.take());
let _ = self.child.wait(); // Reap the subprocess
}
}
```
Closing stdin before `wait()` is critical — without it, the ssh subprocess blocks waiting for input and `wait()` hangs forever.
## Shell: `Shell`
```rust
struct Shell {
client: SftpClient<SshChannel>,
remote_cwd: String,
}
```
The shell wraps a `SftpClient` and tracks the remote working directory.
### Construction
```rust
fn new(client: SftpClient<SshChannel>) -> Result<Self, SftpError>
```
Initializes `remote_cwd` by calling `realpath(".", None, None)` to get the server's current directory.
### Path Resolution
```rust
fn resolve_remote(&self, path: &str) -> String
```
Relative paths are joined with `remote_cwd`:
- If path starts with `/`, used as-is
- If `remote_cwd` ends with `/`, concatenated directly
- Otherwise, joined with `/`
## Commands
### Remote Commands
| Command | Method | SFTP Operations |
|---------|--------|-----------------|
| `pwd` | `cmd_pwd()` | None (prints cached `remote_cwd`) |
| `cd [path]` | `cmd_cd()` | `realpath` + `stat` (validates it's a directory) |
| `ls [-l] [path]` | `cmd_ls()` | `stat``opendir` → loop `readdir``closedir` |
| `mkdir path` | `cmd_mkdir()` | `mkdir` |
| `rmdir path` | `cmd_rmdir()` | `rmdir` |
| `rm path` | `cmd_rm()` | `remove` |
| `rename old new` | `cmd_rename()` | `rename` |
| `symlink target link` | `cmd_symlink()` | `symlink` |
| `ln [-s] target link` | `cmd_ln()` | `symlink` or `hardlink` |
| `chmod mode path` | `cmd_chmod()` | `setstat` with `permissions` set |
| `stat path` | `cmd_stat()` | `stat` |
| `get remote [local]` | `cmd_get()` | `open` → loop `pread``fclose` |
| `put local [remote]` | `cmd_put()` | `open` → loop `pwrite``fclose` |
### Local Commands
| Command | Implementation |
|---------|---------------|
| `lpwd` | `std::env::current_dir()` |
| `lcd [path]` | `std::env::set_current_dir()` (defaults to `$HOME`) |
| `lls [path]` | `std::fs::read_dir()` |
### Meta Commands
| Command | Action |
|---------|--------|
| `help`, `?` | Print command listing |
| `quit`, `exit`, `bye` | Exit the loop |
## File Transfer: `get` and `put`
### `get remote [local]`
Downloads a remote file to the local filesystem:
```rust
fn cmd_get(&self, remote: &str, local: Option<&str>) -> Result<(), SftpError>
```
1. Opens the remote file with `OpenOptions::new().read(true)`
2. Creates a local file with `std::fs::File::create()`
3. Loops calling `pread(file, offset, 32*1024)` until `Eof` or empty data
4. Writes each chunk to the local file
5. Closes the remote file handle with `fclose`
6. Prints the total bytes transferred
If no local path is given, derives the filename from the remote path's basename.
### `put local [remote]`
Uploads a local file to the remote server:
```rust
fn cmd_put(&self, local: &str, remote: Option<&str>) -> Result<(), SftpError>
```
1. Opens the local file with `std::fs::File::open()`
2. Opens the remote file with `OpenOptions::new().write(true).create(true).truncate(true)`
3. Loops reading 32 KiB chunks from the local file
4. Writes each chunk with `pwrite(file, offset, data)`
5. Closes the remote file handle with `fclose`
6. Prints the total bytes transferred
If no remote path is given, derives the remote filename from the local path's basename.
Both operations use a **32 KiB chunk size** as a balance between latency and throughput.
## Directory Listing: `ls`
The `ls` command handles both files and directories:
1. Calls `stat()` on the target path
2. If the target is a directory:
- Opens with `opendir()`
- Loops on `readdir()` until `Eof`
- Sorts entries by name
- Closes with `closedir()`
3. If the target is a regular file:
- Shows just that file's entry
With `-l` flag, prints the `longname` field from the server (human-readable `ls -l` style output). Without `-l`, prints just filenames.
### Directory Detection
```rust
fn is_dir(attrs: &Attributes) -> bool {
attrs.permissions.is_some_and(|p| (p & 0o170000) == 0o040000)
}
```
Checks the POSIX file type bits in the permissions mask. `0o040000` is `S_IFDIR`.
## Attribute Display
### `format_long()`
```rust
fn format_long(path: &str, attrs: &Attributes) -> String
```
Produces a line with octal permissions, size, and path:
```
100644 1234 myfile.txt
```
### `print_attrs()`
```rust
fn print_attrs(path: &str, attrs: &Attributes)
```
Detailed attribute display for the `stat` command:
```
/path:
size: 1234
permissions: 100644
uid/gid: 1000/1000
owner/group: alice/staff
mtime: 1700000000
```
## Main Loop
```rust
fn main() -> std::io::Result<()>
```
1. Parses the destination from `args[1]` (e.g., `user@host`)
2. Spawns the `SshChannel`
3. Creates a `SftpClient`
4. Initializes the `Shell`
5. Creates a `rustyline::DefaultEditor` with history at `~/.sftp_history`
6. Loops on `readline("sftp> ")`, dispatches commands via `dispatch()`
7. On `Interrupted`, continues; on `Eof`, exits
## Dispatch
```rust
fn dispatch(shell: &mut Shell, line: &str) -> bool
```
Parses the input line with `shell_words::split()`, matches the first token against known commands, and calls the appropriate method. Returns `false` for quit/exit/bye (breaking the loop), `true` otherwise. Errors are printed to stderr with `{:?}` formatting.

View File

@@ -0,0 +1,266 @@
# sftp-rs: Data Flow and Examples
## File Download Flow (Sync Client)
The most common SFTP operation — reading a file from the server:
```
Client Server
│ │
│ SSH_FXP_OPEN [req_id=1] [path] [flags] [attrs] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_HANDLE [req_id=1] [handle_bytes] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_READ [req_id=2] [handle] [offset=0] [len=32K]
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_DATA [req_id=2] [data_bytes] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_READ [req_id=3] [handle] [offset=N] [len=32K]
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [req_id=3] [SSH_FX_EOF] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_CLOSE [req_id=4] [handle] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [req_id=4] [SSH_FX_OK] │
│←───────────────────────────────────────────────│
```
### Sync Code Path
```rust
let file = client.open("/remote/file", OpenOptions::new().read(true), &Attributes::new())?;
let mut offset: u64 = 0;
const CHUNK: u32 = 32 * 1024;
loop {
match client.pread(&file, offset, CHUNK) {
Ok(data) if data.is_empty() => break,
Ok(data) => { /* write data locally */ offset += data.len() as u64; }
Err(Error::Eof(_, _)) => break,
Err(e) => return Err(e),
}
}
client.fclose(&file)?;
```
### Async Code Path
```rust
let file = client.open("/remote/file", OpenOptions::new().read(true), &Attributes::new()).await?;
let mut offset: u64 = 0;
const CHUNK: u32 = 32 * 1024;
loop {
match client.pread(&file, offset, CHUNK).await {
Ok(data) if data.is_empty() => break,
Ok(data) => { /* write data locally */ offset += data.len() as u64; }
Err(Error::Eof(_, _)) => break,
Err(e) => return Err(e),
}
}
client.fclose(&file).await?;
```
## File Upload Flow
```
Client Server
│ │
│ SSH_FXP_OPEN [path] [WRITE|CREAT|TRUNC] [attrs]│
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_HANDLE [handle] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_WRITE [handle] [offset=0] [data] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [SSH_FX_OK] │
│←───────────────────────────────────────────────│
│ │
│ ... (repeat for each chunk) ... │
│ │
│ SSH_FXP_CLOSE [handle] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [SSH_FX_OK] │
│←───────────────────────────────────────────────│
```
## Directory Listing Flow
```
Client Server
│ │
│ SSH_FXP_OPENDIR [path] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_HANDLE [dir_handle] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_READDIR [dir_handle] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_NAME [count] [name+longname+attrs] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_READDIR [dir_handle] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [SSH_FX_EOF] │
│←───────────────────────────────────────────────│
│ │
│ SSH_FXP_CLOSE [dir_handle] │
│───────────────────────────────────────────────→│
│ │
│ SSH_FXP_STATUS [SSH_FX_OK] │
│←───────────────────────────────────────────────│
```
## Async Pipelining Flow
The async client can send multiple requests before any responses arrive:
```
Task 1 Task 2 Task 3 Reader Task
│ │ │ │
│ process() │ process() │ process() │
│ req_id=1 │ req_id=2 │ req_id=3 │
│ insert tx1 │ insert tx2 │ insert tx3 │
│ write pkt │ write pkt │ write pkt │
│ await rx1 │ await rx2 │ await rx3 │
│ │ │ │ read pkt
│ │ │ │ req_id=2 → tx2
│ │ │ │
│ │ rx2 resolved │ │
│ │ returns ───────│ │
│ │ │ │ read pkt
│ │ │ │ req_id=1 → tx1
│ rx1 resolved │ │ │
│ returns ─────│ │ │
│ │ │ │ read pkt
│ │ │ │ req_id=3 → tx3
│ │ │ rx3 resolved │
│ │ │ returns ───────│
```
## russh Integration Flow
```
┌──────────────────────────────────────────────────────────┐
│ Application Code │
│ │
│ // 1. Establish SSH session │
│ let session = /* russh client session setup */; │
│ │
│ // 2. Open a channel │
│ let channel = session.channel_open_session().await?; │
│ │
│ // 3. Request SFTP subsystem + SFTP handshake │
│ let sftp = sftp::russh::from_channel(channel).await?; │
│ │
│ // 4. Use SFTP │
│ let file = sftp.open("/path", opts, &attrs).await?; │
│ let data = sftp.pread(&file, 0, 4096).await?; │
│ sftp.fclose(&file).await?; │
└──────────────────────────────────────────────────────────┘
Expanded step 3:
from_channel(channel)
├─ channel.request_subsystem(true, "sftp")
│ └─ SSH_MSG_CHANNEL_REQUEST subsystem=sftp
│ └─ SSH_MSG_CHANNEL_SUCCESS
├─ channel.into_stream() → ChannelStream<Msg>
├─ tokio::io::split(stream) → (read_half, write_half)
└─ AsyncSftpClient::new(read_half, write_half)
├─ write SSH_FXP_INIT version=3
├─ read SSH_FXP_VERSION
├─ parse version + extensions
└─ spawn reader task
```
## Wire Format: `build_open` Example
Opening a file `/hello.txt` for read:
```rust
let opts = OpenOptions::new().read(true);
let body = build_open("/hello.txt", opts.get(), &Attributes::new())?;
// body layout:
// [0x00 0x00 0x00 0x0A] path length = 10
// [0x2F 0x68 0x65 0x6C 0x6C 0x6F 0x2E 0x74 0x78 0x74] "/hello.txt"
// [0x00 0x00 0x00 0x01] flags = SFTP_FLAG_READ
// [0x00 0x00 0x00 0x00] attrs (empty: valid_attribute_flags = 0)
```
Full packet on wire (with request-id = 42):
```
[0x00 0x00 0x00 0x19] length = 25 (1 type + 4 req_id + 10 path + 4 flags + 4 flags + 4 attrs_len)
[0x03] type = SSH_FXP_OPEN
[0x00 0x00 0x00 0x2A] request_id = 42
[0x00 0x00 0x00 0x0A] path length = 10
"/hello.txt" path
[0x00 0x00 0x00 0x01] open flags = READ
[0x00 0x00 0x00 0x00] attrs valid_flags = 0 (no attributes)
```
## Wire Format: `build_pread` Example
Reading 1024 bytes from offset 4096:
```rust
let body = build_pread(&handle, 4096, 1024);
// body layout:
// [0x00 0x00 0x00 0x04] handle length = 4
// [0x48 0x41 0x4E 0x44] handle = "HAND"
// [0x00 0x00 0x00 0x00 0x00 0x00 0x10 0x00] offset = 4096
// [0x00 0x00 0x04 0x00] length = 1024
```
## Wire Format: `SSH_FXP_STATUS` Response
Server returns `SSH_FX_NO_SUCH_FILE`:
```
[0x00 0x00 0x00 0x2A] request_id = 42 (matching the request)
[0x00 0x00 0x00 0x02] status = SSH_FX_NO_SUCH_FILE
[0x00 0x00 0x00 0x0F] error message length = 15
"No such file" error message
[0x00 0x00 0x00 0x02] language tag length = 2
"en" language tag
```
The `parse_status()` function maps this to `Error::NoSuchFile("No such file".into(), "en".into())`.
## Error Handling Pattern
Both sync and async clients follow the same pattern for response handling:
```rust
// For operations that expect a handle (open, opendir):
let (cmd, data) = self.process(SSH_FXP_OPEN, &body).await?;
Ok(File(expect_handle(cmd, &data)?))
// If server sends SSH_FXP_STATUS instead of SSH_FXP_HANDLE,
// expect_handle() converts the status error to the appropriate Error variant.
// For operations that expect only status (mkdir, remove, close):
let (cmd, data) = self.process(SSH_FXP_MKDIR, &body).await?;
expect_status(cmd, &data)
// If server sends anything other than SSH_FXP_STATUS, returns unexpected response error.
// For operations that expect data (read):
let (cmd, data) = self.process(SSH_FXP_READ, &body).await?;
expect_data(cmd, &data)
// SSH_FXP_DATA → returns the bytes
// SSH_FXP_STATUS with SSH_FX_EOF → Error::Eof (used to signal end-of-file)
// SSH_FXP_STATUS with other codes → appropriate Error variant
```

View File

@@ -0,0 +1,159 @@
# sftp-rs: Quick Reference
## Crate Info
| Field | Value |
|-------|-------|
| Name | `sftp` |
| Version | 0.3.0 |
| License | Apache-2.0 |
| Edition | 2021 |
| Repository | https://github.com/jelmer/sftp-rs |
| Author | Jelmer Vernooij |
## Feature Flags
| Feature | Default | Requires | Provides |
|---------|---------|----------|----------|
| `default` | ✅ | `bin` | CLI binary |
| `bin` | ✅ (via default) | `rustyline`, `shell-words` | `sftp` binary |
| `ssh2` | ❌ | `ssh2` crate | `TryFrom<ssh2::Channel>` |
| `async` | ❌ | `tokio` | `AsyncSftpClient` |
| `russh` | ❌ | `russh`, `async`, `tokio` | russh transport glue |
## Module Map
| Module | Feature Gate | Contents |
|--------|-------------|----------|
| `protocol` | always | Wire codec: types, builders, parsers |
| `sync` | always | `SftpClient<C>` |
| `r#async` | `async` | `AsyncSftpClient<W>` |
| `russh` | `russh` | `from_channel()`, `from_subsystem_channel()` |
## Re-exports (`lib.rs`)
```rust
// Always available
pub use protocol::{Attributes, Directory, Error, File, Kind, OpenOptions, Result, TextHint,
/* all SSH_FILEXFER_ATTR_* constants */};
pub use sync::SftpClient;
// With "async" feature
pub use r#async::AsyncSftpClient;
```
## Client Construction
```rust
// Sync: from any Read+Write
let client = SftpClient::new(channel)?; // channel: impl Read + Write
let client = SftpClient::from_fd(fd)?; // Unix: from raw fd
let client = SftpClient::from_handle(handle)?; // Windows: from raw handle
let client = SftpClient::try_from(ssh2_channel)?; // ssh2 feature
// Async: from split halves
let client = AsyncSftpClient::new(reader, writer).await?; // impl AsyncRead + AsyncWrite
// russh: from channel
let client = sftp::russh::from_channel(channel).await?; // requests sftp subsystem
let client = sftp::russh::from_subsystem_channel(channel).await?; // subsystem already requested
```
## Operations Cheat Sheet
### File Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Open | `open(path, opts, attrs)` | `open(path, opts, attrs).await` | OPEN | HANDLE → File |
| Read | `pread(&file, offset, len)` | `pread(&file, offset, len).await` | READ | DATA → Vec\<u8\> |
| Write | `pwrite(&file, offset, data)` | `pwrite(&file, offset, data).await` | WRITE | STATUS |
| Close | `fclose(&file)` | `fclose(&file).await` | CLOSE | STATUS |
| Line seek | `flineseek(&file, lineno)` | `flineseek(&file, lineno).await` | EXTENDED "text-seek" | STATUS/REPLY |
### Directory Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Open dir | `opendir(path)` | `opendir(path).await` | OPENDIR | HANDLE → Directory |
| Read dir | `readdir(&dir)` | `readdir(&dir).await` | READDIR | NAME → Vec\<(name, long, attrs)\> |
| Close dir | `closedir(&dir)` | `closedir(&dir).await` | CLOSE | STATUS |
| Make dir | `mkdir(path, attrs)` | `mkdir(path, attrs).await` | MKDIR | STATUS |
| Remove dir | `rmdir(path)` | `rmdir(path).await` | RMDIR | STATUS |
### Attribute Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Stat (follow) | `stat(path, flags)` | `stat(path, flags).await` | STAT | ATTRS |
| Lstat (no follow) | `lstat(path, flags)` | `lstat(path, flags).await` | LSTAT | ATTRS |
| Fstat (by handle) | `fstat(&file, flags)` | `fstat(&file, flags).await` | FSTAT | ATTRS |
| Set stat (path) | `setstat(path, attrs)` | `setstat(path, attrs).await` | SETSTAT | STATUS |
| Set stat (handle) | `fsetstat(&file, attrs)` | `fsetstat(&file, attrs).await` | FSETSTAT | STATUS |
### Path Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Canonicalize | `realpath(path, ctrl, compose)` | `realpath(path, ctrl, compose).await` | REALPATH | NAME → String |
| Read symlink | `readlink(path)` | `readlink(path).await` | READLINK | NAME → String |
| Remove file | `remove(path)` | `remove(path).await` | REMOVE | STATUS |
| Rename | `rename(old, new, flags)` | `rename(old, new, flags).await` | RENAME | STATUS |
| Symlink | `symlink(path, target)` | `symlink(path, target).await` | SYMLINK | STATUS |
| Hard link | `hardlink(path, target)` | `hardlink(path, target).await` | LINK | STATUS |
| Link (generic) | `link(path, target, sym)` | `link(path, target, sym).await` | LINK | STATUS |
### Lock Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Block | `block(&file, off, len, mask)` | `block(&file, off, len, mask).await` | BLOCK | STATUS |
| Unblock | `unblock(&file, off, len)` | `unblock(&file, off, len).await` | UNBLOCK | STATUS |
### Extension Operations
| Operation | Sync | Async | Request | Response |
|-----------|------|-------|---------|----------|
| Extended | `extended(req, data)` | `extended(req, data).await` | EXTENDED | REPLY → Option\<Vec\<u8\>\> |
## OpenOptions Builder
```rust
OpenOptions::new()
.read(true) // SFTP_FLAG_READ = 0x01
.write(true) // SFTP_FLAG_WRITE = 0x02
.append(true) // SFTP_FLAG_APPEND = 0x04
.create(true) // SFTP_FLAG_CREAT = 0x08
.truncate(true) // SFTP_FLAG_TRUNC = 0x10
.excl(true) // SFTP_FLAG_EXCL = 0x20
```
## Error Variants (Most Common)
| Error | When |
|-------|------|
| `Eof(msg, lang)` | End of file read, or end of directory listing |
| `NoSuchFile(msg, lang)` | File/path does not exist |
| `PermissionDenied(msg, lang)` | Insufficient permissions |
| `Failure(msg, lang)` | Generic failure |
| `DirNotEmpty(msg, lang)` | rmdir on non-empty directory |
| `NotADirectory(msg, lang)` | Expected directory, got file |
| `FileAlreadyExists(msg, lang)` | Exclusive create on existing file |
| `Io(err)` | Local I/O error or unexpected protocol message |
| `Other(code, msg, lang)` | Unrecognized SFTP status code |
## Testing Approach
Both sync and async clients have extensive test suites using **stub servers**:
- **Sync**: `spawn_stub()` on TCP sockets — a background thread that handles INIT/VERSION then routes requests through a handler closure
- **Async**: `with_stub()` using `tokio::io::duplex()` — a spawned async task that runs a router against a handler closure
Both approaches allow per-test programmable server behavior: the handler receives `(cmd, body_without_req_id)` and returns `(response_cmd, response_body_without_req_id)`.
The protocol module has unit tests for:
- `Attributes` round-trip serialization (empty, all fields, individual field groups)
- Builder byte layout verification (exact byte-level assertions)
- Request-ID wrap/unwrap
- Status parsing (OK, EOF, typed errors)
- Expect functions (correct type, status-as-error, unexpected-type rejection)