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)]
49pub struct DirEntry {
50    /// The entry's base name, with no directory prefix.
51    pub name: String,
52    /// Whether the entry is a file, directory, symlink, or other special file.
53    /// Reported for the entry itself, so a symlink is `Symlink` regardless of
54    /// what it points at.
55    #[serde(rename = "type")]
56    pub entry_type: EntryType,
57    /// Size in bytes as reported by the guest.
58    pub size: u64,
59    /// Last-modified time as a Unix timestamp in seconds (with a fractional
60    /// part).
61    pub modified_time: f64,
62    /// Unix permission bits, e.g. `0o644`. The file-type bits are not included.
63    pub mode: u32,
64}
65
66/// The `find` argv `list_dir` runs. `-H` follows `path` itself when it is a
67/// symlink to a directory (without following symlinks found underneath). The
68/// `-printf` emits one NUL-terminated record per entry with tab-separated
69/// fields `type, size, mtime, mode, name`; `find` interprets the `\t` and `\0`
70/// escapes. The name is last, so a name containing a tab or newline is not
71/// misread as a field or record break. The start point itself is the first
72/// record (`find` visits it before its contents): `list_dir` reads the path's
73/// own type from it, then drops it from the listing. A relative path could
74/// start with `-`, `!`, `(`, or another token `find` parses as an expression,
75/// so every relative path is `./`-prefixed to force it to read as a path.
76pub(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
93/// Parse the NUL-terminated `%y\t%s\t%T@\t%m\t%f` records `list_dir` asks `find`
94/// to emit. This takes the raw stdout bytes: the string-typed exec result
95/// replaces NUL with U+FFFD (matching the guest's persisted tail), which would
96/// erase the record breaks. The name is everything after the fourth tab, so a
97/// tab in the name is preserved and a newline never splits a record. A record
98/// that does not parse fails the whole listing (the caller reports it):
99/// dropping it or zero-filling its fields would return a plausible-looking
100/// listing with entries missing or metadata invented, the same silent
101/// corruption the truncation checks exist to prevent. A name that is not
102/// valid UTF-8 also fails the listing: decoding it lossily would let distinct
103/// names collide as U+FFFD, and the string path API cannot address the entry
104/// anyway. Empty records carry no data (every real record ends with the NUL
105/// terminator) and are skipped.
106pub(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        // The four leading fields are ASCII when well-formed.
127        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
160/// Reject an empty filesystem path. An empty path is a caller bug that `rm -rf`
161/// would silently treat as success and `find` would read as the working
162/// directory, so fail loudly instead.
163pub(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        // Fields tab-separated as type, size, mtime, mode, name; NUL-terminated.
179        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        // A newline in the name survives (records split on NUL, not newline).
188        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        // The trailing NUL terminator produces an empty final split, and an
204        // adjacent pair carries no data; neither is a malformed record.
205        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        // Too few fields.
215        let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
216        assert!(err.contains("garbage"), "{err}");
217        // A numeric field that does not parse (9 is not an octal digit).
218        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        // Distinct names like 0xFE and 0xFF would both decode lossily to
225        // U+FFFD, making two real entries indistinguishable, and the string
226        // path API could not address either; refuse the listing instead.
227        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}