docs(research): add russh and sftp-rs deep-dive references
This commit is contained in:
153
docs/research/references/ssh/russh/01-overview-architecture.md
Normal file
153
docs/research/references/ssh/russh/01-overview-architecture.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Russh: Overview & Architecture
|
||||||
|
|
||||||
|
**Version**: 0.60.2
|
||||||
|
**Repository**: https://github.com/warp-tech/russh
|
||||||
|
**License**: Apache-2.0
|
||||||
|
**Rust Edition**: 2024
|
||||||
|
**MSRV**: 1.85
|
||||||
|
**Origin**: Fork of [Thrussh](https://nest.pijul.com/pijul/thrussh) by Pierre-Étienne Meunier
|
||||||
|
|
||||||
|
## What is Russh?
|
||||||
|
|
||||||
|
Russh is a **low-level, asynchronous SSH2 client and server implementation** for Rust, built on Tokio and Futures. It provides a complete SSH-2 protocol stack — key exchange, authentication, channel multiplexing, port forwarding, and subsystems — exposed through handler traits that users implement for their specific needs.
|
||||||
|
|
||||||
|
Core characteristics:
|
||||||
|
- **Async-native** — built entirely on Tokio and Futures; no blocking I/O
|
||||||
|
- **Handler-driven** — both client and server are used by implementing trait handlers (`client::Handler`, `server::Handler`)
|
||||||
|
- **Both client and server** — a single crate supports both sides of the SSH connection
|
||||||
|
- **Streaming I/O** — channels implement `AsyncRead`/`AsyncWrite` for ergonomic integration
|
||||||
|
- **Safety-conscious** — `deny(clippy::unwrap_used)`, `deny(clippy::expect_used)`, `deny(clippy::panic)`, `deny(clippy::indexing_slicing)` by default; sensitive data uses `CryptoVec` with `mlock`
|
||||||
|
- **Two crypto backends** — `aws-lc-rs` (default) or `ring`, at least one required
|
||||||
|
|
||||||
|
## Workspace Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
russh/ # Main SSH library crate
|
||||||
|
├── russh-util/ # Runtime abstraction utilities (WASM support, time, spawn)
|
||||||
|
├── russh-config/ # SSH config file parser + ProxyCommand support
|
||||||
|
├── cryptovec/ # Zeroing-on-drop vector with mlock (CryptoVec)
|
||||||
|
└── pageant/ # Windows Pageant SSH agent transport client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
russh depends on:
|
||||||
|
├── russh-cryptovec (CryptoVec: zeroing vector with mlock, ssh-encoding support)
|
||||||
|
├── russh-util (runtime abstraction: tokio spawn, time, WASM compat)
|
||||||
|
├── ssh-key (internal-russh-forked-ssh-key: key types, parsing, certificates)
|
||||||
|
├── ssh-encoding (SSH wire format encode/decode)
|
||||||
|
├── tokio (async runtime, IO, sync, time)
|
||||||
|
├── futures (future combinators)
|
||||||
|
├── curve25519-dalek (Curve25519 DH)
|
||||||
|
├── ed25519-dalek (Ed25519 signing)
|
||||||
|
├── p256/p384/p521 (NIST ECDSA curves + ECDH)
|
||||||
|
├── aes (AES cipher implementations)
|
||||||
|
├── flate2 (zlib compression, optional)
|
||||||
|
├── rsa (RSA signing, optional feature)
|
||||||
|
└── aws-lc-rs / ring (crypto backend for GCM etc.)
|
||||||
|
|
||||||
|
russh-config depends on:
|
||||||
|
├── tokio (async IO, process for ProxyCommand)
|
||||||
|
├── futures (future utilities)
|
||||||
|
└── globset / whoami (config matching, username detection)
|
||||||
|
|
||||||
|
cryptovec depends on:
|
||||||
|
├── nix (Unix) (mlock/munlock via mmap)
|
||||||
|
├── windows-sys (Win) (VirtualLock/VirtualUnlock)
|
||||||
|
└── ssh-encoding (optional, for Encode support)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Russh implements the SSH-2 protocol as a **state machine** driven by an event loop. The core design separates protocol handling from application logic via handler traits.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Application Layer │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ client::Handler │ │ server::Handler │ │
|
||||||
|
│ │ (user implements) │ │ (user implements) │ │
|
||||||
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
|
├───────────┼──────────────────────────┼───────────────────┤
|
||||||
|
│ │ Russh Library │ │
|
||||||
|
│ ┌────────▼─────────────────────────▼─────────┐ │
|
||||||
|
│ │ Event Loop (tokio::select!) │ │
|
||||||
|
│ │ ┌──────────┐ ┌─────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │ │ Reading │ │ Writing │ │ Handler │ │ │
|
||||||
|
│ │ │ packets │ │ packets │ │ dispatch │ │ │
|
||||||
|
│ │ └────┬─────┘ └────┬────┘ └──────┬───────┘ │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ │ ┌────▼────────────▼─────────────▼───────┐ │ │
|
||||||
|
│ │ │ Session State Machine │ │ │
|
||||||
|
│ │ │ KEX → Auth → Channels → Forwarding │ │ │
|
||||||
|
│ │ └──────────────────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ Transport Layer │
|
||||||
|
│ ┌────────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ Cipher (enc) │ │ MAC (auth) │ │ Compression │ │
|
||||||
|
│ └────────────────┘ └────────────┘ └────────────┘ │
|
||||||
|
│ ┌────────────────┐ ┌────────────────────────────┐ │
|
||||||
|
│ │ PacketWriter │ │ SSHBuffer / SshRead │ │
|
||||||
|
│ └────────────────┘ └────────────────────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ TCP Stream (tokio::net) │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Principle: Buffered Writes
|
||||||
|
|
||||||
|
From the library documentation:
|
||||||
|
|
||||||
|
> It might seem a little odd that the read/write methods for server or client sessions often return neither `Result` nor `Future`. This is because the data sent to the remote side is **buffered**, because it needs to be encrypted first, and encryption works on buffers, and for many algorithms, not in place.
|
||||||
|
|
||||||
|
The event loop works as follows:
|
||||||
|
1. Wait for incoming packets
|
||||||
|
2. React by calling the provided `Handler`, which fills some buffers
|
||||||
|
3. If buffers are non-empty, send them to the socket, flush, empty the buffers
|
||||||
|
4. For servers, unsolicited messages sent through a `server::Handle` are processed when there is no incoming packet
|
||||||
|
|
||||||
|
## Module Map
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `client/` | Client-side SSH: `Handler` trait, `Session`, `Handle`, `Config`, kex, encrypted state |
|
||||||
|
| `server/` | Server-side SSH: `Handler` trait, `Server` trait, `Session`, `Handle`, `Config` |
|
||||||
|
| `kex/` | Key exchange algorithms: Curve25519, DH groups, ECDH-NIST, ML-KEM hybrid |
|
||||||
|
| `cipher/` | Encryption: Chacha20-Poly1305, AES-GCM, AES-CTR, AES-CBC, 3DES-CBC |
|
||||||
|
| `mac/` | Message authentication: HMAC-SHA2, ETM variants |
|
||||||
|
| `keys/` | Key loading, parsing, SSH agent protocol, known hosts |
|
||||||
|
| `channels/` | Channel abstraction, `Channel`/`ChannelMsg`, `AsyncRead`/`AsyncWrite` streams |
|
||||||
|
| `negotiation` | Algorithm negotiation: `Preferred`, `Names`, `write_kex`, `read_kex` |
|
||||||
|
| `compression` | zlib and zlib@openssh.com compression |
|
||||||
|
| `session` | `CommonSession`, `Encrypted` state, `NewKeys`, `Exchange` |
|
||||||
|
| `sshbuffer` | `SSHBuffer`, `PacketWriter`, `SshId` |
|
||||||
|
| `auth` | Auth methods: `Method`, `MethodSet`, `Signer`, `AuthResult` |
|
||||||
|
| `cert` | OpenSSH certificate handling |
|
||||||
|
| `pty` | PTY terminal modes |
|
||||||
|
| `msg` | SSH message type constants (RFC 4250/4253/4254) |
|
||||||
|
| `parsing` | Wire format parsing helpers |
|
||||||
|
|
||||||
|
## Crypto Backend Selection
|
||||||
|
|
||||||
|
At least one crypto backend feature must be enabled, or compilation fails:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(not(any(feature = "ring", feature = "aws-lc-rs")))]
|
||||||
|
compile_error!(
|
||||||
|
"`russh` requires enabling either the `ring` or `aws-lc-rs` feature as a crypto backend."
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`aws-lc-rs`** (default): Used for AES-GCM via the `aws-lc-rs` crate's AEAD interface
|
||||||
|
- **`ring`**: Alternative backend for AES-GCM via the `ring` crate's AEAD interface
|
||||||
|
|
||||||
|
Other features:
|
||||||
|
- **`rsa`** (default): Enables RSA key support
|
||||||
|
- **`des`**: Enables insecure 3DES-CBC cipher
|
||||||
|
- **`dsa`**: Enables insecure DSA key support
|
||||||
|
- **`flate2`** (default): Enables zlib compression
|
||||||
|
- **`async-trait`**: Enables `#[async_trait]` attribute on handler traits
|
||||||
|
- **`legacy-ed25519-pkcs8-parser`**: Enables ASN1-based Ed25519 PKCS#8 parsing
|
||||||
|
- **`serde`**: Enables serde support for ssh-key types
|
||||||
410
docs/research/references/ssh/russh/02-key-types-traits.md
Normal file
410
docs/research/references/ssh/russh/02-key-types-traits.md
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# Russh: Key Types and Traits
|
||||||
|
|
||||||
|
## Client API
|
||||||
|
|
||||||
|
### `client::Handler` Trait
|
||||||
|
|
||||||
|
The primary interface for implementing SSH clients. All methods have default implementations (mostly no-ops returning `Ok(())`), except `check_server_key` which defaults to rejecting all keys.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Handler: Sized + Send {
|
||||||
|
type Error: From<crate::Error> + Send + core::fmt::Debug;
|
||||||
|
|
||||||
|
// --- Must implement (security critical) ---
|
||||||
|
fn check_server_key(
|
||||||
|
&mut self,
|
||||||
|
server_public_key: &ssh_key::PublicKey,
|
||||||
|
) -> impl Future<Output = Result<bool, Self::Error>> + Send;
|
||||||
|
// Default: async { Ok(false) } — REJECTS ALL KEYS
|
||||||
|
|
||||||
|
// --- Optional callbacks ---
|
||||||
|
fn auth_banner(&mut self, banner: &str, session: &mut Session) -> ...;
|
||||||
|
fn kex_done(&mut self, shared_secret: Option<&[u8]>, names: &Names, session: &mut Session) -> ...;
|
||||||
|
fn channel_open_confirmation(&mut self, id: ChannelId, max_packet_size: u32, window_size: u32, session: &mut Session) -> ...;
|
||||||
|
fn channel_success(&mut self, channel: ChannelId, session: &mut Session) -> ...;
|
||||||
|
fn channel_failure(&mut self, channel: ChannelId, session: &mut Session) -> ...;
|
||||||
|
fn channel_close(&mut self, channel: ChannelId, session: &mut Session) -> ...;
|
||||||
|
fn channel_eof(&mut self, channel: ChannelId, session: &mut Session) -> ...;
|
||||||
|
fn channel_open_failure(&mut self, channel: ChannelId, reason: ChannelOpenFailure, description: &str, language: &str, session: &mut Session) -> ...;
|
||||||
|
fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> ...;
|
||||||
|
fn extended_data(&mut self, channel: ChannelId, ext: u32, data: &[u8], session: &mut Session) -> ...;
|
||||||
|
fn exit_status(&mut self, channel: ChannelId, exit_status: u32, session: &mut Session) -> ...;
|
||||||
|
fn exit_signal(&mut self, channel: ChannelId, signal_name: Sig, core_dumped: bool, error_message: &str, lang_tag: &str, session: &mut Session) -> ...;
|
||||||
|
fn window_adjusted(&mut self, channel: ChannelId, new_size: u32, session: &mut Session) -> ...;
|
||||||
|
fn adjust_window(&mut self, channel: ChannelId, window: u32) -> u32;
|
||||||
|
|
||||||
|
// Server-initiated channels (port forwarding, agent, X11, etc.)
|
||||||
|
fn server_channel_open_forwarded_tcpip(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_forwarded_streamlocal(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_agent_forward(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_session(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_x11(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_direct_tcpip(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
fn server_channel_open_direct_streamlocal(&mut self, channel: Channel<Msg>, ...) -> ...;
|
||||||
|
|
||||||
|
// OpenSSH extensions
|
||||||
|
fn openssh_ext_host_keys_announced(&mut self, keys: Vec<PublicKey>, session: &mut Session) -> ...;
|
||||||
|
fn disconnected(&mut self, reason: DisconnectReason<Self::Error>) -> ...;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `client::Config`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Config {
|
||||||
|
pub client_id: SshId, // SSH version string, default: "SSH-2.0-russh_0.60.2"
|
||||||
|
pub limits: Limits, // Rekey limits (1GB read/write, 1hr time)
|
||||||
|
pub window_size: u32, // Initial channel window (default: 2097152 = 2MB)
|
||||||
|
pub maximum_packet_size: u32, // Max single packet (default: 32768)
|
||||||
|
pub channel_buffer_size: usize, // Channel message buffer (default: 100)
|
||||||
|
pub preferred: Preferred, // Algorithm preferences
|
||||||
|
pub inactivity_timeout: Option<Duration>, // Connection timeout (default: None)
|
||||||
|
pub keepalive_interval: Option<Duration>, // Keepalive frequency (default: None)
|
||||||
|
pub keepalive_max: usize, // Max missed keepalives (default: 3)
|
||||||
|
pub anonymous: bool, // Skip authentication (default: false)
|
||||||
|
pub gex: GexParams, // DH-GEX parameters (default: 3072-8192 bits)
|
||||||
|
pub nodelay: bool, // TCP_NODELAY (default: false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `client::Handle<H>`
|
||||||
|
|
||||||
|
The handle returned after connecting. Used to send commands, open channels, authenticate, etc.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<H: Handler> Handle<H> {
|
||||||
|
// Authentication
|
||||||
|
pub async fn authenticate_none(&mut self, user: U) -> Result<AuthResult, Error>;
|
||||||
|
pub async fn authenticate_password(&mut self, user: U, password: P) -> Result<AuthResult, Error>;
|
||||||
|
pub async fn authenticate_publickey(&mut self, user: U, key: PrivateKeyWithHashAlg) -> Result<AuthResult, Error>;
|
||||||
|
pub async fn authenticate_openssh_cert(&mut self, user: U, key: Arc<PrivateKey>, cert: Certificate) -> Result<AuthResult, Error>;
|
||||||
|
pub async fn authenticate_publickey_with<U, S: Signer>(&mut self, user: U, key: PublicKey, hash_alg: Option<HashAlg>, signer: &mut S) -> Result<AuthResult, S::Error>;
|
||||||
|
pub async fn authenticate_keyboard_interactive_start<U, S>(&mut self, user: U, submethods: S) -> Result<KeyboardInteractiveAuthResponse, Error>;
|
||||||
|
pub async fn authenticate_keyboard_interactive_respond(&mut self, responses: Vec<String>) -> Result<KeyboardInteractiveAuthResponse, Error>;
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
pub async fn channel_open_session(&self) -> Result<Channel<Msg>, Error>;
|
||||||
|
pub async fn channel_open_x11(&self, originator_address: A, originator_port: u32) -> Result<Channel<Msg>, Error>;
|
||||||
|
pub async fn channel_open_direct_tcpip(&self, host_to_connect: A, port_to_connect: u32, originator_address: B, originator_port: u32) -> Result<Channel<Msg>, Error>;
|
||||||
|
pub async fn channel_open_direct_streamlocal(&self, socket_path: S) -> Result<Channel<Msg>, Error>;
|
||||||
|
|
||||||
|
// Port forwarding
|
||||||
|
pub async fn tcpip_forward(&self, address: A, port: u32) -> Result<u32, Error>;
|
||||||
|
pub async fn cancel_tcpip_forward(&self, address: A, port: u32) -> Result<(), Error>;
|
||||||
|
pub async fn streamlocal_forward(&self, socket_path: A) -> Result<(), Error>;
|
||||||
|
pub async fn cancel_streamlocal_forward(&self, socket_path: A) -> Result<(), Error>;
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
pub async fn disconnect(&self, reason: Disconnect, description: &str, language_tag: &str) -> Result<(), Error>;
|
||||||
|
pub async fn rekey_soon(&self) -> Result<(), Error>;
|
||||||
|
pub async fn send_keepalive(&self, want_reply: bool) -> Result<(), Error>;
|
||||||
|
pub async fn send_ping(&self) -> Result<(), Error>;
|
||||||
|
pub async fn no_more_sessions(&self, want_reply: bool) -> Result<(), Error>;
|
||||||
|
pub async fn best_supported_rsa_hash(&self) -> Result<Option<Option<HashAlg>>, Error>;
|
||||||
|
pub fn is_closed(&self) -> bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Handle<H>` also implements `Future<Output = Result<(), H::Error>>`, so you can `.await` it to wait for the session to end.
|
||||||
|
|
||||||
|
### `client::connect` and `client::connect_stream`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Connect via TCP
|
||||||
|
pub async fn connect<H: Handler + Send + 'static, A: ToSocketAddrs>(
|
||||||
|
config: Arc<Config>, addrs: A, handler: H,
|
||||||
|
) -> Result<Handle<H>, H::Error>;
|
||||||
|
|
||||||
|
// Connect via any AsyncRead+AsyncWrite stream
|
||||||
|
pub async fn connect_stream<H, R>(
|
||||||
|
config: Arc<Config>, stream: R, handler: H,
|
||||||
|
) -> Result<Handle<H>, H::Error>
|
||||||
|
where
|
||||||
|
H: Handler + Send + 'static,
|
||||||
|
R: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
### `server::Server` Trait
|
||||||
|
|
||||||
|
Factory trait that creates a new `Handler` for each client connection:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Server {
|
||||||
|
type Handler: Handler + Send + 'static;
|
||||||
|
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler;
|
||||||
|
fn handle_session_error(&mut self, _error: <Self::Handler as Handler>::Error) {}
|
||||||
|
|
||||||
|
// Run on a pre-bound TcpListener
|
||||||
|
fn run_on_socket(&mut self, config: Arc<Config>, socket: &TcpListener) -> RunningServer<...>;
|
||||||
|
|
||||||
|
// Bind and run on an address
|
||||||
|
fn run_on_address<A: ToSocketAddrs + Send>(&mut self, config: Arc<Config>, addrs: A) -> impl Future<...>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `server::Handler` Trait
|
||||||
|
|
||||||
|
Per-client handler, similar to `client::Handler` but with different callback signatures (receives `&mut Session` for sending responses):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Handler: Sized {
|
||||||
|
type Error: From<crate::Error> + Send;
|
||||||
|
|
||||||
|
// Authentication callbacks
|
||||||
|
fn auth_none(&mut self, user: &str) -> impl Future<Output = Result<Auth, Self::Error>> + Send;
|
||||||
|
fn auth_password(&mut self, user: &str, password: &str) -> impl Future<Output = Result<Auth, Self::Error>> + Send;
|
||||||
|
fn auth_publickey_offered(&mut self, user: &str, public_key: &PublicKey) -> impl Future<...> + Send;
|
||||||
|
fn auth_publickey(&mut self, user: &str, public_key: &PublicKey) -> impl Future<...> + Send;
|
||||||
|
fn auth_openssh_certificate(&mut self, user: &str, certificate: &Certificate) -> impl Future<...> + Send;
|
||||||
|
fn auth_keyboard_interactive<'a>(&'a mut self, user: &str, submethods: &str, response: Option<Response<'a>>) -> impl Future<...> + Send;
|
||||||
|
fn auth_succeeded(&mut self, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn authentication_banner(&mut self) -> impl Future<Output = Result<Option<String>, Self::Error>> + Send;
|
||||||
|
|
||||||
|
// Channel callbacks (return bool = whether to grant the channel)
|
||||||
|
fn channel_open_session(&mut self, channel: Channel<Msg>, session: &mut Session) -> impl Future<Output = Result<bool, Self::Error>> + Send;
|
||||||
|
fn channel_open_x11(&mut self, channel: Channel<Msg>, originator_address: &str, originator_port: u32, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn channel_open_direct_tcpip(&mut self, channel: Channel<Msg>, host_to_connect: &str, port_to_connect: u32, originator_address: &str, originator_port: u32, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn channel_open_forwarded_tcpip(&mut self, channel: Channel<Msg>, ...) -> impl Future<...> + Send;
|
||||||
|
fn channel_open_direct_streamlocal(&mut self, channel: Channel<Msg>, socket_path: &str, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
|
||||||
|
// Channel events
|
||||||
|
fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn extended_data(&mut self, channel: ChannelId, code: u32, data: &[u8], session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn channel_close(&mut self, channel: ChannelId, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn channel_eof(&mut self, channel: ChannelId, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn window_adjusted(&mut self, channel: ChannelId, new_size: u32, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn adjust_window(&mut self, channel: ChannelId, current: u32) -> u32;
|
||||||
|
|
||||||
|
// Channel requests (use session.channel_success/failure to respond)
|
||||||
|
fn pty_request(&mut self, channel: ChannelId, term: &str, col_width: u32, row_height: u32, pix_width: u32, pix_height: u32, modes: &[(Pty, u32)], session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn x11_request(&mut self, channel: ChannelId, ...) -> impl Future<...> + Send;
|
||||||
|
fn env_request(&mut self, channel: ChannelId, variable_name: &str, variable_value: &str, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn subsystem_request(&mut self, channel: ChannelId, name: &str, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn window_change_request(&mut self, channel: ChannelId, ...) -> impl Future<...> + Send;
|
||||||
|
fn agent_request(&mut self, channel: ChannelId, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn signal(&mut self, channel: ChannelId, signal: Sig, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
|
||||||
|
// Port forwarding
|
||||||
|
fn tcpip_forward(&mut self, address: &str, port: &mut u32, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn cancel_tcpip_forward(&mut self, address: &str, port: u32, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn streamlocal_forward(&mut self, socket_path: &str, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
fn cancel_streamlocal_forward(&mut self, socket_path: &str, session: &mut Session) -> impl Future<...> + Send;
|
||||||
|
|
||||||
|
// DH-GEX group lookup
|
||||||
|
fn lookup_dh_gex_group(&mut self, gex_params: &GexParams) -> impl Future<Output = Result<Option<DhGroup>, Self::Error>> + Send;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `server::Auth` Enum
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Auth {
|
||||||
|
Reject { proceed_with_methods: Option<MethodSet>, partial_success: bool },
|
||||||
|
Accept,
|
||||||
|
UnsupportedMethod,
|
||||||
|
Partial { name: Cow<'static, str>, instructions: Cow<'static, str>, prompts: Cow<'static, [(Cow<'static, str>, bool)]> },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `server::Config`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Config {
|
||||||
|
pub server_id: SshId, // "SSH-2.0-russh_0.60.2"
|
||||||
|
pub methods: auth::MethodSet, // All methods by default
|
||||||
|
pub auth_rejection_time: Duration, // Constant-time rejection (default: 1s)
|
||||||
|
pub auth_rejection_time_initial: Option<Duration>, // For "none" probe (default: None)
|
||||||
|
pub keys: Vec<PrivateKey>, // Server host keys
|
||||||
|
pub limits: Limits,
|
||||||
|
pub window_size: u32, // Default: 2097152
|
||||||
|
pub maximum_packet_size: u32, // Default: 32768
|
||||||
|
pub channel_buffer_size: usize, // Default: 100
|
||||||
|
pub event_buffer_size: usize, // Default: 10
|
||||||
|
pub preferred: Preferred,
|
||||||
|
pub max_auth_attempts: usize, // Default: 10
|
||||||
|
pub inactivity_timeout: Option<Duration>, // Default: 600s
|
||||||
|
pub keepalive_interval: Option<Duration>, // Default: None
|
||||||
|
pub keepalive_max: usize, // Default: 3
|
||||||
|
pub nodelay: bool, // Default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `server::Handle`
|
||||||
|
|
||||||
|
Server-side handle for sending unsolicited messages to a client:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Handle {
|
||||||
|
pub async fn data(&self, id: ChannelId, data: impl Into<Bytes>) -> Result<(), Bytes>;
|
||||||
|
pub async fn extended_data(&self, id: ChannelId, ext: u32, data: impl Into<Bytes>) -> Result<(), Bytes>;
|
||||||
|
pub async fn eof(&self, id: ChannelId) -> Result<(), ()>;
|
||||||
|
pub async fn channel_success(&self, id: ChannelId) -> Result<(), ()>;
|
||||||
|
pub async fn channel_failure(&self, id: ChannelId) -> Result<(), ()>;
|
||||||
|
pub async fn close(&self, id: ChannelId) -> Result<(), ()>;
|
||||||
|
pub async fn xon_xoff_request(&self, id: ChannelId, client_can_do: bool) -> Result<(), ()>;
|
||||||
|
pub async fn exit_status_request(&self, id: ChannelId, exit_status: u32) -> Result<(), ()>;
|
||||||
|
pub async fn forward_tcpip(&self, address: String, port: u32) -> Result<u32, ()>;
|
||||||
|
pub async fn cancel_tcpip_forward(&self, address: String, port: u32) -> Result<(), ()>;
|
||||||
|
// ... etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Types
|
||||||
|
|
||||||
|
### `Channel<Send>`
|
||||||
|
|
||||||
|
A bidirectional handle to an SSH channel. `Send` is the message type (`client::Msg` or `server::Msg`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Channel<Send: From<(ChannelId, ChannelMsg)>> {
|
||||||
|
pub read_half: ChannelReadHalf,
|
||||||
|
pub write_half: ChannelWriteHalf<Send>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: From<(ChannelId, ChannelMsg)> + Send + Sync + 'static> Channel<S> {
|
||||||
|
pub fn id(&self) -> ChannelId;
|
||||||
|
pub async fn writable_packet_size(&self) -> usize;
|
||||||
|
pub fn split(self) -> (ChannelReadHalf, ChannelWriteHalf<S>);
|
||||||
|
pub async fn wait(&mut self) -> Option<ChannelMsg>;
|
||||||
|
|
||||||
|
// Client-side operations
|
||||||
|
pub async fn request_pty(&self, ...) -> Result<(), Error>;
|
||||||
|
pub async fn request_shell(&self, want_reply: bool) -> Result<(), Error>;
|
||||||
|
pub async fn exec(&self, want_reply: bool, command: A) -> Result<(), Error>;
|
||||||
|
pub async fn signal(&self, signal: Sig) -> Result<(), Error>;
|
||||||
|
pub async fn request_subsystem(&self, want_reply: bool, name: A) -> Result<(), Error>;
|
||||||
|
pub async fn request_x11(&self, ...) -> Result<(), Error>;
|
||||||
|
pub async fn set_env(&self, ...) -> Result<(), Error>;
|
||||||
|
pub async fn window_change(&self, ...) -> Result<(), Error>;
|
||||||
|
pub async fn agent_forward(&self, want_reply: bool) -> Result<(), Error>;
|
||||||
|
pub async fn data<R: AsyncRead + Unpin>(&self, data: R) -> Result<(), Error>;
|
||||||
|
pub async fn extended_data<R: AsyncRead + Unpin>(&self, ext: u32, data: R) -> Result<(), Error>;
|
||||||
|
pub async fn eof(&self) -> Result<(), Error>;
|
||||||
|
pub async fn exit_status(&self, exit_status: u32) -> Result<(), Error>;
|
||||||
|
pub async fn close(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
// Streaming
|
||||||
|
pub fn into_stream(self) -> ChannelStream<S>;
|
||||||
|
pub fn make_reader(&mut self) -> impl AsyncRead + '_;
|
||||||
|
pub fn make_reader_ext(&mut self, ext: Option<u32>) -> impl AsyncRead + '_;
|
||||||
|
pub fn make_writer(&self) -> impl AsyncWrite + 'static;
|
||||||
|
pub fn make_writer_ext(&self, ext: Option<u32>) -> impl AsyncWrite + 'static;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ChannelMsg` Enum
|
||||||
|
|
||||||
|
All possible messages receivable on a channel:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ChannelMsg {
|
||||||
|
Open { id: ChannelId, max_packet_size: u32, window_size: u32 },
|
||||||
|
Data { data: Bytes },
|
||||||
|
ExtendedData { data: Bytes, ext: u32 },
|
||||||
|
Eof,
|
||||||
|
Close,
|
||||||
|
RequestPty { want_reply: bool, term: String, col_width: u32, row_height: u32, pix_width: u32, pix_height: u32, terminal_modes: Vec<(Pty, u32)> },
|
||||||
|
RequestShell { want_reply: bool },
|
||||||
|
Exec { want_reply: bool, command: Vec<u8> },
|
||||||
|
Signal { signal: Sig },
|
||||||
|
RequestSubsystem { want_reply: bool, name: String },
|
||||||
|
RequestX11 { want_reply: bool, single_connection: bool, x11_authentication_protocol: String, x11_authentication_cookie: String, x11_screen_number: u32 },
|
||||||
|
SetEnv { want_reply: bool, variable_name: String, variable_value: String },
|
||||||
|
WindowChange { col_width: u32, row_height: u32, pix_width: u32, pix_height: u32 },
|
||||||
|
AgentForward { want_reply: bool },
|
||||||
|
XonXoff { client_can_do: bool },
|
||||||
|
ExitStatus { exit_status: u32 },
|
||||||
|
ExitSignal { signal_name: Sig, core_dumped: bool, error_message: String, lang_tag: String },
|
||||||
|
WindowAdjusted { new_size: u32 },
|
||||||
|
Success,
|
||||||
|
Failure,
|
||||||
|
OpenFailure(ChannelOpenFailure),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ChannelId`
|
||||||
|
|
||||||
|
A `u32` wrapper identifying a channel within a session:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
|
||||||
|
pub struct ChannelId(u32);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Types
|
||||||
|
|
||||||
|
### `Limits`
|
||||||
|
|
||||||
|
Rekey thresholds (following RFC 4253 Section 9):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Limits {
|
||||||
|
pub rekey_write_limit: usize, // Default: 1 << 30 (1 GB)
|
||||||
|
pub rekey_read_limit: usize, // Default: 1 << 30 (1 GB)
|
||||||
|
pub rekey_time_limit: Duration, // Default: 3600s (1 hour)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Preferred`
|
||||||
|
|
||||||
|
Algorithm preference lists for negotiation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Preferred {
|
||||||
|
pub kex: Cow<'static, [kex::Name]>,
|
||||||
|
pub key: Cow<'static, [Algorithm]>,
|
||||||
|
pub cipher: Cow<'static, [cipher::Name]>,
|
||||||
|
pub mac: Cow<'static, [mac::Name]>,
|
||||||
|
pub compression: Cow<'static, [compression::Name]>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default order prioritizes modern algorithms:
|
||||||
|
- **KEX**: ML-KEM-768-X25519 → Curve25519 → DH-GEX-SHA256 → DH-G18/17/16/15/14
|
||||||
|
- **Key**: Ed25519 → ECDSA-P256/P384/P521 → RSA-SHA512/256
|
||||||
|
- **Cipher**: Chacha20-Poly1305 → AES-256-GCM → AES-256/192/128-CTR
|
||||||
|
- **MAC**: HMAC-SHA512-ETM → HMAC-SHA256-ETM → HMAC-SHA512/256
|
||||||
|
|
||||||
|
### `Disconnect` Enum
|
||||||
|
|
||||||
|
RFC 4253 Section 11.1 disconnect reason codes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Disconnect {
|
||||||
|
HostNotAllowedToConnect = 1,
|
||||||
|
ProtocolError = 2,
|
||||||
|
KeyExchangeFailed = 3,
|
||||||
|
MACError = 5,
|
||||||
|
CompressionError = 6,
|
||||||
|
ServiceNotAvailable = 7,
|
||||||
|
ProtocolVersionNotSupported = 8,
|
||||||
|
HostKeyNotVerifiable = 9,
|
||||||
|
ConnectionLost = 10,
|
||||||
|
ByApplication = 11,
|
||||||
|
TooManyConnections = 12,
|
||||||
|
AuthCancelledByUser = 13,
|
||||||
|
NoMoreAuthMethodsAvailable = 14,
|
||||||
|
IllegalUserName = 15,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CryptoVec`
|
||||||
|
|
||||||
|
A vector that zeroes its memory on clears and reallocations, using `mlock` on Unix and `VirtualLock` on Windows. Used for all sensitive key material.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// From russh-cryptovec
|
||||||
|
pub struct CryptoVec { /* ... */ }
|
||||||
|
// Implements mlock/munlock for sensitive data
|
||||||
|
// Zeroes memory on drop/resize
|
||||||
|
```
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Russh: SSH Protocol Implementation
|
||||||
|
|
||||||
|
This document covers how russh implements the SSH-2 protocol — key exchange, authentication, channel multiplexing, port forwarding, and extensions.
|
||||||
|
|
||||||
|
## Protocol Message Constants (`msg` module)
|
||||||
|
|
||||||
|
Russh defines SSH protocol message type codes as constants:
|
||||||
|
|
||||||
|
| Constant | Value | RFC Reference |
|
||||||
|
|----------|-------|---------------|
|
||||||
|
| `DISCONNECT` | 1 | RFC 4253 §11.1 |
|
||||||
|
| `IGNORE` | 2 | RFC 4253 §11.2 |
|
||||||
|
| `UNIMPLEMENTED` | 3 | RFC 4253 §11.3 |
|
||||||
|
| `DEBUG` | 4 | RFC 4253 §11.4 |
|
||||||
|
| `SERVICE_REQUEST` | 5 | RFC 4253 §7 |
|
||||||
|
| `SERVICE_ACCEPT` | 6 | RFC 4253 §7 |
|
||||||
|
| `EXT_INFO` | 7 | RFC 8308 |
|
||||||
|
| `KEXINIT` | 20 | RFC 4253 §7.1 |
|
||||||
|
| `NEWKEYS` | 21 | RFC 4253 §7.3 |
|
||||||
|
| `KEX_ECDH_INIT` | 30 | RFC 5656 §4 |
|
||||||
|
| `KEX_ECDH_REPLY` | 31 | RFC 5656 §4 |
|
||||||
|
| `KEX_DH_GEX_REQUEST` | 34 | RFC 4419 §3 |
|
||||||
|
| `KEX_DH_GEX_GROUP` | 31 | RFC 4419 §3 |
|
||||||
|
| `KEX_DH_GEX_INIT` | 32 | RFC 4419 §3 |
|
||||||
|
| `KEX_DH_GEX_REPLY` | 33 | RFC 4419 §3 |
|
||||||
|
| `USERAUTH_REQUEST` | 50 | RFC 4252 §5 |
|
||||||
|
| `USERAUTH_FAILURE` | 51 | RFC 4252 §5 |
|
||||||
|
| `USERAUTH_SUCCESS` | 52 | RFC 4252 §5 |
|
||||||
|
| `USERAUTH_BANNER` | 53 | RFC 4252 §5.4 |
|
||||||
|
| `USERAUTH_INFO_REQUEST` | 60 | RFC 4256 §5 |
|
||||||
|
| `USERAUTH_INFO_RESPONSE` | 61 | RFC 4256 §5 |
|
||||||
|
| `GLOBAL_REQUEST` | 80 | RFC 4254 §4 |
|
||||||
|
| `REQUEST_SUCCESS` | 81 | RFC 4254 §4 |
|
||||||
|
| `REQUEST_FAILURE` | 82 | RFC 4254 §4 |
|
||||||
|
| `CHANNEL_OPEN` | 90 | RFC 4254 §5.1 |
|
||||||
|
| `CHANNEL_OPEN_CONFIRMATION` | 91 | RFC 4254 §5.1 |
|
||||||
|
| `CHANNEL_OPEN_FAILURE` | 92 | RFC 4254 §5.1 |
|
||||||
|
| `CHANNEL_WINDOW_ADJUST` | 93 | RFC 4254 §5.2 |
|
||||||
|
| `CHANNEL_DATA` | 94 | RFC 4254 §5.2 |
|
||||||
|
| `CHANNEL_EXTENDED_DATA` | 95 | RFC 4254 §5.2 |
|
||||||
|
| `CHANNEL_EOF` | 96 | RFC 4254 §5.3 |
|
||||||
|
| `CHANNEL_CLOSE` | 97 | RFC 4254 §5.3 |
|
||||||
|
| `CHANNEL_REQUEST` | 98 | RFC 4254 §5.4 |
|
||||||
|
| `CHANNEL_SUCCESS` | 99 | RFC 4254 §5.4 |
|
||||||
|
| `CHANNEL_FAILURE` | 100 | RFC 4254 §5.4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connection Lifecycle
|
||||||
|
|
||||||
|
### 1. Version Exchange
|
||||||
|
|
||||||
|
Both sides send their SSH identification string:
|
||||||
|
|
||||||
|
```
|
||||||
|
SSH-2.0-russh_0.60.2\r\n
|
||||||
|
```
|
||||||
|
|
||||||
|
The `SshId` type handles this:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum SshId {
|
||||||
|
Standard(Cow<'static, str>), // Appends \r\n
|
||||||
|
Raw(Cow<'static, str>), // Sent as-is
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Client sends first, then reads server's ID. Server reads client's ID first, then sends its own.
|
||||||
|
|
||||||
|
### 2. Key Exchange (KEXINIT)
|
||||||
|
|
||||||
|
The `negotiation` module handles algorithm negotiation. Each side sends a `KEXINIT` packet containing lists of supported algorithms. The first algorithm from the client's list that also appears in the server's list is chosen (for client), or vice versa (for server).
|
||||||
|
|
||||||
|
**Algorithm selection order** (per `Preferred::DEFAULT`):
|
||||||
|
- **KEX**: `mlkem768x25519-sha256` → `curve25519-sha256` → `curve25519-sha256@libssh.org` → `diffie-hellman-group-exchange-sha256` → group18/17/16/15/14
|
||||||
|
- **Host Key**: `ssh-ed25519` → `ecdsa-sha2-nistp256/384/521` → `rsa-sha2-512/256` → `ssh-rsa`
|
||||||
|
- **Cipher**: `chacha20-poly1305@openssh.com` → `aes256-gcm@openssh.com` → `aes256-ctr` → `aes192-ctr` → `aes128-ctr`
|
||||||
|
- **MAC**: `hmac-sha2-512-etm@openssh.com` → `hmac-sha2-256-etm@openssh.com` → `hmac-sha2-512` → `hmac-sha2-256`
|
||||||
|
|
||||||
|
**Negotiation flow** (`write_kex` / `read_kex`):
|
||||||
|
1. Both sides send KEXINIT with their algorithm lists
|
||||||
|
2. Each side parses the other's KEXINIT using `Client::read_kex()` or `Server::read_kex()`
|
||||||
|
3. The `Select` trait implements the first-match algorithm
|
||||||
|
4. `Names` struct is produced with the negotiated algorithms
|
||||||
|
5. Extension kex names (like `ext-info-c`, `kex-strict-c-v00@openssh.com`) are handled specially — filtered from negotiation but used for capability detection
|
||||||
|
|
||||||
|
### 3. Diffie-Hellman Key Exchange
|
||||||
|
|
||||||
|
After KEXINIT, the actual key exchange runs. This is managed by `ClientKex` and `ServerKex` state machines.
|
||||||
|
|
||||||
|
**Client KEX state machine** (`ClientKex`):
|
||||||
|
```
|
||||||
|
Created → (receive KEXINIT) → WaitingForDhReply or WaitingForGexReply → WaitingForNewKeys → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps for a typical Curve25519 exchange:
|
||||||
|
1. **Client sends KEX_ECDH_INIT**: Contains client's ephemeral public key
|
||||||
|
2. **Server sends KEX_ECDH_REPLY**: Contains server host key, server's ephemeral public key, and a signature
|
||||||
|
3. **Client verifies**:
|
||||||
|
- Parses the server host key
|
||||||
|
- Computes the shared secret from the ephemeral keys
|
||||||
|
- Computes the exchange hash (H) from: `V_C || V_S || I_C || I_S || K_S || e || f || K`
|
||||||
|
- Verifies the server's signature on H using the server host key
|
||||||
|
- Calls `handler.check_server_key()` for application-level trust verification
|
||||||
|
4. **Both sides send NEWKEYS**: Activate the newly computed encryption keys
|
||||||
|
|
||||||
|
**Server KEX** mirrors this, responding to client's init and sending the reply.
|
||||||
|
|
||||||
|
**DH-GEX (Group Exchange) flow** has an extra step:
|
||||||
|
1. Client sends `KEX_DH_GEX_REQUEST` with min/preferred/max group sizes
|
||||||
|
2. Server sends `KEX_DH_GEX_GROUP` with the prime and generator
|
||||||
|
3. Client validates group size and sends `KEX_DH_GEX_INIT`
|
||||||
|
4. Server sends `KEX_DH_GEX_REPLY`
|
||||||
|
5. Both send `NEWKEYS`
|
||||||
|
|
||||||
|
### 4. Key Derivation (`compute_keys`)
|
||||||
|
|
||||||
|
After the shared secret and exchange hash are computed, keys are derived per RFC 4253 §7.2:
|
||||||
|
|
||||||
|
```
|
||||||
|
Key = HASH(K || H || X || session_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `X` is a single character:
|
||||||
|
- `A` = client-to-server IV (nonce)
|
||||||
|
- `B` = server-to-client IV (nonce)
|
||||||
|
- `C` = client-to-server encryption key
|
||||||
|
- `D` = server-to-client encryption key
|
||||||
|
- `E` = client-to-server MAC key
|
||||||
|
- `F` = server-to-client MAC key
|
||||||
|
|
||||||
|
If the derived key is shorter than needed, it's extended by hashing `K || H || Key[0..n]` repeatedly.
|
||||||
|
|
||||||
|
The `session_id` is set to the exchange hash from the first key exchange and remains constant across rekeys.
|
||||||
|
|
||||||
|
### 5. Strict Key Exchange
|
||||||
|
|
||||||
|
Russh supports the OpenSSH strict key exchange extension (`kex-strict-c-v00@openssh.com` / `kex-strict-s-v00@openssh.com`), which:
|
||||||
|
- Resets sequence numbers after NEWKEYS
|
||||||
|
- Enforces that only KEXINIT, DH init, and NEWKEYS messages appear during initial kex
|
||||||
|
- Validated by `validate_client_msg_strict_kex` / `validate_server_msg_strict_kex`
|
||||||
|
- Prevents the Terrapin attack (CVE-2023-48799)
|
||||||
|
|
||||||
|
### 6. Authentication
|
||||||
|
|
||||||
|
After NEWKEYS, the encrypted state machine transitions through:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum EncryptedState {
|
||||||
|
WaitingAuthServiceRequest { sent: bool, accepted: bool },
|
||||||
|
WaitingAuthRequest(auth::AuthRequest),
|
||||||
|
InitCompression,
|
||||||
|
Authenticated,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Client sends `SERVICE_REQUEST` for "ssh-connection"
|
||||||
|
2. Server responds with `SERVICE_ACCEPT`
|
||||||
|
3. Client sends authentication requests (password, publickey, keyboard-interactive, none)
|
||||||
|
4. Server responds with `USERAUTH_SUCCESS`, `USERAUTH_FAILURE`, or `USERAUTH_INFO_REQUEST`
|
||||||
|
5. Upon success, state transitions to `InitCompression` → `Authenticated`
|
||||||
|
|
||||||
|
**Authentication methods** (`auth::Method`):
|
||||||
|
```rust
|
||||||
|
pub enum Method {
|
||||||
|
None,
|
||||||
|
Password { password: String },
|
||||||
|
PublicKey { key: PrivateKeyWithHashAlg },
|
||||||
|
OpenSshCertificate { key: Arc<PrivateKey>, cert: Certificate },
|
||||||
|
FuturePublicKey { key: PublicKey, hash_alg: Option<HashAlg> },
|
||||||
|
FutureCertificate { cert: Certificate, hash_alg: Option<HashAlg> },
|
||||||
|
KeyboardInteractive { submethods: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `FuturePublicKey` / `FutureCertificate` variants are used with the `Signer` trait (e.g., SSH agent), where signing is delegated to an external component.
|
||||||
|
|
||||||
|
### 7. Rekeying
|
||||||
|
|
||||||
|
Rekeying is triggered automatically when:
|
||||||
|
- Bytes written exceed `Limits::rekey_write_limit` (default 1 GB)
|
||||||
|
- Bytes read exceed `Limits::rekey_read_limit` (default 1 GB)
|
||||||
|
- Time since last rekey exceeds `Limits::rekey_time_limit` (default 1 hour)
|
||||||
|
- Explicitly via `Handle::rekey_soon()`
|
||||||
|
|
||||||
|
During rekey:
|
||||||
|
- New `KEXINIT` packets are exchanged
|
||||||
|
- Pending channel data is buffered until rekey completes
|
||||||
|
- Sequence numbers are reset if strict kex is negotiated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Multiplexing
|
||||||
|
|
||||||
|
SSH channels are multiplexed over a single connection. Each channel has:
|
||||||
|
- A `ChannelId` (local identifier)
|
||||||
|
- A `recipient_channel` (remote identifier)
|
||||||
|
- Window size for flow control
|
||||||
|
- Maximum packet size
|
||||||
|
|
||||||
|
### Channel Types
|
||||||
|
|
||||||
|
| Type | Open Method | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `session` | `channel_open_session()` | Standard shell/exec channel |
|
||||||
|
| `x11` | `channel_open_x11()` | X11 forwarding |
|
||||||
|
| `direct-tcpip` | `channel_open_direct_tcpip()` | Local TCP port forwarding |
|
||||||
|
| `forwarded-tcpip` | Server-initiated | Remote TCP port forwarding |
|
||||||
|
| `direct-streamlocal` | `channel_open_direct_streamlocal()` | Local UNIX socket forwarding |
|
||||||
|
| `forwarded-streamlocal` | Server-initiated | Remote UNIX socket forwarding |
|
||||||
|
| `auth-agent@openssh.com` | Server-initiated | Agent forwarding |
|
||||||
|
|
||||||
|
### Flow Control
|
||||||
|
|
||||||
|
SSH uses a window-based flow control mechanism:
|
||||||
|
- Each side maintains a `sender_window_size` and `recipient_window_size`
|
||||||
|
- Data cannot be sent if the recipient's window is exhausted
|
||||||
|
- `CHANNEL_WINDOW_ADJUST` messages increase the window
|
||||||
|
- `adjust_window()` is called when data is received to determine the next target window
|
||||||
|
- Data is queued in `pending_data` when the window is full, and flushed when the window is adjusted
|
||||||
|
|
||||||
|
### Channel Request/Response Pattern
|
||||||
|
|
||||||
|
Many channel operations follow a request-response pattern:
|
||||||
|
1. Client sends `CHANNEL_REQUEST` with `want_reply = true`
|
||||||
|
2. Server processes the request via `Handler` callback
|
||||||
|
3. Server calls `session.channel_success(channel)` or `session.channel_failure(channel)`
|
||||||
|
4. Client receives `CHANNEL_SUCCESS` or `CHANNEL_FAILURE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port Forwarding
|
||||||
|
|
||||||
|
### Local TCP Forwarding (direct-tcpip)
|
||||||
|
|
||||||
|
Client opens a channel to forward a TCP connection through the server:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Client side
|
||||||
|
let channel = handle.channel_open_direct_tcpip(
|
||||||
|
"target-host", 8080, // host:port to connect to
|
||||||
|
"origin-host", 12345, // originator info
|
||||||
|
).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote TCP Forwarding (forward-tcpip)
|
||||||
|
|
||||||
|
**Step 1**: Client requests the server to listen on a port:
|
||||||
|
```rust
|
||||||
|
let allocated_port = handle.tcpip_forward("0.0.0.0", 0).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2**: When a connection arrives, the server calls `Handler::channel_open_forwarded_tcpip()`.
|
||||||
|
|
||||||
|
**Step 3**: Client receives the channel via `Handler::server_channel_open_forwarded_tcpip()`.
|
||||||
|
|
||||||
|
**Step 4**: To stop:
|
||||||
|
```rust
|
||||||
|
handle.cancel_tcpip_forward("0.0.0.0", allocated_port).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### UNIX Socket Forwarding (streamlocal)
|
||||||
|
|
||||||
|
Similar to TCP forwarding but for UNIX domain sockets:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Local
|
||||||
|
handle.channel_open_direct_streamlocal("/tmp/socket").await?;
|
||||||
|
|
||||||
|
// Remote
|
||||||
|
handle.streamlocal_forward("/tmp/socket").await?;
|
||||||
|
handle.cancel_streamlocal_forward("/tmp/socket").await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
### `ext-info-c` / `ext-info-s` (RFC 8308)
|
||||||
|
|
||||||
|
Extension info is sent after NEWKEYS. Russh supports:
|
||||||
|
- **`server-sig-algs`**: Server tells client which signature algorithms it accepts for public key authentication. Used by `Handle::best_supported_rsa_hash()`.
|
||||||
|
|
||||||
|
### `kex-strict-c-v00@openssh.com` / `kex-strict-s-v00@openssh.com`
|
||||||
|
|
||||||
|
OpenSSH strict key exchange (Terrapin attack mitigation). Negotiated during KEXINIT and enforced during the initial key exchange.
|
||||||
|
|
||||||
|
### OpenSSH Agent Forwarding
|
||||||
|
|
||||||
|
The server can open an `auth-agent@openssh.com` channel to forward the client's SSH agent. The client handles this via `Handler::server_channel_open_agent_forward()`.
|
||||||
|
|
||||||
|
### OpenSSH `no-more-sessions@openssh.com`
|
||||||
|
|
||||||
|
Requests that the other side not open any more sessions:
|
||||||
|
```rust
|
||||||
|
handle.no_more_sessions(true).await?;
|
||||||
|
```
|
||||||
299
docs/research/references/ssh/russh/04-crypto.md
Normal file
299
docs/research/references/ssh/russh/04-crypto.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Russh: Cryptographic Primitives
|
||||||
|
|
||||||
|
This document covers the cryptographic implementations in russh — key exchange algorithms, ciphers, MACs, and key handling.
|
||||||
|
|
||||||
|
## Key Exchange Algorithms (`kex` module)
|
||||||
|
|
||||||
|
All KEX algorithms implement the `KexAlgorithmImplementor` trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) trait KexAlgorithmImplementor {
|
||||||
|
fn skip_exchange(&self) -> bool;
|
||||||
|
fn is_dh_gex(&self) -> bool;
|
||||||
|
fn client_dh_gex_init(&mut self, gex: &GexParams, writer: &mut impl Writer) -> Result<(), Error>;
|
||||||
|
fn dh_gex_set_group(&mut self, group: DhGroup) -> Result<(), Error>;
|
||||||
|
fn server_dh(&mut self, exchange: &mut Exchange, payload: &[u8]) -> Result<(), Error>;
|
||||||
|
fn client_dh(&mut self, client_ephemeral: &mut Vec<u8>, writer: &mut impl Writer) -> Result<(), Error>;
|
||||||
|
fn compute_shared_secret(&mut self, remote_pubkey: &[u8]) -> Result<(), Error>;
|
||||||
|
fn shared_secret_bytes(&self) -> Option<&[u8]>;
|
||||||
|
fn compute_exchange_hash(&self, key: &[u8], exchange: &Exchange, buffer: &mut CryptoVec) -> Result<Vec<u8>, Error>;
|
||||||
|
fn compute_keys(&self, session_id: &[u8], exchange_hash: &[u8], cipher: cipher::Name, remote_to_local_mac: mac::Name, local_to_remote_mac: mac::Name, is_server: bool) -> Result<CipherPair, Error>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `KexAlgorithm` enum dispatches via `enum_dispatch`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) enum KexAlgorithm {
|
||||||
|
DhGroupKexSha1(DhGroupKex<Sha1>),
|
||||||
|
DhGroupKexSha256(DhGroupKex<Sha256>),
|
||||||
|
DhGroupKexSha512(DhGroupKex<Sha512>),
|
||||||
|
Curve25519Kex(Curve25519Kex),
|
||||||
|
EcdhNistP256Kex(EcdhNistPKex<NistP256, Sha256>),
|
||||||
|
EcdhNistP384Kex(EcdhNistPKex<NistP384, Sha384>),
|
||||||
|
EcdhNistP521Kex(EcdhNistPKex<NistP521, Sha512>),
|
||||||
|
MlKem768X25519Kex(MlKem768X25519Kex),
|
||||||
|
NoneKexAlgorithm(NoneKexAlgorithm),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported KEX Algorithms
|
||||||
|
|
||||||
|
| Algorithm Name | Constant | Type | Hash | Notes |
|
||||||
|
|----------------|----------|------|------|-------|
|
||||||
|
| `mlkem768x25519-sha256` | `MLKEM768X25519_SHA256` | PQ/T Hybrid (ML-KEM + X25519) | SHA-256 | **Default first choice**, post-quantum |
|
||||||
|
| `curve25519-sha256` | `CURVE25519` | Curve25519 ECDH | SHA-256 | Recommended |
|
||||||
|
| `curve25519-sha256@libssh.org` | `CURVE25519_PRE_RFC_8731` | Curve25519 ECDH | SHA-256 | Pre-RFC name, same impl |
|
||||||
|
| `ecdh-sha2-nistp256` | `ECDH_SHA2_NISTP256` | NIST P-256 ECDH | SHA-256 | |
|
||||||
|
| `ecdh-sha2-nistp384` | `ECDH_SHA2_NISTP384` | NIST P-384 ECDH | SHA-384 | |
|
||||||
|
| `ecdh-sha2-nistp521` | `ECDH_SHA2_NISTP521` | NIST P-521 ECDH | SHA-512 | |
|
||||||
|
| `diffie-hellman-group-exchange-sha256` | `DH_GEX_SHA256` | DH-GEX | SHA-256 | Dynamic group |
|
||||||
|
| `diffie-hellman-group-exchange-sha1` | `DH_GEX_SHA1` | DH-GEX | SHA-1 | Legacy |
|
||||||
|
| `diffie-hellman-group14-sha256` | `DH_G14_SHA256` | DH Group 14 | SHA-256 | 2048-bit |
|
||||||
|
| `diffie-hellman-group14-sha1` | `DH_G14_SHA1` | DH Group 14 | SHA-1 | Legacy |
|
||||||
|
| `diffie-hellman-group15-sha512` | `DH_G15_SHA512` | DH Group 15 | SHA-512 | 3072-bit |
|
||||||
|
| `diffie-hellman-group16-sha512` | `DH_G16_SHA512` | DH Group 16 | SHA-512 | 4096-bit |
|
||||||
|
| `diffie-hellman-group17-sha512` | `DH_G17_SHA512` | DH Group 17 | SHA-512 | 6144-bit |
|
||||||
|
| `diffie-hellman-group18-sha512` | `DH_G18_SHA512` | DH Group 18 | SHA-512 | 8192-bit |
|
||||||
|
| `diffie-hellman-group1-sha1` | `DH_G1_SHA1` | DH Group 1 | SHA-1 | **Insecure**, 1024-bit |
|
||||||
|
| `none` | `NONE` | No exchange | N/A | Testing only |
|
||||||
|
|
||||||
|
### Curve25519 Implementation
|
||||||
|
|
||||||
|
Located in `kex/curve25519.rs`. Uses `curve25519-dalek` for the Diffie-Hellman computation:
|
||||||
|
- Generates an ephemeral keypair
|
||||||
|
- Sends the public key as the DH init
|
||||||
|
- Computes the shared secret from the remote public key
|
||||||
|
- The shared secret is encoded as a raw 32-byte string (not mpint) in the exchange hash
|
||||||
|
|
||||||
|
### NIST ECDH Implementation
|
||||||
|
|
||||||
|
Located in `kex/ecdh_nistp.rs`. Generic over the curve (`NistP256`, `NistP384`, `NistP521`):
|
||||||
|
- Uses `p256`/`p384`/`p521` crates for ECDH
|
||||||
|
- Points are encoded in the SSH EC point format (32/48/66 bytes for uncompressed)
|
||||||
|
|
||||||
|
### DH Group Exchange Implementation
|
||||||
|
|
||||||
|
Located in `kex/dh/`. Two variants: `DhGroupKex<D>` (GEX) and fixed-group variants.
|
||||||
|
- GEX uses `num-bigint` for modular exponentiation with safe primes
|
||||||
|
- Fixed groups use pre-defined safe primes from RFC 3526
|
||||||
|
- `DhGroup` struct: `{ prime: CryptoVec, generator: CryptoVec }`
|
||||||
|
- Server provides groups via `Handler::lookup_dh_gex_group()` — default uses `BUILTIN_SAFE_DH_GROUPS`
|
||||||
|
- `GexParams` controls min/preferred/max group sizes (default: 3072/8192/8192 bits)
|
||||||
|
|
||||||
|
### ML-KEM Hybrid Implementation
|
||||||
|
|
||||||
|
Located in `kex/hybrid_mlkem.rs`. Implements the `mlkem768x25519-sha256` hybrid key exchange:
|
||||||
|
- Combines ML-KEM-768 (post-quantum) with X25519 (classical)
|
||||||
|
- Both shared secrets are concatenated before hashing
|
||||||
|
- Provides quantum-resistant key exchange while maintaining classical security
|
||||||
|
|
||||||
|
### Exchange Hash Computation
|
||||||
|
|
||||||
|
The exchange hash `H` is computed from the following data (per RFC 4253 §8):
|
||||||
|
|
||||||
|
```
|
||||||
|
H = HASH(V_C || V_S || I_C || I_S || K_S || e || f || K)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `V_C` = client version string
|
||||||
|
- `V_S` = server version string
|
||||||
|
- `I_C` = client's KEXINIT payload
|
||||||
|
- `I_S` = server's KEXINIT payload
|
||||||
|
- `K_S` = server host key
|
||||||
|
- `e` = client ephemeral public key
|
||||||
|
- `f` = server ephemeral public key
|
||||||
|
- `K` = shared secret
|
||||||
|
|
||||||
|
For DH-GEX, additional fields are included (p, g, and the GEX parameters).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ciphers (`cipher` module)
|
||||||
|
|
||||||
|
The `Cipher` trait defines how to construct opening and sealing keys:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) trait Cipher {
|
||||||
|
fn needs_mac(&self) -> bool; // Whether a separate MAC is needed
|
||||||
|
fn key_len(&self) -> usize;
|
||||||
|
fn nonce_len(&self) -> usize;
|
||||||
|
fn make_opening_key(&self, key, nonce, mac_key, mac) -> Box<dyn OpeningKey + Send>;
|
||||||
|
fn make_sealing_key(&self, key, nonce, mac_key, mac) -> Box<dyn SealingKey + Send>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Ciphers
|
||||||
|
|
||||||
|
| Algorithm Name | Constant | Type | Key Size | MAC Required | AEAD |
|
||||||
|
|----------------|----------|------|----------|-------------|------|
|
||||||
|
| `chacha20-poly1305@openssh.com` | `CHACHA20_POLY1305` | Stream cipher + Poly1305 | 256-bit + 256-bit | No | Yes |
|
||||||
|
| `aes256-gcm@openssh.com` | `AES_256_GCM` | AES-GCM | 256-bit | No | Yes |
|
||||||
|
| `aes128-gcm@openssh.com` | `AES_128_GCM` | AES-GCM | 128-bit | No | Yes |
|
||||||
|
| `aes256-ctr` | `AES_256_CTR` | AES-CTR | 256-bit | Yes | No |
|
||||||
|
| `aes192-ctr` | `AES_192_CTR` | AES-CTR | 192-bit | Yes | No |
|
||||||
|
| `aes128-ctr` | `AES_128_CTR` | AES-CTR | 128-bit | Yes | No |
|
||||||
|
| `aes256-cbc` | `AES_256_CBC` | AES-CBC | 256-bit | Yes | No |
|
||||||
|
| `aes192-cbc` | `AES_192_CBC` | AES-CBC | 192-bit | Yes | No |
|
||||||
|
| `aes128-cbc` | `AES_128_CBC` | AES-CBC | 128-bit | Yes | No |
|
||||||
|
| `3des-cbc` | `TRIPLE_DES_CBC` | 3DES-CBC | 168-bit | Yes | No | (feature `des`) |
|
||||||
|
|
||||||
|
### AEAD Ciphers (Chacha20-Poly1305, AES-GCM)
|
||||||
|
|
||||||
|
AEAD ciphers do not need a separate MAC. They handle both encryption and authentication:
|
||||||
|
- **Chacha20-Poly1305**: Uses the OpenSSH construction where the sequence number is used as the Chacha20 counter. The Poly1305 key is derived from a separate Chacha20 stream.
|
||||||
|
- **AES-GCM**: Uses `aws-lc-rs` or `ring` as the backend depending on feature flags. The nonce is constructed from the sequence number.
|
||||||
|
|
||||||
|
### Block Ciphers (AES-CTR, AES-CBC, 3DES-CBC)
|
||||||
|
|
||||||
|
Block ciphers require a separate MAC. They implement `SshBlockCipher<C>`:
|
||||||
|
- **CTR mode**: Uses `ctr::Ctr128BE` from the `ctr` crate
|
||||||
|
- **CBC mode**: Uses `cbc::Encryptor`/`cbc::Decryptor` from the `cbc` crate with PKCS#7 padding
|
||||||
|
- The `needs_mac()` method returns `true`
|
||||||
|
|
||||||
|
### `OpeningKey` and `SealingKey` Traits
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) trait OpeningKey {
|
||||||
|
fn packet_length_to_read_for_block_length(&self) -> usize { 4 }
|
||||||
|
fn decrypt_packet_length(&self, seqn: u32, encrypted_packet_length: &[u8]) -> [u8; 4];
|
||||||
|
fn tag_len(&self) -> usize;
|
||||||
|
fn open<'a>(&mut self, seqn: u32, ciphertext_and_tag: &'a mut [u8]) -> Result<&'a [u8], Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait SealingKey {
|
||||||
|
fn padding_length(&self, plaintext: &[u8]) -> usize;
|
||||||
|
fn fill_padding(&self, padding_out: &mut [u8]);
|
||||||
|
fn tag_len(&self) -> usize;
|
||||||
|
fn seal(&mut self, seqn: u32, plaintext_in_ciphertext_out: &mut [u8], tag_out: &mut [u8]);
|
||||||
|
fn write(&mut self, payload: &[u8], buffer: &mut SSHBuffer);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `write()` method on `SealingKey` handles the full packet construction:
|
||||||
|
1. Compute padding length (minimum 4 bytes, block-aligned)
|
||||||
|
2. Write packet length (4 bytes) + padding length (1 byte) + payload + padding
|
||||||
|
3. Encrypt the packet
|
||||||
|
4. Append the authentication tag
|
||||||
|
5. Increment the sequence number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MACs (`mac` module)
|
||||||
|
|
||||||
|
MAC algorithms are split into two categories: regular and Encrypt-Then-MAC (ETM).
|
||||||
|
|
||||||
|
### Supported MACs
|
||||||
|
|
||||||
|
| Algorithm Name | Constant | Hash | Key Length | ETM |
|
||||||
|
|----------------|----------|------|-----------|-----|
|
||||||
|
| `hmac-sha2-512-etm@openssh.com` | `HMAC_SHA512_ETM` | SHA-512 | 64 bytes | Yes |
|
||||||
|
| `hmac-sha2-256-etm@openssh.com` | `HMAC_SHA256_ETM` | SHA-256 | 32 bytes | Yes |
|
||||||
|
| `hmac-sha2-512` | `HMAC_SHA512` | SHA-512 | 64 bytes | No |
|
||||||
|
| `hmac-sha2-256` | `HMAC_SHA256` | SHA-256 | 32 bytes | No |
|
||||||
|
| `hmac-sha1-etm@openssh.com` | `HMAC_SHA1_ETM` | SHA-1 | 20 bytes | Yes |
|
||||||
|
| `hmac-sha1` | `HMAC_SHA1` | SHA-1 | 20 bytes | No |
|
||||||
|
| `none` | `NONE` | — | 0 | — |
|
||||||
|
|
||||||
|
### ETM (Encrypt-Then-MAC) Mode
|
||||||
|
|
||||||
|
With ETM MACs:
|
||||||
|
- The packet length field is sent unencrypted (read first)
|
||||||
|
- The rest of the packet is encrypted
|
||||||
|
- The MAC is computed over the unencrypted length + encrypted payload
|
||||||
|
- This prevents padding oracle attacks
|
||||||
|
|
||||||
|
### Regular MAC Mode
|
||||||
|
|
||||||
|
With regular MACs:
|
||||||
|
- The entire packet (including length) is encrypted
|
||||||
|
- The MAC is computed over the sequence number + unencrypted packet
|
||||||
|
- This is the traditional SSH MAC construction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Handling (`keys` module)
|
||||||
|
|
||||||
|
### Key Formats Supported
|
||||||
|
|
||||||
|
- **OpenSSH format** (private keys): `-----BEGIN OPENSSH PRIVATE KEY-----` with bcrypt-pbkdf encrypted keys
|
||||||
|
- **PKCS#8** (unencrypted and encrypted): `-----BEGIN PRIVATE KEY-----` / `-----BEGIN ENCRYPTED PRIVATE KEY-----`
|
||||||
|
- **PKCS#1** (RSA): `-----BEGIN RSA PRIVATE KEY-----`
|
||||||
|
- **SEC1** (EC): `-----BEGIN EC PRIVATE KEY-----`
|
||||||
|
- **OpenSSH public keys**: `ssh-ed25519 AAAAC3N...`
|
||||||
|
- **PPK format**: PuTTY private key files
|
||||||
|
- **OpenSSH certificates**: `-----BEGIN OPENSSH SSH2 CERTIFICATE-----`
|
||||||
|
|
||||||
|
### Key Algorithms
|
||||||
|
|
||||||
|
| Algorithm | Key Type | Signing |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| `ssh-ed25519` | Ed25519 | Ed25519 |
|
||||||
|
| `ecdsa-sha2-nistp256` | ECDSA P-256 | SHA-256 |
|
||||||
|
| `ecdsa-sha2-nistp384` | ECDSA P-384 | SHA-384 |
|
||||||
|
| `ecdsa-sha2-nistp521` | ECDSA P-521 | SHA-512 |
|
||||||
|
| `rsa-sha2-512` | RSA | SHA-512 |
|
||||||
|
| `rsa-sha2-256` | RSA | SHA-256 |
|
||||||
|
| `ssh-rsa` | RSA | SHA-1 (legacy) |
|
||||||
|
|
||||||
|
### `PrivateKeyWithHashAlg`
|
||||||
|
|
||||||
|
Wrapper that pairs a private key with a specific hash algorithm (needed for RSA key disambiguation):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PrivateKeyWithHashAlg {
|
||||||
|
key: Arc<PrivateKey>,
|
||||||
|
hash: Option<HashAlg>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is required because RSA keys can sign with different hash algorithms (`rsa-sha2-256`, `rsa-sha2-512`, `ssh-rsa`), and the server needs to know which one the client intends.
|
||||||
|
|
||||||
|
### SSH Agent Protocol (`keys::agent`)
|
||||||
|
|
||||||
|
Russh implements both the client and server sides of the SSH agent protocol:
|
||||||
|
|
||||||
|
**Client** (`agent::client::AgentClient`):
|
||||||
|
```rust
|
||||||
|
impl<R: AsyncRead + AsyncWrite + Unpin + Send + 'static> AgentClient<R> {
|
||||||
|
pub async fn request_identities(&mut self) -> Result<Vec<AgentIdentity>, Error>;
|
||||||
|
pub async fn sign_request(&mut self, key: &AgentIdentity, hash_alg: Option<HashAlg>, data: Vec<u8>) -> Result<Vec<u8>, Error>;
|
||||||
|
pub async fn add_identity(&mut self, key: &PrivateKey, constraints: &[Constraint]) -> Result<(), Error>;
|
||||||
|
pub async fn remove_identity(&mut self, key: &PublicKey) -> Result<(), Error>;
|
||||||
|
pub async fn remove_all_identities(&mut self) -> Result<(), Error>;
|
||||||
|
pub async fn lock(&mut self, passphrase: &str) -> Result<(), Error>;
|
||||||
|
pub async fn unlock(&mut self, passphrase: &str) -> Result<(), Error>;
|
||||||
|
// ... ping, add smartcard, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server** (`agent::server::Agent`):
|
||||||
|
```rust
|
||||||
|
pub trait Agent: Clone + Send + 'static {
|
||||||
|
fn confirm(self, key: Arc<PrivateKey>) -> Box<dyn Future<Output = (Self, bool)> + Send + Unpin>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AgentIdentity** distinguishes between plain public keys and certificates:
|
||||||
|
```rust
|
||||||
|
pub enum AgentIdentity {
|
||||||
|
PublicKey { pubkey: PublicKey, comment: String },
|
||||||
|
Certificate { certificate: Certificate, comment: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known Hosts (`keys::known_hosts`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn check_known_hosts(host: &str, port: u16, key: &PublicKey) -> Result<(), Error>;
|
||||||
|
pub fn check_known_hosts_path<P: AsRef<Path>>(path: P, host: &str, port: u16, key: &PublicKey) -> Result<(), Error>;
|
||||||
|
```
|
||||||
|
|
||||||
|
These functions read `~/.ssh/known_hosts` and verify the server's public key matches. Returns `Error::KeyChanged` on mismatch.
|
||||||
|
|
||||||
|
### Windows Pageant Support
|
||||||
|
|
||||||
|
On Windows, russh uses the `pageant` crate to communicate with the Pageant SSH agent via either:
|
||||||
|
- WM_MESSAGE-based protocol (legacy)
|
||||||
|
- Named pipes protocol (since PuTTY 0.75)
|
||||||
430
docs/research/references/ssh/russh/05-internal-architecture.md
Normal file
430
docs/research/references/ssh/russh/05-internal-architecture.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Russh: Internal Architecture & Data Flow
|
||||||
|
|
||||||
|
This document covers the internal mechanics — session state machines, the event loop, packet handling, buffering, and the interaction between components.
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
### `CommonSession<C>`
|
||||||
|
|
||||||
|
The shared session state used by both client and server:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct CommonSession<C> {
|
||||||
|
pub auth_user: String,
|
||||||
|
pub remote_sshid: Vec<u8>,
|
||||||
|
pub config: C, // Arc<client::Config> or Arc<server::Config>
|
||||||
|
pub encrypted: Option<Encrypted>,
|
||||||
|
pub auth_method: Option<auth::Method>, // Client only
|
||||||
|
pub auth_attempts: usize,
|
||||||
|
pub packet_writer: PacketWriter,
|
||||||
|
pub remote_to_local: Box<dyn OpeningKey + Send>,
|
||||||
|
pub wants_reply: bool,
|
||||||
|
pub disconnected: bool,
|
||||||
|
pub buffer: Vec<u8>, // Incoming packet scratch buffer
|
||||||
|
pub strict_kex: bool,
|
||||||
|
pub alive_timeouts: usize,
|
||||||
|
pub received_data: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Encrypted`
|
||||||
|
|
||||||
|
The state after encryption keys are established:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct Encrypted {
|
||||||
|
pub state: EncryptedState,
|
||||||
|
pub exchange: Option<Exchange>,
|
||||||
|
pub kex: KexAlgorithm,
|
||||||
|
pub key: usize,
|
||||||
|
pub client_mac: mac::Name,
|
||||||
|
pub server_mac: mac::Name,
|
||||||
|
pub session_id: CryptoVec, // Constant across rekeys
|
||||||
|
pub channels: HashMap<ChannelId, ChannelParams>,
|
||||||
|
pub last_channel_id: Wrapping<u32>,
|
||||||
|
pub write: Vec<u8>, // Outgoing packet assembly buffer
|
||||||
|
pub write_cursor: usize, // Current position in write buffer
|
||||||
|
pub last_rekey: Instant,
|
||||||
|
pub server_compression: Compression,
|
||||||
|
pub client_compression: Compression,
|
||||||
|
pub decompress: Decompress,
|
||||||
|
pub rekey_wanted: bool,
|
||||||
|
pub received_extensions: Vec<String>,
|
||||||
|
pub extension_info_awaiters: HashMap<String, Vec<oneshot::Sender<()>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `EncryptedState`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum EncryptedState {
|
||||||
|
WaitingAuthServiceRequest { sent: bool, accepted: bool },
|
||||||
|
WaitingAuthRequest(auth::AuthRequest),
|
||||||
|
InitCompression,
|
||||||
|
Authenticated,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Exchange`
|
||||||
|
|
||||||
|
Protocol values collected during key exchange (all non-secret, visible on wire):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Exchange {
|
||||||
|
pub client_id: Vec<u8>,
|
||||||
|
pub server_id: Vec<u8>,
|
||||||
|
pub client_kex_init: Vec<u8>,
|
||||||
|
pub server_kex_init: Vec<u8>,
|
||||||
|
pub client_ephemeral: Vec<u8>,
|
||||||
|
pub server_ephemeral: Vec<u8>,
|
||||||
|
pub gex: Option<(GexParams, DhGroup)>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `NewKeys`
|
||||||
|
|
||||||
|
Produced when key exchange completes, contains everything needed to activate encryption:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct NewKeys {
|
||||||
|
pub exchange: Exchange,
|
||||||
|
pub names: negotiation::Names,
|
||||||
|
pub kex: KexAlgorithm,
|
||||||
|
pub key: usize,
|
||||||
|
pub cipher: CipherPair, // { local_to_remote, remote_to_local }
|
||||||
|
pub session_id: CryptoVec,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ChannelParams`
|
||||||
|
|
||||||
|
Internal channel state (not exposed to users):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct ChannelParams {
|
||||||
|
pub recipient_channel: u32, // Remote channel ID
|
||||||
|
pub sender_channel: ChannelId, // Local channel ID
|
||||||
|
pub recipient_window_size: u32,
|
||||||
|
pub sender_window_size: u32,
|
||||||
|
pub recipient_maximum_packet_size: u32,
|
||||||
|
pub sender_maximum_packet_size: u32,
|
||||||
|
pub confirmed: bool, // Whether server confirmed the channel
|
||||||
|
pub wants_reply: bool,
|
||||||
|
pub pending_data: VecDeque<(Bytes, Option<u32>, usize)>, // (data, ext_code, offset)
|
||||||
|
pub pending_eof: bool,
|
||||||
|
pub pending_close: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Event Loop
|
||||||
|
|
||||||
|
The client event loop is the core of `client::Session::run_inner()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn run_inner<H, R>(&mut self, stream_read, stream_write, handler, kex_done_signal)
|
||||||
|
-> Result<RemoteDisconnectInfo, H::Error>
|
||||||
|
{
|
||||||
|
// Initial setup
|
||||||
|
self.flush()?;
|
||||||
|
self.common.packet_writer.flush_into(stream_write).await?;
|
||||||
|
|
||||||
|
// Set up timers
|
||||||
|
let keepalive_timer = ...;
|
||||||
|
let inactivity_timer = ...;
|
||||||
|
let reading = start_reading(stream_read, buffer, opening_cipher);
|
||||||
|
|
||||||
|
while !self.common.disconnected {
|
||||||
|
tokio::select! {
|
||||||
|
// 1. Incoming SSH packet
|
||||||
|
r = &mut reading => {
|
||||||
|
// Decrypt and decompress packet
|
||||||
|
// Process DISCONNECT or pass to reply()
|
||||||
|
// Restart reading
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Keepalive timer
|
||||||
|
() = &mut keepalive_timer => {
|
||||||
|
// Send keepalive if authenticated
|
||||||
|
// Track timeout count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inactivity timer
|
||||||
|
() = &mut inactivity_timer => {
|
||||||
|
// Return InactivityTimeout error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Outgoing messages from Handle
|
||||||
|
msg = self.receiver.recv(), if !self.kex.active() => {
|
||||||
|
// Process message (auth, channel open, data, etc.)
|
||||||
|
// Batch all pending outgoing messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Inbound channel messages
|
||||||
|
msg = self.inbound_channel_receiver.recv(), if !self.kex.active() => {
|
||||||
|
// Process channel data/eof/close
|
||||||
|
// Batch all pending messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flush all pending writes
|
||||||
|
self.flush()?;
|
||||||
|
self.common.packet_writer.flush_into(stream_write).await?;
|
||||||
|
|
||||||
|
// Handle deferred compression after authentication
|
||||||
|
if EncryptedState::InitCompression { ... } {
|
||||||
|
// Init client compression if deferred (zlib@openssh.com)
|
||||||
|
// Transition to Authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset timers if data received or keepalive sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Event Loop Behaviors
|
||||||
|
|
||||||
|
1. **Kex blocking**: When `self.kex.active()` is true, outgoing messages from `Handle` are NOT processed. This prevents sending data during key exchange.
|
||||||
|
|
||||||
|
2. **Batching**: After receiving one message from `receiver`, all pending messages are drained with `try_recv()` to batch writes.
|
||||||
|
|
||||||
|
3. **Keepalive management**: Keepalive timer resets when data is received. `alive_timeouts` tracks consecutive unanswered keepalives.
|
||||||
|
|
||||||
|
4. **Compression activation**: `zlib@openssh.com` compression is deferred until after authentication succeeds (handled by `InitCompression` state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Event Loop
|
||||||
|
|
||||||
|
Similar to the client, but the server accepts connections via `run_on_socket()` or `run_stream()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Server::run_on_socket
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.recv() => { /* Graceful shutdown */ },
|
||||||
|
accept_result = socket.accept() => {
|
||||||
|
// For each connection:
|
||||||
|
// 1. Create a new Handler via Server::new_client()
|
||||||
|
// 2. Call run_stream() in a spawned task
|
||||||
|
// 3. Wait for session or shutdown
|
||||||
|
},
|
||||||
|
error = error_rx.recv() => {
|
||||||
|
// Report session errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `run_stream`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn run_stream<H, R>(config, stream, handler) -> Result<RunningSession<H>, H::Error>
|
||||||
|
{
|
||||||
|
// 1. Write server SSH ID
|
||||||
|
// 2. Read client SSH ID
|
||||||
|
// 3. Create Session with CommonSession state
|
||||||
|
// 4. Begin initial rekey (sends KEXINIT)
|
||||||
|
// 5. Spawn session.run() in a task
|
||||||
|
// 6. Return RunningSession (implements Future)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `RunningServer` and `RunningServerHandle`
|
||||||
|
|
||||||
|
The server returns a `RunningServer` that implements `Future` and a `RunningServerHandle` for graceful shutdown:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RunningServer<F: Future<Output = io::Result<()>> + Unpin + Send> {
|
||||||
|
inner: F,
|
||||||
|
shutdown_tx: broadcast::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunningServerHandle {
|
||||||
|
pub fn shutdown(&self, reason: String) {
|
||||||
|
let _ = self.shutdown_tx.send(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Packet Handling Pipeline
|
||||||
|
|
||||||
|
### Reading Packets (`cipher::read`)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read 4 bytes (or more for block ciphers) → encrypted packet length
|
||||||
|
2. Decrypt packet length
|
||||||
|
3. Parse length, check against MAXIMUM_PACKET_LEN (262159 bytes)
|
||||||
|
4. Read remaining bytes (length + tag_len - already_read)
|
||||||
|
5. Decrypt the ciphertext (including tag verification for AEAD)
|
||||||
|
6. Remove padding
|
||||||
|
7. Increment sequence number
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing Packets (`SealingKey::write`)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Compute padding length (block-aligned, min 4 bytes)
|
||||||
|
2. Write: [packet_length (4B)] [padding_length (1B)] [payload] [padding] [tag]
|
||||||
|
3. Encrypt the packet
|
||||||
|
4. Compute and append MAC/tag
|
||||||
|
5. Increment sequence number
|
||||||
|
6. Add payload bytes to rekey counter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Packet Assembly (`PacketWriter`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct PacketWriter {
|
||||||
|
cipher: Box<dyn SealingKey + Send>,
|
||||||
|
compress: Compress,
|
||||||
|
compress_buffer: Vec<u8>,
|
||||||
|
write_buffer: SSHBuffer,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `packet_raw(buf)`: Compress and encrypt a raw packet
|
||||||
|
- `packet(f)`: Build a packet via closure, compress, encrypt, return the plaintext
|
||||||
|
- `flush_into(w)`: Write all buffered data to the async writer
|
||||||
|
- `set_cipher(c)`: Swap the cipher (for rekeying)
|
||||||
|
- `reset_seqn()`: Reset sequence number (for strict kex)
|
||||||
|
|
||||||
|
### `push_packet!` Macro
|
||||||
|
|
||||||
|
Used throughout for building packets:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
macro_rules! push_packet {
|
||||||
|
( $buffer:expr, $x:expr ) => {{
|
||||||
|
let i0 = $buffer.len();
|
||||||
|
$buffer.extend(b"\0\0\0\0"); // Placeholder for length
|
||||||
|
let x = $x; // Build the packet body
|
||||||
|
let i1 = $buffer.len();
|
||||||
|
BigEndian::write_u32(&mut buf[i0..], (i1 - i0 - 4) as u32);
|
||||||
|
x
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Data Flow
|
||||||
|
|
||||||
|
### Client-side Channel Creation
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Handle::channel_open_session()
|
||||||
|
→ Creates ChannelRef with mpsc::channel
|
||||||
|
→ Sends Msg::ChannelOpenSession to session
|
||||||
|
|
||||||
|
2. Session::handle_msg(Msg::ChannelOpenSession)
|
||||||
|
→ Calls self.channel_open_session()
|
||||||
|
→ Sends CHANNEL_OPEN packet with channel type "session"
|
||||||
|
→ Stores ChannelRef in self.channels
|
||||||
|
|
||||||
|
3. Server responds with CHANNEL_OPEN_CONFIRMATION
|
||||||
|
→ Session updates ChannelParams (recipient_channel, window sizes)
|
||||||
|
→ Sends ChannelMsg::Open through ChannelRef's sender
|
||||||
|
|
||||||
|
4. wait_channel_confirmation() receives the Open message
|
||||||
|
→ Creates Channel { read_half, write_half }
|
||||||
|
→ Returns the Channel to the caller
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Transmission (Client → Server)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. channel.data(reader)
|
||||||
|
→ Uses ChannelTx (AsyncWrite) that reads from the reader
|
||||||
|
→ Sends ChannelMsg::Data through the channel sender
|
||||||
|
→ Waits for window availability via WindowSizeRef
|
||||||
|
|
||||||
|
2. Session receives ChannelMsg::Data
|
||||||
|
→ Calls Encrypted::data(channel, bytes, is_rekeying)
|
||||||
|
→ If pending data exists or rekeying: queues in pending_data
|
||||||
|
→ Otherwise: writes CHANNEL_DATA packet immediately
|
||||||
|
→ If window exhausted: queues remaining data
|
||||||
|
|
||||||
|
3. PacketWriter encrypts and buffers the packet
|
||||||
|
4. Event loop flushes the buffer to the TCP stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Reception (Server → Client)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Encrypted packet arrives, decrypted, decompressed
|
||||||
|
2. Packet type = CHANNEL_DATA
|
||||||
|
3. Encrypted::adjust_window_size() checks if window needs adjustment
|
||||||
|
4. ChannelMsg::Data sent through ChannelRef's sender
|
||||||
|
5. Channel::wait() returns the ChannelMsg::Data
|
||||||
|
6. ChannelRx (AsyncRead) reads from the channel receiver
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rekeying Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Trigger:
|
||||||
|
- Bytes written/read exceed limits
|
||||||
|
- Time since last rekey exceeds limit
|
||||||
|
- Explicit: Handle::rekey_soon()
|
||||||
|
- Server-initiated: receiving KEXINIT when idle
|
||||||
|
|
||||||
|
2. Session::begin_rekey():
|
||||||
|
- Creates new ClientKex/ServerKex
|
||||||
|
- Sends new KEXINIT
|
||||||
|
- Sets kex state to InProgress
|
||||||
|
|
||||||
|
3. During rekey:
|
||||||
|
- Outgoing messages from Handle are blocked (!kex.active())
|
||||||
|
- Incoming non-kex packets are buffered in pending_reads
|
||||||
|
- Kex state machine processes kex packets (KEXINIT, DH init/reply, NEWKEYS)
|
||||||
|
|
||||||
|
4. On completion:
|
||||||
|
- Flush all pending channel data
|
||||||
|
- Process buffered pending_reads
|
||||||
|
- Call CommonSession::newkeys() to swap ciphers
|
||||||
|
- Reset byte counters
|
||||||
|
- Set kex state to Idle
|
||||||
|
- Resume processing outgoing messages
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub-Crates
|
||||||
|
|
||||||
|
### `russh-cryptovec` (cryptovec/)
|
||||||
|
|
||||||
|
A `Vec<u8>` alternative that:
|
||||||
|
- Zeroes memory on drop and reallocation (via `memset` / `ExplicitZero`)
|
||||||
|
- Locks memory pages with `mlock` (Unix) / `VirtualLock` (Windows) to prevent swapping
|
||||||
|
- Uses `unsafe` for performance-critical operations (copying, initialization)
|
||||||
|
- Integrates with `ssh-encoding` for Encode support
|
||||||
|
|
||||||
|
Used for all sensitive data: session keys, shared secrets, MAC keys, exchange hashes.
|
||||||
|
|
||||||
|
### `russh-util` (russh-util/)
|
||||||
|
|
||||||
|
Runtime abstraction layer:
|
||||||
|
- `russh_util::runtime::spawn()` — spawns a task (tokio or wasm)
|
||||||
|
- `russh_util::runtime::JoinHandle` — task join handle
|
||||||
|
- `russh_util::time::Instant` — time source (tokio or chrono for WASM)
|
||||||
|
- WASM compatibility: uses `wasm-bindgen-futures` and `chrono` when target is `wasm32`
|
||||||
|
|
||||||
|
### `russh-config` (russh-config/)
|
||||||
|
|
||||||
|
SSH config file parser:
|
||||||
|
- Parses `~/.ssh/config` format
|
||||||
|
- Supports `Host` matching with `globset`
|
||||||
|
- Provides `Stream::tcp_connect()` and `Stream::proxy_command()` for establishing connections based on config
|
||||||
|
|
||||||
|
### `pageant` (pageant/)
|
||||||
|
|
||||||
|
Windows Pageant SSH agent transport:
|
||||||
|
- `wmmessage` feature: Classic Pageant protocol via Windows messages
|
||||||
|
- `namedpipes` feature: Modern Pageant protocol via named pipes (PuTTY ≥ 0.75)
|
||||||
413
docs/research/references/ssh/russh/06-usage-patterns.md
Normal file
413
docs/research/references/ssh/russh/06-usage-patterns.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# Russh: Usage Patterns & Examples
|
||||||
|
|
||||||
|
This document provides practical usage patterns for both client and server sides of russh.
|
||||||
|
|
||||||
|
## Minimal Client
|
||||||
|
|
||||||
|
The simplest client connects, authenticates, opens a session channel, and runs a command:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::Arc;
|
||||||
|
use russh::*;
|
||||||
|
use russh::keys::*;
|
||||||
|
|
||||||
|
struct Client;
|
||||||
|
|
||||||
|
impl client::Handler for Client {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn check_server_key(&mut self, key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
|
||||||
|
// In production: verify against known_hosts
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(host: &str, command: &str) -> Result<u32, Box<dyn std::error::Error>> {
|
||||||
|
let config = Arc::new(client::Config::default());
|
||||||
|
let mut session = client::connect(config, (host, 22), Client).await?;
|
||||||
|
|
||||||
|
let auth = session.authenticate_publickey(
|
||||||
|
"user",
|
||||||
|
PrivateKeyWithHashAlg::new(
|
||||||
|
Arc::new(keys::load_secret_key("/path/to/key", None)?),
|
||||||
|
session.best_supported_rsa_hash().await?.flatten(),
|
||||||
|
),
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if !auth.success() {
|
||||||
|
return Err("Auth failed".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut channel = session.channel_open_session().await?;
|
||||||
|
channel.exec(true, command).await?;
|
||||||
|
|
||||||
|
let mut exit_code = 0;
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(msg) = channel.wait().await else { break };
|
||||||
|
match msg {
|
||||||
|
ChannelMsg::Data { data } => { stdout.write_all(&data).await?; }
|
||||||
|
ChannelMsg::ExtendedData { data, ext } => {
|
||||||
|
// ext == 1 is stderr
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&data));
|
||||||
|
}
|
||||||
|
ChannelMsg::ExitStatus { exit_status } => { exit_code = exit_status; }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.disconnect(Disconnect::ByApplication, "", "en").await?;
|
||||||
|
Ok(exit_code)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive PTY Client
|
||||||
|
|
||||||
|
For interactive sessions (shell access), request a PTY and handle window changes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn interactive_session(session: &client::Handle<Client>) -> Result<Channel<client::Msg>, russh::Error> {
|
||||||
|
let mut channel = session.channel_open_session().await?;
|
||||||
|
|
||||||
|
// Request PTY
|
||||||
|
channel.request_pty(
|
||||||
|
false, // want_reply
|
||||||
|
"xterm-256bit", // term type
|
||||||
|
80, 24, // cols, rows
|
||||||
|
0, 0, // pixel width/height (0 = not specified)
|
||||||
|
&[], // terminal modes
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// Request shell
|
||||||
|
channel.request_shell(true).await?;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Implementation
|
||||||
|
|
||||||
|
A basic server that accepts all public keys and echoes input:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::Arc;
|
||||||
|
use russh::server::{self, Server as _, Session};
|
||||||
|
use russh::*;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct App;
|
||||||
|
|
||||||
|
impl server::Server for App {
|
||||||
|
type Handler = ClientHandler;
|
||||||
|
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> ClientHandler {
|
||||||
|
ClientHandler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientHandler;
|
||||||
|
|
||||||
|
impl server::Handler for ClientHandler {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn auth_publickey(&mut self, _: &str, _: &ssh_key::PublicKey) -> Result<server::Auth, Self::Error> {
|
||||||
|
Ok(server::Auth::Accept)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
channel: Channel<server::Msg>,
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn data(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
data: &[u8],
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
// Echo back
|
||||||
|
session.data(channel, data.to_vec().into())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_request(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
data: &[u8],
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
// Handle exec request
|
||||||
|
session.channel_success(channel);
|
||||||
|
// Process the command...
|
||||||
|
session.channel_failure(channel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server() {
|
||||||
|
let config = Arc::new(server::Config {
|
||||||
|
keys: vec![russh::keys::PrivateKey::random(&mut rand::rng(), russh::keys::Algorithm::Ed25519).unwrap()],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut app = App;
|
||||||
|
let socket = tokio::net::TcpListener::bind(("0.0.0.0", 22)).await.unwrap();
|
||||||
|
app.run_on_socket(config, &socket).await.unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication with SSH Agent
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use russh::keys::agent::client::AgentClient;
|
||||||
|
|
||||||
|
async fn auth_with_agent(
|
||||||
|
session: &mut client::Handle<Client>,
|
||||||
|
user: &str,
|
||||||
|
) -> Result<AuthResult, Box<dyn std::error::Error>> {
|
||||||
|
// Connect to SSH agent (Unix socket or Windows Pageant)
|
||||||
|
let stream = tokio::net::UnixStream::connect(std::env::var("SSH_AUTH_SOCK")?).await?;
|
||||||
|
let mut agent = AgentClient::connect(stream);
|
||||||
|
|
||||||
|
// List available identities
|
||||||
|
let identities = agent.request_identities().await?;
|
||||||
|
|
||||||
|
// Try each identity
|
||||||
|
for identity in &identities {
|
||||||
|
let result = session.authenticate_publickey_with(
|
||||||
|
user,
|
||||||
|
identity.public_key(),
|
||||||
|
None, // hash_alg (None for Ed25519)
|
||||||
|
&mut agent,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if result.success() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("No matching key found in agent".into())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port Forwarding
|
||||||
|
|
||||||
|
### Local TCP Forwarding
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn local_forward(
|
||||||
|
session: &client::Handle<Client>,
|
||||||
|
target_host: &str,
|
||||||
|
target_port: u16,
|
||||||
|
) -> Result<Channel<client::Msg>, russh::Error> {
|
||||||
|
let channel = session.channel_open_direct_tcpip(
|
||||||
|
target_host,
|
||||||
|
target_port as u32,
|
||||||
|
"127.0.0.1", // originator
|
||||||
|
0, // originator port
|
||||||
|
).await?;
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote TCP Forwarding
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn remote_forward(
|
||||||
|
session: &client::Handle<Client>,
|
||||||
|
listen_addr: &str,
|
||||||
|
listen_port: u32,
|
||||||
|
) -> Result<u32, russh::Error> {
|
||||||
|
// Request server to listen
|
||||||
|
let assigned_port = session.tcpip_forward(listen_addr, listen_port).await?;
|
||||||
|
Ok(assigned_port)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channel as AsyncRead/AsyncWrite
|
||||||
|
|
||||||
|
Channels can be converted to `AsyncRead` + `AsyncWrite` streams:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn use_channel_as_stream(channel: Channel<client::Msg>) {
|
||||||
|
// Convert to bidirectional stream
|
||||||
|
let stream = channel.into_stream();
|
||||||
|
|
||||||
|
// Or split into read/write halves
|
||||||
|
let (read_half, write_half) = channel.split();
|
||||||
|
let mut reader = read_half.make_reader();
|
||||||
|
let writer = write_half.make_writer();
|
||||||
|
|
||||||
|
// Use with any tokio IO utility
|
||||||
|
let mut buf = vec![0u8; 1024];
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let n = reader.read(&mut buf).await.unwrap();
|
||||||
|
|
||||||
|
// Read stderr separately
|
||||||
|
let stderr_reader = read_half.make_reader_ext(Some(1)); // ext code 1 = stderr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Algorithm Preferences
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
let config = client::Config {
|
||||||
|
preferred: Preferred {
|
||||||
|
kex: Cow::Owned(vec![
|
||||||
|
russh::kex::CURVE25519,
|
||||||
|
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
|
||||||
|
]),
|
||||||
|
cipher: Cow::Owned(vec![
|
||||||
|
russh::cipher::CHACHA20_POLY1305,
|
||||||
|
russh::cipher::AES_256_GCM,
|
||||||
|
]),
|
||||||
|
mac: Cow::Owned(vec![
|
||||||
|
russh::mac::HMAC_SHA256_ETM,
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rekeying
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Automatic rekeying happens based on Limits (default 1GB / 1 hour)
|
||||||
|
// Explicit rekeying:
|
||||||
|
session.rekey_soon().await?;
|
||||||
|
|
||||||
|
// Access shared secret after kex (for custom key derivation):
|
||||||
|
struct MyHandler;
|
||||||
|
impl client::Handler for MyHandler {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn kex_done(
|
||||||
|
&mut self,
|
||||||
|
shared_secret: Option<&[u8]>,
|
||||||
|
names: &Names,
|
||||||
|
session: &mut client::Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
// shared_secret is the raw DH shared secret
|
||||||
|
// names contains all negotiated algorithms
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_server_key(&mut self, key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard-Interactive Authentication
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn keyboard_interactive_auth(
|
||||||
|
session: &mut client::Handle<Client>,
|
||||||
|
user: &str,
|
||||||
|
) -> Result<AuthResult, russh::Error> {
|
||||||
|
let response = session.authenticate_keyboard_interactive_start(user, None).await?;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
KeyboardInteractiveAuthResponse::Success => Ok(AuthResult::Success),
|
||||||
|
KeyboardInteractiveAuthResponse::Failure { .. } => Ok(AuthResult::Failure { remaining_methods: MethodSet::empty(), partial_success: false }),
|
||||||
|
KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
|
||||||
|
// Respond to each prompt
|
||||||
|
let responses: Vec<String> = prompts.iter()
|
||||||
|
.map(|p| /* get user input for p.prompt */ String::new())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let response = session.authenticate_keyboard_interactive_respond(responses).await?;
|
||||||
|
// May need to loop if server sends more challenges
|
||||||
|
// ...
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server: Handling Channel Requests
|
||||||
|
|
||||||
|
Server-side channel requests (shell, exec, pty, etc.) must be explicitly accepted or rejected:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl server::Handler for MyHandler {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn shell_request(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
// Must call success or failure!
|
||||||
|
session.channel_success(channel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_request(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
data: &[u8],
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
let command = String::from_utf8_lossy(data);
|
||||||
|
if is_allowed(&command) {
|
||||||
|
session.channel_success(channel);
|
||||||
|
// Process command, send data back...
|
||||||
|
session.data(channel, output.into())?;
|
||||||
|
session.eof(channel)?;
|
||||||
|
} else {
|
||||||
|
session.channel_failure(channel);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pty_request(
|
||||||
|
&mut self,
|
||||||
|
channel: ChannelId,
|
||||||
|
term: &str,
|
||||||
|
col_width: u32,
|
||||||
|
row_height: u32,
|
||||||
|
pix_width: u32,
|
||||||
|
pix_height: u32,
|
||||||
|
modes: &[(Pty, u32)],
|
||||||
|
session: &mut Session,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
session.channel_success(channel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Hosts Verification
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use russh::keys::check_known_hosts;
|
||||||
|
|
||||||
|
impl client::Handler for SecureClient {
|
||||||
|
type Error = russh::Error;
|
||||||
|
|
||||||
|
async fn check_server_key(&mut self, key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
|
||||||
|
match check_known_hosts("example.com", 22, key) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(e) => {
|
||||||
|
if matches!(e, russh::keys::Error::KeyChanged { .. }) {
|
||||||
|
// Possible MITM attack!
|
||||||
|
eprintln!("WARNING: Host key changed! Possible MITM attack.");
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
// Unknown host - prompt user to verify fingerprint
|
||||||
|
eprintln!("Unknown host key. Fingerprint: {}", key.fingerprint(ssh_key::HashAlg::Sha256));
|
||||||
|
// In a real app, ask the user
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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