aria2_protocol/ftp/
listing.rs1use std::time::SystemTime;
7
8#[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 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 pub fn is_parent_reference(&self) -> bool {
32 self.name == "." || self.name == ".."
33 }
34}
35
36pub 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 if line.len() < 15 {
52 return None;
53 }
54
55 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 let rest = line[10..].trim_start();
66
67 let fields: Vec<&str> = rest.split_whitespace().collect();
70
71 if fields.len() < 8 {
73 return None;
74 }
75
76 let size: u64 = fields[3].parse().unwrap_or(0);
78
79 let _month = fields[4];
82 let _day = fields[5];
83 let _time_or_year = fields[6];
84
85 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, })
99}
100
101pub 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 let parts: Vec<&str> = line.split_whitespace().collect();
119
120 if parts.len() < 4 {
122 return None;
123 }
124
125 let date_part = parts[0];
127 if !validate_msdos_date(date_part) {
128 return None;
129 }
130
131 let time_part = parts[1];
133 if !validate_msdos_time(time_part) {
134 return None;
135 }
136
137 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 let name = parts[3..].join(" ");
150
151 Some(ListingEntry {
152 name,
153 size,
154 is_directory,
155 permissions: String::new(), modified_time: None,
157 })
158}
159
160pub 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 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 if !e.is_parent_reference() {
183 entries.push(e);
184 }
185 }
186 }
187
188 entries
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193enum ListingFormat {
194 Unix,
195 MsDos,
196}
197
198fn detect_listing_format(lines: &[&str]) -> ListingFormat {
200 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
202 let trimmed = line.trim();
203
204 if trimmed.len() >= 10 && is_valid_unix_permissions(&trimmed[..10]) {
206 return ListingFormat::Unix;
207 }
208
209 if trimmed.len() >= 8 && validate_msdos_date(&trimmed[..8]) {
211 return ListingFormat::MsDos;
212 }
213 }
214
215 ListingFormat::Unix
217}
218
219fn is_valid_unix_permissions(perm_str: &str) -> bool {
221 if perm_str.len() < 10 {
222 return false;
223 }
224
225 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 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
242fn 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 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, _ => return false,
268 };
269
270 let _ = month; true
273}
274
275fn validate_msdos_time(time: &str) -> bool {
277 if time.len() < 6 {
279 return false;
280 }
281
282 if !time.ends_with("AM") && !time.ends_with("PM") {
284 return false;
285 }
286
287 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 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; 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); 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 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")); assert!(is_valid_unix_permissions("-rw-r--r-T")); 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")); assert!(!validate_msdos_date("00-15-24")); assert!(!validate_msdos_date("01-32-24")); }
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")); assert!(!validate_msdos_time("25:70AM")); assert!(!validate_msdos_time("1230PM")); }
518}