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`.
53///
54/// Consumed by the SSH and SFTP directory-listing paths; a build with only
55/// the `ftp` or `postgres` feature compiles this module but doesn't call it.
56#[allow(dead_code)]
57pub(crate) fn format_permissions(mode: u32) -> String {
58    let mut s = String::with_capacity(9);
59    let flags = [
60        (0o400, 'r'),
61        (0o200, 'w'),
62        (0o100, 'x'),
63        (0o040, 'r'),
64        (0o020, 'w'),
65        (0o010, 'x'),
66        (0o004, 'r'),
67        (0o002, 'w'),
68        (0o001, 'x'),
69    ];
70    for (bit, ch) in flags.iter() {
71        if mode & bit != 0 {
72            s.push(*ch);
73        } else {
74            s.push('-');
75        }
76    }
77    s
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn test_format_permissions_full() {
86        assert_eq!(format_permissions(0o777), "rwxrwxrwx");
87    }
88
89    #[test]
90    fn test_format_permissions_none() {
91        assert_eq!(format_permissions(0o000), "---------");
92    }
93
94    #[test]
95    fn test_format_permissions_typical_file() {
96        assert_eq!(format_permissions(0o644), "rw-r--r--");
97    }
98
99    #[test]
100    fn test_format_permissions_typical_dir() {
101        assert_eq!(format_permissions(0o755), "rwxr-xr-x");
102    }
103
104    #[test]
105    fn test_format_permissions_write_only() {
106        assert_eq!(format_permissions(0o200), "-w-------");
107    }
108
109    #[test]
110    fn format_unix_timestamp_epoch() {
111        assert_eq!(format_unix_timestamp(0), "1970-01-01 00:00:00");
112    }
113
114    #[test]
115    fn format_unix_timestamp_known_date() {
116        // 2024-01-01 00:00:00 UTC
117        assert_eq!(format_unix_timestamp(1704067200), "2024-01-01 00:00:00");
118    }
119
120    #[test]
121    fn format_unix_timestamp_with_time() {
122        // 2000-06-15 11:30:45 UTC
123        assert_eq!(format_unix_timestamp(961068645), "2000-06-15 11:30:45");
124    }
125
126    #[test]
127    fn format_unix_timestamp_post_2106() {
128        // 2200-01-01 00:00:00 UTC — past the u32 epoch cutoff that the old
129        // hand-rolled code would have silently truncated.
130        assert_eq!(format_unix_timestamp(7258118400), "2200-01-01 00:00:00");
131    }
132
133    #[test]
134    fn test_file_entry_type_serialization() {
135        let entry = RemoteFileEntry {
136            name: "test.txt".to_string(),
137            size: 1024,
138            modified: Some("2024-01-01 00:00:00".to_string()),
139            modified_unix: Some(1_704_067_200),
140            permissions: Some("rw-r--r--".to_string()),
141            owner: Some("501".to_string()),
142            group: Some("20".to_string()),
143            file_type: FileEntryType::File,
144        };
145        let json = serde_json::to_string(&entry).unwrap();
146        assert!(json.contains("\"name\":\"test.txt\""));
147        assert!(json.contains("\"size\":1024"));
148        assert!(json.contains("File"));
149    }
150
151    #[test]
152    fn test_directory_entry_serialization() {
153        let entry = RemoteFileEntry {
154            name: "mydir".to_string(),
155            size: 4096,
156            modified: None,
157            modified_unix: None,
158            permissions: Some("rwxr-xr-x".to_string()),
159            owner: None,
160            group: None,
161            file_type: FileEntryType::Directory,
162        };
163        let json = serde_json::to_string(&entry).unwrap();
164        assert!(json.contains("Directory"));
165        assert!(json.contains("\"modified\":null"));
166    }
167
168    #[test]
169    fn test_symlink_entry_serialization() {
170        let entry = RemoteFileEntry {
171            name: "link".to_string(),
172            size: 0,
173            modified: None,
174            modified_unix: None,
175            permissions: None,
176            owner: None,
177            group: None,
178            file_type: FileEntryType::Symlink,
179        };
180        let json = serde_json::to_string(&entry).unwrap();
181        assert!(json.contains("Symlink"));
182    }
183}