6.6 KiB
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 supportshell-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:
struct SshChannel {
child: Child,
stdin: Option<ChildStdin>,
stdout: Option<ChildStdout>,
}
Spawning
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
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
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
struct Shell {
client: SftpClient<SshChannel>,
remote_cwd: String,
}
The shell wraps a SftpClient and tracks the remote working directory.
Construction
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
fn resolve_remote(&self, path: &str) -> String
Relative paths are joined with remote_cwd:
- If path starts with
/, used as-is - If
remote_cwdends 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:
fn cmd_get(&self, remote: &str, local: Option<&str>) -> Result<(), SftpError>
- Opens the remote file with
OpenOptions::new().read(true) - Creates a local file with
std::fs::File::create() - Loops calling
pread(file, offset, 32*1024)untilEofor empty data - Writes each chunk to the local file
- Closes the remote file handle with
fclose - 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:
fn cmd_put(&self, local: &str, remote: Option<&str>) -> Result<(), SftpError>
- Opens the local file with
std::fs::File::open() - Opens the remote file with
OpenOptions::new().write(true).create(true).truncate(true) - Loops reading 32 KiB chunks from the local file
- Writes each chunk with
pwrite(file, offset, data) - Closes the remote file handle with
fclose - 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:
- Calls
stat()on the target path - If the target is a directory:
- Opens with
opendir() - Loops on
readdir()untilEof - Sorts entries by name
- Closes with
closedir()
- Opens with
- 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
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()
fn format_long(path: &str, attrs: &Attributes) -> String
Produces a line with octal permissions, size, and path:
100644 1234 myfile.txt
print_attrs()
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
fn main() -> std::io::Result<()>
- Parses the destination from
args[1](e.g.,user@host) - Spawns the
SshChannel - Creates a
SftpClient - Initializes the
Shell - Creates a
rustyline::DefaultEditorwith history at~/.sftp_history - Loops on
readline("sftp> "), dispatches commands viadispatch() - On
Interrupted, continues; onEof, exits
Dispatch
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.