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 {
347 crate::snippet::shell_escape(path)
348}
349
350pub fn get_remote_home(
352 alias: &str,
353 config_path: &Path,
354 askpass: Option<&str>,
355 bw_session: Option<&str>,
356 has_active_tunnel: bool,
357) -> anyhow::Result<String> {
358 let result = crate::snippet::run_snippet(
359 alias,
360 config_path,
361 "pwd",
362 askpass,
363 bw_session,
364 true,
365 has_active_tunnel,
366 )?;
367 if result.status.success() {
368 Ok(result.stdout.trim().to_string())
369 } else {
370 let msg = filter_ssh_warnings(result.stderr.trim());
371 if msg.is_empty() {
372 anyhow::bail!("Failed to connect.")
373 } else {
374 anyhow::bail!("{}", msg)
375 }
376 }
377}
378
379#[allow(clippy::too_many_arguments)]
381pub fn fetch_remote_listing(
382 alias: &str,
383 config_path: &Path,
384 remote_path: &str,
385 show_hidden: bool,
386 sort: BrowserSort,
387 askpass: Option<&str>,
388 bw_session: Option<&str>,
389 has_tunnel: bool,
390) -> Result<Vec<FileEntry>, String> {
391 let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
392 let result = crate::snippet::run_snippet(
393 alias,
394 config_path,
395 &command,
396 askpass,
397 bw_session,
398 true,
399 has_tunnel,
400 );
401 match result {
402 Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
403 Ok(r) => {
404 let msg = filter_ssh_warnings(r.stderr.trim());
405 if msg.is_empty() {
406 Err(format!("ls exited with code {}.", r.status.code().unwrap_or(1)))
407 } else {
408 Err(msg)
409 }
410 }
411 Err(e) => Err(e.to_string()),
412 }
413}
414
415#[allow(clippy::too_many_arguments)]
418pub fn spawn_remote_listing<F>(
419 alias: String,
420 config_path: PathBuf,
421 remote_path: String,
422 show_hidden: bool,
423 sort: BrowserSort,
424 askpass: Option<String>,
425 bw_session: Option<String>,
426 has_tunnel: bool,
427 send: F,
428) where
429 F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
430{
431 std::thread::spawn(move || {
432 let listing = fetch_remote_listing(
433 &alias,
434 &config_path,
435 &remote_path,
436 show_hidden,
437 sort,
438 askpass.as_deref(),
439 bw_session.as_deref(),
440 has_tunnel,
441 );
442 send(alias, remote_path, listing);
443 });
444}
445
446pub struct ScpResult {
448 pub status: ExitStatus,
449 pub stderr_output: String,
450}
451
452pub fn run_scp(
458 alias: &str,
459 config_path: &Path,
460 askpass: Option<&str>,
461 bw_session: Option<&str>,
462 has_active_tunnel: bool,
463 scp_args: &[String],
464) -> anyhow::Result<ScpResult> {
465 let mut cmd = Command::new("scp");
466 cmd.arg("-F").arg(config_path);
467
468 if has_active_tunnel {
469 cmd.arg("-o").arg("ClearAllForwardings=yes");
470 }
471
472 for arg in scp_args {
473 cmd.arg(arg);
474 }
475
476 cmd.stdin(Stdio::null())
477 .stdout(Stdio::null())
478 .stderr(Stdio::piped());
479
480 if askpass.is_some() {
481 let exe = std::env::current_exe()
482 .ok()
483 .map(|p| p.to_string_lossy().to_string())
484 .or_else(|| std::env::args().next())
485 .unwrap_or_else(|| "purple".to_string());
486 cmd.env("SSH_ASKPASS", &exe)
487 .env("SSH_ASKPASS_REQUIRE", "prefer")
488 .env("PURPLE_ASKPASS_MODE", "1")
489 .env("PURPLE_HOST_ALIAS", alias)
490 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
491 }
492
493 if let Some(token) = bw_session {
494 cmd.env("BW_SESSION", token);
495 }
496
497 let output = cmd
498 .output()
499 .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
500
501 let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
502
503 Ok(ScpResult { status: output.status, stderr_output })
504}
505
506pub fn filter_ssh_warnings(stderr: &str) -> String {
509 stderr
510 .lines()
511 .filter(|line| {
512 let trimmed = line.trim();
513 !trimmed.is_empty()
514 && !trimmed.starts_with("** ")
515 && !trimmed.starts_with("Warning:")
516 && !trimmed.contains("see https://")
517 && !trimmed.contains("See https://")
518 && !trimmed.starts_with("The server may need")
519 && !trimmed.starts_with("This session may be")
520 })
521 .collect::<Vec<_>>()
522 .join("\n")
523}
524
525pub fn build_scp_args(
533 alias: &str,
534 source_pane: BrowserPane,
535 local_path: &Path,
536 remote_path: &str,
537 filenames: &[String],
538 has_dirs: bool,
539) -> Vec<String> {
540 let mut args = Vec::new();
541 if has_dirs {
542 args.push("-r".to_string());
543 }
544 args.push("--".to_string());
545
546 match source_pane {
547 BrowserPane::Local => {
549 for name in filenames {
550 args.push(local_path.join(name).to_string_lossy().to_string());
551 }
552 let dest = format!("{}:{}", alias, remote_path);
553 args.push(dest);
554 }
555 BrowserPane::Remote => {
557 let base = remote_path.trim_end_matches('/');
558 for name in filenames {
559 let rpath = format!("{}/{}", base, name);
560 args.push(format!("{}:{}", alias, rpath));
561 }
562 args.push(local_path.to_string_lossy().to_string());
563 }
564 }
565 args
566}
567
568pub fn format_size(bytes: u64) -> String {
570 if bytes >= 1024 * 1024 * 1024 {
571 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
572 } else if bytes >= 1024 * 1024 {
573 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
574 } else if bytes >= 1024 {
575 format!("{:.1} KB", bytes as f64 / 1024.0)
576 } else {
577 format!("{} B", bytes)
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
590 fn test_shell_escape_simple() {
591 assert_eq!(shell_escape("/home/user"), "'/home/user'");
592 }
593
594 #[test]
595 fn test_shell_escape_with_single_quote() {
596 assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
597 }
598
599 #[test]
600 fn test_shell_escape_with_spaces() {
601 assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
602 }
603
604 #[test]
609 fn test_parse_ls_basic() {
610 let output = "\
611total 24
612drwxr-xr-x 2 user user 4096 Jan 1 12:00 subdir
613-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
614-rw-r--r-- 1 user user 1.1K Jan 1 12:00 big.log
615";
616 let entries = parse_ls_output(output, true, BrowserSort::Name);
617 assert_eq!(entries.len(), 3);
618 assert_eq!(entries[0].name, "subdir");
619 assert!(entries[0].is_dir);
620 assert_eq!(entries[0].size, None);
621 assert_eq!(entries[1].name, "big.log");
623 assert!(!entries[1].is_dir);
624 assert_eq!(entries[1].size, Some(1126)); assert_eq!(entries[2].name, "file.txt");
626 assert!(!entries[2].is_dir);
627 assert_eq!(entries[2].size, Some(512));
628 }
629
630 #[test]
631 fn test_parse_ls_hidden_filter() {
632 let output = "\
633total 8
634-rw-r--r-- 1 user user 100 Jan 1 12:00 .hidden
635-rw-r--r-- 1 user user 200 Jan 1 12:00 visible
636";
637 let entries = parse_ls_output(output, false, BrowserSort::Name);
638 assert_eq!(entries.len(), 1);
639 assert_eq!(entries[0].name, "visible");
640
641 let entries = parse_ls_output(output, true, BrowserSort::Name);
642 assert_eq!(entries.len(), 2);
643 }
644
645 #[test]
646 fn test_parse_ls_symlink_to_file_dereferenced() {
647 let output = "\
649total 4
650-rw-r--r-- 1 user user 11 Jan 1 12:00 link
651";
652 let entries = parse_ls_output(output, true, BrowserSort::Name);
653 assert_eq!(entries.len(), 1);
654 assert_eq!(entries[0].name, "link");
655 assert!(!entries[0].is_dir);
656 }
657
658 #[test]
659 fn test_parse_ls_symlink_to_dir_dereferenced() {
660 let output = "\
662total 4
663drwxr-xr-x 3 user user 4096 Jan 1 12:00 link
664";
665 let entries = parse_ls_output(output, true, BrowserSort::Name);
666 assert_eq!(entries.len(), 1);
667 assert_eq!(entries[0].name, "link");
668 assert!(entries[0].is_dir);
669 }
670
671 #[test]
672 fn test_parse_ls_filename_with_spaces() {
673 let output = "\
674total 4
675-rw-r--r-- 1 user user 100 Jan 1 12:00 my file name.txt
676";
677 let entries = parse_ls_output(output, true, BrowserSort::Name);
678 assert_eq!(entries.len(), 1);
679 assert_eq!(entries[0].name, "my file name.txt");
680 }
681
682 #[test]
683 fn test_parse_ls_empty() {
684 let output = "total 0\n";
685 let entries = parse_ls_output(output, true, BrowserSort::Name);
686 assert!(entries.is_empty());
687 }
688
689 #[test]
694 fn test_parse_human_size() {
695 assert_eq!(parse_human_size("512"), 512);
696 assert_eq!(parse_human_size("1.0K"), 1024);
697 assert_eq!(parse_human_size("1.5M"), 1572864);
698 assert_eq!(parse_human_size("2.0G"), 2147483648);
699 }
700
701 #[test]
706 fn test_format_size() {
707 assert_eq!(format_size(0), "0 B");
708 assert_eq!(format_size(512), "512 B");
709 assert_eq!(format_size(1024), "1.0 KB");
710 assert_eq!(format_size(1536), "1.5 KB");
711 assert_eq!(format_size(1048576), "1.0 MB");
712 assert_eq!(format_size(1073741824), "1.0 GB");
713 }
714
715 #[test]
720 fn test_build_scp_args_upload() {
721 let args = build_scp_args(
722 "myhost",
723 BrowserPane::Local,
724 Path::new("/home/user/docs"),
725 "/remote/path/",
726 &["file.txt".to_string()],
727 false,
728 );
729 assert_eq!(args, vec![
730 "--",
731 "/home/user/docs/file.txt",
732 "myhost:/remote/path/",
733 ]);
734 }
735
736 #[test]
737 fn test_build_scp_args_download() {
738 let args = build_scp_args(
739 "myhost",
740 BrowserPane::Remote,
741 Path::new("/home/user/docs"),
742 "/remote/path",
743 &["file.txt".to_string()],
744 false,
745 );
746 assert_eq!(args, vec![
747 "--",
748 "myhost:/remote/path/file.txt",
749 "/home/user/docs",
750 ]);
751 }
752
753 #[test]
754 fn test_build_scp_args_spaces_in_path() {
755 let args = build_scp_args(
756 "myhost",
757 BrowserPane::Remote,
758 Path::new("/local"),
759 "/remote/my path",
760 &["my file.txt".to_string()],
761 false,
762 );
763 assert_eq!(args, vec![
765 "--",
766 "myhost:/remote/my path/my file.txt",
767 "/local",
768 ]);
769 }
770
771 #[test]
772 fn test_build_scp_args_with_dirs() {
773 let args = build_scp_args(
774 "myhost",
775 BrowserPane::Local,
776 Path::new("/local"),
777 "/remote/",
778 &["mydir".to_string()],
779 true,
780 );
781 assert_eq!(args[0], "-r");
782 }
783
784 #[test]
789 fn test_list_local_sorts_dirs_first() {
790 let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
791 let _ = std::fs::remove_dir_all(&base);
792 std::fs::create_dir_all(&base).unwrap();
793 std::fs::create_dir(base.join("zdir")).unwrap();
794 std::fs::write(base.join("afile.txt"), "hello").unwrap();
795 std::fs::write(base.join("bfile.txt"), "world").unwrap();
796
797 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
798 assert_eq!(entries.len(), 3);
799 assert!(entries[0].is_dir);
800 assert_eq!(entries[0].name, "zdir");
801 assert_eq!(entries[1].name, "afile.txt");
802 assert_eq!(entries[2].name, "bfile.txt");
803
804 let _ = std::fs::remove_dir_all(&base);
805 }
806
807 #[test]
808 fn test_list_local_hidden() {
809 let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
810 let _ = std::fs::remove_dir_all(&base);
811 std::fs::create_dir_all(&base).unwrap();
812 std::fs::write(base.join(".hidden"), "").unwrap();
813 std::fs::write(base.join("visible"), "").unwrap();
814
815 let entries = list_local(&base, false, BrowserSort::Name).unwrap();
816 assert_eq!(entries.len(), 1);
817 assert_eq!(entries[0].name, "visible");
818
819 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
820 assert_eq!(entries.len(), 2);
821
822 let _ = std::fs::remove_dir_all(&base);
823 }
824
825 #[test]
830 fn test_filter_ssh_warnings_filters_warnings() {
831 let stderr = "\
832** WARNING: connection is not using a post-quantum key exchange algorithm.
833** This session may be vulnerable to \"store now, decrypt later\" attacks.
834** The server may need to be upgraded. See https://openssh.com/pq.html
835scp: '/root/file.rpm': No such file or directory";
836 assert_eq!(
837 filter_ssh_warnings(stderr),
838 "scp: '/root/file.rpm': No such file or directory"
839 );
840 }
841
842 #[test]
843 fn test_filter_ssh_warnings_keeps_plain_error() {
844 let stderr = "scp: /etc/shadow: Permission denied\n";
845 assert_eq!(filter_ssh_warnings(stderr), "scp: /etc/shadow: Permission denied");
846 }
847
848 #[test]
849 fn test_filter_ssh_warnings_empty() {
850 assert_eq!(filter_ssh_warnings(""), "");
851 assert_eq!(filter_ssh_warnings(" \n \n"), "");
852 }
853
854 #[test]
855 fn test_filter_ssh_warnings_warning_prefix() {
856 let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
857 assert_eq!(filter_ssh_warnings(stderr), "Permission denied (publickey).");
858 }
859
860 #[test]
861 fn test_filter_ssh_warnings_lowercase_see_https() {
862 let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
863 assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
864 }
865
866 #[test]
867 fn test_filter_ssh_warnings_only_warnings() {
868 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";
869 assert_eq!(filter_ssh_warnings(stderr), "");
870 }
871
872 #[test]
877 fn test_approximate_epoch_known_dates() {
878 let ts = approximate_epoch(2024, 0, 1, 0, 0);
880 assert_eq!(ts, 1704067200);
881 let ts = approximate_epoch(2000, 0, 1, 0, 0);
883 assert_eq!(ts, 946684800);
884 assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
886 }
887
888 #[test]
889 fn test_approximate_epoch_leap_year() {
890 let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
892 let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
893 assert_eq!(mar01 - feb29, 86400);
894 }
895
896 #[test]
901 fn test_epoch_to_year() {
902 assert_eq!(epoch_to_year(0), 1970);
903 assert_eq!(epoch_to_year(1672531200), 2023);
905 assert_eq!(epoch_to_year(1704067200), 2024);
907 assert_eq!(epoch_to_year(1735689599), 2024);
909 assert_eq!(epoch_to_year(1735689600), 2025);
911 }
912
913 #[test]
918 fn test_parse_ls_date_recent_format() {
919 let ts = parse_ls_date("Jan", "15", "12:34");
921 assert!(ts.is_some());
922 let ts = ts.unwrap();
923 let now = std::time::SystemTime::now()
925 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
926 assert!(ts <= now + 86400);
927 assert!(ts > now - 366 * 86400);
928 }
929
930 #[test]
931 fn test_parse_ls_date_old_format() {
932 let ts = parse_ls_date("Mar", "5", "2023");
933 assert!(ts.is_some());
934 let ts = ts.unwrap();
935 assert_eq!(epoch_to_year(ts), 2023);
937 }
938
939 #[test]
940 fn test_parse_ls_date_invalid_month() {
941 assert!(parse_ls_date("Foo", "1", "12:00").is_none());
942 }
943
944 #[test]
945 fn test_parse_ls_date_invalid_day() {
946 assert!(parse_ls_date("Jan", "0", "12:00").is_none());
947 assert!(parse_ls_date("Jan", "32", "12:00").is_none());
948 }
949
950 #[test]
951 fn test_parse_ls_date_invalid_year() {
952 assert!(parse_ls_date("Jan", "1", "1969").is_none());
953 }
954
955 #[test]
960 fn test_format_relative_time_ranges() {
961 let now = std::time::SystemTime::now()
962 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
963 assert_eq!(format_relative_time(now), "just now");
964 assert_eq!(format_relative_time(now - 30), "just now");
965 assert_eq!(format_relative_time(now - 120), "2m ago");
966 assert_eq!(format_relative_time(now - 7200), "2h ago");
967 assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
968 }
969
970 #[test]
971 fn test_format_relative_time_old_date() {
972 let old = approximate_epoch(2020, 5, 15, 0, 0);
974 let result = format_relative_time(old);
975 assert!(result.contains("2020"), "Expected year in '{}' for old date", result);
976 }
977
978 #[test]
979 fn test_format_relative_time_future() {
980 let now = std::time::SystemTime::now()
981 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
982 let result = format_relative_time(now + 86400 * 30);
984 assert!(!result.is_empty());
985 }
986
987 #[test]
992 fn test_format_short_date_different_year() {
993 let ts = approximate_epoch(2020, 2, 15, 0, 0); let result = format_short_date(ts);
995 assert!(result.contains("2020"), "Expected year in '{}'", result);
996 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
997 }
998
999 #[test]
1000 fn test_format_short_date_leap_year() {
1001 let ts = approximate_epoch(2024, 2, 1, 0, 0);
1003 let result = format_short_date(ts);
1004 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1005 assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1006 let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1008 let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1009 let feb29_date = format_short_date(feb29);
1010 let mar01_date = format_short_date(mar01);
1011 assert!(feb29_date.starts_with("Feb"), "Expected Feb in '{}'", feb29_date);
1012 assert!(mar01_date.starts_with("Mar"), "Expected Mar in '{}'", mar01_date);
1013 }
1014
1015 #[test]
1020 fn test_sort_entries_date_dirs_first_newest_first() {
1021 let mut entries = vec![
1022 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1023 FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1024 FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1025 FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1026 ];
1027 sort_entries(&mut entries, BrowserSort::Date);
1028 assert!(entries[0].is_dir);
1029 assert_eq!(entries[0].name, "adir");
1030 assert_eq!(entries[1].name, "new.txt");
1031 assert_eq!(entries[2].name, "mid.txt");
1032 assert_eq!(entries[3].name, "old.txt");
1033 }
1034
1035 #[test]
1036 fn test_sort_entries_name_mode() {
1037 let mut entries = vec![
1038 FileEntry { name: "zebra.txt".into(), is_dir: false, size: Some(100), modified: Some(3000) },
1039 FileEntry { name: "alpha.txt".into(), is_dir: false, size: Some(200), modified: Some(1000) },
1040 FileEntry { name: "mydir".into(), is_dir: true, size: None, modified: Some(2000) },
1041 ];
1042 sort_entries(&mut entries, BrowserSort::Name);
1043 assert!(entries[0].is_dir);
1044 assert_eq!(entries[1].name, "alpha.txt");
1045 assert_eq!(entries[2].name, "zebra.txt");
1046 }
1047
1048 #[test]
1053 fn test_parse_ls_output_populates_modified() {
1054 let output = "\
1055total 4
1056-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
1057";
1058 let entries = parse_ls_output(output, true, BrowserSort::Name);
1059 assert_eq!(entries.len(), 1);
1060 assert!(entries[0].modified.is_some(), "modified should be populated");
1061 }
1062
1063 #[test]
1064 fn test_parse_ls_output_date_sort() {
1065 let output = "\
1067total 12
1068-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1069-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1070-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1071";
1072 let entries = parse_ls_output(output, true, BrowserSort::Date);
1073 assert_eq!(entries.len(), 3);
1074 assert_eq!(entries[0].name, "new.txt");
1076 assert_eq!(entries[1].name, "mid.txt");
1077 assert_eq!(entries[2].name, "old.txt");
1078 }
1079
1080 #[test]
1085 fn test_list_local_populates_modified() {
1086 let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1087 let _ = std::fs::remove_dir_all(&base);
1088 std::fs::create_dir_all(&base).unwrap();
1089 std::fs::write(base.join("test.txt"), "hello").unwrap();
1090
1091 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1092 assert_eq!(entries.len(), 1);
1093 assert!(entries[0].modified.is_some(), "modified should be populated for local files");
1094
1095 let _ = std::fs::remove_dir_all(&base);
1096 }
1097
1098 #[test]
1103 fn test_epoch_to_year_2100_boundary() {
1104 let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1105 assert_eq!(epoch_to_year(ts_2100), 2100);
1106 assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1107 let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1108 assert_eq!(epoch_to_year(mid_2100), 2100);
1109 }
1110
1111 #[test]
1116 fn test_parse_ls_date_midnight() {
1117 let ts = parse_ls_date("Jan", "1", "00:00");
1118 assert!(ts.is_some(), "00:00 should parse successfully");
1119 let ts = ts.unwrap();
1120 let now = std::time::SystemTime::now()
1121 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1122 assert!(ts <= now + 86400);
1123 assert!(ts > now - 366 * 86400);
1124 }
1125
1126 #[test]
1131 fn test_sort_entries_date_with_none_modified() {
1132 let mut entries = vec![
1133 FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1134 FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1135 FileEntry { name: "recent.txt".into(), is_dir: false, size: Some(300), modified: Some(9000) },
1136 ];
1137 sort_entries(&mut entries, BrowserSort::Date);
1138 assert_eq!(entries[0].name, "recent.txt");
1139 assert_eq!(entries[1].name, "known.txt");
1140 assert_eq!(entries[2].name, "unknown.txt");
1141 }
1142
1143 #[test]
1144 fn test_sort_entries_date_asc_oldest_first() {
1145 let mut entries = vec![
1146 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(100), modified: Some(1000) },
1147 FileEntry { name: "new.txt".into(), is_dir: false, size: Some(200), modified: Some(3000) },
1148 FileEntry { name: "mid.txt".into(), is_dir: false, size: Some(150), modified: Some(2000) },
1149 FileEntry { name: "adir".into(), is_dir: true, size: None, modified: Some(500) },
1150 ];
1151 sort_entries(&mut entries, BrowserSort::DateAsc);
1152 assert!(entries[0].is_dir);
1153 assert_eq!(entries[0].name, "adir");
1154 assert_eq!(entries[1].name, "old.txt");
1155 assert_eq!(entries[2].name, "mid.txt");
1156 assert_eq!(entries[3].name, "new.txt");
1157 }
1158
1159 #[test]
1160 fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1161 let mut entries = vec![
1162 FileEntry { name: "known.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1163 FileEntry { name: "unknown.txt".into(), is_dir: false, size: Some(200), modified: None },
1164 FileEntry { name: "old.txt".into(), is_dir: false, size: Some(300), modified: Some(1000) },
1165 ];
1166 sort_entries(&mut entries, BrowserSort::DateAsc);
1167 assert_eq!(entries[0].name, "old.txt");
1168 assert_eq!(entries[1].name, "known.txt");
1169 assert_eq!(entries[2].name, "unknown.txt"); }
1171
1172 #[test]
1173 fn test_parse_ls_output_date_asc_sort() {
1174 let output = "\
1175total 12
1176-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1177-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1178-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1179";
1180 let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1181 assert_eq!(entries.len(), 3);
1182 assert_eq!(entries[0].name, "old.txt");
1184 assert_eq!(entries[1].name, "mid.txt");
1185 assert_eq!(entries[2].name, "new.txt");
1186 }
1187
1188 #[test]
1189 fn test_sort_entries_date_multiple_dirs() {
1190 let mut entries = vec![
1191 FileEntry { name: "old_dir".into(), is_dir: true, size: None, modified: Some(1000) },
1192 FileEntry { name: "new_dir".into(), is_dir: true, size: None, modified: Some(3000) },
1193 FileEntry { name: "mid_dir".into(), is_dir: true, size: None, modified: Some(2000) },
1194 FileEntry { name: "file.txt".into(), is_dir: false, size: Some(100), modified: Some(5000) },
1195 ];
1196 sort_entries(&mut entries, BrowserSort::Date);
1197 assert!(entries[0].is_dir);
1198 assert_eq!(entries[0].name, "new_dir");
1199 assert_eq!(entries[1].name, "mid_dir");
1200 assert_eq!(entries[2].name, "old_dir");
1201 assert_eq!(entries[3].name, "file.txt");
1202 }
1203
1204 #[test]
1209 fn test_format_relative_time_exactly_60s() {
1210 let now = std::time::SystemTime::now()
1211 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
1212 assert_eq!(format_relative_time(now - 60), "1m ago");
1213 assert_eq!(format_relative_time(now - 59), "just now");
1214 }
1215
1216 #[test]
1221 fn test_parse_ls_output_date_sort_with_dirs() {
1222 let output = "\
1223total 16
1224drwxr-xr-x 2 user user 4096 Jan 1 2020 old_dir
1225-rw-r--r-- 1 user user 200 Jun 15 2023 new_file.txt
1226drwxr-xr-x 2 user user 4096 Dec 1 2023 new_dir
1227-rw-r--r-- 1 user user 100 Mar 5 2022 old_file.txt
1228";
1229 let entries = parse_ls_output(output, true, BrowserSort::Date);
1230 assert_eq!(entries.len(), 4);
1231 assert!(entries[0].is_dir);
1232 assert_eq!(entries[0].name, "new_dir");
1233 assert!(entries[1].is_dir);
1234 assert_eq!(entries[1].name, "old_dir");
1235 assert_eq!(entries[2].name, "new_file.txt");
1236 assert_eq!(entries[3].name, "old_file.txt");
1237 }
1238}