1use serde::Serialize;
6
7use crate::error::SailError;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
11#[serde(rename_all = "lowercase")]
12pub enum EntryType {
13 File,
15 Directory,
17 Symlink,
20 Other,
22}
23
24impl EntryType {
25 fn from_find_letter(letter: &str) -> EntryType {
27 match letter {
28 "d" => EntryType::Directory,
29 "l" => EntryType::Symlink,
30 "f" => EntryType::File,
31 _ => EntryType::Other,
32 }
33 }
34
35 pub fn as_str(self) -> &'static str {
38 match self {
39 EntryType::File => "file",
40 EntryType::Directory => "directory",
41 EntryType::Symlink => "symlink",
42 EntryType::Other => "other",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize)]
49#[non_exhaustive]
50pub struct DirEntry {
51 pub name: String,
53 #[serde(rename = "type")]
57 pub entry_type: EntryType,
58 pub size: u64,
60 pub modified_time: f64,
63 pub mode: u32,
65}
66
67pub(crate) fn list_dir_argv(path: &str) -> Vec<String> {
78 let start = if path.starts_with('/') {
79 path.to_string()
80 } else {
81 format!("./{path}")
82 };
83 vec![
84 "find".to_string(),
85 "-H".to_string(),
86 start,
87 "-maxdepth".to_string(),
88 "1".to_string(),
89 "-printf".to_string(),
90 "%y\\t%s\\t%T@\\t%m\\t%f\\0".to_string(),
91 ]
92}
93
94pub(crate) fn parse_dir_entries(stdout: &[u8]) -> Result<Vec<DirEntry>, String> {
108 let mut entries = Vec::new();
109 for record in stdout.split(|byte| *byte == b'\0') {
110 if record.is_empty() {
111 continue;
112 }
113 let malformed = || {
114 let snippet: String = String::from_utf8_lossy(record).chars().take(80).collect();
115 format!("malformed directory listing record {snippet:?}")
116 };
117 let mut fields = record.splitn(5, |byte| *byte == b'\t');
118 let (Some(type_letter), Some(size_text), Some(mtime_text), Some(mode_text), Some(name)) = (
119 fields.next(),
120 fields.next(),
121 fields.next(),
122 fields.next(),
123 fields.next(),
124 ) else {
125 return Err(malformed());
126 };
127 let (Ok(type_letter), Ok(size_text), Ok(mtime_text), Ok(mode_text)) = (
129 std::str::from_utf8(type_letter),
130 std::str::from_utf8(size_text),
131 std::str::from_utf8(mtime_text),
132 std::str::from_utf8(mode_text),
133 ) else {
134 return Err(malformed());
135 };
136 let (Ok(size), Ok(modified_time), Ok(mode)) = (
137 size_text.parse(),
138 mtime_text.parse(),
139 u32::from_str_radix(mode_text, 8),
140 ) else {
141 return Err(malformed());
142 };
143 let Ok(name) = std::str::from_utf8(name) else {
144 return Err(format!(
145 "the name {:?} is not valid UTF-8, which the path API cannot \
146 address",
147 String::from_utf8_lossy(name)
148 ));
149 };
150 entries.push(DirEntry {
151 name: name.to_string(),
152 entry_type: EntryType::from_find_letter(type_letter),
153 size,
154 modified_time,
155 mode,
156 });
157 }
158 Ok(entries)
159}
160
161pub(crate) fn require_path(path: &str) -> Result<(), SailError> {
165 if path.is_empty() {
166 return Err(SailError::InvalidArgument {
167 message: "path must be non-empty".to_string(),
168 });
169 }
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn parses_records_with_all_fields() {
179 let stdout = b"d\t4096\t1700000000.5\t755\tsub\0f\t12\t1700000001\t644\ta\nb\0";
181 let entries = parse_dir_entries(stdout).unwrap();
182 assert_eq!(entries.len(), 2);
183 assert_eq!(entries[0].name, "sub");
184 assert_eq!(entries[0].entry_type, EntryType::Directory);
185 assert_eq!(entries[0].size, 4096);
186 assert_eq!(entries[0].modified_time, 1_700_000_000.5);
187 assert_eq!(entries[0].mode, 0o755);
188 assert_eq!(entries[1].name, "a\nb");
190 assert_eq!(entries[1].entry_type, EntryType::File);
191 assert_eq!(entries[1].mode, 0o644);
192 }
193
194 #[test]
195 fn keeps_a_tab_in_the_name() {
196 let stdout = b"f\t3\t1700000000\t600\tta\tb\0";
197 let entries = parse_dir_entries(stdout).unwrap();
198 assert_eq!(entries.len(), 1);
199 assert_eq!(entries[0].name, "ta\tb");
200 }
201
202 #[test]
203 fn skips_empty_records() {
204 let stdout = b"d\t4096\t1\t755\tsub\0\0f\t3\t1\t644\tx\0";
207 let entries = parse_dir_entries(stdout).unwrap();
208 assert_eq!(entries.len(), 2);
209 assert_eq!(entries[0].name, "sub");
210 assert_eq!(entries[1].name, "x");
211 }
212
213 #[test]
214 fn fails_the_listing_on_a_malformed_record() {
215 let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
217 assert!(err.contains("garbage"), "{err}");
218 assert!(parse_dir_entries(b"f\t3\t1\t798\tx\0").is_err());
220 assert!(parse_dir_entries(b"f\tbig\t1\t644\tx\0").is_err());
221 }
222
223 #[test]
224 fn fails_the_listing_on_a_non_utf8_name() {
225 let stdout = b"f\t3\t1700000000\t644\tb\xffad\0";
229 let err = parse_dir_entries(stdout).unwrap_err();
230 assert!(err.contains("not valid UTF-8"), "{err}");
231 }
232
233 #[test]
234 fn symlink_type_is_reported() {
235 let stdout = b"l\t7\t1700000000\t777\tlink\0";
236 let entries = parse_dir_entries(stdout).unwrap();
237 assert_eq!(entries[0].entry_type, EntryType::Symlink);
238 }
239
240 #[test]
241 fn relative_paths_are_dot_prefixed() {
242 assert_eq!(list_dir_argv("-weird")[2], "./-weird");
243 assert_eq!(list_dir_argv("work")[2], "./work");
244 assert_eq!(list_dir_argv("/abs")[2], "/abs");
245 }
246}