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)]
49pub struct DirEntry {
50 pub name: String,
52 #[serde(rename = "type")]
56 pub entry_type: EntryType,
57 pub size: u64,
59 pub modified_time: f64,
62 pub mode: u32,
64}
65
66pub(crate) fn list_dir_argv(path: &str) -> Vec<String> {
77 let start = if path.starts_with('/') {
78 path.to_string()
79 } else {
80 format!("./{path}")
81 };
82 vec![
83 "find".to_string(),
84 "-H".to_string(),
85 start,
86 "-maxdepth".to_string(),
87 "1".to_string(),
88 "-printf".to_string(),
89 "%y\\t%s\\t%T@\\t%m\\t%f\\0".to_string(),
90 ]
91}
92
93pub(crate) fn parse_dir_entries(stdout: &[u8]) -> Result<Vec<DirEntry>, String> {
107 let mut entries = Vec::new();
108 for record in stdout.split(|byte| *byte == b'\0') {
109 if record.is_empty() {
110 continue;
111 }
112 let malformed = || {
113 let snippet: String = String::from_utf8_lossy(record).chars().take(80).collect();
114 format!("malformed record {snippet:?}; GNU find is required in the guest")
115 };
116 let mut fields = record.splitn(5, |byte| *byte == b'\t');
117 let (Some(type_letter), Some(size_text), Some(mtime_text), Some(mode_text), Some(name)) = (
118 fields.next(),
119 fields.next(),
120 fields.next(),
121 fields.next(),
122 fields.next(),
123 ) else {
124 return Err(malformed());
125 };
126 let (Ok(type_letter), Ok(size_text), Ok(mtime_text), Ok(mode_text)) = (
128 std::str::from_utf8(type_letter),
129 std::str::from_utf8(size_text),
130 std::str::from_utf8(mtime_text),
131 std::str::from_utf8(mode_text),
132 ) else {
133 return Err(malformed());
134 };
135 let (Ok(size), Ok(modified_time), Ok(mode)) = (
136 size_text.parse(),
137 mtime_text.parse(),
138 u32::from_str_radix(mode_text, 8),
139 ) else {
140 return Err(malformed());
141 };
142 let Ok(name) = std::str::from_utf8(name) else {
143 return Err(format!(
144 "the name {:?} is not valid UTF-8, which the path API cannot \
145 address",
146 String::from_utf8_lossy(name)
147 ));
148 };
149 entries.push(DirEntry {
150 name: name.to_string(),
151 entry_type: EntryType::from_find_letter(type_letter),
152 size,
153 modified_time,
154 mode,
155 });
156 }
157 Ok(entries)
158}
159
160pub(crate) fn require_path(path: &str) -> Result<(), SailError> {
164 if path.is_empty() {
165 return Err(SailError::InvalidArgument {
166 message: "path must be non-empty".to_string(),
167 });
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn parses_records_with_all_fields() {
178 let stdout = b"d\t4096\t1700000000.5\t755\tsub\0f\t12\t1700000001\t644\ta\nb\0";
180 let entries = parse_dir_entries(stdout).unwrap();
181 assert_eq!(entries.len(), 2);
182 assert_eq!(entries[0].name, "sub");
183 assert_eq!(entries[0].entry_type, EntryType::Directory);
184 assert_eq!(entries[0].size, 4096);
185 assert_eq!(entries[0].modified_time, 1_700_000_000.5);
186 assert_eq!(entries[0].mode, 0o755);
187 assert_eq!(entries[1].name, "a\nb");
189 assert_eq!(entries[1].entry_type, EntryType::File);
190 assert_eq!(entries[1].mode, 0o644);
191 }
192
193 #[test]
194 fn keeps_a_tab_in_the_name() {
195 let stdout = b"f\t3\t1700000000\t600\tta\tb\0";
196 let entries = parse_dir_entries(stdout).unwrap();
197 assert_eq!(entries.len(), 1);
198 assert_eq!(entries[0].name, "ta\tb");
199 }
200
201 #[test]
202 fn skips_empty_records() {
203 let stdout = b"d\t4096\t1\t755\tsub\0\0f\t3\t1\t644\tx\0";
206 let entries = parse_dir_entries(stdout).unwrap();
207 assert_eq!(entries.len(), 2);
208 assert_eq!(entries[0].name, "sub");
209 assert_eq!(entries[1].name, "x");
210 }
211
212 #[test]
213 fn fails_the_listing_on_a_malformed_record() {
214 let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
216 assert!(err.contains("garbage"), "{err}");
217 assert!(parse_dir_entries(b"f\t3\t1\t798\tx\0").is_err());
219 assert!(parse_dir_entries(b"f\tbig\t1\t644\tx\0").is_err());
220 }
221
222 #[test]
223 fn fails_the_listing_on_a_non_utf8_name() {
224 let stdout = b"f\t3\t1700000000\t644\tb\xffad\0";
228 let err = parse_dir_entries(stdout).unwrap_err();
229 assert!(err.contains("not valid UTF-8"), "{err}");
230 }
231
232 #[test]
233 fn symlink_type_is_reported() {
234 let stdout = b"l\t7\t1700000000\t777\tlink\0";
235 let entries = parse_dir_entries(stdout).unwrap();
236 assert_eq!(entries[0].entry_type, EntryType::Symlink);
237 }
238
239 #[test]
240 fn relative_paths_are_dot_prefixed() {
241 assert_eq!(list_dir_argv("-weird")[2], "./-weird");
242 assert_eq!(list_dir_argv("work")[2], "./work");
243 assert_eq!(list_dir_argv("/abs")[2], "/abs");
244 }
245}