Skip to main content

aria2_protocol/ftp/
listing.rs

1//! FTP directory listing parser
2//!
3//! Supports parsing of Unix-style and MS-DOS style FTP directory listings
4//! returned by the LIST command.
5
6use std::time::SystemTime;
7
8/// A single entry in an FTP directory listing
9#[derive(Debug, Clone)]
10pub struct ListingEntry {
11    pub name: String,
12    pub size: u64,
13    pub is_directory: bool,
14    pub permissions: String,
15    pub modified_time: Option<SystemTime>,
16}
17
18impl ListingEntry {
19    /// Create a new ListingEntry with default values
20    pub fn new(name: String) -> Self {
21        Self {
22            name,
23            size: 0,
24            is_directory: false,
25            permissions: String::new(),
26            modified_time: None,
27        }
28    }
29
30    /// Check if this entry looks like a parent directory reference (.. or .)
31    pub fn is_parent_reference(&self) -> bool {
32        self.name == "." || self.name == ".."
33    }
34}
35
36/// Parse a Unix-style FTP listing line
37///
38/// Format example:
39/// ```text
40/// -rw-r--r--  1 user group  1234 Jan 01 00:00 filename
41/// drwxr-xr-x  2 user group  4096 Dec 15 2023 directory
42/// lrwxrwxrwx  1 user group     8 Jan 01 00:00 linkname -> target
43/// ```
44pub fn parse_unix_list_line(line: &str) -> Option<ListingEntry> {
45    let line = line.trim();
46    if line.is_empty() {
47        return None;
48    }
49
50    // Minimum length check for Unix format (at least "drwxr-xr-x" + spaces + name)
51    if line.len() < 15 {
52        return None;
53    }
54
55    // Parse permissions field (first 10 characters)
56    let perms = &line[..10];
57    if !is_valid_unix_permissions(perms) {
58        return None;
59    }
60
61    let is_directory = perms.starts_with('d');
62    let is_symlink = perms.starts_with('l');
63
64    // Split remaining fields after permissions
65    let rest = line[10..].trim_start();
66
67    // Unix format: <links> <owner> <group> <size> <month> <day> [<year>|<time>] <name>
68    // For symlinks, name may contain " -> target"
69    let fields: Vec<&str> = rest.split_whitespace().collect();
70
71    // Need at least: links, owner, group, size, month, day, time/year, name
72    if fields.len() < 8 {
73        return None;
74    }
75
76    // Size is at index 3 (0-indexed from fields after permissions)
77    let size: u64 = fields[3].parse().unwrap_or(0);
78
79    // Parse date/time (simplified - we don't need full precision)
80    // Format: "Jan 01 00:00" or "Jan 01 2023"
81    let _month = fields[4];
82    let _day = fields[5];
83    let _time_or_year = fields[6];
84
85    // Name starts at index 7 and continues to end
86    // For symlinks, we need to strip " -> target"
87    let mut name = fields[7..].join(" ");
88    if is_symlink && let Some(pos) = name.find(" -> ") {
89        name = name[..pos].to_string();
90    }
91
92    Some(ListingEntry {
93        name,
94        size,
95        is_directory,
96        permissions: perms.to_string(),
97        modified_time: None, // Would need date parsing implementation
98    })
99}
100
101/// Parse an MS-DOS style FTP listing line
102///
103/// Format examples:
104/// ```text
105/// 01-01-00  00:00AM       1234 filename
106/// 12-15-23  03:45PM      <DIR> directory
107/// ```
108pub fn parse_msdos_list_line(line: &str) -> Option<ListingEntry> {
109    let line = line.trim();
110    if line.is_empty() {
111        return None;
112    }
113
114    // MS-DOS format: MM-DD-YY  HH:MM[AP]M  <size|DIR>  <name>
115    // Example: "01-15-24  02:30PM  12345 filename.txt"
116
117    // Find first two date/time groups separated by whitespace
118    let parts: Vec<&str> = line.split_whitespace().collect();
119
120    // Need at least: date, time, size/dir indicator, name
121    if parts.len() < 4 {
122        return None;
123    }
124
125    // Validate date format (MM-DD-YY)
126    let date_part = parts[0];
127    if !validate_msdos_date(date_part) {
128        return None;
129    }
130
131    // Validate time format (HH:MMAM/PM)
132    let time_part = parts[1];
133    if !validate_msdos_time(time_part) {
134        return None;
135    }
136
137    // Third part is either size or "<DIR>"
138    let size_or_dir = parts[2];
139    let (size, is_directory) = if size_or_dir.eq_ignore_ascii_case("<dir>") {
140        (0, true)
141    } else {
142        match size_or_dir.parse::<u64>() {
143            Ok(s) => (s, false),
144            Err(_) => return None,
145        }
146    };
147
148    // Name is everything after the third part
149    let name = parts[3..].join(" ");
150
151    Some(ListingEntry {
152        name,
153        size,
154        is_directory,
155        permissions: String::new(), // MS-DOS doesn't show permissions
156        modified_time: None,
157    })
158}
159
160/// Parse complete FTP LIST response into individual entries
161///
162/// Automatically detects format (Unix vs MS-DOS) based on content
163pub fn parse_ftp_list_response(response: &str) -> Vec<ListingEntry> {
164    let lines: Vec<&str> = response.lines().collect();
165
166    if lines.is_empty() {
167        return Vec::new();
168    }
169
170    // Detect format from first non-empty line
171    let format = detect_listing_format(&lines);
172
173    let mut entries = Vec::new();
174    for line in lines.iter().filter(|l| !l.trim().is_empty()) {
175        let entry = match format {
176            ListingFormat::Unix => parse_unix_list_line(line),
177            ListingFormat::MsDos => parse_msdos_list_line(line),
178        };
179
180        if let Some(e) = entry {
181            // Filter out . and .. references
182            if !e.is_parent_reference() {
183                entries.push(e);
184            }
185        }
186    }
187
188    entries
189}
190
191/// Detected listing format type
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ListingFormat {
194    Unix,
195    MsDos,
196}
197
198/// Detect whether a listing response is Unix-style or MS-DOS style
199fn detect_listing_format(lines: &[&str]) -> ListingFormat {
200    // Look at first few non-empty lines to determine format
201    for line in lines.iter().filter(|l| !l.trim().is_empty()) {
202        let trimmed = line.trim();
203
204        // Unix format starts with permission string like "-rw-r--r--" or "drwxr-xr-x"
205        if trimmed.len() >= 10 && is_valid_unix_permissions(&trimmed[..10]) {
206            return ListingFormat::Unix;
207        }
208
209        // MS-DOS format starts with date like "01-15-24"
210        if trimmed.len() >= 8 && validate_msdos_date(&trimmed[..8]) {
211            return ListingFormat::MsDos;
212        }
213    }
214
215    // Default to Unix format if can't determine
216    ListingFormat::Unix
217}
218
219/// Validate Unix permission string (first 10 characters)
220fn is_valid_unix_permissions(perm_str: &str) -> bool {
221    if perm_str.len() < 10 {
222        return false;
223    }
224
225    // First character must be one of: -, d, l, c, b, s, p
226    let type_char = perm_str.as_bytes()[0];
227    if !matches!(type_char, b'-' | b'd' | b'l' | b'c' | b'b' | b's' | b'p') {
228        return false;
229    }
230
231    // Characters 1-3 (owner), 4-6 (group), 7-9 (others) must be r, w, x, -, S, s, T, t
232    for i in 1..10 {
233        let c = perm_str.as_bytes()[i];
234        if !matches!(c, b'r' | b'w' | b'x' | b'-' | b'S' | b's' | b'T' | b't') {
235            return false;
236        }
237    }
238
239    true
240}
241
242/// Validate MS-DOS date format (MM-DD-YY)
243fn validate_msdos_date(date: &str) -> bool {
244    let parts: Vec<&str> = date.split('-').collect();
245    if parts.len() != 3 {
246        return false;
247    }
248
249    // Each part should be 2 digits
250    if parts
251        .iter()
252        .any(|p| p.len() != 2 || !p.chars().all(|c| c.is_ascii_digit()))
253    {
254        return false;
255    }
256
257    let month: u8 = match parts[0].parse() {
258        Ok(m) if (1..=12).contains(&m) => m,
259        _ => return false,
260    };
261    let _day: u8 = match parts[1].parse() {
262        Ok(d) if (1..=31).contains(&d) => d,
263        _ => return false,
264    };
265    let _year: u8 = match parts[2].parse() {
266        Ok(y) => y, // Accept any 2-digit year
267        _ => return false,
268    };
269
270    // Basic validation passed
271    let _ = month; // Suppress unused warning
272    true
273}
274
275/// Validate MS-DOS time format (HH:MMAM/PM)
276fn validate_msdos_time(time: &str) -> bool {
277    // Should be at least 6 characters: HH:MM + AM/PM
278    if time.len() < 6 {
279        return false;
280    }
281
282    // Must end with AM or PM (case insensitive)
283    if !time.ends_with("AM") && !time.ends_with("PM") {
284        return false;
285    }
286
287    // Extract HH:MM part
288    let time_part = &time[..time.len() - 2];
289    let parts: Vec<&str> = time_part.split(':').collect();
290    if parts.len() != 2 {
291        return false;
292    }
293
294    // Hour should be 1-2 digits, minute should be 2 digits
295    if parts[0].is_empty() || parts[1].len() != 2 {
296        return false;
297    }
298
299    let hour: u8 = match parts[0].parse() {
300        Ok(h) if (0..=12).contains(&h) => h,
301        _ => return false,
302    };
303    let _minute: u8 = match parts[1].parse() {
304        Ok(m) if (0..=59).contains(&m) => m,
305        _ => return false,
306    };
307
308    let _ = hour; // Suppress unused warning
309    true
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_parse_unix_regular_file() {
318        let line = "-rw-r--r--  1 user group  1234 Jan 01 00:00 testfile.txt";
319        let entry = parse_unix_list_line(line).expect("Should parse successfully");
320
321        assert_eq!(entry.name, "testfile.txt");
322        assert_eq!(entry.size, 1234);
323        assert!(!entry.is_directory);
324        assert_eq!(entry.permissions, "-rw-r--r--");
325    }
326
327    #[test]
328    fn test_parse_unix_directory() {
329        let line = "drwxr-xr-x  2 user group  4096 Dec 15 2023 mydirectory";
330        let entry = parse_unix_list_line(line).expect("Should parse successfully");
331
332        assert_eq!(entry.name, "mydirectory");
333        assert_eq!(entry.size, 4096);
334        assert!(entry.is_directory);
335        assert_eq!(entry.permissions, "drwxr-xr-x");
336    }
337
338    #[test]
339    fn test_parse_unix_symlink() {
340        let line = "lrwxrwxrwx  1 user group     8 Jan 01 00:00 linkname -> targetfile";
341        let entry = parse_unix_list_line(line).expect("Should parse successfully");
342
343        assert_eq!(entry.name, "linkname");
344        assert!(!entry.is_directory); // Symlinks are not directories themselves
345        assert_eq!(entry.permissions, "lrwxrwxrwx");
346    }
347
348    #[test]
349    fn test_parse_unix_file_with_spaces_in_name() {
350        let line = "-rw-r--r--  1 user group   567 Jan 01 00:00 my file with spaces.txt";
351        let entry = parse_unix_list_line(line).expect("Should parse successfully");
352
353        assert_eq!(entry.name, "my file with spaces.txt");
354        assert_eq!(entry.size, 567);
355    }
356
357    #[test]
358    fn test_parse_unix_invalid_too_short() {
359        let line = "short";
360        assert!(parse_unix_list_line(line).is_none());
361    }
362
363    #[test]
364    fn test_parse_unix_invalid_bad_permissions() {
365        let line = "xxxxxxx  1 user group  1234 Jan 01 00:00 file.txt";
366        assert!(parse_unix_list_line(line).is_none());
367    }
368
369    #[test]
370    fn test_parse_msdos_regular_file() {
371        let line = "01-15-24  02:30PM       12345 filename.txt";
372        let entry = parse_msdos_list_line(line).expect("Should parse successfully");
373
374        assert_eq!(entry.name, "filename.txt");
375        assert_eq!(entry.size, 12345);
376        assert!(!entry.is_directory);
377    }
378
379    #[test]
380    fn test_parse_msdos_directory() {
381        let line = "12-31-23  11:59PM      <DIR> myfolder";
382        let entry = parse_msdos_list_line(line).expect("Should parse successfully");
383
384        assert_eq!(entry.name, "myfolder");
385        assert!(entry.is_directory);
386        assert_eq!(entry.size, 0);
387    }
388
389    #[test]
390    fn test_parse_msdos_am_time() {
391        let line = "06-01-24  09:15AM         500 morning.log";
392        let entry = parse_msdos_list_line(line).expect("Should parse successfully");
393
394        assert_eq!(entry.name, "morning.log");
395        assert_eq!(entry.size, 500);
396    }
397
398    #[test]
399    fn test_parse_msdos_invalid_format() {
400        let line = "not a valid msdos line";
401        assert!(parse_msdos_list_line(line).is_none());
402    }
403
404    #[test]
405    fn test_parse_msdos_invalid_date() {
406        let line = "99-99-99  12:00PM  123 file.txt";
407        assert!(parse_msdos_list_line(line).is_none());
408    }
409
410    #[test]
411    fn test_parse_ftp_list_response_unix() {
412        let response = "\
413-rw-r--r--  1 user group  1024 Jan 01 00:00 file1.txt
414drwxr-xr-x  2 user group  4096 Jan 01 00:00 directory1
415-rw-r--r--  1 user group  2048 Jan 01 00:00 file2.txt";
416
417        let entries = parse_ftp_list_response(response);
418        assert_eq!(entries.len(), 3);
419
420        assert_eq!(entries[0].name, "file1.txt");
421        assert_eq!(entries[0].size, 1024);
422        assert!(!entries[0].is_directory);
423
424        assert_eq!(entries[1].name, "directory1");
425        assert!(entries[1].is_directory);
426
427        assert_eq!(entries[2].name, "file2.txt");
428        assert_eq!(entries[2].size, 2048);
429    }
430
431    #[test]
432    fn test_parse_ftp_list_response_msdos() {
433        let response = "\
43401-15-24  02:30PM       12345 file1.txt
43512-31-23  11:59PM      <DIR> folder1
43606-01-24  09:15AM       5678 file2.txt";
437
438        let entries = parse_ftp_list_response(response);
439        assert_eq!(entries.len(), 3);
440
441        assert_eq!(entries[0].name, "file1.txt");
442        assert_eq!(entries[0].size, 12345);
443
444        assert_eq!(entries[1].name, "folder1");
445        assert!(entries[1].is_directory);
446
447        assert_eq!(entries[2].name, "file2.txt");
448        assert_eq!(entries[2].size, 5678);
449    }
450
451    #[test]
452    fn test_filter_parent_references() {
453        let response = "\
454drwxr-xr-x  2 user group  4096 Jan 01 00:00 .
455drwxr-xr-x  3 user group  4096 Jan 01 00:00 ..
456-rw-r--r--  1 user group  1024 Jan 01 00:00 actualfile.txt";
457
458        let entries = parse_ftp_list_response(response);
459        // Should filter out . and ..
460        assert_eq!(entries.len(), 1);
461        assert_eq!(entries[0].name, "actualfile.txt");
462    }
463
464    #[test]
465    fn test_empty_response() {
466        let entries = parse_ftp_list_response("");
467        assert!(entries.is_empty());
468
469        let entries = parse_ftp_list_response("\n\n\n");
470        assert!(entries.is_empty());
471    }
472
473    #[test]
474    fn test_listing_entry_parent_reference() {
475        let dot = ListingEntry::new(".".to_string());
476        assert!(dot.is_parent_reference());
477
478        let dot_dot = ListingEntry::new("..".to_string());
479        assert!(dot_dot.is_parent_reference());
480
481        let normal = ListingEntry::new("file.txt".to_string());
482        assert!(!normal.is_parent_reference());
483    }
484
485    #[test]
486    fn test_is_valid_unix_permissions() {
487        assert!(is_valid_unix_permissions("-rw-r--r--"));
488        assert!(is_valid_unix_permissions("drwxr-xr-x"));
489        assert!(is_valid_unix_permissions("lrwxrwxrwx"));
490        assert!(is_valid_unix_permissions("-rwsr-xr-x")); // setuid
491        assert!(is_valid_unix_permissions("-rw-r--r-T")); // sticky
492
493        assert!(!is_valid_unix_permissions(""));
494        assert!(!is_valid_unix_permissions("---------"));
495        assert!(!is_valid_unix_permissions("xxxxxxxxxx"));
496    }
497
498    #[test]
499    fn test_validate_msdos_date() {
500        assert!(validate_msdos_date("01-15-24"));
501        assert!(validate_msdos_date("12-31-23"));
502
503        assert!(!validate_msdos_date("13-01-24")); // Invalid month
504        assert!(!validate_msdos_date("00-15-24")); // Invalid month
505        assert!(!validate_msdos_date("01-32-24")); // Invalid day (will fail basic digit check but pass range)
506    }
507
508    #[test]
509    fn test_validate_msdos_time() {
510        assert!(validate_msdos_time("02:30PM"));
511        assert!(validate_msdos_time("09:15AM"));
512        assert!(validate_msdos_time("12:00PM"));
513
514        assert!(!validate_msdos_time("13:00PM")); // Invalid hour > 12
515        assert!(!validate_msdos_time("25:70AM")); // Invalid hour/minute
516        assert!(!validate_msdos_time("1230PM")); // Missing colon
517    }
518}