Files
alknet/docs/research/references/ssh/sftp-rs/02-wire-protocol-codec.md

11 KiB

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

// 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

// 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

// 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

// 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

// 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

// 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:

// 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:

// 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.