use crate::config::ProxyConfig;
use crate::optimization::Optimizer;
use crate::progress::create_progress_bar;
use std::error::Error;
use std::io::{Read, Write};
use url::Url;
pub struct SftpDownloader {
url: String,
output: String,
quiet: bool,
#[allow(dead_code)]
proxy: ProxyConfig,
#[allow(dead_code)]
optimizer: Optimizer,
}
impl SftpDownloader {
pub fn new(
url: String,
output: String,
quiet: bool,
proxy: ProxyConfig,
optimizer: Optimizer,
) -> Self {
Self {
url,
output,
quiet,
proxy,
optimizer,
}
}
pub fn download(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let parsed = Url::parse(&self.url)
.map_err(|e| format!("Invalid SFTP URL '{}': {}", self.url, e))?;
if parsed.scheme() != "sftp" {
return Err(format!(
"Expected sftp:// URL, got scheme '{}'",
parsed.scheme()
)
.into());
}
let host = parsed
.host_str()
.ok_or("SFTP URL is missing a host (expected sftp://user@host/path)")?;
let port = parsed.port().unwrap_or(22);
let remote_path = parsed.path();
if remote_path.is_empty() || remote_path == "/" {
return Err("SFTP URL must include a file path (e.g., sftp://user@host/path/to/file)"
.into());
}
let username = if parsed.username().is_empty() {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "anonymous".to_string())
} else {
parsed.username().to_string()
};
if !self.quiet {
println!("Connecting to {}:{} ...", host, port);
}
let tcp = std::net::TcpStream::connect((host, port))
.map_err(|e| format!("Cannot connect to {}:{} — {}", host, port, e))?;
let mut sess = ssh2::Session::new()
.map_err(|e| format!("SSH session init failed: {}", e))?;
sess.set_tcp_stream(tcp);
sess.handshake()
.map_err(|e| format!("SSH handshake with {}:{} failed: {}", host, port, e))?;
self.check_host_key(&sess, host, port)?;
if let Some(password) = parsed.password() {
sess.userauth_password(&username, password)
.map_err(|e| format!("Password authentication for '{}' failed: {}", username, e))?;
} else {
self.try_key_auth(&sess, &username)?;
}
if !sess.authenticated() {
return Err(format!(
"SFTP authentication failed for user '{}' on {}:{}. \
Provide password in URL (sftp://user:pass@host/path) or configure SSH keys/agent.",
username, host, port
)
.into());
}
if !self.quiet {
println!("Authenticated as '{}'. Opening SFTP channel...", username);
}
let sftp = sess
.sftp()
.map_err(|e| format!("Failed to open SFTP channel: {}", e))?;
let remote = std::path::Path::new(remote_path);
let file_size = sftp.stat(remote).ok().and_then(|s| s.size);
if !self.quiet {
match file_size {
Some(sz) => println!("Remote file size: {} bytes", sz),
None => println!("Remote file size unknown"),
}
}
let progress = create_progress_bar(
self.quiet,
remote_path.to_string(),
file_size,
false,
);
let mut remote_file = sftp
.open(remote)
.map_err(|e| format!("Cannot open remote file '{}': {}", remote_path, e))?;
let mut dest = std::fs::File::create(&self.output)
.map_err(|e| format!("Cannot create local file '{}': {}", self.output, e))?;
let mut buffer = [0u8; 32768];
loop {
let n = remote_file
.read(&mut buffer)
.map_err(|e| format!("SFTP read error: {}", e))?;
if n == 0 {
break;
}
dest.write_all(&buffer[..n])
.map_err(|e| format!("Write error to '{}': {}", self.output, e))?;
progress.inc(n as u64);
}
progress.finish_with_message("Download complete");
if !self.quiet {
println!("Saved to '{}'", self.output);
}
Ok(())
}
fn check_host_key(
&self,
sess: &ssh2::Session,
host: &str,
port: u16,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let (key_bytes, _key_type) = sess
.host_key()
.ok_or("Server did not provide a host key — connection refused")?;
let home = match dirs::home_dir() {
Some(h) => h,
None => {
if !self.quiet {
eprintln!("Warning: Could not find home directory; skipping known_hosts check");
}
return Ok(());
}
};
let known_hosts_path = home.join(".ssh/known_hosts");
let mut known_hosts = sess
.known_hosts()
.map_err(|e| format!("Failed to open known_hosts: {}", e))?;
if known_hosts_path.exists() {
known_hosts
.read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH)
.map_err(|e| format!("Failed to read known_hosts: {}", e))?;
}
let check_host = if port == 22 {
host.to_string()
} else {
format!("[{}]:{}", host, port)
};
match known_hosts.check(&check_host, key_bytes) {
ssh2::CheckResult::Match => {}
ssh2::CheckResult::NotFound => {
eprintln!(
"Warning: The host '{}' is not in your known_hosts file ({}).\n\
Connecting anyway. To suppress this warning, add the host key:\n\
ssh-keyscan -p {} {} >> {}",
host,
known_hosts_path.display(),
port,
host,
known_hosts_path.display()
);
}
ssh2::CheckResult::Mismatch => {
return Err(format!(
"WARNING: Host key verification FAILED for '{}'!\n\
The host key has changed. This could indicate a MITM attack.\n\
If you trust the new key, remove the old entry:\n\
ssh-keygen -R '{}'",
host, check_host
)
.into());
}
ssh2::CheckResult::Failure => {
eprintln!(
"Warning: Could not check host key for '{}'; proceeding without verification",
host
);
}
}
Ok(())
}
fn try_key_auth(
&self,
sess: &ssh2::Session,
username: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
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(username, identity).is_ok() && sess.authenticated() {
return Ok(());
}
}
}
}
}
let home = match dirs::home_dir() {
Some(h) => h,
None => return Ok(()), };
let key_candidates = [
home.join(".ssh/id_ed25519"),
home.join(".ssh/id_rsa"),
home.join(".ssh/id_ecdsa"),
];
for key_path in &key_candidates {
if key_path.exists() {
if sess
.userauth_pubkey_file(username, None, key_path, None)
.is_ok()
&& sess.authenticated()
{
return Ok(());
}
}
}
Ok(())
}
}