Skip to main content

kget/sftp/
mod.rs

1//! SFTP (SSH File Transfer Protocol) download support.
2//!
3//! This module provides secure file downloads over SSH using SFTP.
4//!
5//! # Security
6//!
7//! SFTP provides encrypted file transfer, unlike plain FTP.
8//! Authentication is handled via SSH (password or key-based).
9//!
10//! # Authentication Order
11//!
12//! 1. Password embedded in URL (`sftp://user:pass@host/path`)
13//! 2. SSH agent (if running)
14//! 3. Default key files (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, `~/.ssh/id_ecdsa`)
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use kget::sftp::SftpDownloader;
20//! use kget::{ProxyConfig, Optimizer};
21//!
22//! // Password authentication
23//! let downloader = SftpDownloader::new(
24//!     "sftp://user:pass@server.com:22/path/to/file".to_string(),
25//!     "local_file.txt".to_string(),
26//!     false,
27//!     ProxyConfig::default(),
28//!     Optimizer::new(),
29//! );
30//!
31//! // Key-based (uses SSH agent or ~/.ssh/id_* automatically)
32//! let downloader = SftpDownloader::new(
33//!     "sftp://user@server.com/path/to/file".to_string(),
34//!     "local_file.txt".to_string(),
35//!     false,
36//!     ProxyConfig::default(),
37//!     Optimizer::new(),
38//! );
39//!
40//! downloader.download().unwrap();
41//! ```
42
43use crate::config::ProxyConfig;
44use crate::optimization::Optimizer;
45use crate::progress::create_progress_bar;
46use std::error::Error;
47use std::io::{Read, Write};
48use url::Url;
49
50/// SFTP file downloader using SSH.
51///
52/// Downloads files securely over SSH File Transfer Protocol.
53///
54/// Authentication tries, in order:
55/// 1. Password from the URL (`sftp://user:pass@host/path`)
56/// 2. Running SSH agent
57/// 3. Default key files in `~/.ssh/`
58pub struct SftpDownloader {
59    url: String,
60    output: String,
61    quiet: bool,
62    #[allow(dead_code)]
63    proxy: ProxyConfig,
64    #[allow(dead_code)]
65    optimizer: Optimizer,
66}
67
68impl SftpDownloader {
69    /// Create a new SFTP downloader.
70    ///
71    /// # Arguments
72    ///
73    /// * `url` - SFTP URL (e.g., `sftp://user:pass@host/path` or `sftp://user@host/path`)
74    /// * `output` - Local path to save the file
75    /// * `quiet` - Suppress console output
76    /// * `proxy` - Proxy configuration (not used for SFTP; SSH tunneling must be configured at OS level)
77    /// * `optimizer` - Optimizer instance
78    pub fn new(
79        url: String,
80        output: String,
81        quiet: bool,
82        proxy: ProxyConfig,
83        optimizer: Optimizer,
84    ) -> Self {
85        Self {
86            url,
87            output,
88            quiet,
89            proxy,
90            optimizer,
91        }
92    }
93
94    /// Download the file via SFTP.
95    pub fn download(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
96        let parsed =
97            Url::parse(&self.url).map_err(|e| format!("Invalid SFTP URL '{}': {}", self.url, e))?;
98
99        if parsed.scheme() != "sftp" {
100            return Err(format!("Expected sftp:// URL, got scheme '{}'", parsed.scheme()).into());
101        }
102
103        let host = parsed
104            .host_str()
105            .ok_or("SFTP URL is missing a host (expected sftp://user@host/path)")?;
106        let port = parsed.port().unwrap_or(22);
107        let remote_path = parsed.path();
108
109        if remote_path.is_empty() || remote_path == "/" {
110            return Err(
111                "SFTP URL must include a file path (e.g., sftp://user@host/path/to/file)".into(),
112            );
113        }
114
115        let username = if parsed.username().is_empty() {
116            std::env::var("USER")
117                .or_else(|_| std::env::var("USERNAME"))
118                .unwrap_or_else(|_| "anonymous".to_string())
119        } else {
120            parsed.username().to_string()
121        };
122
123        if !self.quiet {
124            println!("Connecting to {}:{} ...", host, port);
125        }
126
127        let tcp = std::net::TcpStream::connect((host, port))
128            .map_err(|e| format!("Cannot connect to {}:{} — {}", host, port, e))?;
129
130        let mut sess =
131            ssh2::Session::new().map_err(|e| format!("SSH session init failed: {}", e))?;
132        sess.set_tcp_stream(tcp);
133        sess.handshake()
134            .map_err(|e| format!("SSH handshake with {}:{} failed: {}", host, port, e))?;
135
136        // Verify host key against ~/.ssh/known_hosts
137        self.check_host_key(&sess, host, port)?;
138
139        // Authenticate: password from URL → SSH agent → default key files
140        if let Some(password) = parsed.password() {
141            sess.userauth_password(&username, password)
142                .map_err(|e| format!("Password authentication for '{}' failed: {}", username, e))?;
143        } else {
144            self.try_key_auth(&sess, &username)?;
145        }
146
147        if !sess.authenticated() {
148            return Err(format!(
149                "SFTP authentication failed for user '{}' on {}:{}. \
150                 Provide password in URL (sftp://user:pass@host/path) or configure SSH keys/agent.",
151                username, host, port
152            )
153            .into());
154        }
155
156        if !self.quiet {
157            println!("Authenticated as '{}'. Opening SFTP channel...", username);
158        }
159
160        let sftp = sess
161            .sftp()
162            .map_err(|e| format!("Failed to open SFTP channel: {}", e))?;
163
164        let remote = std::path::Path::new(remote_path);
165
166        // Get file size for the progress bar (best-effort; fall back to spinner)
167        let file_size = sftp.stat(remote).ok().and_then(|s| s.size);
168
169        if !self.quiet {
170            match file_size {
171                Some(sz) => println!("Remote file size: {} bytes", sz),
172                None => println!("Remote file size unknown"),
173            }
174        }
175
176        let progress = create_progress_bar(self.quiet, remote_path.to_string(), file_size, false);
177
178        let mut remote_file = sftp
179            .open(remote)
180            .map_err(|e| format!("Cannot open remote file '{}': {}", remote_path, e))?;
181
182        let mut dest = std::fs::File::create(&self.output)
183            .map_err(|e| format!("Cannot create local file '{}': {}", self.output, e))?;
184
185        let mut buffer = [0u8; 32768];
186        loop {
187            let n = remote_file
188                .read(&mut buffer)
189                .map_err(|e| format!("SFTP read error: {}", e))?;
190            if n == 0 {
191                break;
192            }
193            dest.write_all(&buffer[..n])
194                .map_err(|e| format!("Write error to '{}': {}", self.output, e))?;
195            progress.inc(n as u64);
196        }
197
198        progress.finish_with_message("Download complete");
199
200        if !self.quiet {
201            println!("Saved to '{}'", self.output);
202        }
203
204        Ok(())
205    }
206
207    /// Verify the server's host key against ~/.ssh/known_hosts.
208    ///
209    /// Behaviour mirrors OpenSSH's `StrictHostKeyChecking=accept-new`:
210    /// - **Match** in known_hosts → proceed silently.
211    /// - **Not found** in known_hosts → warn and continue (first connection).
212    /// - **Mismatch** → hard error (possible MITM attack).
213    fn check_host_key(
214        &self,
215        sess: &ssh2::Session,
216        host: &str,
217        port: u16,
218    ) -> Result<(), Box<dyn Error + Send + Sync>> {
219        let (key_bytes, _key_type) = sess
220            .host_key()
221            .ok_or("Server did not provide a host key — connection refused")?;
222
223        let home = match dirs::home_dir() {
224            Some(h) => h,
225            None => {
226                if !self.quiet {
227                    eprintln!("Warning: Could not find home directory; skipping known_hosts check");
228                }
229                return Ok(());
230            }
231        };
232
233        let known_hosts_path = home.join(".ssh/known_hosts");
234
235        let mut known_hosts = sess
236            .known_hosts()
237            .map_err(|e| format!("Failed to open known_hosts: {}", e))?;
238
239        if known_hosts_path.exists() {
240            known_hosts
241                .read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH)
242                .map_err(|e| format!("Failed to read known_hosts: {}", e))?;
243        }
244
245        let check_host = if port == 22 {
246            host.to_string()
247        } else {
248            format!("[{}]:{}", host, port)
249        };
250
251        match known_hosts.check(&check_host, key_bytes) {
252            ssh2::CheckResult::Match => {}
253            ssh2::CheckResult::NotFound => {
254                eprintln!(
255                    "Warning: The host '{}' is not in your known_hosts file ({}).\n\
256                     Connecting anyway. To suppress this warning, add the host key:\n\
257                     ssh-keyscan -p {} {} >> {}",
258                    host,
259                    known_hosts_path.display(),
260                    port,
261                    host,
262                    known_hosts_path.display()
263                );
264            }
265            ssh2::CheckResult::Mismatch => {
266                return Err(format!(
267                    "WARNING: Host key verification FAILED for '{}'!\n\
268                     The host key has changed. This could indicate a MITM attack.\n\
269                     If you trust the new key, remove the old entry:\n\
270                     ssh-keygen -R '{}'",
271                    host, check_host
272                )
273                .into());
274            }
275            ssh2::CheckResult::Failure => {
276                return Err(format!(
277                    "Host key check failed for '{}': internal libssh2 error. \
278                     Connection aborted to prevent security bypass.",
279                    host
280                )
281                .into());
282            }
283        }
284
285        Ok(())
286    }
287
288    /// Try SSH agent first, then fall back to well-known key files.
289    fn try_key_auth(
290        &self,
291        sess: &ssh2::Session,
292        username: &str,
293    ) -> Result<(), Box<dyn Error + Send + Sync>> {
294        // 1. SSH agent
295        if let Ok(mut agent) = sess.agent() {
296            if agent.connect().is_ok() && agent.list_identities().is_ok() {
297                if let Ok(identities) = agent.identities() {
298                    for identity in &identities {
299                        if agent.userauth(username, identity).is_ok() && sess.authenticated() {
300                            return Ok(());
301                        }
302                    }
303                }
304            }
305        }
306
307        // 2. Default key files
308        let home = match dirs::home_dir() {
309            Some(h) => h,
310            None => return Ok(()), // Let caller handle the unauthenticated state
311        };
312
313        let key_candidates = [
314            home.join(".ssh/id_ed25519"),
315            home.join(".ssh/id_rsa"),
316            home.join(".ssh/id_ecdsa"),
317        ];
318
319        for key_path in &key_candidates {
320            if key_path.exists() {
321                if sess
322                    .userauth_pubkey_file(username, None, key_path, None)
323                    .is_ok()
324                    && sess.authenticated()
325                {
326                    return Ok(());
327                }
328            }
329        }
330
331        // Return Ok regardless — caller checks sess.authenticated()
332        Ok(())
333    }
334}