Skip to main content

ssh_commander_core/
ftp_client.rs

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/// Configuration for an FTP/FTPS connection.
11#[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    /// Explicit opt-in to skip TLS certificate validation for FTPS. Defaults to
20    /// `false` so an untrusted/self-signed cert aborts the handshake. The
21    /// frontend must set this to `true` *with the user's informed consent*.
22    #[serde(default)]
23    pub allow_invalid_certs: bool,
24}
25
26/// Wrapper enum to handle both plain and TLS FTP streams.
27enum FtpStreamKind {
28    Plain(suppaftp::AsyncFtpStream),
29    Secure(suppaftp::AsyncNativeTlsFtpStream),
30}
31
32/// Dispatch a method call to whichever stream variant is active.
33macro_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
46/// FTP/FTPS client using `suppaftp` with async support.
47pub struct FtpClient {
48    stream: Option<FtpStreamKind>,
49}
50
51impl FtpClient {
52    /// Connect to an FTP server, authenticate, and switch to binary transfer mode.
53    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        // Use async_std timeout since suppaftp uses async_std internally
64        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        // Authenticate
127        {
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        // Set binary transfer type
144        {
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    /// Test-only hook used by integration tests to assert lifecycle state.
180    /// Production code dispatches via `ConnectionManager`, not via a direct
181    /// handle, so this is kept behind `#[cfg(test)]` to avoid dead-code noise.
182    #[cfg(test)]
183    pub fn is_connected(&self) -> bool {
184        self.stream.is_some()
185    }
186
187    // ===== File Operations =====
188
189    /// List directory contents at `path`.
190    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        // Sort: directories first, then by name
208        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    /// Download a remote file to a local path. Streams in 32 KiB chunks —
220    /// never buffers the entire file in memory. Returns bytes downloaded.
221    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    /// Upload a local file to a remote path. Streams in 32 KiB chunks —
258    /// never buffers the entire file in memory. Returns bytes uploaded.
259    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    /// Create a directory on the remote server.
293    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    /// Rename a file or directory.
303    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    /// Delete a file on the remote server.
313    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    /// Delete a directory on the remote server.
323    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
333/// Parse a single line from the FTP LIST command (Unix format).
334/// Example: `drwxr-xr-x   2 user group  4096 Jan 01 12:00 dirname`
335fn parse_ftp_list_line(line: &str) -> Option<FileEntry> {
336    let line = line.trim();
337    if line.is_empty() {
338        return None;
339    }
340
341    // Unix-style listing
342    let parts: Vec<&str> = line.splitn(9, char::is_whitespace).collect();
343    if parts.len() < 9 {
344        // Try to at least get the name
345        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    // Filter out empty parts from whitespace splitting
370    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    // Name is everything after the 8th token (handles spaces in names)
391    let name = tokens[8..].join(" ");
392    // For symlinks, strip the " -> target" part from the name
393    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// =============================================================================
412// Integration tests — require a live FTP server
413//
414// The tests are gated behind the FTP_TEST_HOST env var so they are skipped
415// in CI / normal `cargo test` runs.
416// =============================================================================
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    /// Helper – read env vars or skip the test.
422    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    // ---- 1. Connect & disconnect -----------------------------------------
442
443    #[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    // ---- 2. Connect with wrong credentials --------------------------------
464
465    #[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    // ---- 3. List root directory -------------------------------------------
488
489    #[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        // Root should be listable (may be empty on fresh server)
504
505        client.disconnect().await.ok();
506    }
507
508    // ---- 4. Full CRUD cycle: mkdir → upload → list → download → rename → delete
509
510    #[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        // --- Clean up from any previous failed run ---
524        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        // 4a. Create directory
529        client
530            .create_dir(test_dir)
531            .await
532            .expect("create_dir should succeed");
533        eprintln!("Created directory: {}", test_dir);
534
535        // 4b. Upload a file
536        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        // 4c. List directory — should contain our file
550        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        // 4d. Download the file and verify contents
560        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        // 4e. Rename the file
577        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        // Verify rename: old name gone, new name present
584        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        // 4f. Delete the file
595        client
596            .delete_file(&renamed_file_remote)
597            .await
598            .expect("delete_file should succeed");
599        eprintln!("Deleted {}", renamed_file_remote);
600
601        // 4g. Delete the directory
602        client
603            .delete_dir(test_dir)
604            .await
605            .expect("delete_dir should succeed");
606        eprintln!("Deleted directory {}", test_dir);
607
608        // Verify cleanup
609        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        // Cleanup temp files
617        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    // ---- 5. Parse FTP LIST line -------------------------------------------
625
626    #[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        // These are filtered out in list_dir, but the parser itself should parse them
669        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    // ---- Task 5.4: Additional unit tests ----
675
676    #[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}