1use anyhow::Result;
2use async_std::io::ReadExt;
3use async_std::io::WriteExt as AsyncWriteExt;
4use serde::Deserialize;
5use std::io::{Read, Write};
6use std::time::Duration;
7
8use crate::sftp_client::{FileEntry, FileEntryType};
9
10#[derive(Clone, Deserialize)]
12pub struct FtpConfig {
13 pub host: String,
14 pub port: u16,
15 pub username: String,
16 pub password: String,
17 pub ftps_enabled: bool,
18 pub anonymous: bool,
19 #[serde(default)]
23 pub allow_invalid_certs: bool,
24}
25
26enum FtpStreamKind {
28 Plain(suppaftp::AsyncFtpStream),
29 Secure(suppaftp::AsyncNativeTlsFtpStream),
30}
31
32macro_rules! ftp_stream {
34 ($self:expr, $s:ident => $body:expr) => {{
35 let kind = $self
36 .stream
37 .as_mut()
38 .ok_or_else(|| anyhow::anyhow!("FTP session not connected"))?;
39 match kind {
40 FtpStreamKind::Plain($s) => $body,
41 FtpStreamKind::Secure($s) => $body,
42 }
43 }};
44}
45
46pub struct FtpClient {
48 stream: Option<FtpStreamKind>,
49}
50
51impl FtpClient {
52 pub async fn connect(config: &FtpConfig) -> Result<Self> {
54 let addr = format!("{}:{}", config.host, config.port);
55
56 tracing::info!(
57 "FTP connecting to {} (ftps={}, anonymous={})",
58 addr,
59 config.ftps_enabled,
60 config.anonymous
61 );
62
63 let timeout_duration = Duration::from_secs(15);
65
66 let mut stream_kind = if config.ftps_enabled {
67 let ftp_stream = async_std::future::timeout(
68 timeout_duration,
69 suppaftp::AsyncNativeTlsFtpStream::connect(&addr),
70 )
71 .await
72 .map_err(|_| {
73 anyhow::anyhow!(
74 "FTPS connection timed out after 15s. Check host {} and port {}.",
75 config.host,
76 config.port
77 )
78 })?
79 .map_err(|e| anyhow::anyhow!("FTPS TCP connect to {} failed: {}", addr, e))?;
80
81 tracing::info!("FTPS TCP connected, starting TLS handshake...");
82
83 let mut tls_connector = suppaftp::async_native_tls::TlsConnector::new();
84 if config.allow_invalid_certs {
85 tracing::warn!(
86 "FTPS: TLS certificate validation DISABLED for {} — insecure, user opt-in",
87 config.host
88 );
89 tls_connector = tls_connector.danger_accept_invalid_certs(true);
90 }
91 let secure_stream = ftp_stream
92 .into_secure(
93 suppaftp::AsyncNativeTlsConnector::from(tls_connector),
94 &config.host,
95 )
96 .await
97 .map_err(|e| {
98 anyhow::anyhow!(
99 "FTPS TLS handshake failed: {}. \
100 If the server uses a self-signed certificate, re-connect with 'Allow invalid TLS certificates' enabled.",
101 e
102 )
103 })?;
104
105 tracing::info!("FTPS TLS handshake complete");
106 FtpStreamKind::Secure(secure_stream)
107 } else {
108 let ftp_stream = async_std::future::timeout(
109 timeout_duration,
110 suppaftp::AsyncFtpStream::connect(&addr),
111 )
112 .await
113 .map_err(|_| {
114 anyhow::anyhow!(
115 "FTP connection timed out after 15s. Check host {} and port {}.",
116 config.host,
117 config.port
118 )
119 })?
120 .map_err(|e| anyhow::anyhow!("FTP TCP connect to {} failed: {}", addr, e))?;
121
122 tracing::info!("FTP TCP connected to {}", addr);
123 FtpStreamKind::Plain(ftp_stream)
124 };
125
126 {
128 let (user, pass) = if config.anonymous {
129 ("anonymous", "anonymous@")
130 } else {
131 (config.username.as_str(), config.password.as_str())
132 };
133 tracing::info!("FTP authenticating as '{}'", user);
134 match &mut stream_kind {
135 FtpStreamKind::Plain(s) => s.login(user, pass).await,
136 FtpStreamKind::Secure(s) => s.login(user, pass).await,
137 }
138 .map_err(|e| anyhow::anyhow!("FTP authentication failed for user '{}': {}", user, e))?;
139 }
140
141 tracing::info!("FTP authenticated successfully");
142
143 {
145 match &mut stream_kind {
146 FtpStreamKind::Plain(s) => s.transfer_type(suppaftp::types::FileType::Binary).await,
147 FtpStreamKind::Secure(s) => {
148 s.transfer_type(suppaftp::types::FileType::Binary).await
149 }
150 }
151 .map_err(|e| anyhow::anyhow!("Failed to set binary transfer type: {}", e))?;
152 }
153
154 tracing::info!("FTP connection fully established to {}", addr);
155
156 Ok(Self {
157 stream: Some(stream_kind),
158 })
159 }
160
161 pub async fn disconnect(&mut self) -> Result<()> {
162 if let Some(kind) = self.stream.take() {
163 match kind {
164 FtpStreamKind::Plain(mut s) => {
165 if let Err(e) = s.quit().await {
166 tracing::warn!("FTP quit failed cleanly: {}", e);
167 }
168 }
169 FtpStreamKind::Secure(mut s) => {
170 if let Err(e) = s.quit().await {
171 tracing::warn!("FTPS quit failed cleanly: {}", e);
172 }
173 }
174 }
175 }
176 Ok(())
177 }
178
179 #[cfg(test)]
183 pub fn is_connected(&self) -> bool {
184 self.stream.is_some()
185 }
186
187 pub async fn list_dir(&mut self, path: &str) -> Result<Vec<FileEntry>> {
191 let entries: Vec<String> = ftp_stream!(self, s => {
192 s.list(Some(path)).await.map_err(|e| {
193 anyhow::anyhow!("Failed to list directory '{}': {}", path, e)
194 })?
195 });
196
197 let mut result = Vec::new();
198 for line in entries {
199 if let Some(entry) = parse_ftp_list_line(&line) {
200 if entry.name == "." || entry.name == ".." {
201 continue;
202 }
203 result.push(entry);
204 }
205 }
206
207 result.sort_by(|a, b| {
209 let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
210 let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
211 b_is_dir
212 .cmp(&a_is_dir)
213 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
214 });
215
216 Ok(result)
217 }
218
219 pub async fn download_file(&mut self, remote_path: &str, local_path: &str) -> Result<u64> {
222 let remote = remote_path.to_string();
223 let local = local_path.to_string();
224
225 Ok(ftp_stream!(self, s => {
226 let mut data_stream =
227 s.retr_as_stream(&remote)
228 .await
229 .map_err(|e| anyhow::anyhow!("Failed to download file '{}': {}", remote, e))?;
230 let mut local_file = std::fs::File::create(&local)
231 .map_err(|e| anyhow::anyhow!("Failed to create local file '{}': {}", local, e))?;
232 let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
233 let mut total = 0u64;
234 loop {
235 let n = data_stream
236 .read(&mut buf)
237 .await
238 .map_err(|e| anyhow::anyhow!("Failed to read download stream: {}", e))?;
239 if n == 0 {
240 break;
241 }
242 local_file
243 .write_all(&buf[..n])
244 .map_err(|e| anyhow::anyhow!("Failed to write local file: {}", e))?;
245 total += n as u64;
246 }
247 local_file
248 .flush()
249 .map_err(|e| anyhow::anyhow!("Failed to flush local file: {}", e))?;
250 s.finalize_retr_stream(data_stream)
251 .await
252 .map_err(|e| anyhow::anyhow!("Failed to finalize download: {}", e))?;
253 total
254 }))
255 }
256
257 pub async fn upload_file(&mut self, local_path: &str, remote_path: &str) -> Result<u64> {
260 let local = local_path.to_string();
261 let remote = remote_path.to_string();
262
263 Ok(ftp_stream!(self, s => {
264 let mut data_stream =
265 s.put_with_stream(&remote)
266 .await
267 .map_err(|e| anyhow::anyhow!("Failed to open upload stream: {}", e))?;
268 let mut local_file = std::fs::File::open(&local)
269 .map_err(|e| anyhow::anyhow!("Failed to open local file '{}': {}", local, e))?;
270 let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
271 let mut total = 0u64;
272 loop {
273 let n = local_file
274 .read(&mut buf)
275 .map_err(|e| anyhow::anyhow!("Failed to read local file: {}", e))?;
276 if n == 0 {
277 break;
278 }
279 data_stream
280 .write_all(&buf[..n])
281 .await
282 .map_err(|e| anyhow::anyhow!("Failed to write upload stream: {}", e))?;
283 total += n as u64;
284 }
285 s.finalize_put_stream(data_stream)
286 .await
287 .map_err(|e| anyhow::anyhow!("Failed to finalize upload: {}", e))?;
288 total
289 }))
290 }
291
292 pub async fn create_dir(&mut self, path: &str) -> Result<()> {
294 ftp_stream!(self, s => {
295 s.mkdir(path).await.map_err(|e| {
296 anyhow::anyhow!("Failed to create directory '{}': {}", path, e)
297 })?
298 });
299 Ok(())
300 }
301
302 pub async fn rename(&mut self, old_path: &str, new_path: &str) -> Result<()> {
304 ftp_stream!(self, s => {
305 s.rename(old_path, new_path).await.map_err(|e| {
306 anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
307 })?
308 });
309 Ok(())
310 }
311
312 pub async fn delete_file(&mut self, path: &str) -> Result<()> {
314 ftp_stream!(self, s => {
315 s.rm(path).await.map_err(|e| {
316 anyhow::anyhow!("Failed to delete file '{}': {}", path, e)
317 })?
318 });
319 Ok(())
320 }
321
322 pub async fn delete_dir(&mut self, path: &str) -> Result<()> {
324 ftp_stream!(self, s => {
325 s.rmdir(path).await.map_err(|e| {
326 anyhow::anyhow!("Failed to delete directory '{}': {}", path, e)
327 })?
328 });
329 Ok(())
330 }
331}
332
333fn parse_ftp_list_line(line: &str) -> Option<FileEntry> {
336 let line = line.trim();
337 if line.is_empty() {
338 return None;
339 }
340
341 let parts: Vec<&str> = line.splitn(9, char::is_whitespace).collect();
343 if parts.len() < 9 {
344 if let Some(last) = line.split_whitespace().last() {
346 return Some(FileEntry {
347 name: last.to_string(),
348 size: 0,
349 modified: None,
350 modified_unix: None,
351 permissions: None,
352 owner: None,
353 group: None,
354 file_type: FileEntryType::File,
355 });
356 }
357 return None;
358 }
359
360 let perms_str = parts[0];
361 let file_type = if perms_str.starts_with('d') {
362 FileEntryType::Directory
363 } else if perms_str.starts_with('l') {
364 FileEntryType::Symlink
365 } else {
366 FileEntryType::File
367 };
368
369 let tokens: Vec<&str> = line.split_whitespace().collect();
371 if tokens.len() < 9 {
372 let name = tokens.last().unwrap_or(&"").to_string();
373 return Some(FileEntry {
374 name,
375 size: 0,
376 modified: None,
377 modified_unix: None,
378 permissions: Some(perms_str.to_string()),
379 owner: None,
380 group: None,
381 file_type,
382 });
383 }
384
385 let size = tokens[4].parse::<u64>().unwrap_or(0);
386 let month = tokens[5];
387 let day = tokens[6];
388 let time_or_year = tokens[7];
389 let modified = Some(format!("{} {} {}", month, day, time_or_year));
390 let name = tokens[8..].join(" ");
392 let name = if matches!(file_type, FileEntryType::Symlink) {
394 name.split(" -> ").next().unwrap_or(&name).to_string()
395 } else {
396 name
397 };
398
399 Some(FileEntry {
400 name,
401 size,
402 modified,
403 modified_unix: None,
404 permissions: Some(perms_str.to_string()),
405 owner: None,
406 group: None,
407 file_type,
408 })
409}
410
411#[cfg(test)]
418mod tests {
419 use super::*;
420
421 fn test_config() -> Option<FtpConfig> {
423 let host = std::env::var("FTP_TEST_HOST").ok()?;
424 let user = std::env::var("FTP_TEST_USER").unwrap_or_else(|_| "xxxx".into());
425 let pass = std::env::var("FTP_TEST_PASS").unwrap_or_else(|_| "xxxxxxx".into());
426 let port: u16 = std::env::var("FTP_TEST_PORT")
427 .ok()
428 .and_then(|p| p.parse().ok())
429 .unwrap_or(21);
430 Some(FtpConfig {
431 host,
432 port,
433 username: user,
434 password: pass,
435 ftps_enabled: false,
436 anonymous: false,
437 allow_invalid_certs: false,
438 })
439 }
440
441 #[tokio::test]
444 async fn test_ftp_connect_and_disconnect() {
445 let Some(cfg) = test_config() else {
446 eprintln!("SKIP: FTP_TEST_HOST not set");
447 return;
448 };
449
450 let mut client = FtpClient::connect(&cfg)
451 .await
452 .expect("FTP connect should succeed");
453
454 assert!(client.is_connected(), "client should be connected");
455
456 client
457 .disconnect()
458 .await
459 .expect("disconnect should succeed");
460 assert!(!client.is_connected(), "client should be disconnected");
461 }
462
463 #[tokio::test]
466 async fn test_ftp_connect_bad_credentials() {
467 let Some(mut cfg) = test_config() else {
468 eprintln!("SKIP: FTP_TEST_HOST not set");
469 return;
470 };
471 cfg.password = "wrong-password-definitely".into();
472
473 let result = FtpClient::connect(&cfg).await;
474 assert!(result.is_err(), "connect with bad password should fail");
475
476 let err_msg = result.err().unwrap().to_string();
477 eprintln!("Expected error: {}", err_msg);
478 assert!(
479 err_msg.to_lowercase().contains("auth")
480 || err_msg.to_lowercase().contains("login")
481 || err_msg.to_lowercase().contains("fail"),
482 "error should mention authentication failure, got: {}",
483 err_msg
484 );
485 }
486
487 #[tokio::test]
490 async fn test_ftp_list_root() {
491 let Some(cfg) = test_config() else {
492 eprintln!("SKIP: FTP_TEST_HOST not set");
493 return;
494 };
495
496 let mut client = FtpClient::connect(&cfg).await.expect("connect");
497
498 let entries = client.list_dir("/").await.expect("list root directory");
499 eprintln!("Root contains {} entries:", entries.len());
500 for e in &entries {
501 eprintln!(" {:?} {:>10} {}", e.file_type, e.size, e.name);
502 }
503 client.disconnect().await.ok();
506 }
507
508 #[tokio::test]
511 async fn test_ftp_crud_cycle() {
512 let Some(cfg) = test_config() else {
513 eprintln!("SKIP: FTP_TEST_HOST not set");
514 return;
515 };
516
517 let mut client = FtpClient::connect(&cfg).await.expect("connect");
518
519 let test_dir = "/rshell_e2e_test";
520 let test_file_remote = format!("{}/hello.txt", test_dir);
521 let renamed_file_remote = format!("{}/hello_renamed.txt", test_dir);
522
523 let _ = client.delete_file(&renamed_file_remote).await;
525 let _ = client.delete_file(&test_file_remote).await;
526 let _ = client.delete_dir(test_dir).await;
527
528 client
530 .create_dir(test_dir)
531 .await
532 .expect("create_dir should succeed");
533 eprintln!("Created directory: {}", test_dir);
534
535 let tmp_upload = std::env::temp_dir().join("rshell_e2e_upload.txt");
537 let upload_content = b"Hello from R-Shell E2E test!\nLine 2\n";
538 tokio::fs::write(&tmp_upload, upload_content)
539 .await
540 .expect("write temp file");
541
542 let uploaded_bytes = client
543 .upload_file(tmp_upload.to_str().unwrap(), &test_file_remote)
544 .await
545 .expect("upload_file should succeed");
546 assert_eq!(uploaded_bytes, upload_content.len() as u64);
547 eprintln!("Uploaded {} bytes to {}", uploaded_bytes, test_file_remote);
548
549 let entries = client.list_dir(test_dir).await.expect("list test dir");
551 eprintln!("Directory {} contains {} entries", test_dir, entries.len());
552 let found = entries.iter().any(|e| e.name == "hello.txt");
553 assert!(
554 found,
555 "uploaded file should appear in listing: {:?}",
556 entries.iter().map(|e| &e.name).collect::<Vec<_>>()
557 );
558
559 let tmp_download = std::env::temp_dir().join("rshell_e2e_download.txt");
561 let downloaded_bytes = client
562 .download_file(&test_file_remote, tmp_download.to_str().unwrap())
563 .await
564 .expect("download_file should succeed");
565 assert_eq!(downloaded_bytes, upload_content.len() as u64);
566
567 let downloaded_data = tokio::fs::read(&tmp_download)
568 .await
569 .expect("read downloaded");
570 assert_eq!(
571 downloaded_data, upload_content,
572 "downloaded content should match uploaded content"
573 );
574 eprintln!("Download verified: {} bytes match", downloaded_bytes);
575
576 client
578 .rename(&test_file_remote, &renamed_file_remote)
579 .await
580 .expect("rename should succeed");
581 eprintln!("Renamed {} → {}", test_file_remote, renamed_file_remote);
582
583 let entries_after = client.list_dir(test_dir).await.expect("list after rename");
585 assert!(
586 !entries_after.iter().any(|e| e.name == "hello.txt"),
587 "old file name should be gone"
588 );
589 assert!(
590 entries_after.iter().any(|e| e.name == "hello_renamed.txt"),
591 "renamed file should exist"
592 );
593
594 client
596 .delete_file(&renamed_file_remote)
597 .await
598 .expect("delete_file should succeed");
599 eprintln!("Deleted {}", renamed_file_remote);
600
601 client
603 .delete_dir(test_dir)
604 .await
605 .expect("delete_dir should succeed");
606 eprintln!("Deleted directory {}", test_dir);
607
608 let root_entries = client.list_dir("/").await.expect("list root");
610 assert!(
611 !root_entries.iter().any(|e| e.name == "rshell_e2e_test"),
612 "test directory should be removed"
613 );
614 eprintln!("Cleanup verified: test directory removed from root listing");
615
616 let _ = tokio::fs::remove_file(&tmp_upload).await;
618 let _ = tokio::fs::remove_file(&tmp_download).await;
619
620 client.disconnect().await.ok();
621 eprintln!("FTP CRUD E2E test PASSED ✓");
622 }
623
624 #[test]
627 fn test_parse_ftp_list_line_unix_dir() {
628 let line = "drwxr-xr-x 2 user group 4096 Jan 15 12:00 mydir";
629 let entry = parse_ftp_list_line(line).expect("should parse");
630 assert_eq!(entry.name, "mydir");
631 assert!(matches!(entry.file_type, FileEntryType::Directory));
632 assert_eq!(entry.size, 4096);
633 assert_eq!(entry.permissions.as_deref(), Some("drwxr-xr-x"));
634 }
635
636 #[test]
637 fn test_parse_ftp_list_line_unix_file() {
638 let line = "-rw-r--r-- 1 user group 12345 Feb 28 09:30 report.pdf";
639 let entry = parse_ftp_list_line(line).expect("should parse");
640 assert_eq!(entry.name, "report.pdf");
641 assert!(matches!(entry.file_type, FileEntryType::File));
642 assert_eq!(entry.size, 12345);
643 }
644
645 #[test]
646 fn test_parse_ftp_list_line_symlink() {
647 let line = "lrwxrwxrwx 1 user group 10 Mar 01 00:00 link -> target";
648 let entry = parse_ftp_list_line(line).expect("should parse");
649 assert_eq!(entry.name, "link");
650 assert!(matches!(entry.file_type, FileEntryType::Symlink));
651 }
652
653 #[test]
654 fn test_parse_ftp_list_line_name_with_spaces() {
655 let line = "-rw-r--r-- 1 user group 100 Dec 25 23:59 my file name.txt";
656 let entry = parse_ftp_list_line(line).expect("should parse");
657 assert_eq!(entry.name, "my file name.txt");
658 }
659
660 #[test]
661 fn test_parse_ftp_list_line_empty() {
662 assert!(parse_ftp_list_line("").is_none());
663 assert!(parse_ftp_list_line(" ").is_none());
664 }
665
666 #[test]
667 fn test_parse_ftp_list_line_dot_entries() {
668 let line = "drwxr-xr-x 2 user group 4096 Jan 01 00:00 .";
670 let entry = parse_ftp_list_line(line).expect("should parse");
671 assert_eq!(entry.name, ".");
672 }
673
674 #[test]
677 fn test_ftp_config_deserialization() {
678 let json = r#"{"host":"192.168.1.1","port":21,"username":"user","password":"pass","ftps_enabled":false,"anonymous":false}"#;
679 let config: FtpConfig = serde_json::from_str(json).unwrap();
680 assert_eq!(config.host, "192.168.1.1");
681 assert_eq!(config.port, 21);
682 assert_eq!(config.username, "user");
683 assert_eq!(config.password, "pass");
684 assert!(!config.ftps_enabled);
685 assert!(!config.anonymous);
686 }
687
688 #[test]
689 fn test_ftp_config_anonymous() {
690 let json = r#"{"host":"ftp.example.com","port":21,"username":"","password":"","ftps_enabled":false,"anonymous":true}"#;
691 let config: FtpConfig = serde_json::from_str(json).unwrap();
692 assert!(config.anonymous);
693 }
694
695 #[test]
696 fn test_ftp_config_ftps_enabled() {
697 let json = r#"{"host":"secure.example.com","port":990,"username":"admin","password":"secret","ftps_enabled":true,"anonymous":false}"#;
698 let config: FtpConfig = serde_json::from_str(json).unwrap();
699 assert!(config.ftps_enabled);
700 assert_eq!(config.port, 990);
701 }
702
703 #[test]
704 fn test_parse_ftp_list_large_file_size() {
705 let line = "-rw-r--r-- 1 user group 9999999999 Dec 31 23:59 huge.iso";
706 let entry = parse_ftp_list_line(line).expect("should parse");
707 assert_eq!(entry.name, "huge.iso");
708 assert_eq!(entry.size, 9999999999);
709 }
710
711 #[test]
712 fn test_parse_ftp_list_zero_size() {
713 let line = "-rw-r--r-- 1 user group 0 Apr 01 00:00 empty.txt";
714 let entry = parse_ftp_list_line(line).expect("should parse");
715 assert_eq!(entry.size, 0);
716 }
717}