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 = 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 self.check_host_key(&sess, host, port)?;
141
142 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 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 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 fn try_key_auth(
296 &self,
297 sess: &ssh2::Session,
298 username: &str,
299 ) -> Result<(), Box<dyn Error + Send + Sync>> {
300 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 let home = match dirs::home_dir() {
315 Some(h) => h,
316 None => return Ok(()), };
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 Ok(())
339 }
340}