1use 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
50pub 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 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 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 self.check_host_key(&sess, host, port)?;
138
139 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 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 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 fn try_key_auth(
290 &self,
291 sess: &ssh2::Session,
292 username: &str,
293 ) -> Result<(), Box<dyn Error + Send + Sync>> {
294 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 let home = match dirs::home_dir() {
309 Some(h) => h,
310 None => return Ok(()), };
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 Ok(())
333 }
334}