Skip to main content

ssh_commander_core/
file_entry.rs

1//! Protocol-agnostic file listing types and transfer tuning shared by the
2//! SFTP and FTP clients.
3//!
4//! These are deliberately free of any SSH/SFTP/FTP dependency so they
5//! compile under any feature combination — a build with only the `ftp`
6//! feature can list and transfer files without pulling in the russh stack.
7
8use serde::Serialize;
9
10/// Chunk size for streamed file transfers (uploads/downloads). 32 KiB is a
11/// good balance between syscall overhead and memory for interactive use.
12pub const FILE_TRANSFER_CHUNK_SIZE: usize = 32 * 1024;
13
14/// A single file/directory entry returned from directory listings.
15/// Used by both local and remote (SFTP/FTP) file operations.
16#[derive(Debug, Clone, Serialize)]
17pub struct FileEntry {
18    pub name: String,
19    pub size: u64,
20    /// Pre-formatted timestamp string for human display.
21    pub modified: Option<String>,
22    /// Raw modification time as Unix epoch seconds. Surfaced
23    /// alongside `modified` so consumers (the macOS file table)
24    /// can sort numerically and reformat per-locale instead of
25    /// relying on lexical comparison of the formatted string.
26    pub modified_unix: Option<i64>,
27    pub permissions: Option<String>,
28    pub owner: Option<String>,
29    pub group: Option<String>,
30    pub file_type: FileEntryType,
31}
32
33/// Backward-compatible alias for code that still references RemoteFileEntry.
34pub type RemoteFileEntry = FileEntry;
35
36#[derive(Debug, Clone, Serialize, PartialEq)]
37pub enum FileEntryType {
38    File,
39    Directory,
40    Symlink,
41}
42
43/// Convert a Unix timestamp (seconds since epoch) to a readable UTC datetime
44/// string ("YYYY-MM-DD HH:MM:SS"). Uses chrono for correct leap-year and
45/// post-2106 handling.
46pub fn format_unix_timestamp(secs: i64) -> String {
47    chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
48        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
49        .unwrap_or_else(|| "invalid-timestamp".to_string())
50}
51
52/// Format Unix file permissions (mode bits) as a string like `rwxr-xr-x`.
53pub(crate) fn format_permissions(mode: u32) -> String {
54    let mut s = String::with_capacity(9);
55    let flags = [
56        (0o400, 'r'),
57        (0o200, 'w'),
58        (0o100, 'x'),
59        (0o040, 'r'),
60        (0o020, 'w'),
61        (0o010, 'x'),
62        (0o004, 'r'),
63        (0o002, 'w'),
64        (0o001, 'x'),
65    ];
66    for (bit, ch) in flags.iter() {
67        if mode & bit != 0 {
68            s.push(*ch);
69        } else {
70            s.push('-');
71        }
72    }
73    s
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn test_format_permissions_full() {
82        assert_eq!(format_permissions(0o777), "rwxrwxrwx");
83    }
84
85    #[test]
86    fn test_format_permissions_none() {
87        assert_eq!(format_permissions(0o000), "---------");
88    }
89
90    #[test]
91    fn test_format_permissions_typical_file() {
92        assert_eq!(format_permissions(0o644), "rw-r--r--");
93    }
94
95    #[test]
96    fn test_format_permissions_typical_dir() {
97        assert_eq!(format_permissions(0o755), "rwxr-xr-x");
98    }
99
100    #[test]
101    fn test_format_permissions_write_only() {
102        assert_eq!(format_permissions(0o200), "-w-------");
103    }
104
105    #[test]
106    fn format_unix_timestamp_epoch() {
107        assert_eq!(format_unix_timestamp(0), "1970-01-01 00:00:00");
108    }
109
110    #[test]
111    fn format_unix_timestamp_known_date() {
112        // 2024-01-01 00:00:00 UTC
113        assert_eq!(format_unix_timestamp(1704067200), "2024-01-01 00:00:00");
114    }
115
116    #[test]
117    fn format_unix_timestamp_with_time() {
118        // 2000-06-15 11:30:45 UTC
119        assert_eq!(format_unix_timestamp(961068645), "2000-06-15 11:30:45");
120    }
121
122    #[test]
123    fn format_unix_timestamp_post_2106() {
124        // 2200-01-01 00:00:00 UTC — past the u32 epoch cutoff that the old
125        // hand-rolled code would have silently truncated.
126        assert_eq!(format_unix_timestamp(7258118400), "2200-01-01 00:00:00");
127    }
128
129    #[test]
130    fn test_file_entry_type_serialization() {
131        let entry = RemoteFileEntry {
132            name: "test.txt".to_string(),
133            size: 1024,
134            modified: Some("2024-01-01 00:00:00".to_string()),
135            modified_unix: Some(1_704_067_200),
136            permissions: Some("rw-r--r--".to_string()),
137            owner: Some("501".to_string()),
138            group: Some("20".to_string()),
139            file_type: FileEntryType::File,
140        };
141        let json = serde_json::to_string(&entry).unwrap();
142        assert!(json.contains("\"name\":\"test.txt\""));
143        assert!(json.contains("\"size\":1024"));
144        assert!(json.contains("File"));
145    }
146
147    #[test]
148    fn test_directory_entry_serialization() {
149        let entry = RemoteFileEntry {
150            name: "mydir".to_string(),
151            size: 4096,
152            modified: None,
153            modified_unix: None,
154            permissions: Some("rwxr-xr-x".to_string()),
155            owner: None,
156            group: None,
157            file_type: FileEntryType::Directory,
158        };
159        let json = serde_json::to_string(&entry).unwrap();
160        assert!(json.contains("Directory"));
161        assert!(json.contains("\"modified\":null"));
162    }
163
164    #[test]
165    fn test_symlink_entry_serialization() {
166        let entry = RemoteFileEntry {
167            name: "link".to_string(),
168            size: 0,
169            modified: None,
170            modified_unix: None,
171            permissions: None,
172            owner: None,
173            group: None,
174            file_type: FileEntryType::Symlink,
175        };
176        let json = serde_json::to_string(&entry).unwrap();
177        assert!(json.contains("Symlink"));
178    }
179}