use super::{is_archive_file, DirectoryListing, FileEntry};
use ssh2::{KeyboardInteractivePrompt, Prompt, Session};
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{info, warn};
#[derive(Debug, Clone)]
pub struct SftpParams {
pub host: String,
pub port: u16,
pub user: String,
pub password: Option<String>,
pub key_path: Option<String>,
pub remote_path: String,
pub host_key_checking: Option<String>,
pub known_hosts_file: Option<String>,
}
impl SftpParams {
pub fn with_config(mut self, config: &crate::config::SftpConfig) -> Self {
self.host_key_checking = Some(config.host_key_checking.clone());
self.known_hosts_file = config.known_hosts_file.clone();
self
}
}
pub fn resolve_known_hosts_path(custom_path: Option<&str>) -> PathBuf {
if let Some(custom) = custom_path.filter(|p| !p.trim().is_empty()) {
let trimmed = custom.trim();
if trimmed.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(trimmed.trim_start_matches("~/"));
}
} else if trimmed == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
return PathBuf::from(trimmed);
}
if let Some(home) = dirs::home_dir() {
let ssh_dir = home.join(".ssh");
let known = ssh_dir.join("known_hosts");
if known.exists() || ssh_dir.exists() {
return known;
}
}
if let Some(cfg) = dirs::config_dir() {
return cfg.join("brum").join("known_hosts");
}
PathBuf::from("known_hosts")
}
struct KbdInteractiveHelper<'a> {
password: &'a str,
}
impl<'a> KeyboardInteractivePrompt for KbdInteractiveHelper<'a> {
fn prompt<'b>(
&mut self,
_username: &str,
_instructions: &str,
prompts: &[Prompt<'b>],
) -> Vec<String> {
prompts.iter().map(|_| self.password.to_string()).collect()
}
}
pub struct SftpClient;
impl SftpClient {
pub fn verify_host_key(sess: &Session, params: &SftpParams) -> Result<(), std::io::Error> {
let mode = params
.host_key_checking
.as_deref()
.unwrap_or("tofu")
.trim()
.to_lowercase();
if mode == "auto_accept" || mode == "no" || mode == "off" || mode == "accept-all" {
return Ok(());
}
let (key, key_type) = match sess.host_key() {
Some(k) => k,
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Remote host {}:{} did not present an SSH host key", params.host, params.port),
));
}
};
let mut known_hosts = sess.known_hosts().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to initialize known_hosts handler: {}", e),
)
})?;
let known_hosts_path = resolve_known_hosts_path(params.known_hosts_file.as_deref());
if known_hosts_path.exists() {
let _ = known_hosts.read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH);
}
let check = known_hosts.check_port(¶ms.host, params.port, key);
let fp_sha256 = sess.host_key_hash(ssh2::HashType::Sha256)
.map(|h| {
use base64::Engine;
format!("SHA256:{}", base64::engine::general_purpose::STANDARD_NO_PAD.encode(h))
})
.unwrap_or_else(|| "unknown".to_string());
match check {
ssh2::CheckResult::Match => Ok(()),
ssh2::CheckResult::NotFound => {
if mode == "strict" {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"Host key verification failed: Host '{}:{}' (fingerprint: {}) is not in known_hosts file '{}' and host_key_checking is set to 'strict'.",
params.host, params.port, fp_sha256, known_hosts_path.display()
),
))
} else {
let host_pattern = if params.port == 22 {
params.host.clone()
} else {
format!("[{}]:{}", params.host, params.port)
};
let _ = known_hosts.add(
&host_pattern,
key,
&format!("Added by Brum {}", chrono::Utc::now().format("%Y-%m-%d")),
key_type.into(),
);
if let Some(parent) = known_hosts_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = known_hosts.write_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH) {
warn!(
"Failed to write known_hosts entry for {}:{} to '{}': {}",
params.host, params.port, known_hosts_path.display(), e
);
} else {
info!(
"Recorded new host key for {}:{} (fingerprint: {}) in '{}'",
params.host, params.port, fp_sha256, known_hosts_path.display()
);
}
Ok(())
}
}
ssh2::CheckResult::Mismatch => {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"HOST KEY VERIFICATION FAILED: Host key for '{}:{}' has changed! Server presented fingerprint: {}. Offending key in: {}. Connection rejected to prevent potential Man-In-The-Middle attacks.",
params.host, params.port, fp_sha256, known_hosts_path.display()
),
))
}
ssh2::CheckResult::Failure => {
if mode == "strict" {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to verify host key in known_hosts for {}:{}", params.host, params.port),
))
} else {
Ok(())
}
}
}
}
pub fn connect(params: &SftpParams) -> Result<Session, std::io::Error> {
let addr_str = format!("{}:{}", params.host, params.port);
let socket_addrs: Vec<_> = addr_str.to_socket_addrs()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, format!("Cannot resolve host {}: {}", params.host, e)))?
.collect();
if socket_addrs.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::AddrNotAvailable,
format!("No IP addresses found for host '{}'", params.host),
));
}
let tcp = TcpStream::connect_timeout(&socket_addrs[0], Duration::from_secs(10))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::TimedOut, format!("TCP connection to {}:{} failed: {}", params.host, params.port, e)))?;
let mut sess = Session::new()?;
sess.set_tcp_stream(tcp);
sess.set_timeout(15000);
let _ = sess.method_pref(
ssh2::MethodType::Kex,
"curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1",
);
let _ = sess.method_pref(
ssh2::MethodType::HostKey,
"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss",
);
let _ = sess.method_pref(
ssh2::MethodType::CryptCs,
"chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc",
);
let _ = sess.method_pref(
ssh2::MethodType::CryptSc,
"chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc",
);
let _ = sess.method_pref(
ssh2::MethodType::MacCs,
"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1,hmac-sha1-96,umac-64-etm@openssh.com,umac-128-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com",
);
let _ = sess.method_pref(
ssh2::MethodType::MacSc,
"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1,hmac-sha1-96,umac-64-etm@openssh.com,umac-128-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com",
);
let _ = sess.method_pref(
ssh2::MethodType::CompCs,
"none,zlib@openssh.com,zlib",
);
let _ = sess.method_pref(
ssh2::MethodType::CompSc,
"none,zlib@openssh.com,zlib",
);
sess.handshake()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::ConnectionRefused, format!("SSH handshake failed with {}:{}: {}", params.host, params.port, e)))?;
Self::verify_host_key(&sess, params)?;
if let Some(password) = params.password.as_deref().filter(|p| !p.is_empty()) {
if sess.userauth_password(¶ms.user, password).is_ok() && sess.authenticated() {
return Ok(sess);
}
let mut prompt_handler = KbdInteractiveHelper { password };
if sess.userauth_keyboard_interactive(¶ms.user, &mut prompt_handler).is_ok() && sess.authenticated() {
return Ok(sess);
}
}
if let Some(ref key_path) = params.key_path {
let p = Path::new(key_path);
if p.exists() {
if sess.userauth_pubkey_file(¶ms.user, None, p, params.password.as_deref()).is_ok() && sess.authenticated() {
return Ok(sess);
}
}
}
if !sess.authenticated() {
if let Ok(mut agent) = sess.agent() {
if agent.connect().is_ok() && agent.list_identities().is_ok() {
if let Ok(identities) = agent.identities() {
for identity in identities {
if agent.userauth(¶ms.user, &identity).is_ok() && sess.authenticated() {
break;
}
}
}
}
}
}
if !sess.authenticated() {
if let Some(home) = dirs::home_dir() {
let ssh_dir = home.join(".ssh");
for key_name in &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"] {
let priv_key = ssh_dir.join(key_name);
if priv_key.exists() {
let pub_key = ssh_dir.join(format!("{}.pub", key_name));
let pub_ref = if pub_key.exists() { Some(pub_key.as_path()) } else { None };
if sess.userauth_pubkey_file(¶ms.user, pub_ref, &priv_key, params.password.as_deref()).is_ok() && sess.authenticated() {
break;
}
}
}
}
}
if !sess.authenticated() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"SFTP authentication failed for user '{}' at {}:{}. Please verify password or SSH key.",
params.user, params.host, params.port
),
));
}
Ok(sess)
}
pub fn resolve_remote_path(sftp: &ssh2::Sftp, raw_path: &str) -> String {
let clean = raw_path.trim();
if clean.is_empty() || clean == "." || clean == "~" {
if let Ok(real) = sftp.realpath(Path::new(".")) {
return real.to_string_lossy().to_string();
}
"/".to_string()
} else if clean.starts_with("~/") {
let sub = clean.trim_start_matches("~/");
if let Ok(real) = sftp.realpath(Path::new(".")) {
let home_str = real.to_string_lossy().to_string();
format!("{}/{}", home_str.trim_end_matches('/'), sub)
} else {
format!("/{}", sub)
}
} else {
clean.to_string()
}
}
pub fn list_dir(params: &SftpParams) -> Result<DirectoryListing, std::io::Error> {
let sess = Self::connect(params)?;
let sftp = sess.sftp().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP subsystem initialization failed: {}", e)))?;
let resolved_path = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let path = if resolved_path.is_empty() { "/" } else { &resolved_path };
let entries_raw = sftp.readdir(Path::new(path))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to read SFTP directory '{}': {}", path, e)))?;
let mut entries = Vec::new();
let mut total_files = 0;
let mut total_dirs = 0;
let mut total_size = 0u64;
for (p, stat) in entries_raw {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
if name == "." || name == ".." {
continue;
}
let is_dir = stat.is_dir();
let size = stat.size.unwrap_or(0);
let modified = stat.mtime;
let raw_perm = stat.perm.unwrap_or(0o644);
let mode_octal = format!("{:04o}", raw_perm & 0o7777);
let permissions = if is_dir { format!("d{:o}", raw_perm & 0o777) } else { format!("-{:o}", raw_perm & 0o777) };
let is_archive = !is_dir && is_archive_file(&name);
let uid = stat.uid.unwrap_or(1000);
let gid = stat.gid.unwrap_or(1000);
if is_dir {
total_dirs += 1;
} else {
total_files += 1;
total_size += size;
}
let full_path = if path == "/" {
format!("/{}", name)
} else {
format!("{}/{}", path.trim_end_matches('/'), name)
};
let entry_uri = format!("sftp://{}@{}:{}{}", percent_encode(¶ms.user), params.host, params.port, full_path);
entries.push(FileEntry {
name: name.clone(),
path: entry_uri,
is_dir,
is_symlink: false,
is_empty: None,
size,
modified,
permissions,
mode_octal,
owner: params.user.clone(),
group: "remote".to_string(),
uid,
gid,
mime_type: if is_dir { None } else { Some(mime_guess::from_path(&name).first_or_octet_stream().to_string()) },
is_archive,
});
}
entries.sort_by(|a, b| {
if a.is_dir == b.is_dir {
a.name.to_lowercase().cmp(&b.name.to_lowercase())
} else if a.is_dir {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
}
});
let parent_path = Path::new(path).parent().map(|p| {
let p_str = p.to_string_lossy();
let final_p = if p_str.is_empty() { "/" } else { &p_str };
format!("sftp://{}@{}:{}{}", percent_encode(¶ms.user), params.host, params.port, final_p)
});
let current_uri = format!("sftp://{}@{}:{}{}", percent_encode(¶ms.user), params.host, params.port, path);
Ok(DirectoryListing {
current_path: current_uri,
parent_path,
entries,
total_files,
total_dirs,
total_size,
protocol: "sftp".to_string(),
is_truncated: None,
max_limit: None,
})
}
pub fn download_file(
host: &str,
port: u16,
user: &str,
pass: Option<&str>,
remote_path: &str,
max_bytes: usize,
) -> Result<Vec<u8>, std::io::Error> {
let params = SftpParams {
host: host.to_string(),
port,
user: user.to_string(),
password: pass.map(|s| s.to_string()),
key_path: None,
remote_path: remote_path.to_string(),
host_key_checking: None,
known_hosts_file: None,
};
Self::download_file_with_params(¶ms, max_bytes)
}
pub fn download_file_with_params(
params: &SftpParams,
max_bytes: usize,
) -> Result<Vec<u8>, std::io::Error> {
let sess = Self::connect(params)?;
let sftp = sess.sftp().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP subsystem error: {}", e)))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let mut file = sftp.open(Path::new(&resolved))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP open error for '{}': {}", resolved, e)))?;
let mut buffer = Vec::new();
if max_bytes > 0 {
let mut handle = (&mut file).take(max_bytes as u64);
handle.read_to_end(&mut buffer)?;
} else {
file.read_to_end(&mut buffer)?;
}
Ok(buffer)
}
pub fn write_file(params: &SftpParams, data: &[u8]) -> Result<(), std::io::Error> {
let sess = Self::connect(params)?;
let sftp = sess.sftp().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP subsystem error: {}", e)))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let mut file = sftp.create(Path::new(&resolved))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP create file error for '{}': {}", resolved, e)))?;
file.write_all(data)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("SFTP write error for '{}': {}", resolved, e)))?;
Ok(())
}
pub fn parse_uri_with_config(
uri: &str,
default_user: Option<&str>,
default_pass: Option<&str>,
config: Option<&crate::config::SftpConfig>,
) -> Result<SftpParams, String> {
let mut params = Self::parse_uri(uri, default_user, default_pass)?;
if let Some(cfg) = config {
params.host_key_checking = Some(cfg.host_key_checking.clone());
params.known_hosts_file = cfg.known_hosts_file.clone();
}
Ok(params)
}
pub fn parse_uri(uri: &str, default_user: Option<&str>, default_pass: Option<&str>) -> Result<SftpParams, String> {
let clean = if let Some(stripped) = uri.strip_prefix("sftp://") {
stripped
} else if let Some(stripped) = uri.strip_prefix("ssh://") {
stripped
} else {
return Err("Invalid SFTP/SSH URI: must start with 'sftp://' or 'ssh://'".to_string());
};
let (auth_part, host_and_path) = if let Some(at) = clean.rfind('@') {
(&clean[..at], &clean[at + 1..])
} else {
("", clean)
};
let current_sys_user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "root".to_string());
let mut user = default_user.unwrap_or(¤t_sys_user).to_string();
let mut password = default_pass.filter(|p| !p.trim().is_empty()).map(|s| s.to_string());
if !auth_part.is_empty() {
if let Some(colon) = auth_part.find(':') {
user = percent_decode(&auth_part[..colon]);
password = Some(percent_decode(&auth_part[colon + 1..]));
} else {
user = percent_decode(auth_part);
}
}
let (host_port, remote_path) = if let Some(slash) = host_and_path.find('/') {
(&host_and_path[..slash], &host_and_path[slash..])
} else {
(host_and_path, "")
};
let (host, port) = if let Some(colon) = host_port.rfind(':') {
let h = &host_port[..colon];
let p = host_port[colon + 1..].parse::<u16>().unwrap_or(22);
(h.to_string(), p)
} else {
(host_port.to_string(), 22)
};
let final_path = remote_path.to_string();
Ok(SftpParams {
host,
port,
user,
password,
key_path: None,
remote_path: final_path,
host_key_checking: None,
known_hosts_file: None,
})
}
pub fn stat_path(params: &SftpParams) -> Result<(u64, bool), String> {
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let stat = sftp.stat(Path::new(&resolved))
.map_err(|e| format!("SFTP stat error for {}: {}", resolved, e))?;
let is_dir = stat.is_dir();
let size = stat.size.unwrap_or(0);
Ok((size, is_dir))
}
pub fn scan_dir_totals(params: &SftpParams) -> Result<(u64, u64), String> {
let mut total_files = 0u64;
let mut total_bytes = 0u64;
let listing = Self::list_dir(params).map_err(|e| e.to_string())?;
for entry in listing.entries {
if entry.is_dir {
let child_remote = if params.remote_path == "/" {
format!("/{}", entry.name)
} else {
format!("{}/{}", params.remote_path.trim_end_matches('/'), entry.name)
};
let mut child_params = params.clone();
child_params.remote_path = child_remote;
if let Ok((c, b)) = Self::scan_dir_totals(&child_params) {
total_files += c;
total_bytes += b;
}
} else {
total_files += 1;
total_bytes += entry.size;
}
}
Ok((total_files, total_bytes))
}
pub fn download_to_file(params: &SftpParams, local_dest: &Path) -> Result<(), String> {
Self::download_to_file_streaming(params, local_dest, false, |_, _, _| Ok(())).map(|_| ())
}
pub fn download_to_file_streaming<F>(
params: &SftpParams,
local_dest: &Path,
verify: bool,
mut on_progress: F,
) -> Result<Option<String>, String>
where
F: FnMut(u64, u64, u64) -> Result<(), std::io::Error>,
{
use std::io::{Read, Write};
use sha2::{Digest, Sha256};
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let mut remote_file = sftp.open(Path::new(&resolved))
.map_err(|e| format!("SFTP open error for {}: {}", resolved, e))?;
let stat = sftp.stat(Path::new(&resolved)).ok();
let total_file_bytes = stat.and_then(|s| s.size).unwrap_or(0);
if let Some(parent) = local_dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut local_file = std::fs::File::create(local_dest)
.map_err(|e| format!("Failed to create local file {}: {}", local_dest.display(), e))?;
let mut buffer = vec![0u8; 128 * 1024]; let mut bytes_copied = 0u64;
let mut hasher = if verify { Some(Sha256::new()) } else { None };
loop {
let n = remote_file.read(&mut buffer)
.map_err(|e| format!("SFTP read stream error: {}", e))?;
if n == 0 {
break;
}
local_file.write_all(&buffer[..n])
.map_err(|e| format!("Local write error: {}", e))?;
bytes_copied += n as u64;
if let Some(ref mut h) = hasher {
h.update(&buffer[..n]);
}
on_progress(n as u64, bytes_copied, total_file_bytes)
.map_err(|e| format!("Transfer interrupted: {}", e))?;
}
local_file.flush().map_err(|e| format!("Local flush error: {}", e))?;
let verified_hash = hasher.map(|h| hex::encode(h.finalize()));
Ok(verified_hash)
}
pub fn upload_from_file(params: &SftpParams, local_src: &Path) -> Result<(), String> {
Self::upload_from_file_streaming(params, local_src, false, |_, _, _| Ok(())).map(|_| ())
}
pub fn upload_from_file_streaming<F>(
params: &SftpParams,
local_src: &Path,
verify: bool,
mut on_progress: F,
) -> Result<Option<String>, String>
where
F: FnMut(u64, u64, u64) -> Result<(), std::io::Error>,
{
use std::io::{Read, Write};
use sha2::{Digest, Sha256};
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let mut remote_file = sftp.create(Path::new(&resolved))
.map_err(|e| format!("SFTP create remote file error for {}: {}", resolved, e))?;
let mut local_file = std::fs::File::open(local_src)
.map_err(|e| format!("Failed to open local source {}: {}", local_src.display(), e))?;
let total_file_bytes = local_src.metadata().map(|m| m.len()).unwrap_or(0);
let mut buffer = vec![0u8; 128 * 1024]; let mut bytes_copied = 0u64;
let mut hasher = if verify { Some(Sha256::new()) } else { None };
loop {
let n = local_file.read(&mut buffer)
.map_err(|e| format!("Local read stream error: {}", e))?;
if n == 0 {
break;
}
remote_file.write_all(&buffer[..n])
.map_err(|e| format!("SFTP write error: {}", e))?;
bytes_copied += n as u64;
if let Some(ref mut h) = hasher {
h.update(&buffer[..n]);
}
on_progress(n as u64, bytes_copied, total_file_bytes)
.map_err(|e| format!("Transfer interrupted: {}", e))?;
}
remote_file.flush().map_err(|e| format!("SFTP flush error: {}", e))?;
let verified_hash = hasher.map(|h| hex::encode(h.finalize()));
Ok(verified_hash)
}
pub fn mkdir(params: &SftpParams) -> Result<(), String> {
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
sftp.mkdir(Path::new(&resolved), 0o755)
.map_err(|e| format!("SFTP mkdir error: {}", e))?;
Ok(())
}
pub fn rename(params: &SftpParams, new_remote_path: &str) -> Result<(), String> {
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved_from = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
let resolved_to = Self::resolve_remote_path(&sftp, new_remote_path);
sftp.rename(Path::new(&resolved_from), Path::new(&resolved_to), None)
.map_err(|e| format!("SFTP rename error: {}", e))?;
Ok(())
}
pub fn delete(params: &SftpParams, is_dir: bool) -> Result<(), String> {
let sess = Self::connect(params)
.map_err(|e| format!("SFTP connect error: {}", e))?;
let sftp = sess.sftp().map_err(|e| format!("SFTP session error: {}", e))?;
let resolved = Self::resolve_remote_path(&sftp, ¶ms.remote_path);
if is_dir {
sftp.rmdir(Path::new(&resolved))
.map_err(|e| format!("SFTP rmdir error: {}", e))?;
} else {
sftp.unlink(Path::new(&resolved))
.map_err(|e| format!("SFTP unlink error: {}", e))?;
}
Ok(())
}
}
fn percent_decode(s: &str) -> String {
let mut bytes = Vec::new();
let mut chars = s.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let h1 = chars.next();
let h2 = chars.next();
if let (Some(h1), Some(h2)) = (h1, h2) {
if let (Some(d1), Some(d2)) = (hex_val(h1), hex_val(h2)) {
bytes.push((d1 << 4) | d2);
continue;
}
}
} else if b == b'+' {
bytes.push(b' ');
continue;
}
bytes.push(b);
}
String::from_utf8_lossy(&bytes).to_string()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn percent_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sftp_parse_uri_variations() {
let p1 = SftpClient::parse_uri("sftp://user:secret@192.168.1.100:2222/var/log", None, None).unwrap();
assert_eq!(p1.host, "192.168.1.100");
assert_eq!(p1.port, 2222);
assert_eq!(p1.user, "user");
assert_eq!(p1.password.as_deref(), Some("secret"));
assert_eq!(p1.remote_path, "/var/log");
let p2 = SftpClient::parse_uri("sftp://admin@myserver.com/home/admin", None, None).unwrap();
assert_eq!(p2.host, "myserver.com");
assert_eq!(p2.port, 22);
assert_eq!(p2.user, "admin");
assert_eq!(p2.password, None);
assert_eq!(p2.remote_path, "/home/admin");
let p3 = SftpClient::parse_uri("sftp://user%40boop:p%40ss%3Aword@nas.local/data", None, None).unwrap();
assert_eq!(p3.user, "user@boop");
assert_eq!(p3.password.as_deref(), Some("p@ss:word"));
assert_eq!(p3.host, "nas.local");
assert_eq!(p3.remote_path, "/data");
let p4 = SftpClient::parse_uri("ssh://root:secret@10.0.0.1:2222/etc", None, None).unwrap();
assert_eq!(p4.host, "10.0.0.1");
assert_eq!(p4.port, 2222);
assert_eq!(p4.user, "root");
assert_eq!(p4.password.as_deref(), Some("secret"));
assert_eq!(p4.remote_path, "/etc");
}
#[test]
fn test_sftp_supported_methods() {
let sess = Session::new().unwrap();
let kex_pref = "curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1";
let hostkey_pref = "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss";
let crypt_pref = "aes256-ctr,aes192-ctr,aes128-ctr,aes256-cbc,aes192-cbc,aes128-cbc,3des-cbc";
let mac_pref = "hmac-sha2-256,hmac-sha2-512,hmac-sha1,hmac-sha1-96";
let r_kex = sess.method_pref(ssh2::MethodType::Kex, kex_pref);
let r_hk = sess.method_pref(ssh2::MethodType::HostKey, hostkey_pref);
let r_ccs = sess.method_pref(ssh2::MethodType::CryptCs, crypt_pref);
let r_csc = sess.method_pref(ssh2::MethodType::CryptSc, crypt_pref);
let r_mcs = sess.method_pref(ssh2::MethodType::MacCs, mac_pref);
let r_msc = sess.method_pref(ssh2::MethodType::MacSc, mac_pref);
println!("r_kex: {:?}", r_kex);
println!("r_hk: {:?}", r_hk);
println!("r_ccs: {:?}", r_ccs);
println!("r_csc: {:?}", r_csc);
println!("r_mcs: {:?}", r_mcs);
println!("r_msc: {:?}", r_msc);
assert!(r_kex.is_ok());
assert!(r_hk.is_ok());
assert!(r_ccs.is_ok());
assert!(r_csc.is_ok());
assert!(r_mcs.is_ok());
assert!(r_msc.is_ok());
}
#[test]
fn test_resolve_known_hosts_path() {
let custom = resolve_known_hosts_path(Some("/tmp/custom_hosts"));
assert_eq!(custom, PathBuf::from("/tmp/custom_hosts"));
let tilde_path = resolve_known_hosts_path(Some("~/.ssh/test_hosts"));
if let Some(home) = dirs::home_dir() {
assert_eq!(tilde_path, home.join(".ssh/test_hosts"));
}
let default_path = resolve_known_hosts_path(None);
assert!(default_path.to_string_lossy().contains("known_hosts"));
}
#[test]
fn test_sftp_params_config_binding() {
let p = SftpClient::parse_uri("sftp://user:pass@example.com:2222/data", None, None).unwrap();
assert_eq!(p.host_key_checking, None);
assert_eq!(p.known_hosts_file, None);
let cfg = crate::config::SftpConfig {
host_key_checking: "strict".to_string(),
known_hosts_file: Some("/var/lib/brum/known_hosts".to_string()),
};
let bound = p.with_config(&cfg);
assert_eq!(bound.host_key_checking, Some("strict".to_string()));
assert_eq!(bound.known_hosts_file, Some("/var/lib/brum/known_hosts".to_string()));
}
#[test]
fn test_known_hosts_file_lifecycle_and_checks() {
let tmp = tempfile::tempdir().unwrap();
let hosts_file = tmp.path().join("known_hosts");
let sess = Session::new().unwrap();
let mut kh = sess.known_hosts().unwrap();
let host = "192.168.1.150";
let port = 2222;
let host_pattern = format!("[{}]:{}", host, port);
let sample_key = vec![42u8; 32];
kh.add(&host_pattern, &sample_key, "Test server key", ssh2::KnownHostKeyFormat::Ed25519).unwrap();
kh.write_file(&hosts_file, ssh2::KnownHostFileKind::OpenSSH).unwrap();
assert!(hosts_file.exists());
let sess2 = Session::new().unwrap();
let mut kh2 = sess2.known_hosts().unwrap();
kh2.read_file(&hosts_file, ssh2::KnownHostFileKind::OpenSSH).unwrap();
let res_match = kh2.check_port(host, port, &sample_key);
assert!(matches!(res_match, ssh2::CheckResult::Match));
let altered_key = vec![99u8; 32];
let res_mismatch = kh2.check_port(host, port, &altered_key);
assert!(matches!(res_mismatch, ssh2::CheckResult::Mismatch));
let res_not_found = kh2.check_port("10.0.0.99", 22, &sample_key);
assert!(matches!(res_not_found, ssh2::CheckResult::NotFound));
}
#[test]
fn test_sftp_auto_accept_mode() {
let sess = Session::new().unwrap();
let params = SftpParams {
host: "unreachable.invalid".to_string(),
port: 22,
user: "test".to_string(),
password: None,
key_path: None,
remote_path: "/".to_string(),
host_key_checking: Some("auto_accept".to_string()),
known_hosts_file: None,
};
let res = SftpClient::verify_host_key(&sess, ¶ms);
assert!(res.is_ok());
}
}