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 = Url::parse(&self.url)
97            .map_err(|e| format!("Invalid SFTP URL '{}': {}", self.url, e))?;
98
99        if parsed.scheme() != "sftp" {
100            return Err(format!(
101                "Expected sftp:// URL, got scheme '{}'",
102                parsed.scheme()
103            )
104            .into());
105        }
106
107        let host = parsed
108            .host_str()
109            .ok_or("SFTP URL is missing a host (expected sftp://user@host/path)")?;
110        let port = parsed.port().unwrap_or(22);
111        let remote_path = parsed.path();
112
113        if remote_path.is_empty() || remote_path == "/" {
114            return Err("SFTP URL must include a file path (e.g., sftp://user@host/path/to/file)"
115                .into());
116        }
117
118        let username = if parsed.username().is_empty() {
119            std::env::var("USER")
120                .or_else(|_| std::env::var("USERNAME"))
121                .unwrap_or_else(|_| "anonymous".to_string())
122        } else {
123            parsed.username().to_string()
124        };
125
126        if !self.quiet {
127            println!("Connecting to {}:{} ...", host, port);
128        }
129
130        let tcp = std::net::TcpStream::connect((host, port))
131            .map_err(|e| format!("Cannot connect to {}:{} — {}", host, port, e))?;
132
133        let mut sess = ssh2::Session::new()
134            .map_err(|e| format!("SSH session init failed: {}", e))?;
135        sess.set_tcp_stream(tcp);
136        sess.handshake()
137            .map_err(|e| format!("SSH handshake with {}:{} failed: {}", host, port, e))?;
138
139        // Verify host key against ~/.ssh/known_hosts
140        self.check_host_key(&sess, host, port)?;
141
142        // Authenticate: password from URL → SSH agent → default key files
143        if let Some(password) = parsed.password() {
144            sess.userauth_password(&username, password)
145                .map_err(|e| format!("Password authentication for '{}' failed: {}", username, e))?;
146        } else {
147            self.try_key_auth(&sess, &username)?;
148        }
149
150        if !sess.authenticated() {
151            return Err(format!(
152                "SFTP authentication failed for user '{}' on {}:{}. \
153                 Provide password in URL (sftp://user:pass@host/path) or configure SSH keys/agent.",
154                username, host, port
155            )
156            .into());
157        }
158
159        if !self.quiet {
160            println!("Authenticated as '{}'. Opening SFTP channel...", username);
161        }
162
163        let sftp = sess
164            .sftp()
165            .map_err(|e| format!("Failed to open SFTP channel: {}", e))?;
166
167        let remote = std::path::Path::new(remote_path);
168
169        // Get file size for the progress bar (best-effort; fall back to spinner)
170        let file_size = sftp.stat(remote).ok().and_then(|s| s.size);
171
172        if !self.quiet {
173            match file_size {
174                Some(sz) => println!("Remote file size: {} bytes", sz),
175                None => println!("Remote file size unknown"),
176            }
177        }
178
179        let progress = create_progress_bar(
180            self.quiet,
181            remote_path.to_string(),
182            file_size,
183            false,
184        );
185
186        let mut remote_file = sftp
187            .open(remote)
188            .map_err(|e| format!("Cannot open remote file '{}': {}", remote_path, e))?;
189
190        let mut dest = std::fs::File::create(&self.output)
191            .map_err(|e| format!("Cannot create local file '{}': {}", self.output, e))?;
192
193        let mut buffer = [0u8; 32768];
194        loop {
195            let n = remote_file
196                .read(&mut buffer)
197                .map_err(|e| format!("SFTP read error: {}", e))?;
198            if n == 0 {
199                break;
200            }
201            dest.write_all(&buffer[..n])
202                .map_err(|e| format!("Write error to '{}': {}", self.output, e))?;
203            progress.inc(n as u64);
204        }
205
206        progress.finish_with_message("Download complete");
207
208        if !self.quiet {
209            println!("Saved to '{}'", self.output);
210        }
211
212        Ok(())
213    }
214
215    /// Verify the server's host key against ~/.ssh/known_hosts.
216    ///
217    /// Behaviour mirrors OpenSSH's `StrictHostKeyChecking=accept-new`:
218    /// - **Match** in known_hosts → proceed silently.
219    /// - **Not found** in known_hosts → warn and continue (first connection).
220    /// - **Mismatch** → hard error (possible MITM attack).
221    fn check_host_key(
222        &self,
223        sess: &ssh2::Session,
224        host: &str,
225        port: u16,
226    ) -> Result<(), Box<dyn Error + Send + Sync>> {
227        let (key_bytes, _key_type) = sess
228            .host_key()
229            .ok_or("Server did not provide a host key — connection refused")?;
230
231        let home = match dirs::home_dir() {
232            Some(h) => h,
233            None => {
234                if !self.quiet {
235                    eprintln!("Warning: Could not find home directory; skipping known_hosts check");
236                }
237                return Ok(());
238            }
239        };
240
241        let known_hosts_path = home.join(".ssh/known_hosts");
242
243        let mut known_hosts = sess
244            .known_hosts()
245            .map_err(|e| format!("Failed to open known_hosts: {}", e))?;
246
247        if known_hosts_path.exists() {
248            known_hosts
249                .read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH)
250                .map_err(|e| format!("Failed to read known_hosts: {}", e))?;
251        }
252
253        let check_host = if port == 22 {
254            host.to_string()
255        } else {
256            format!("[{}]:{}", host, port)
257        };
258
259        match known_hosts.check(&check_host, key_bytes) {
260            ssh2::CheckResult::Match => {}
261            ssh2::CheckResult::NotFound => {
262                eprintln!(
263                    "Warning: The host '{}' is not in your known_hosts file ({}).\n\
264                     Connecting anyway. To suppress this warning, add the host key:\n\
265                     ssh-keyscan -p {} {} >> {}",
266                    host,
267                    known_hosts_path.display(),
268                    port,
269                    host,
270                    known_hosts_path.display()
271                );
272            }
273            ssh2::CheckResult::Mismatch => {
274                return Err(format!(
275                    "WARNING: Host key verification FAILED for '{}'!\n\
276                     The host key has changed. This could indicate a MITM attack.\n\
277                     If you trust the new key, remove the old entry:\n\
278                     ssh-keygen -R '{}'",
279                    host, check_host
280                )
281                .into());
282            }
283            ssh2::CheckResult::Failure => {
284                eprintln!(
285                    "Warning: Could not check host key for '{}'; proceeding without verification",
286                    host
287                );
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Try SSH agent first, then fall back to well-known key files.
295    fn try_key_auth(
296        &self,
297        sess: &ssh2::Session,
298        username: &str,
299    ) -> Result<(), Box<dyn Error + Send + Sync>> {
300        // 1. SSH agent
301        if let Ok(mut agent) = sess.agent() {
302            if agent.connect().is_ok() && agent.list_identities().is_ok() {
303                if let Ok(identities) = agent.identities() {
304                    for identity in &identities {
305                        if agent.userauth(username, identity).is_ok() && sess.authenticated() {
306                            return Ok(());
307                        }
308                    }
309                }
310            }
311        }
312
313        // 2. Default key files
314        let home = match dirs::home_dir() {
315            Some(h) => h,
316            None => return Ok(()), // Let caller handle the unauthenticated state
317        };
318
319        let key_candidates = [
320            home.join(".ssh/id_ed25519"),
321            home.join(".ssh/id_rsa"),
322            home.join(".ssh/id_ecdsa"),
323        ];
324
325        for key_path in &key_candidates {
326            if key_path.exists() {
327                if sess
328                    .userauth_pubkey_file(username, None, key_path, None)
329                    .is_ok()
330                    && sess.authenticated()
331                {
332                    return Ok(());
333                }
334            }
335        }
336
337        // Return Ok regardless — caller checks sess.authenticated()
338        Ok(())
339    }
340}