Files
alknet/docs/research/references/ssh/sftp-rs/07-cli-binary.md

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

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_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() statopendir → loop readdirclosedir
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 preadfclose
put local [remote] cmd_put() open → loop pwritefclose

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

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

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<()>
  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

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.