docs(research): add russh and sftp-rs deep-dive references
This commit is contained in:
@@ -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;
|
||||
```
|
||||
276
docs/research/references/ssh/sftp-rs/02-wire-protocol-codec.md
Normal file
276
docs/research/references/ssh/sftp-rs/02-wire-protocol-codec.md
Normal 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.
|
||||
308
docs/research/references/ssh/sftp-rs/03-key-types.md
Normal file
308
docs/research/references/ssh/sftp-rs/03-key-types.md
Normal 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 |
|
||||
185
docs/research/references/ssh/sftp-rs/04-sync-client.md
Normal file
185
docs/research/references/ssh/sftp-rs/04-sync-client.md
Normal 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` |
|
||||
211
docs/research/references/ssh/sftp-rs/05-async-client.md
Normal file
211
docs/research/references/ssh/sftp-rs/05-async-client.md
Normal 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)
|
||||
132
docs/research/references/ssh/sftp-rs/06-russh-integration.md
Normal file
132
docs/research/references/ssh/sftp-rs/06-russh-integration.md
Normal 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.
|
||||
243
docs/research/references/ssh/sftp-rs/07-cli-binary.md
Normal file
243
docs/research/references/ssh/sftp-rs/07-cli-binary.md
Normal 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.
|
||||
@@ -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
|
||||
```
|
||||
159
docs/research/references/ssh/sftp-rs/09-quick-reference.md
Normal file
159
docs/research/references/ssh/sftp-rs/09-quick-reference.md
Normal 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)
|
||||
Reference in New Issue
Block a user