Skip to main content

sail/sailbox/
fs.rs

1//! Guest filesystem helpers: the [`DirEntry`] listing type, the `find` argv
2//! that `list_dir` runs, and the parser for its output. The language bindings
3//! consume the structured [`DirEntry`]; only this module reads `find`'s output.
4
5use serde::Serialize;
6
7use crate::error::SailError;
8
9/// The kind of a directory entry, from the guest's `find` type letter.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
11#[serde(rename_all = "lowercase")]
12pub enum EntryType {
13    /// A regular file (`find` type `f`).
14    File,
15    /// A directory (`find` type `d`).
16    Directory,
17    /// A symbolic link (`find` type `l`), reported for the link itself, not its
18    /// target.
19    Symlink,
20    /// Any other special file: block/char device, FIFO, socket, ...
21    Other,
22}
23
24impl EntryType {
25    /// Map a `find` `%y` type letter onto an [`EntryType`].
26    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    /// The lowercase name used in the language bindings (`file`, `directory`,
36    /// `symlink`, `other`).
37    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/// One entry in a directory listing from the filesystem `ls` helper.
48#[derive(Debug, Clone, PartialEq, Serialize)]
49#[non_exhaustive]
50pub struct DirEntry {
51    /// The entry's base name, with no directory prefix.
52    pub name: String,
53    /// Whether the entry is a file, directory, symlink, or other special file.
54    /// Reported for the entry itself, so a symlink is `Symlink` regardless of
55    /// what it points at.
56    #[serde(rename = "type")]
57    pub entry_type: EntryType,
58    /// Size in bytes as reported by the guest.
59    pub size: u64,
60    /// Last-modified time as a Unix timestamp in seconds (with a fractional
61    /// part).
62    pub modified_time: f64,
63    /// Unix permission bits, e.g. `0o644`. The file-type bits are not included.
64    pub mode: u32,
65}
66
67/// The `find` argv `list_dir` runs. `-H` follows `path` itself when it is a
68/// symlink to a directory (without following symlinks found underneath). The
69/// `-printf` emits one NUL-terminated record per entry with tab-separated
70/// fields `type, size, mtime, mode, name`; `find` interprets the `\t` and `\0`
71/// escapes. The name is last, so a name containing a tab or newline is not
72/// misread as a field or record break. The start point itself is the first
73/// record (`find` visits it before its contents): `list_dir` reads the path's
74/// own type from it, then drops it from the listing. A relative path could
75/// start with `-`, `!`, `(`, or another token `find` parses as an expression,
76/// so every relative path is `./`-prefixed to force it to read as a path.
77pub(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
94/// Parse the NUL-terminated `%y\t%s\t%T@\t%m\t%f` records `list_dir` asks `find`
95/// to emit. This takes the raw stdout bytes: the string-typed exec result
96/// replaces NUL with U+FFFD (matching the guest's persisted tail), which would
97/// erase the record breaks. The name is everything after the fourth tab, so a
98/// tab in the name is preserved and a newline never splits a record. A record
99/// that does not parse fails the whole listing (the caller reports it):
100/// dropping it or zero-filling its fields would return a plausible-looking
101/// listing with entries missing or metadata invented, the same silent
102/// corruption the truncation checks exist to prevent. A name that is not
103/// valid UTF-8 also fails the listing: decoding it lossily would let distinct
104/// names collide as U+FFFD, and the string path API cannot address the entry
105/// anyway. Empty records carry no data (every real record ends with the NUL
106/// terminator) and are skipped.
107pub(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        // The four leading fields are ASCII when well-formed.
128        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
161/// Reject an empty filesystem path. An empty path is a caller bug that `rm -rf`
162/// would silently treat as success and `find` would read as the working
163/// directory, so fail loudly instead.
164pub(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        // Fields tab-separated as type, size, mtime, mode, name; NUL-terminated.
180        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        // A newline in the name survives (records split on NUL, not newline).
189        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        // The trailing NUL terminator produces an empty final split, and an
205        // adjacent pair carries no data; neither is a malformed record.
206        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        // Too few fields.
216        let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
217        assert!(err.contains("garbage"), "{err}");
218        // A numeric field that does not parse (9 is not an octal digit).
219        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        // Distinct names like 0xFE and 0xFF would both decode lossily to
226        // U+FFFD, making two real entries indistinguishable, and the string
227        // path API could not address either; refuse the listing instead.
228        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}