use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use puressh::auth::ClientCredential;
use puressh::client::{Client, Config, HostKeyPolicy};
use puressh::sftp::{
Attrs, FxpStatus, SftpClient, SftpError, FXF_CREAT, FXF_READ, FXF_TRUNC, FXF_WRITE,
};
#[path = "common.rs"]
mod common;
use common::{load_identity, read_password_from_stdin, resolve_user};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const USAGE: &str = "usage: sftp [-P port] [-i identity_file] [-l user] [user@]host";
struct Cli {
port: u16,
identities: Vec<String>,
cli_user: Option<String>,
host: String,
user_in_host: Option<String>,
}
fn parse_args(args: &[String]) -> Result<Cli, String> {
let mut port = 22u16;
let mut identities: Vec<String> = Vec::new();
let mut cli_user: Option<String> = None;
let mut positional: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
positional.extend_from_slice(&args[i + 1..]);
break;
}
match a.as_str() {
"-P" => {
i += 1;
let v = args.get(i).ok_or("-P requires a value")?;
port = v.parse::<u16>().map_err(|_| "invalid port".to_string())?;
}
"-i" => {
i += 1;
let v = args.get(i).ok_or("-i requires a value")?.clone();
identities.push(v);
}
"-l" => {
i += 1;
let v = args.get(i).ok_or("-l requires a value")?.clone();
cli_user = Some(v);
}
s if s.starts_with('-') => {
return Err(format!("unknown flag: {s}"));
}
_ => positional.push(a.clone()),
}
i += 1;
}
if positional.is_empty() {
return Err("missing host argument".into());
}
let target = positional.remove(0);
let (user_in_host, host) = match target.split_once('@') {
Some((u, h)) => (Some(u.to_string()), h.to_string()),
None => (None, target),
};
if host.is_empty() {
return Err("empty host".into());
}
Ok(Cli {
port,
identities,
cli_user,
host,
user_in_host,
})
}
fn remote_join(cwd: &[u8], rel: &str) -> Vec<u8> {
if rel.starts_with('/') {
rel.as_bytes().to_vec()
} else {
let mut out = cwd.to_vec();
if !out.ends_with(b"/") {
out.push(b'/');
}
out.extend_from_slice(rel.as_bytes());
out
}
}
fn sftp_err_to_string(e: SftpError) -> String {
match e {
SftpError::Status { code, message } => {
if message.is_empty() {
format!("{code:?}")
} else {
format!("{code:?}: {message}")
}
}
other => format!("{other:?}"),
}
}
struct Repl<T: Read + Write> {
sftp: SftpClient<T>,
remote_cwd: Vec<u8>,
local_cwd: PathBuf,
}
impl<T: Read + Write> Repl<T> {
fn new(mut sftp: SftpClient<T>) -> Result<Self, String> {
let remote_cwd = sftp.realpath(b".").map_err(sftp_err_to_string)?;
let local_cwd = std::env::current_dir().map_err(|e| format!("getcwd: {e}"))?;
Ok(Self {
sftp,
remote_cwd,
local_cwd,
})
}
fn run(&mut self) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdin = stdin.lock();
let mut line = String::new();
loop {
eprint!("sftp> ");
std::io::stderr().flush().ok();
line.clear();
let n = match stdin.read_line(&mut line) {
Ok(n) => n,
Err(e) => return Err(format!("stdin: {e}")),
};
if n == 0 {
eprintln!();
return Ok(());
}
let cmd: Vec<&str> = line.split_whitespace().collect();
if cmd.is_empty() {
continue;
}
match cmd[0] {
"quit" | "exit" | "bye" => return Ok(()),
"pwd" => println!(
"Remote working directory: {}",
String::from_utf8_lossy(&self.remote_cwd)
),
"lpwd" => println!("Local working directory: {}", self.local_cwd.display()),
"cd" => {
if let Err(e) = self.cmd_cd(cmd.get(1).copied().unwrap_or("")) {
eprintln!("cd: {e}");
}
}
"lcd" => {
if let Err(e) = self.cmd_lcd(cmd.get(1).copied().unwrap_or("")) {
eprintln!("lcd: {e}");
}
}
"ls" => {
if let Err(e) = self.cmd_ls(cmd.get(1).copied()) {
eprintln!("ls: {e}");
}
}
"get" => {
if cmd.len() < 2 {
eprintln!("usage: get remote-path [local-path]");
continue;
}
let local = cmd.get(2).copied();
if let Err(e) = self.cmd_get(cmd[1], local) {
eprintln!("get: {e}");
}
}
"put" => {
if cmd.len() < 2 {
eprintln!("usage: put local-path [remote-path]");
continue;
}
let remote = cmd.get(2).copied();
if let Err(e) = self.cmd_put(cmd[1], remote) {
eprintln!("put: {e}");
}
}
"mkdir" => {
if cmd.len() < 2 {
eprintln!("usage: mkdir remote-path");
continue;
}
let abs = remote_join(&self.remote_cwd, cmd[1]);
if let Err(e) = self.sftp.mkdir(&abs, Attrs::default()) {
eprintln!("mkdir: {}", sftp_err_to_string(e));
}
}
"rmdir" => {
if cmd.len() < 2 {
eprintln!("usage: rmdir remote-path");
continue;
}
let abs = remote_join(&self.remote_cwd, cmd[1]);
if let Err(e) = self.sftp.rmdir(&abs) {
eprintln!("rmdir: {}", sftp_err_to_string(e));
}
}
"rm" => {
if cmd.len() < 2 {
eprintln!("usage: rm remote-path");
continue;
}
let abs = remote_join(&self.remote_cwd, cmd[1]);
if let Err(e) = self.sftp.remove(&abs) {
eprintln!("rm: {}", sftp_err_to_string(e));
}
}
"mv" | "rename" => {
if cmd.len() < 3 {
eprintln!("usage: mv oldpath newpath");
continue;
}
let from = remote_join(&self.remote_cwd, cmd[1]);
let to = remote_join(&self.remote_cwd, cmd[2]);
if let Err(e) = self.sftp.rename(&from, &to) {
eprintln!("mv: {}", sftp_err_to_string(e));
}
}
"chmod" => {
if cmd.len() < 3 {
eprintln!("usage: chmod mode remote-path");
continue;
}
let mode = match u32::from_str_radix(cmd[1], 8) {
Ok(m) => m,
Err(_) => {
eprintln!("chmod: invalid mode (octal expected): {}", cmd[1]);
continue;
}
};
let abs = remote_join(&self.remote_cwd, cmd[2]);
let attrs = Attrs {
permissions: Some(mode),
..Attrs::default()
};
if let Err(e) = self.sftp.setstat(&abs, attrs) {
eprintln!("chmod: {}", sftp_err_to_string(e));
}
}
"help" | "?" => {
eprintln!("commands: ls cd lcd pwd lpwd get put mkdir rmdir rm mv chmod quit");
}
other => eprintln!("unknown command: {other} (try 'help')"),
}
}
}
fn cmd_cd(&mut self, arg: &str) -> Result<(), String> {
let target = if arg.is_empty() { "." } else { arg };
let abs = remote_join(&self.remote_cwd, target);
let h = self.sftp.opendir(&abs).map_err(sftp_err_to_string)?;
let _ = self.sftp.close(&h);
let canon = self.sftp.realpath(&abs).map_err(sftp_err_to_string)?;
self.remote_cwd = canon;
Ok(())
}
fn cmd_lcd(&mut self, arg: &str) -> Result<(), String> {
let target = if arg.is_empty() {
std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| "$HOME not set".to_string())?
} else {
let p = Path::new(arg);
if p.is_absolute() {
p.to_path_buf()
} else {
self.local_cwd.join(p)
}
};
let meta = std::fs::metadata(&target).map_err(|e| format!("{}: {e}", target.display()))?;
if !meta.is_dir() {
return Err(format!("{}: not a directory", target.display()));
}
self.local_cwd = target;
Ok(())
}
fn cmd_ls(&mut self, arg: Option<&str>) -> Result<(), String> {
let target = match arg {
Some(s) => remote_join(&self.remote_cwd, s),
None => self.remote_cwd.clone(),
};
let h = self.sftp.opendir(&target).map_err(sftp_err_to_string)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
while let Some(batch) = self.sftp.readdir(&h).map_err(sftp_err_to_string)? {
for e in batch {
let _ = out.write_all(&e.longname);
let _ = out.write_all(b"\n");
}
}
let _ = self.sftp.close(&h);
Ok(())
}
fn cmd_get(&mut self, remote: &str, local: Option<&str>) -> Result<(), String> {
let abs = remote_join(&self.remote_cwd, remote);
let local_path = match local {
Some(s) => {
let p = Path::new(s);
if p.is_absolute() {
p.to_path_buf()
} else {
self.local_cwd.join(p)
}
}
None => {
let base = remote.rsplit('/').next().unwrap_or("file");
self.local_cwd.join(base)
}
};
let h = self
.sftp
.open(&abs, FXF_READ, Attrs::default())
.map_err(sftp_err_to_string)?;
let mut f = std::fs::File::create(&local_path)
.map_err(|e| format!("{}: {e}", local_path.display()))?;
let mut offset: u64 = 0;
let chunk: u32 = 32 * 1024;
loop {
let data = self
.sftp
.read(&h, offset, chunk)
.map_err(sftp_err_to_string)?;
if data.is_empty() {
break;
}
offset += data.len() as u64;
f.write_all(&data).map_err(|e| format!("write: {e}"))?;
}
let _ = self.sftp.close(&h);
Ok(())
}
fn cmd_put(&mut self, local: &str, remote: Option<&str>) -> Result<(), String> {
let local_path = {
let p = Path::new(local);
if p.is_absolute() {
p.to_path_buf()
} else {
self.local_cwd.join(p)
}
};
let remote_path = match remote {
Some(s) => remote_join(&self.remote_cwd, s),
None => {
let base = local.rsplit('/').next().unwrap_or("file");
remote_join(&self.remote_cwd, base)
}
};
let data =
std::fs::read(&local_path).map_err(|e| format!("{}: {e}", local_path.display()))?;
let h = self
.sftp
.open(
&remote_path,
FXF_WRITE | FXF_CREAT | FXF_TRUNC,
Attrs::default(),
)
.map_err(sftp_err_to_string)?;
let mut offset: u64 = 0;
let chunk: usize = 32 * 1024;
while offset < data.len() as u64 {
let end = std::cmp::min(offset as usize + chunk, data.len());
self.sftp
.write(&h, offset, &data[offset as usize..end])
.map_err(sftp_err_to_string)?;
offset = end as u64;
}
let _ = self.sftp.close(&h);
let _ = FxpStatus::Ok;
Ok(())
}
}
fn run() -> Result<i32, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
println!();
println!("A pure-Rust SFTP client built on puressh {VERSION}.");
return Ok(0);
}
if args.iter().any(|a| a == "-V" || a == "--version") {
println!("puressh sftp {VERSION}");
return Ok(0);
}
let cli = parse_args(&args).map_err(|e| format!("{e}\n{USAGE}"))?;
let user = resolve_user(cli.cli_user.as_deref(), cli.user_in_host.as_deref())?;
let cfg = Config {
host_key_policy: HostKeyPolicy::AcceptAny,
timeout: None,
};
let addr = (cli.host.as_str(), cli.port);
let mut client = Client::connect(addr, cfg).map_err(|e| format!("connect: {e}"))?;
let mut authed = false;
for id_path in &cli.identities {
let pk = match load_identity(id_path) {
Ok(p) => p,
Err(e) => {
eprintln!("warning: {e}");
continue;
}
};
let hk = match pk.into_host_key() {
Ok(h) => h,
Err(e) => {
eprintln!("warning: identity {id_path}: {e}");
continue;
}
};
match client.authenticate(&user, vec![ClientCredential::PublicKey(hk)]) {
Ok(()) => {
authed = true;
break;
}
Err(e) => {
eprintln!("publickey auth with {id_path}: {e}");
}
}
}
if !authed {
let password = read_password_from_stdin().map_err(|e| format!("read password: {e}"))?;
client
.authenticate_password(&user, &password)
.map_err(|e| format!("Auth failed: {e}"))?;
}
#[allow(deprecated)]
let sftp = client.sftp().map_err(|e| format!("sftp: {e}"))?;
let mut repl = Repl::new(sftp)?;
repl.run()?;
Ok(0)
}
fn main() -> ExitCode {
match run() {
Ok(code) => {
let clamped = code.clamp(0, 255) as u8;
ExitCode::from(clamped)
}
Err(msg) => {
eprintln!("sftp: {msg}");
ExitCode::from(255)
}
}
}