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(
73 path: &Path,
74 show_hidden: bool,
75 sort: BrowserSort,
76) -> anyhow::Result<Vec<FileEntry>> {
77 let mut entries = Vec::new();
78 for entry in std::fs::read_dir(path)? {
79 let entry = entry?;
80 let name = entry.file_name().to_string_lossy().to_string();
81 if !show_hidden && name.starts_with('.') {
82 continue;
83 }
84 let metadata = entry.metadata()?;
85 let is_dir = metadata.is_dir();
86 let size = if is_dir { None } else { Some(metadata.len()) };
87 let modified = metadata.modified().ok().and_then(|t| {
88 t.duration_since(std::time::UNIX_EPOCH)
89 .ok()
90 .map(|d| d.as_secs() as i64)
91 });
92 entries.push(FileEntry {
93 name,
94 is_dir,
95 size,
96 modified,
97 });
98 }
99 sort_entries(&mut entries, sort);
100 Ok(entries)
101}
102
103pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
105 match sort {
106 BrowserSort::Name => {
107 entries.sort_by(|a, b| {
108 b.is_dir.cmp(&a.is_dir).then_with(|| {
109 a.name
110 .to_ascii_lowercase()
111 .cmp(&b.name.to_ascii_lowercase())
112 })
113 });
114 }
115 BrowserSort::Date => {
116 entries.sort_by(|a, b| {
117 b.is_dir.cmp(&a.is_dir).then_with(|| {
118 b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
120 })
121 });
122 }
123 BrowserSort::DateAsc => {
124 entries.sort_by(|a, b| {
125 b.is_dir.cmp(&a.is_dir).then_with(|| {
126 a.modified
128 .unwrap_or(i64::MAX)
129 .cmp(&b.modified.unwrap_or(i64::MAX))
130 })
131 });
132 }
133 }
134}
135
136pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
141 let mut entries = Vec::new();
142 for line in output.lines() {
143 let line = line.trim();
144 if line.is_empty() || line.starts_with("total ") {
145 continue;
146 }
147 let mut parts: Vec<&str> = Vec::with_capacity(9);
150 let mut rest = line;
151 for _ in 0..8 {
152 rest = rest.trim_start();
153 if rest.is_empty() {
154 break;
155 }
156 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
157 parts.push(&rest[..end]);
158 rest = &rest[end..];
159 }
160 rest = rest.trim_start();
161 if !rest.is_empty() {
162 parts.push(rest);
163 }
164 if parts.len() < 9 {
165 continue;
166 }
167 let permissions = parts[0];
168 let is_dir = permissions.starts_with('d');
169 let name = parts[8];
170 if name.is_empty() {
172 continue;
173 }
174 if !show_hidden && name.starts_with('.') {
175 continue;
176 }
177 let size = if is_dir {
179 None
180 } else {
181 Some(parse_human_size(parts[4]))
182 };
183 let modified = parse_ls_date(parts[5], parts[6], parts[7]);
185 entries.push(FileEntry {
186 name: name.to_string(),
187 is_dir,
188 size,
189 modified,
190 });
191 }
192 sort_entries(&mut entries, sort);
193 entries
194}
195
196fn parse_human_size(s: &str) -> u64 {
198 let s = s.trim();
199 if s.is_empty() {
200 return 0;
201 }
202 let last = s.as_bytes()[s.len() - 1];
203 let multiplier = match last {
204 b'K' => 1024,
205 b'M' => 1024 * 1024,
206 b'G' => 1024 * 1024 * 1024,
207 b'T' => 1024u64 * 1024 * 1024 * 1024,
208 _ => 1,
209 };
210 let num_str = if multiplier > 1 { &s[..s.len() - 1] } else { s };
211 let num: f64 = num_str.parse().unwrap_or(0.0);
212 (num * multiplier as f64) as u64
213}
214
215fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
220 let month = match month_str {
221 "Jan" => 0,
222 "Feb" => 1,
223 "Mar" => 2,
224 "Apr" => 3,
225 "May" => 4,
226 "Jun" => 5,
227 "Jul" => 6,
228 "Aug" => 7,
229 "Sep" => 8,
230 "Oct" => 9,
231 "Nov" => 10,
232 "Dec" => 11,
233 _ => return None,
234 };
235 let day: i64 = day_str.parse().ok()?;
236 if !(1..=31).contains(&day) {
237 return None;
238 }
239
240 let now = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .unwrap_or_default()
243 .as_secs() as i64;
244 let now_year = epoch_to_year(now);
245
246 if time_or_year.contains(':') {
247 let mut parts = time_or_year.splitn(2, ':');
249 let hour: i64 = parts.next()?.parse().ok()?;
250 let min: i64 = parts.next()?.parse().ok()?;
251 let mut year = now_year;
253 let approx = approximate_epoch(year, month, day, hour, min);
254 if approx > now + 86400 {
255 year -= 1;
256 }
257 Some(approximate_epoch(year, month, day, hour, min))
258 } else {
259 let year: i64 = time_or_year.parse().ok()?;
261 if !(1970..=2100).contains(&year) {
262 return None;
263 }
264 Some(approximate_epoch(year, month, day, 0, 0))
265 }
266}
267
268fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270 let y = year - 1970;
272 let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275 days += month_days[month as usize];
276 if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
278 days += 1;
279 }
280 days += day - 1;
281 days * 86400 + hour * 3600 + min * 60
282}
283
284fn epoch_to_year(ts: i64) -> i64 {
286 let mut y = 1970 + ts / 31_557_600;
287 if approximate_epoch(y, 0, 1, 0, 0) > ts {
288 y -= 1;
289 } else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
290 y += 1;
291 }
292 y
293}
294
295fn is_leap_year(year: i64) -> bool {
296 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
297}
298
299pub fn format_relative_time(ts: i64) -> String {
302 let now = std::time::SystemTime::now()
303 .duration_since(std::time::UNIX_EPOCH)
304 .unwrap_or_default()
305 .as_secs() as i64;
306 let diff = now - ts;
307 if diff < 0 {
308 return format_short_date(ts);
310 }
311 if diff < 60 {
312 return "just now".to_string();
313 }
314 if diff < 3600 {
315 return format!("{}m ago", diff / 60);
316 }
317 if diff < 86400 {
318 return format!("{}h ago", diff / 3600);
319 }
320 if diff < 86400 * 30 {
321 return format!("{}d ago", diff / 86400);
322 }
323 format_short_date(ts)
324}
325
326fn format_short_date(ts: i64) -> String {
328 let now = std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap_or_default()
331 .as_secs() as i64;
332 let now_year = epoch_to_year(now);
333 let ts_year = epoch_to_year(ts);
334
335 let months = [
336 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
337 ];
338
339 let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
341 let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
342 let feb = if is_leap_year(ts_year) { 29 } else { 28 };
343 let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
344 let mut m = 0;
345 let mut remaining = day_of_year;
346 for (i, &len) in month_lengths.iter().enumerate() {
347 if remaining < len {
348 m = i;
349 break;
350 }
351 remaining -= len;
352 m = i + 1;
353 }
354 let m = m.min(11);
355 let d = remaining + 1;
356
357 if ts_year == now_year {
358 format!("{} {:>2}", months[m], d)
359 } else {
360 format!("{} {}", months[m], ts_year)
361 }
362}
363
364fn shell_escape(path: &str) -> String {
366 crate::snippet::shell_escape(path)
367}
368
369pub fn get_remote_home(
371 alias: &str,
372 config_path: &Path,
373 askpass: Option<&str>,
374 bw_session: Option<&str>,
375 has_active_tunnel: bool,
376) -> anyhow::Result<String> {
377 let result = crate::snippet::run_snippet(
378 alias,
379 config_path,
380 "pwd",
381 askpass,
382 bw_session,
383 true,
384 has_active_tunnel,
385 )?;
386 if result.status.success() {
387 Ok(result.stdout.trim().to_string())
388 } else {
389 let msg = filter_ssh_warnings(result.stderr.trim());
390 if msg.is_empty() {
391 anyhow::bail!("Failed to connect.")
392 } else {
393 anyhow::bail!("{}", msg)
394 }
395 }
396}
397
398#[allow(clippy::too_many_arguments)]
400pub fn fetch_remote_listing(
401 alias: &str,
402 config_path: &Path,
403 remote_path: &str,
404 show_hidden: bool,
405 sort: BrowserSort,
406 askpass: Option<&str>,
407 bw_session: Option<&str>,
408 has_tunnel: bool,
409) -> Result<Vec<FileEntry>, String> {
410 let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
411 let result = crate::snippet::run_snippet(
412 alias,
413 config_path,
414 &command,
415 askpass,
416 bw_session,
417 true,
418 has_tunnel,
419 );
420 match result {
421 Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
422 Ok(r) => {
423 let msg = filter_ssh_warnings(r.stderr.trim());
424 if msg.is_empty() {
425 Err(format!(
426 "ls exited with code {}.",
427 r.status.code().unwrap_or(1)
428 ))
429 } else {
430 Err(msg)
431 }
432 }
433 Err(e) => Err(e.to_string()),
434 }
435}
436
437#[allow(clippy::too_many_arguments)]
440pub fn spawn_remote_listing<F>(
441 alias: String,
442 config_path: PathBuf,
443 remote_path: String,
444 show_hidden: bool,
445 sort: BrowserSort,
446 askpass: Option<String>,
447 bw_session: Option<String>,
448 has_tunnel: bool,
449 send: F,
450) where
451 F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
452{
453 std::thread::spawn(move || {
454 let listing = fetch_remote_listing(
455 &alias,
456 &config_path,
457 &remote_path,
458 show_hidden,
459 sort,
460 askpass.as_deref(),
461 bw_session.as_deref(),
462 has_tunnel,
463 );
464 send(alias, remote_path, listing);
465 });
466}
467
468pub struct ScpResult {
470 pub status: ExitStatus,
471 pub stderr_output: String,
472}
473
474pub fn run_scp(
480 alias: &str,
481 config_path: &Path,
482 askpass: Option<&str>,
483 bw_session: Option<&str>,
484 has_active_tunnel: bool,
485 scp_args: &[String],
486) -> anyhow::Result<ScpResult> {
487 let mut cmd = Command::new("scp");
488 cmd.arg("-F").arg(config_path);
489
490 if has_active_tunnel {
491 cmd.arg("-o").arg("ClearAllForwardings=yes");
492 }
493
494 for arg in scp_args {
495 cmd.arg(arg);
496 }
497
498 cmd.stdin(Stdio::null())
499 .stdout(Stdio::null())
500 .stderr(Stdio::piped());
501
502 if askpass.is_some() {
503 let exe = std::env::current_exe()
504 .ok()
505 .map(|p| p.to_string_lossy().to_string())
506 .or_else(|| std::env::args().next())
507 .unwrap_or_else(|| "purple".to_string());
508 cmd.env("SSH_ASKPASS", &exe)
509 .env("SSH_ASKPASS_REQUIRE", "prefer")
510 .env("PURPLE_ASKPASS_MODE", "1")
511 .env("PURPLE_HOST_ALIAS", alias)
512 .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
513 }
514
515 if let Some(token) = bw_session {
516 cmd.env("BW_SESSION", token);
517 }
518
519 let output = cmd
520 .output()
521 .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
522
523 let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
524
525 Ok(ScpResult {
526 status: output.status,
527 stderr_output,
528 })
529}
530
531pub fn filter_ssh_warnings(stderr: &str) -> String {
534 stderr
535 .lines()
536 .filter(|line| {
537 let trimmed = line.trim();
538 !trimmed.is_empty()
539 && !trimmed.starts_with("** ")
540 && !trimmed.starts_with("Warning:")
541 && !trimmed.contains("see https://")
542 && !trimmed.contains("See https://")
543 && !trimmed.starts_with("The server may need")
544 && !trimmed.starts_with("This session may be")
545 })
546 .collect::<Vec<_>>()
547 .join("\n")
548}
549
550pub fn build_scp_args(
558 alias: &str,
559 source_pane: BrowserPane,
560 local_path: &Path,
561 remote_path: &str,
562 filenames: &[String],
563 has_dirs: bool,
564) -> Vec<String> {
565 let mut args = Vec::new();
566 if has_dirs {
567 args.push("-r".to_string());
568 }
569 args.push("--".to_string());
570
571 match source_pane {
572 BrowserPane::Local => {
574 for name in filenames {
575 args.push(local_path.join(name).to_string_lossy().to_string());
576 }
577 let dest = format!("{}:{}", alias, remote_path);
578 args.push(dest);
579 }
580 BrowserPane::Remote => {
582 let base = remote_path.trim_end_matches('/');
583 for name in filenames {
584 let rpath = format!("{}/{}", base, name);
585 args.push(format!("{}:{}", alias, rpath));
586 }
587 args.push(local_path.to_string_lossy().to_string());
588 }
589 }
590 args
591}
592
593pub fn format_size(bytes: u64) -> String {
595 if bytes >= 1024 * 1024 * 1024 {
596 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
597 } else if bytes >= 1024 * 1024 {
598 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
599 } else if bytes >= 1024 {
600 format!("{:.1} KB", bytes as f64 / 1024.0)
601 } else {
602 format!("{} B", bytes)
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
615 fn test_shell_escape_simple() {
616 assert_eq!(shell_escape("/home/user"), "'/home/user'");
617 }
618
619 #[test]
620 fn test_shell_escape_with_single_quote() {
621 assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
622 }
623
624 #[test]
625 fn test_shell_escape_with_spaces() {
626 assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
627 }
628
629 #[test]
634 fn test_parse_ls_basic() {
635 let output = "\
636total 24
637drwxr-xr-x 2 user user 4096 Jan 1 12:00 subdir
638-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
639-rw-r--r-- 1 user user 1.1K Jan 1 12:00 big.log
640";
641 let entries = parse_ls_output(output, true, BrowserSort::Name);
642 assert_eq!(entries.len(), 3);
643 assert_eq!(entries[0].name, "subdir");
644 assert!(entries[0].is_dir);
645 assert_eq!(entries[0].size, None);
646 assert_eq!(entries[1].name, "big.log");
648 assert!(!entries[1].is_dir);
649 assert_eq!(entries[1].size, Some(1126)); assert_eq!(entries[2].name, "file.txt");
651 assert!(!entries[2].is_dir);
652 assert_eq!(entries[2].size, Some(512));
653 }
654
655 #[test]
656 fn test_parse_ls_hidden_filter() {
657 let output = "\
658total 8
659-rw-r--r-- 1 user user 100 Jan 1 12:00 .hidden
660-rw-r--r-- 1 user user 200 Jan 1 12:00 visible
661";
662 let entries = parse_ls_output(output, false, BrowserSort::Name);
663 assert_eq!(entries.len(), 1);
664 assert_eq!(entries[0].name, "visible");
665
666 let entries = parse_ls_output(output, true, BrowserSort::Name);
667 assert_eq!(entries.len(), 2);
668 }
669
670 #[test]
671 fn test_parse_ls_symlink_to_file_dereferenced() {
672 let output = "\
674total 4
675-rw-r--r-- 1 user user 11 Jan 1 12:00 link
676";
677 let entries = parse_ls_output(output, true, BrowserSort::Name);
678 assert_eq!(entries.len(), 1);
679 assert_eq!(entries[0].name, "link");
680 assert!(!entries[0].is_dir);
681 }
682
683 #[test]
684 fn test_parse_ls_symlink_to_dir_dereferenced() {
685 let output = "\
687total 4
688drwxr-xr-x 3 user user 4096 Jan 1 12:00 link
689";
690 let entries = parse_ls_output(output, true, BrowserSort::Name);
691 assert_eq!(entries.len(), 1);
692 assert_eq!(entries[0].name, "link");
693 assert!(entries[0].is_dir);
694 }
695
696 #[test]
697 fn test_parse_ls_filename_with_spaces() {
698 let output = "\
699total 4
700-rw-r--r-- 1 user user 100 Jan 1 12:00 my file name.txt
701";
702 let entries = parse_ls_output(output, true, BrowserSort::Name);
703 assert_eq!(entries.len(), 1);
704 assert_eq!(entries[0].name, "my file name.txt");
705 }
706
707 #[test]
708 fn test_parse_ls_empty() {
709 let output = "total 0\n";
710 let entries = parse_ls_output(output, true, BrowserSort::Name);
711 assert!(entries.is_empty());
712 }
713
714 #[test]
719 fn test_parse_human_size() {
720 assert_eq!(parse_human_size("512"), 512);
721 assert_eq!(parse_human_size("1.0K"), 1024);
722 assert_eq!(parse_human_size("1.5M"), 1572864);
723 assert_eq!(parse_human_size("2.0G"), 2147483648);
724 }
725
726 #[test]
731 fn test_format_size() {
732 assert_eq!(format_size(0), "0 B");
733 assert_eq!(format_size(512), "512 B");
734 assert_eq!(format_size(1024), "1.0 KB");
735 assert_eq!(format_size(1536), "1.5 KB");
736 assert_eq!(format_size(1048576), "1.0 MB");
737 assert_eq!(format_size(1073741824), "1.0 GB");
738 }
739
740 #[test]
745 fn test_build_scp_args_upload() {
746 let args = build_scp_args(
747 "myhost",
748 BrowserPane::Local,
749 Path::new("/home/user/docs"),
750 "/remote/path/",
751 &["file.txt".to_string()],
752 false,
753 );
754 assert_eq!(
755 args,
756 vec!["--", "/home/user/docs/file.txt", "myhost:/remote/path/",]
757 );
758 }
759
760 #[test]
761 fn test_build_scp_args_download() {
762 let args = build_scp_args(
763 "myhost",
764 BrowserPane::Remote,
765 Path::new("/home/user/docs"),
766 "/remote/path",
767 &["file.txt".to_string()],
768 false,
769 );
770 assert_eq!(
771 args,
772 vec!["--", "myhost:/remote/path/file.txt", "/home/user/docs",]
773 );
774 }
775
776 #[test]
777 fn test_build_scp_args_spaces_in_path() {
778 let args = build_scp_args(
779 "myhost",
780 BrowserPane::Remote,
781 Path::new("/local"),
782 "/remote/my path",
783 &["my file.txt".to_string()],
784 false,
785 );
786 assert_eq!(
788 args,
789 vec!["--", "myhost:/remote/my path/my file.txt", "/local",]
790 );
791 }
792
793 #[test]
794 fn test_build_scp_args_with_dirs() {
795 let args = build_scp_args(
796 "myhost",
797 BrowserPane::Local,
798 Path::new("/local"),
799 "/remote/",
800 &["mydir".to_string()],
801 true,
802 );
803 assert_eq!(args[0], "-r");
804 }
805
806 #[test]
811 fn test_list_local_sorts_dirs_first() {
812 let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
813 let _ = std::fs::remove_dir_all(&base);
814 std::fs::create_dir_all(&base).unwrap();
815 std::fs::create_dir(base.join("zdir")).unwrap();
816 std::fs::write(base.join("afile.txt"), "hello").unwrap();
817 std::fs::write(base.join("bfile.txt"), "world").unwrap();
818
819 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
820 assert_eq!(entries.len(), 3);
821 assert!(entries[0].is_dir);
822 assert_eq!(entries[0].name, "zdir");
823 assert_eq!(entries[1].name, "afile.txt");
824 assert_eq!(entries[2].name, "bfile.txt");
825
826 let _ = std::fs::remove_dir_all(&base);
827 }
828
829 #[test]
830 fn test_list_local_hidden() {
831 let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
832 let _ = std::fs::remove_dir_all(&base);
833 std::fs::create_dir_all(&base).unwrap();
834 std::fs::write(base.join(".hidden"), "").unwrap();
835 std::fs::write(base.join("visible"), "").unwrap();
836
837 let entries = list_local(&base, false, BrowserSort::Name).unwrap();
838 assert_eq!(entries.len(), 1);
839 assert_eq!(entries[0].name, "visible");
840
841 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
842 assert_eq!(entries.len(), 2);
843
844 let _ = std::fs::remove_dir_all(&base);
845 }
846
847 #[test]
852 fn test_filter_ssh_warnings_filters_warnings() {
853 let stderr = "\
854** WARNING: connection is not using a post-quantum key exchange algorithm.
855** This session may be vulnerable to \"store now, decrypt later\" attacks.
856** The server may need to be upgraded. See https://openssh.com/pq.html
857scp: '/root/file.rpm': No such file or directory";
858 assert_eq!(
859 filter_ssh_warnings(stderr),
860 "scp: '/root/file.rpm': No such file or directory"
861 );
862 }
863
864 #[test]
865 fn test_filter_ssh_warnings_keeps_plain_error() {
866 let stderr = "scp: /etc/shadow: Permission denied\n";
867 assert_eq!(
868 filter_ssh_warnings(stderr),
869 "scp: /etc/shadow: Permission denied"
870 );
871 }
872
873 #[test]
874 fn test_filter_ssh_warnings_empty() {
875 assert_eq!(filter_ssh_warnings(""), "");
876 assert_eq!(filter_ssh_warnings(" \n \n"), "");
877 }
878
879 #[test]
880 fn test_filter_ssh_warnings_warning_prefix() {
881 let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
882 assert_eq!(
883 filter_ssh_warnings(stderr),
884 "Permission denied (publickey)."
885 );
886 }
887
888 #[test]
889 fn test_filter_ssh_warnings_lowercase_see_https() {
890 let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
891 assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
892 }
893
894 #[test]
895 fn test_filter_ssh_warnings_only_warnings() {
896 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";
897 assert_eq!(filter_ssh_warnings(stderr), "");
898 }
899
900 #[test]
905 fn test_approximate_epoch_known_dates() {
906 let ts = approximate_epoch(2024, 0, 1, 0, 0);
908 assert_eq!(ts, 1704067200);
909 let ts = approximate_epoch(2000, 0, 1, 0, 0);
911 assert_eq!(ts, 946684800);
912 assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
914 }
915
916 #[test]
917 fn test_approximate_epoch_leap_year() {
918 let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
920 let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
921 assert_eq!(mar01 - feb29, 86400);
922 }
923
924 #[test]
929 fn test_epoch_to_year() {
930 assert_eq!(epoch_to_year(0), 1970);
931 assert_eq!(epoch_to_year(1672531200), 2023);
933 assert_eq!(epoch_to_year(1704067200), 2024);
935 assert_eq!(epoch_to_year(1735689599), 2024);
937 assert_eq!(epoch_to_year(1735689600), 2025);
939 }
940
941 #[test]
946 fn test_parse_ls_date_recent_format() {
947 let ts = parse_ls_date("Jan", "15", "12:34");
949 assert!(ts.is_some());
950 let ts = ts.unwrap();
951 let now = std::time::SystemTime::now()
953 .duration_since(std::time::UNIX_EPOCH)
954 .unwrap()
955 .as_secs() as i64;
956 assert!(ts <= now + 86400);
957 assert!(ts > now - 366 * 86400);
958 }
959
960 #[test]
961 fn test_parse_ls_date_old_format() {
962 let ts = parse_ls_date("Mar", "5", "2023");
963 assert!(ts.is_some());
964 let ts = ts.unwrap();
965 assert_eq!(epoch_to_year(ts), 2023);
967 }
968
969 #[test]
970 fn test_parse_ls_date_invalid_month() {
971 assert!(parse_ls_date("Foo", "1", "12:00").is_none());
972 }
973
974 #[test]
975 fn test_parse_ls_date_invalid_day() {
976 assert!(parse_ls_date("Jan", "0", "12:00").is_none());
977 assert!(parse_ls_date("Jan", "32", "12:00").is_none());
978 }
979
980 #[test]
981 fn test_parse_ls_date_invalid_year() {
982 assert!(parse_ls_date("Jan", "1", "1969").is_none());
983 }
984
985 #[test]
990 fn test_format_relative_time_ranges() {
991 let now = std::time::SystemTime::now()
992 .duration_since(std::time::UNIX_EPOCH)
993 .unwrap()
994 .as_secs() as i64;
995 assert_eq!(format_relative_time(now), "just now");
996 assert_eq!(format_relative_time(now - 30), "just now");
997 assert_eq!(format_relative_time(now - 120), "2m ago");
998 assert_eq!(format_relative_time(now - 7200), "2h ago");
999 assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
1000 }
1001
1002 #[test]
1003 fn test_format_relative_time_old_date() {
1004 let old = approximate_epoch(2020, 5, 15, 0, 0);
1006 let result = format_relative_time(old);
1007 assert!(
1008 result.contains("2020"),
1009 "Expected year in '{}' for old date",
1010 result
1011 );
1012 }
1013
1014 #[test]
1015 fn test_format_relative_time_future() {
1016 let now = std::time::SystemTime::now()
1017 .duration_since(std::time::UNIX_EPOCH)
1018 .unwrap()
1019 .as_secs() as i64;
1020 let result = format_relative_time(now + 86400 * 30);
1022 assert!(!result.is_empty());
1023 }
1024
1025 #[test]
1030 fn test_format_short_date_different_year() {
1031 let ts = approximate_epoch(2020, 2, 15, 0, 0); let result = format_short_date(ts);
1033 assert!(result.contains("2020"), "Expected year in '{}'", result);
1034 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1035 }
1036
1037 #[test]
1038 fn test_format_short_date_leap_year() {
1039 let ts = approximate_epoch(2024, 2, 1, 0, 0);
1041 let result = format_short_date(ts);
1042 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1043 assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1044 let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1046 let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1047 let feb29_date = format_short_date(feb29);
1048 let mar01_date = format_short_date(mar01);
1049 assert!(
1050 feb29_date.starts_with("Feb"),
1051 "Expected Feb in '{}'",
1052 feb29_date
1053 );
1054 assert!(
1055 mar01_date.starts_with("Mar"),
1056 "Expected Mar in '{}'",
1057 mar01_date
1058 );
1059 }
1060
1061 #[test]
1066 fn test_sort_entries_date_dirs_first_newest_first() {
1067 let mut entries = vec![
1068 FileEntry {
1069 name: "old.txt".into(),
1070 is_dir: false,
1071 size: Some(100),
1072 modified: Some(1000),
1073 },
1074 FileEntry {
1075 name: "new.txt".into(),
1076 is_dir: false,
1077 size: Some(200),
1078 modified: Some(3000),
1079 },
1080 FileEntry {
1081 name: "mid.txt".into(),
1082 is_dir: false,
1083 size: Some(150),
1084 modified: Some(2000),
1085 },
1086 FileEntry {
1087 name: "adir".into(),
1088 is_dir: true,
1089 size: None,
1090 modified: Some(500),
1091 },
1092 ];
1093 sort_entries(&mut entries, BrowserSort::Date);
1094 assert!(entries[0].is_dir);
1095 assert_eq!(entries[0].name, "adir");
1096 assert_eq!(entries[1].name, "new.txt");
1097 assert_eq!(entries[2].name, "mid.txt");
1098 assert_eq!(entries[3].name, "old.txt");
1099 }
1100
1101 #[test]
1102 fn test_sort_entries_name_mode() {
1103 let mut entries = vec![
1104 FileEntry {
1105 name: "zebra.txt".into(),
1106 is_dir: false,
1107 size: Some(100),
1108 modified: Some(3000),
1109 },
1110 FileEntry {
1111 name: "alpha.txt".into(),
1112 is_dir: false,
1113 size: Some(200),
1114 modified: Some(1000),
1115 },
1116 FileEntry {
1117 name: "mydir".into(),
1118 is_dir: true,
1119 size: None,
1120 modified: Some(2000),
1121 },
1122 ];
1123 sort_entries(&mut entries, BrowserSort::Name);
1124 assert!(entries[0].is_dir);
1125 assert_eq!(entries[1].name, "alpha.txt");
1126 assert_eq!(entries[2].name, "zebra.txt");
1127 }
1128
1129 #[test]
1134 fn test_parse_ls_output_populates_modified() {
1135 let output = "\
1136total 4
1137-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
1138";
1139 let entries = parse_ls_output(output, true, BrowserSort::Name);
1140 assert_eq!(entries.len(), 1);
1141 assert!(
1142 entries[0].modified.is_some(),
1143 "modified should be populated"
1144 );
1145 }
1146
1147 #[test]
1148 fn test_parse_ls_output_date_sort() {
1149 let output = "\
1151total 12
1152-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1153-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1154-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1155";
1156 let entries = parse_ls_output(output, true, BrowserSort::Date);
1157 assert_eq!(entries.len(), 3);
1158 assert_eq!(entries[0].name, "new.txt");
1160 assert_eq!(entries[1].name, "mid.txt");
1161 assert_eq!(entries[2].name, "old.txt");
1162 }
1163
1164 #[test]
1169 fn test_list_local_populates_modified() {
1170 let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1171 let _ = std::fs::remove_dir_all(&base);
1172 std::fs::create_dir_all(&base).unwrap();
1173 std::fs::write(base.join("test.txt"), "hello").unwrap();
1174
1175 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1176 assert_eq!(entries.len(), 1);
1177 assert!(
1178 entries[0].modified.is_some(),
1179 "modified should be populated for local files"
1180 );
1181
1182 let _ = std::fs::remove_dir_all(&base);
1183 }
1184
1185 #[test]
1190 fn test_epoch_to_year_2100_boundary() {
1191 let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1192 assert_eq!(epoch_to_year(ts_2100), 2100);
1193 assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1194 let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1195 assert_eq!(epoch_to_year(mid_2100), 2100);
1196 }
1197
1198 #[test]
1203 fn test_parse_ls_date_midnight() {
1204 let ts = parse_ls_date("Jan", "1", "00:00");
1205 assert!(ts.is_some(), "00:00 should parse successfully");
1206 let ts = ts.unwrap();
1207 let now = std::time::SystemTime::now()
1208 .duration_since(std::time::UNIX_EPOCH)
1209 .unwrap()
1210 .as_secs() as i64;
1211 assert!(ts <= now + 86400);
1212 assert!(ts > now - 366 * 86400);
1213 }
1214
1215 #[test]
1220 fn test_sort_entries_date_with_none_modified() {
1221 let mut entries = vec![
1222 FileEntry {
1223 name: "known.txt".into(),
1224 is_dir: false,
1225 size: Some(100),
1226 modified: Some(5000),
1227 },
1228 FileEntry {
1229 name: "unknown.txt".into(),
1230 is_dir: false,
1231 size: Some(200),
1232 modified: None,
1233 },
1234 FileEntry {
1235 name: "recent.txt".into(),
1236 is_dir: false,
1237 size: Some(300),
1238 modified: Some(9000),
1239 },
1240 ];
1241 sort_entries(&mut entries, BrowserSort::Date);
1242 assert_eq!(entries[0].name, "recent.txt");
1243 assert_eq!(entries[1].name, "known.txt");
1244 assert_eq!(entries[2].name, "unknown.txt");
1245 }
1246
1247 #[test]
1248 fn test_sort_entries_date_asc_oldest_first() {
1249 let mut entries = vec![
1250 FileEntry {
1251 name: "old.txt".into(),
1252 is_dir: false,
1253 size: Some(100),
1254 modified: Some(1000),
1255 },
1256 FileEntry {
1257 name: "new.txt".into(),
1258 is_dir: false,
1259 size: Some(200),
1260 modified: Some(3000),
1261 },
1262 FileEntry {
1263 name: "mid.txt".into(),
1264 is_dir: false,
1265 size: Some(150),
1266 modified: Some(2000),
1267 },
1268 FileEntry {
1269 name: "adir".into(),
1270 is_dir: true,
1271 size: None,
1272 modified: Some(500),
1273 },
1274 ];
1275 sort_entries(&mut entries, BrowserSort::DateAsc);
1276 assert!(entries[0].is_dir);
1277 assert_eq!(entries[0].name, "adir");
1278 assert_eq!(entries[1].name, "old.txt");
1279 assert_eq!(entries[2].name, "mid.txt");
1280 assert_eq!(entries[3].name, "new.txt");
1281 }
1282
1283 #[test]
1284 fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1285 let mut entries = vec![
1286 FileEntry {
1287 name: "known.txt".into(),
1288 is_dir: false,
1289 size: Some(100),
1290 modified: Some(5000),
1291 },
1292 FileEntry {
1293 name: "unknown.txt".into(),
1294 is_dir: false,
1295 size: Some(200),
1296 modified: None,
1297 },
1298 FileEntry {
1299 name: "old.txt".into(),
1300 is_dir: false,
1301 size: Some(300),
1302 modified: Some(1000),
1303 },
1304 ];
1305 sort_entries(&mut entries, BrowserSort::DateAsc);
1306 assert_eq!(entries[0].name, "old.txt");
1307 assert_eq!(entries[1].name, "known.txt");
1308 assert_eq!(entries[2].name, "unknown.txt"); }
1310
1311 #[test]
1312 fn test_parse_ls_output_date_asc_sort() {
1313 let output = "\
1314total 12
1315-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1316-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1317-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1318";
1319 let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1320 assert_eq!(entries.len(), 3);
1321 assert_eq!(entries[0].name, "old.txt");
1323 assert_eq!(entries[1].name, "mid.txt");
1324 assert_eq!(entries[2].name, "new.txt");
1325 }
1326
1327 #[test]
1328 fn test_sort_entries_date_multiple_dirs() {
1329 let mut entries = vec![
1330 FileEntry {
1331 name: "old_dir".into(),
1332 is_dir: true,
1333 size: None,
1334 modified: Some(1000),
1335 },
1336 FileEntry {
1337 name: "new_dir".into(),
1338 is_dir: true,
1339 size: None,
1340 modified: Some(3000),
1341 },
1342 FileEntry {
1343 name: "mid_dir".into(),
1344 is_dir: true,
1345 size: None,
1346 modified: Some(2000),
1347 },
1348 FileEntry {
1349 name: "file.txt".into(),
1350 is_dir: false,
1351 size: Some(100),
1352 modified: Some(5000),
1353 },
1354 ];
1355 sort_entries(&mut entries, BrowserSort::Date);
1356 assert!(entries[0].is_dir);
1357 assert_eq!(entries[0].name, "new_dir");
1358 assert_eq!(entries[1].name, "mid_dir");
1359 assert_eq!(entries[2].name, "old_dir");
1360 assert_eq!(entries[3].name, "file.txt");
1361 }
1362
1363 #[test]
1368 fn test_format_relative_time_exactly_60s() {
1369 let now = std::time::SystemTime::now()
1370 .duration_since(std::time::UNIX_EPOCH)
1371 .unwrap()
1372 .as_secs() as i64;
1373 assert_eq!(format_relative_time(now - 60), "1m ago");
1374 assert_eq!(format_relative_time(now - 59), "just now");
1375 }
1376
1377 #[test]
1382 fn test_parse_ls_output_date_sort_with_dirs() {
1383 let output = "\
1384total 16
1385drwxr-xr-x 2 user user 4096 Jan 1 2020 old_dir
1386-rw-r--r-- 1 user user 200 Jun 15 2023 new_file.txt
1387drwxr-xr-x 2 user user 4096 Dec 1 2023 new_dir
1388-rw-r--r-- 1 user user 100 Mar 5 2022 old_file.txt
1389";
1390 let entries = parse_ls_output(output, true, BrowserSort::Date);
1391 assert_eq!(entries.len(), 4);
1392 assert!(entries[0].is_dir);
1393 assert_eq!(entries[0].name, "new_dir");
1394 assert!(entries[1].is_dir);
1395 assert_eq!(entries[1].name, "old_dir");
1396 assert_eq!(entries[2].name, "new_file.txt");
1397 assert_eq!(entries[3].name, "old_file.txt");
1398 }
1399}