1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10 Name,
11 Date,
12 DateAsc,
13}
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18 pub name: String,
19 pub is_dir: bool,
20 pub size: Option<u64>,
21 pub modified: Option<i64>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28 Local,
29 Remote,
30}
31
32pub struct CopyRequest {
34 pub sources: Vec<String>,
35 pub source_pane: BrowserPane,
36 pub has_dirs: bool,
37}
38
39pub struct FileBrowserState {
41 pub alias: String,
42 pub askpass: Option<String>,
43 pub active_pane: BrowserPane,
44 pub local_path: PathBuf,
46 pub local_entries: Vec<FileEntry>,
47 pub local_list_state: ListState,
48 pub local_selected: HashSet<String>,
49 pub local_error: Option<String>,
50 pub remote_path: String,
52 pub remote_entries: Vec<FileEntry>,
53 pub remote_list_state: ListState,
54 pub remote_selected: HashSet<String>,
55 pub remote_error: Option<String>,
56 pub remote_loading: bool,
57 pub show_hidden: bool,
59 pub sort: BrowserSort,
60 pub confirm_copy: Option<CopyRequest>,
62 pub transferring: Option<String>,
64 pub transfer_error: Option<String>,
66 pub connection_recorded: bool,
68}
69
70pub fn list_local(path: &Path, show_hidden: bool, sort: BrowserSort) -> anyhow::Result<Vec<FileEntry>> {
73 let mut entries = Vec::new();
74 for entry in std::fs::read_dir(path)? {
75 let entry = entry?;
76 let name = entry.file_name().to_string_lossy().to_string();
77 if !show_hidden && name.starts_with('.') {
78 continue;
79 }
80 let metadata = entry.metadata()?;
81 let is_dir = metadata.is_dir();
82 let size = if is_dir { None } else { Some(metadata.len()) };
83 let modified = metadata.modified().ok().and_then(|t| {
84 t.duration_since(std::time::UNIX_EPOCH)
85 .ok()
86 .map(|d| d.as_secs() as i64)
87 });
88 entries.push(FileEntry { name, is_dir, size, modified });
89 }
90 sort_entries(&mut entries, sort);
91 Ok(entries)
92}
93
94pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
96 match sort {
97 BrowserSort::Name => {
98 entries.sort_by(|a, b| {
99 b.is_dir.cmp(&a.is_dir).then_with(|| {
100 a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
101 })
102 });
103 }
104 BrowserSort::Date => {
105 entries.sort_by(|a, b| {
106 b.is_dir.cmp(&a.is_dir).then_with(|| {
107 b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
109 })
110 });
111 }
112 BrowserSort::DateAsc => {
113 entries.sort_by(|a, b| {
114 b.is_dir.cmp(&a.is_dir).then_with(|| {
115 a.modified.unwrap_or(i64::MAX).cmp(&b.modified.unwrap_or(i64::MAX))
117 })
118 });
119 }
120 }
121}
122
123pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
128 let mut entries = Vec::new();
129 for line in output.lines() {
130 let line = line.trim();
131 if line.is_empty() || line.starts_with("total ") {
132 continue;
133 }
134 let mut parts: Vec<&str> = Vec::with_capacity(9);
137 let mut rest = line;
138 for _ in 0..8 {
139 rest = rest.trim_start();
140 if rest.is_empty() {
141 break;
142 }
143 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
144 parts.push(&rest[..end]);
145 rest = &rest[end..];
146 }
147 rest = rest.trim_start();
148 if !rest.is_empty() {
149 parts.push(rest);
150 }
151 if parts.len() < 9 {
152 continue;
153 }
154 let permissions = parts[0];
155 let is_dir = permissions.starts_with('d');
156 let name = parts[8];
157 if name.is_empty() {
159 continue;
160 }
161 if !show_hidden && name.starts_with('.') {
162 continue;
163 }
164 let size = if is_dir {
166 None
167 } else {
168 Some(parse_human_size(parts[4]))
169 };
170 let modified = parse_ls_date(parts[5], parts[6], parts[7]);
172 entries.push(FileEntry {
173 name: name.to_string(),
174 is_dir,
175 size,
176 modified,
177 });
178 }
179 sort_entries(&mut entries, sort);
180 entries
181}
182
183fn parse_human_size(s: &str) -> u64 {
185 let s = s.trim();
186 if s.is_empty() {
187 return 0;
188 }
189 let last = s.as_bytes()[s.len() - 1];
190 let multiplier = match last {
191 b'K' => 1024,
192 b'M' => 1024 * 1024,
193 b'G' => 1024 * 1024 * 1024,
194 b'T' => 1024u64 * 1024 * 1024 * 1024,
195 _ => 1,
196 };
197 let num_str = if multiplier > 1 {
198 &s[..s.len() - 1]
199 } else {
200 s
201 };
202 let num: f64 = num_str.parse().unwrap_or(0.0);
203 (num * multiplier as f64) as u64
204}
205
206fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
211 let month = match month_str {
212 "Jan" => 0, "Feb" => 1, "Mar" => 2, "Apr" => 3,
213 "May" => 4, "Jun" => 5, "Jul" => 6, "Aug" => 7,
214 "Sep" => 8, "Oct" => 9, "Nov" => 10, "Dec" => 11,
215 _ => return None,
216 };
217 let day: i64 = day_str.parse().ok()?;
218 if !(1..=31).contains(&day) {
219 return None;
220 }
221
222 let now = std::time::SystemTime::now()
223 .duration_since(std::time::UNIX_EPOCH)
224 .unwrap_or_default()
225 .as_secs() as i64;
226 let now_year = epoch_to_year(now);
227
228 if time_or_year.contains(':') {
229 let mut parts = time_or_year.splitn(2, ':');
231 let hour: i64 = parts.next()?.parse().ok()?;
232 let min: i64 = parts.next()?.parse().ok()?;
233 let mut year = now_year;
235 let approx = approximate_epoch(year, month, day, hour, min);
236 if approx > now + 86400 {
237 year -= 1;
238 }
239 Some(approximate_epoch(year, month, day, hour, min))
240 } else {
241 let year: i64 = time_or_year.parse().ok()?;
243 if !(1970..=2100).contains(&year) {
244 return None;
245 }
246 Some(approximate_epoch(year, month, day, 0, 0))
247 }
248}
249
250fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
252 let y = year - 1970;
254 let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
257 days += month_days[month as usize];
258 if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
260 days += 1;
261 }
262 days += day - 1;
263 days * 86400 + hour * 3600 + min * 60
264}
265
266fn epoch_to_year(ts: i64) -> i64 {
268 let mut y = 1970 + ts / 31_557_600;
269 if approximate_epoch(y, 0, 1, 0, 0) > ts {
270 y -= 1;
271 } else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
272 y += 1;
273 }
274 y
275}
276
277fn is_leap_year(year: i64) -> bool {
278 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
279}
280
281pub fn format_relative_time(ts: i64) -> String {
284 let now = std::time::SystemTime::now()
285 .duration_since(std::time::UNIX_EPOCH)
286 .unwrap_or_default()
287 .as_secs() as i64;
288 let diff = now - ts;
289 if diff < 0 {
290 return format_short_date(ts);
292 }
293 if diff < 60 {
294 return "just now".to_string();
295 }
296 if diff < 3600 {
297 return format!("{}m ago", diff / 60);
298 }
299 if diff < 86400 {
300 return format!("{}h ago", diff / 3600);
301 }
302 if diff < 86400 * 30 {
303 return format!("{}d ago", diff / 86400);
304 }
305 format_short_date(ts)
306}
307
308fn format_short_date(ts: i64) -> String {
310 let now = std::time::SystemTime::now()
311 .duration_since(std::time::UNIX_EPOCH)
312 .unwrap_or_default()
313 .as_secs() as i64;
314 let now_year = epoch_to_year(now);
315 let ts_year = epoch_to_year(ts);
316
317 let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
318 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
319
320 let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
322 let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
323 let feb = if is_leap_year(ts_year) { 29 } else { 28 };
324 let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
325 let mut m = 0;
326 let mut remaining = day_of_year;
327 for (i, &len) in month_lengths.iter().enumerate() {
328 if remaining < len {
329 m = i;
330 break;
331 }
332 remaining -= len;
333 m = i + 1;
334 }
335 let m = m.min(11);
336 let d = remaining + 1;
337
338 if ts_year == now_year {
339 format!("{} {:>2}", months[m], d)
340 } else {
341 format!("{} {}", months[m], ts_year)
342 }
343}
344
345fn shell_escape(path: &str) -> String {
348 format!("'{}'", path.replace('\'', "'\\''"))
349}
350
351pub fn get_remote_home(
353 alias: &str,
354 config_path: &Path,
355 askpass: Option<&str>,
356 bw_session: Option<&str>,
357 has_active_tunnel: bool,
358) -> anyhow::Result<String> {
359 let result = crate::snippet::run_snippet(
360 alias,
361 config_path,
362 "pwd",
363 askpass,
364 bw_session,
365 true,
366 has_active_tunnel,
367 )?;
368 if result.status.success() {
369 Ok(result.stdout.trim().to_string())
370 } else {
371 let msg = filter_ssh_warnings(result.stderr.trim());
372 if msg.is_empty() {
373 anyhow::bail!("Failed to connect.")
374 } else {
375 anyhow::bail!("{}", msg)
376 }
377 }
378}
379
380#[allow(clippy::too_many_arguments)]
382pub fn fetch_remote_listing(
383 alias: &str,
384 config_path: &Path,
385 remote_path: &str,
386 show_hidden: bool,
387 sort: BrowserSort,
388 askpass: Option<&str>,
389 bw_session: Option<&str>,
390 has_tunnel: bool,
391) -> Result<Vec<FileEntry>, String> {
392 let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
393 let result = crate::snippet::run_snippet(
394 alias,
395 config_path,
396 &command,
397 askpass,
398 bw_session,
399 true,
400 has_tunnel,
401 );
402 match result {
403 Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
404 Ok(r) => {
405 let msg = filter_ssh_warnings(r.stderr.trim());
406 if msg.is_empty() {
407 Err(format!("ls exited with code {}.", r.status.code().unwrap_or(1)))
408 } else {
409 Err(msg)
410 }
411 }
412 Err(e) => Err(e.to_string()),
413 }
414}
415
416#[allow(clippy::too_many_arguments)]
419pub fn spawn_remote_listing<F>(
420 alias: String,
421 config_path: PathBuf,
422 remote_path: String,
423 show_hidden: bool,
424 sort: BrowserSort,
425 askpass: Option<String>,
426 bw_session: Option<String>,
427 has_tunnel: bool,
428 send: F,
429) where
430 F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
431{
432 std::thread::spawn(move || {
433 let listing = fetch_remote_listing(
434 &alias,
435 &config_path,
436 &remote_path,
437 show_hidden,
438 sort,
439 askpass.as_deref(),
440 bw_session.as_deref(),
441 has_tunnel,
442 );
443 send(alias, remote_path, listing);
444 });
445}
446
447pub struct ScpResult {
449 pub status: ExitStatus,
450 pub stderr_output: String,
451}
452
453pub fn run_scp(
459 alias: &str,
460 config_path: &Path,
461 askpass: Option<&str>,
462 bw_session: Option<&str>,
463 has_active_tunnel: bool,
464 scp_args: &[String],
465) -> anyhow::Result<ScpResult> {
466 let mut cmd = Command::new("scp");
467 cmd.arg("-F").arg(config_path);
468
469 if has_active_tunnel {
470 cmd.arg("-o").arg("ClearAllForwardings=yes");
471 }
472
473 for arg in scp_args {
474 cmd.arg(arg);
475 }
476
477 cmd.stdin(Stdio::null())
478 .stdout(Stdio::null())
479 .stderr(Stdio::piped());
480
481 if askpass.is_some() {
482 let exe = std::env::current_exe()
483 .ok()
484 .map(|p| p.to_string_lossy().to_string())
485 .or_else(|| std::env::args().next())
486 .unwrap_or_else(|| "purple".to_string());
487 cmd.env("SSH_ASKPASS", &exe)
488 .env("SSH_ASKPASS_REQUIRE", "prefer")
489 .env("PURPLE_ASKPASS_MODE", "1")
490 .env("PURPLE_HOST_ALIAS", alias)
491 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
492 }
493
494 if let Some(token) = bw_session {
495 cmd.env("BW_SESSION", token);
496 }
497
498 let output = cmd
499 .output()
500 .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
501
502 let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
503
504 Ok(ScpResult { status: output.status, stderr_output })
505}
506
507pub fn filter_ssh_warnings(stderr: &str) -> String {
510 stderr
511 .lines()
512 .filter(|line| {
513 let trimmed = line.trim();
514 !trimmed.is_empty()
515 && !trimmed.starts_with("** ")
516 && !trimmed.starts_with("Warning:")
517 && !trimmed.contains("see https://")
518 && !trimmed.contains("See https://")
519 && !trimmed.starts_with("The server may need")
520 && !trimmed.starts_with("This session may be")
521 })
522 .collect::<Vec<_>>()
523 .join("\n")
524}
525
526pub fn build_scp_args(
534 alias: &str,
535 source_pane: BrowserPane,
536 local_path: &Path,
537 remote_path: &str,
538 filenames: &[String],
539 has_dirs: bool,
540) -> Vec<String> {
541 let mut args = Vec::new();
542 if has_dirs {
543 args.push("-r".to_string());
544 }
545 args.push("--".to_string());
546
547 match source_pane {
548 BrowserPane::Local => {
550 for name in filenames {
551 args.push(local_path.join(name).to_string_lossy().to_string());
552 }
553 let dest = format!("{}:{}", alias, remote_path);
554 args.push(dest);
555 }
556 BrowserPane::Remote => {
558 let base = remote_path.trim_end_matches('/');
559 for name in filenames {
560 let rpath = format!("{}/{}", base, name);
561 args.push(format!("{}:{}", alias, rpath));
562 }
563 args.push(local_path.to_string_lossy().to_string());
564 }
565 }
566 args
567}
568
569pub fn format_size(bytes: u64) -> String {
571 if bytes >= 1024 * 1024 * 1024 {
572 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
573 } else if bytes >= 1024 * 1024 {
574 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
575 } else if bytes >= 1024 {
576 format!("{:.1} KB", bytes as f64 / 1024.0)
577 } else {
578 format!("{} B", bytes)
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
591 fn test_shell_escape_simple() {
592 assert_eq!(shell_escape("/home/user"), "'/home/user'");
593 }
594
595 #[test]
596 fn test_shell_escape_with_single_quote() {
597 assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
598 }
599
600 #[test]
601 fn test_shell_escape_with_spaces() {
602 assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
603 }
604
605 #[test]
610 fn test_parse_ls_basic() {
611 let output = "\
612total 24
613drwxr-xr-x 2 user user 4096 Jan 1 12:00 subdir
614-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
615-rw-r--r-- 1 user user 1.1K Jan 1 12:00 big.log
616";
617 let entries = parse_ls_output(output, true, BrowserSort::Name);
618 assert_eq!(entries.len(), 3);
619 assert_eq!(entries[0].name, "subdir");
620 assert!(entries[0].is_dir);
621 assert_eq!(entries[0].size, None);
622 assert_eq!(entries[1].name, "big.log");
624 assert!(!entries[1].is_dir);
625 assert_eq!(entries[1].size, Some(1126)); assert_eq!(entries[2].name, "file.txt");
627 assert!(!entries[2].is_dir);
628 assert_eq!(entries[2].size, Some(512));
629 }
630
631 #[test]
632 fn test_parse_ls_hidden_filter() {
633 let output = "\
634total 8
635-rw-r--r-- 1 user user 100 Jan 1 12:00 .hidden
636-rw-r--r-- 1 user user 200 Jan 1 12:00 visible
637";
638 let entries = parse_ls_output(output, false, BrowserSort::Name);
639 assert_eq!(entries.len(), 1);
640 assert_eq!(entries[0].name, "visible");
641
642 let entries = parse_ls_output(output, true, BrowserSort::Name);
643 assert_eq!(entries.len(), 2);
644 }
645
646 #[test]
647 fn test_parse_ls_symlink_to_file_dereferenced() {
648 let output = "\
650total 4
651-rw-r--r-- 1 user user 11 Jan 1 12:00 link
652";
653 let entries = parse_ls_output(output, true, BrowserSort::Name);
654 assert_eq!(entries.len(), 1);
655 assert_eq!(entries[0].name, "link");
656 assert!(!entries[0].is_dir);
657 }
658
659 #[test]
660 fn test_parse_ls_symlink_to_dir_dereferenced() {
661 let output = "\
663total 4
664drwxr-xr-x 3 user user 4096 Jan 1 12:00 link
665";
666 let entries = parse_ls_output(output, true, BrowserSort::Name);
667 assert_eq!(entries.len(), 1);
668 assert_eq!(entries[0].name, "link");
669 assert!(entries[0].is_dir);
670 }
671
672 #[test]
673 fn test_parse_ls_filename_with_spaces() {
674 let output = "\
675total 4
676-rw-r--r-- 1 user user 100 Jan 1 12:00 my file name.txt
677";
678 let entries = parse_ls_output(output, true, BrowserSort::Name);
679 assert_eq!(entries.len(), 1);
680 assert_eq!(entries[0].name, "my file name.txt");
681 }
682
683 #[test]
684 fn test_parse_ls_empty() {
685 let output = "total 0\n";
686 let entries = parse_ls_output(output, true, BrowserSort::Name);
687 assert!(entries.is_empty());
688 }
689
690 #[test]
695 fn test_parse_human_size() {
696 assert_eq!(parse_human_size("512"), 512);
697 assert_eq!(parse_human_size("1.0K"), 1024);
698 assert_eq!(parse_human_size("1.5M"), 1572864);
699 assert_eq!(parse_human_size("2.0G"), 2147483648);
700 }
701
702 #[test]
707 fn test_format_size() {
708 assert_eq!(format_size(0), "0 B");
709 assert_eq!(format_size(512), "512 B");
710 assert_eq!(format_size(1024), "1.0 KB");
711 assert_eq!(format_size(1536), "1.5 KB");
712 assert_eq!(format_size(1048576), "1.0 MB");
713 assert_eq!(format_size(1073741824), "1.0 GB");
714 }
715
716 #[test]
721 fn test_build_scp_args_upload() {
722 let args = build_scp_args(
723 "myhost",
724 BrowserPane::Local,
725 Path::new("/home/user/docs"),
726 "/remote/path/",
727 &["file.txt".to_string()],
728 false,
729 );
730 assert_eq!(args, vec![
731 "--",
732 "/home/user/docs/file.txt",
733 "myhost:/remote/path/",
734 ]);
735 }
736
737 #[test]
738 fn test_build_scp_args_download() {
739 let args = build_scp_args(
740 "myhost",
741 BrowserPane::Remote,
742 Path::new("/home/user/docs"),
743 "/remote/path",
744 &["file.txt".to_string()],
745 false,
746 );
747 assert_eq!(args, vec![
748 "--",
749 "myhost:/remote/path/file.txt",
750 "/home/user/docs",
751 ]);
752 }
753
754 #[test]
755 fn test_build_scp_args_spaces_in_path() {
756 let args = build_scp_args(
757 "myhost",
758 BrowserPane::Remote,
759 Path::new("/local"),
760 "/remote/my path",
761 &["my file.txt".to_string()],
762 false,
763 );
764 assert_eq!(args, vec![
766 "--",
767 "myhost:/remote/my path/my file.txt",
768 "/local",
769 ]);
770 }
771
772 #[test]
773 fn test_build_scp_args_with_dirs() {
774 let args = build_scp_args(
775 "myhost",
776 BrowserPane::Local,
777 Path::new("/local"),
778 "/remote/",
779 &["mydir".to_string()],
780 true,
781 );
782 assert_eq!(args[0], "-r");
783 }
784
785 #[test]
790 fn test_list_local_sorts_dirs_first() {
791 let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
792 let _ = std::fs::remove_dir_all(&base);
793 std::fs::create_dir_all(&base).unwrap();
794 std::fs::create_dir(base.join("zdir")).unwrap();
795 std::fs::write(base.join("afile.txt"), "hello").unwrap();
796 std::fs::write(base.join("bfile.txt"), "world").unwrap();
797
798 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
799 assert_eq!(entries.len(), 3);
800 assert!(entries[0].is_dir);
801 assert_eq!(entries[0].name, "zdir");
802 assert_eq!(entries[1].name, "afile.txt");
803 assert_eq!(entries[2].name, "bfile.txt");
804
805 let _ = std::fs::remove_dir_all(&base);
806 }
807
808 #[test]
809 fn test_list_local_hidden() {
810 let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
811 let _ = std::fs::remove_dir_all(&base);
812 std::fs::create_dir_all(&base).unwrap();
813 std::fs::write(base.join(".hidden"), "").unwrap();
814 std::fs::write(base.join("visible"), "").unwrap();
815
816 let entries = list_local(&base, false, BrowserSort::Name).unwrap();
817 assert_eq!(entries.len(), 1);
818 assert_eq!(entries[0].name, "visible");
819
820 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
821 assert_eq!(entries.len(), 2);
822
823 let _ = std::fs::remove_dir_all(&base);
824 }
825
826 #[test]
831 fn test_filter_ssh_warnings_filters_warnings() {
832 let stderr = "\
833** WARNING: connection is not using a post-quantum key exchange algorithm.
834** This session may be vulnerable to \"store now, decrypt later\" attacks.
835** The server may need to be upgraded. See https://openssh.com/pq.html
836scp: '/root/file.rpm': No such file or directory";
837 assert_eq!(
838 filter_ssh_warnings(stderr),
839 "scp: '/root/file.rpm': No such file or directory"
840 );
841 }
842
843 #[test]
844 fn test_filter_ssh_warnings_keeps_plain_error() {
845 let stderr = "scp: /etc/shadow: Permission denied\n";
846 assert_eq!(filter_ssh_warnings(stderr), "scp: /etc/shadow: Permission denied");
847 }
848
849 #[test]
850 fn test_filter_ssh_warnings_empty() {
851 assert_eq!(filter_ssh_warnings(""), "");
852 assert_eq!(filter_ssh_warnings(" \n \n"), "");
853 }
854
855 #[test]
856 fn test_filter_ssh_warnings_warning_prefix() {
857 let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
858 assert_eq!(filter_ssh_warnings(stderr), "Permission denied (publickey).");
859 }
860
861 #[test]
862 fn test_filter_ssh_warnings_lowercase_see_https() {
863 let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
864 assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
865 }
866
867 #[test]
868 fn test_filter_ssh_warnings_only_warnings() {
869 let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n** This session may be vulnerable to \"store now, decrypt later\" attacks.\n** The server may need to be upgraded. See https://openssh.com/pq.html";
870 assert_eq!(filter_ssh_warnings(stderr), "");
871 }
872
873 #[test]
878 fn test_approximate_epoch_known_dates() {
879 let ts = approximate_epoch(2024, 0, 1, 0, 0);
881 assert_eq!(ts, 1704067200);
882 let ts = approximate_epoch(2000, 0, 1, 0, 0);
884 assert_eq!(ts, 946684800);
885 assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
887 }
888
889 #[test]
890 fn test_approximate_epoch_leap_year() {
891 let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
893 let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
894 assert_eq!(mar01 - feb29, 86400);
895 }
896
897 #[test]
902 fn test_epoch_to_year() {
903 assert_eq!(epoch_to_year(0), 1970);
904 assert_eq!(epoch_to_year(1672531200), 2023);
906 assert_eq!(epoch_to_year(1704067200), 2024);
908 assert_eq!(epoch_to_year(1735689599), 2024);
910 assert_eq!(epoch_to_year(1735689600), 2025);
912 }
913
914 #[test]
919 fn test_parse_ls_date_recent_format() {
920 let ts = parse_ls_date("Jan", "15", "12:34");
922 assert!(ts.is_some());
923 let ts = ts.unwrap();
924 let now = std::time::SystemTime::now()
926 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
927 assert!(ts <= now + 86400);
928 assert!(ts > now - 366 * 86400);
929 }
930
931 #[test]
932 fn test_parse_ls_date_old_format() {
933 let ts = parse_ls_date("Mar", "5", "2023");
934 assert!(ts.is_some());
935 let ts = ts.unwrap();
936 assert_eq!(epoch_to_year(ts), 2023);
938 }
939
940 #[test]
941 fn test_parse_ls_date_invalid_month() {
942 assert!(parse_ls_date("Foo", "1", "12:00").is_none());
943 }
944
945 #[test]
946 fn test_parse_ls_date_invalid_day() {
947 assert!(parse_ls_date("Jan", "0", "12:00").is_none());
948 assert!(parse_ls_date("Jan", "32", "12:00").is_none());
949 }
950
951 #[test]
952 fn test_parse_ls_date_invalid_year() {
953 assert!(parse_ls_date("Jan", "1", "1969").is_none());
954 }
955
956 #[test]
961 fn test_format_relative_time_ranges() {
962 let now = std::time::SystemTime::now()
963 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
964 assert_eq!(format_relative_time(now), "just now");
965 assert_eq!(format_relative_time(now - 30), "just now");
966 assert_eq!(format_relative_time(now - 120), "2m ago");
967 assert_eq!(format_relative_time(now - 7200), "2h ago");
968 assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
969 }
970
971 #[test]
972 fn test_format_relative_time_old_date() {
973 let old = approximate_epoch(2020, 5, 15, 0, 0);
975 let result = format_relative_time(old);
976 assert!(result.contains("2020"), "Expected year in '{}' for old date", result);
977 }
978
979 #[test]
980 fn test_format_relative_time_future() {
981 let now = std::time::SystemTime::now()
982 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
983 let result = format_relative_time(now + 86400 * 30);
985 assert!(!result.is_empty());
986 }
987
988 #[test]
993 fn test_format_short_date_different_year() {
994 let ts = approximate_epoch(2020, 2, 15, 0, 0); let result = format_short_date(ts);
996 assert!(result.contains("2020"), "Expected year in '{}'", result);
997 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
998 }
999
1000 #[test]
1001 fn test_format_short_date_leap_year() {
1002 let ts = approximate_epoch(2024, 2, 1, 0, 0);
1004 let result = format_short_date(ts);
1005 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1006 assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1007 let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1009 let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1010 let feb29_date = format_short_date(feb29);
1011 let mar01_date = format_short_date(mar01);
1012 assert!(feb29_date.starts_with("Feb"), "Expected Feb in '{}'", feb29_date);
1013 assert!(mar01_date.starts_with("Mar"), "Expected Mar in '{}'", mar01_date);
1014 }
1015
1016 #[test]
1021 fn test_sort_entries_date_dirs_first_newest_first() {
1022 let mut entries = vec![
1023 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1024 FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1025 FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1026 FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1027 ];
1028 sort_entries(&mut entries, BrowserSort::Date);
1029 assert!(entries[0].is_dir);
1030 assert_eq!(entries[0].name, "adir");
1031 assert_eq!(entries[1].name, "new.txt");
1032 assert_eq!(entries[2].name, "mid.txt");
1033 assert_eq!(entries[3].name, "old.txt");
1034 }
1035
1036 #[test]
1037 fn test_sort_entries_name_mode() {
1038 let mut entries = vec![
1039 FileEntry { name: "zebra.txt".into(), is_dir: false, size: Some(100), modified: Some(3000) },
1040 FileEntry { name: "alpha.txt".into(), is_dir: false, size: Some(200), modified: Some(1000) },
1041 FileEntry { name: "mydir".into(), is_dir: true, size: None, modified: Some(2000) },
1042 ];
1043 sort_entries(&mut entries, BrowserSort::Name);
1044 assert!(entries[0].is_dir);
1045 assert_eq!(entries[1].name, "alpha.txt");
1046 assert_eq!(entries[2].name, "zebra.txt");
1047 }
1048
1049 #[test]
1054 fn test_parse_ls_output_populates_modified() {
1055 let output = "\
1056total 4
1057-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
1058";
1059 let entries = parse_ls_output(output, true, BrowserSort::Name);
1060 assert_eq!(entries.len(), 1);
1061 assert!(entries[0].modified.is_some(), "modified should be populated");
1062 }
1063
1064 #[test]
1065 fn test_parse_ls_output_date_sort() {
1066 let output = "\
1068total 12
1069-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1070-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1071-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1072";
1073 let entries = parse_ls_output(output, true, BrowserSort::Date);
1074 assert_eq!(entries.len(), 3);
1075 assert_eq!(entries[0].name, "new.txt");
1077 assert_eq!(entries[1].name, "mid.txt");
1078 assert_eq!(entries[2].name, "old.txt");
1079 }
1080
1081 #[test]
1086 fn test_list_local_populates_modified() {
1087 let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1088 let _ = std::fs::remove_dir_all(&base);
1089 std::fs::create_dir_all(&base).unwrap();
1090 std::fs::write(base.join("test.txt"), "hello").unwrap();
1091
1092 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1093 assert_eq!(entries.len(), 1);
1094 assert!(entries[0].modified.is_some(), "modified should be populated for local files");
1095
1096 let _ = std::fs::remove_dir_all(&base);
1097 }
1098
1099 #[test]
1104 fn test_epoch_to_year_2100_boundary() {
1105 let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1106 assert_eq!(epoch_to_year(ts_2100), 2100);
1107 assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1108 let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1109 assert_eq!(epoch_to_year(mid_2100), 2100);
1110 }
1111
1112 #[test]
1117 fn test_parse_ls_date_midnight() {
1118 let ts = parse_ls_date("Jan", "1", "00:00");
1119 assert!(ts.is_some(), "00:00 should parse successfully");
1120 let ts = ts.unwrap();
1121 let now = std::time::SystemTime::now()
1122 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1123 assert!(ts <= now + 86400);
1124 assert!(ts > now - 366 * 86400);
1125 }
1126
1127 #[test]
1132 fn test_sort_entries_date_with_none_modified() {
1133 let mut entries = vec![
1134 FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1135 FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1136 FileEntry { name: "recent.txt".into(), is_dir: false, size: Some(300), modified: Some(9000) },
1137 ];
1138 sort_entries(&mut entries, BrowserSort::Date);
1139 assert_eq!(entries[0].name, "recent.txt");
1140 assert_eq!(entries[1].name, "known.txt");
1141 assert_eq!(entries[2].name, "unknown.txt");
1142 }
1143
1144 #[test]
1145 fn test_sort_entries_date_asc_oldest_first() {
1146 let mut entries = vec![
1147 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1148 FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1149 FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1150 FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1151 ];
1152 sort_entries(&mut entries, BrowserSort::DateAsc);
1153 assert!(entries[0].is_dir);
1154 assert_eq!(entries[0].name, "adir");
1155 assert_eq!(entries[1].name, "old.txt");
1156 assert_eq!(entries[2].name, "mid.txt");
1157 assert_eq!(entries[3].name, "new.txt");
1158 }
1159
1160 #[test]
1161 fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1162 let mut entries = vec![
1163 FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1164 FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1165 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(300), modified: Some(1000) },
1166 ];
1167 sort_entries(&mut entries, BrowserSort::DateAsc);
1168 assert_eq!(entries[0].name, "old.txt");
1169 assert_eq!(entries[1].name, "known.txt");
1170 assert_eq!(entries[2].name, "unknown.txt"); }
1172
1173 #[test]
1174 fn test_parse_ls_output_date_asc_sort() {
1175 let output = "\
1176total 12
1177-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1178-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1179-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1180";
1181 let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1182 assert_eq!(entries.len(), 3);
1183 assert_eq!(entries[0].name, "old.txt");
1185 assert_eq!(entries[1].name, "mid.txt");
1186 assert_eq!(entries[2].name, "new.txt");
1187 }
1188
1189 #[test]
1190 fn test_sort_entries_date_multiple_dirs() {
1191 let mut entries = vec![
1192 FileEntry { name: "old_dir".into(), is_dir: true, size: None, modified: Some(1000) },
1193 FileEntry { name: "new_dir".into(), is_dir: true, size: None, modified: Some(3000) },
1194 FileEntry { name: "mid_dir".into(), is_dir: true, size: None, modified: Some(2000) },
1195 FileEntry { name: "file.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1196 ];
1197 sort_entries(&mut entries, BrowserSort::Date);
1198 assert!(entries[0].is_dir);
1199 assert_eq!(entries[0].name, "new_dir");
1200 assert_eq!(entries[1].name, "mid_dir");
1201 assert_eq!(entries[2].name, "old_dir");
1202 assert_eq!(entries[3].name, "file.txt");
1203 }
1204
1205 #[test]
1210 fn test_format_relative_time_exactly_60s() {
1211 let now = std::time::SystemTime::now()
1212 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1213 assert_eq!(format_relative_time(now - 60), "1m ago");
1214 assert_eq!(format_relative_time(now - 59), "just now");
1215 }
1216
1217 #[test]
1222 fn test_parse_ls_output_date_sort_with_dirs() {
1223 let output = "\
1224total 16
1225drwxr-xr-x 2 user user 4096 Jan 1 2020 old_dir
1226-rw-r--r-- 1 user user 200 Jun 15 2023 new_file.txt
1227drwxr-xr-x 2 user user 4096 Dec 1 2023 new_dir
1228-rw-r--r-- 1 user user 100 Mar 5 2022 old_file.txt
1229";
1230 let entries = parse_ls_output(output, true, BrowserSort::Date);
1231 assert_eq!(entries.len(), 4);
1232 assert!(entries[0].is_dir);
1233 assert_eq!(entries[0].name, "new_dir");
1234 assert!(entries[1].is_dir);
1235 assert_eq!(entries[1].name, "old_dir");
1236 assert_eq!(entries[2].name, "new_file.txt");
1237 assert_eq!(entries[3].name, "old_file.txt");
1238 }
1239}