sail-rs 0.3.0

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Guest filesystem helpers: the [`DirEntry`] listing type, the `find` argv
//! that `list_dir` runs, and the parser for its output. The language bindings
//! consume the structured [`DirEntry`]; only this module reads `find`'s output.

use serde::Serialize;

use crate::error::SailError;

/// The kind of a directory entry, from the guest's `find` type letter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EntryType {
    /// A regular file (`find` type `f`).
    File,
    /// A directory (`find` type `d`).
    Directory,
    /// A symbolic link (`find` type `l`), reported for the link itself, not its
    /// target.
    Symlink,
    /// Any other special file: block/char device, FIFO, socket, ...
    Other,
}

impl EntryType {
    /// Map a `find` `%y` type letter onto an [`EntryType`].
    fn from_find_letter(letter: &str) -> EntryType {
        match letter {
            "d" => EntryType::Directory,
            "l" => EntryType::Symlink,
            "f" => EntryType::File,
            _ => EntryType::Other,
        }
    }

    /// The lowercase name used in the language bindings (`file`, `directory`,
    /// `symlink`, `other`).
    pub fn as_str(self) -> &'static str {
        match self {
            EntryType::File => "file",
            EntryType::Directory => "directory",
            EntryType::Symlink => "symlink",
            EntryType::Other => "other",
        }
    }
}

/// One entry in a directory listing from the filesystem `ls` helper.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DirEntry {
    /// The entry's base name, with no directory prefix.
    pub name: String,
    /// Whether the entry is a file, directory, symlink, or other special file.
    /// Reported for the entry itself, so a symlink is `Symlink` regardless of
    /// what it points at.
    #[serde(rename = "type")]
    pub entry_type: EntryType,
    /// Size in bytes as reported by the guest.
    pub size: u64,
    /// Last-modified time as a Unix timestamp in seconds (with a fractional
    /// part).
    pub modified_time: f64,
    /// Unix permission bits, e.g. `0o644`. The file-type bits are not included.
    pub mode: u32,
}

/// The `find` argv `list_dir` runs. `-H` follows `path` itself when it is a
/// symlink to a directory (without following symlinks found underneath). The
/// `-printf` emits one NUL-terminated record per entry with tab-separated
/// fields `type, size, mtime, mode, name`; `find` interprets the `\t` and `\0`
/// escapes. The name is last, so a name containing a tab or newline is not
/// misread as a field or record break. The start point itself is the first
/// record (`find` visits it before its contents): `list_dir` reads the path's
/// own type from it, then drops it from the listing. A relative path could
/// start with `-`, `!`, `(`, or another token `find` parses as an expression,
/// so every relative path is `./`-prefixed to force it to read as a path.
pub(crate) fn list_dir_argv(path: &str) -> Vec<String> {
    let start = if path.starts_with('/') {
        path.to_string()
    } else {
        format!("./{path}")
    };
    vec![
        "find".to_string(),
        "-H".to_string(),
        start,
        "-maxdepth".to_string(),
        "1".to_string(),
        "-printf".to_string(),
        "%y\\t%s\\t%T@\\t%m\\t%f\\0".to_string(),
    ]
}

/// Parse the NUL-terminated `%y\t%s\t%T@\t%m\t%f` records `list_dir` asks `find`
/// to emit. This takes the raw stdout bytes: the string-typed exec result
/// replaces NUL with U+FFFD (matching the guest's persisted tail), which would
/// erase the record breaks. The name is everything after the fourth tab, so a
/// tab in the name is preserved and a newline never splits a record. A record
/// that does not parse fails the whole listing (the caller reports it):
/// dropping it or zero-filling its fields would return a plausible-looking
/// listing with entries missing or metadata invented, the same silent
/// corruption the truncation checks exist to prevent. A name that is not
/// valid UTF-8 also fails the listing: decoding it lossily would let distinct
/// names collide as U+FFFD, and the string path API cannot address the entry
/// anyway. Empty records carry no data (every real record ends with the NUL
/// terminator) and are skipped.
pub(crate) fn parse_dir_entries(stdout: &[u8]) -> Result<Vec<DirEntry>, String> {
    let mut entries = Vec::new();
    for record in stdout.split(|byte| *byte == b'\0') {
        if record.is_empty() {
            continue;
        }
        let malformed = || {
            let snippet: String = String::from_utf8_lossy(record).chars().take(80).collect();
            format!("malformed record {snippet:?}; GNU find is required in the guest")
        };
        let mut fields = record.splitn(5, |byte| *byte == b'\t');
        let (Some(type_letter), Some(size_text), Some(mtime_text), Some(mode_text), Some(name)) = (
            fields.next(),
            fields.next(),
            fields.next(),
            fields.next(),
            fields.next(),
        ) else {
            return Err(malformed());
        };
        // The four leading fields are ASCII when well-formed.
        let (Ok(type_letter), Ok(size_text), Ok(mtime_text), Ok(mode_text)) = (
            std::str::from_utf8(type_letter),
            std::str::from_utf8(size_text),
            std::str::from_utf8(mtime_text),
            std::str::from_utf8(mode_text),
        ) else {
            return Err(malformed());
        };
        let (Ok(size), Ok(modified_time), Ok(mode)) = (
            size_text.parse(),
            mtime_text.parse(),
            u32::from_str_radix(mode_text, 8),
        ) else {
            return Err(malformed());
        };
        let Ok(name) = std::str::from_utf8(name) else {
            return Err(format!(
                "the name {:?} is not valid UTF-8, which the path API cannot \
                 address",
                String::from_utf8_lossy(name)
            ));
        };
        entries.push(DirEntry {
            name: name.to_string(),
            entry_type: EntryType::from_find_letter(type_letter),
            size,
            modified_time,
            mode,
        });
    }
    Ok(entries)
}

/// Reject an empty filesystem path. An empty path is a caller bug that `rm -rf`
/// would silently treat as success and `find` would read as the working
/// directory, so fail loudly instead.
pub(crate) fn require_path(path: &str) -> Result<(), SailError> {
    if path.is_empty() {
        return Err(SailError::InvalidArgument {
            message: "path must be non-empty".to_string(),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_records_with_all_fields() {
        // Fields tab-separated as type, size, mtime, mode, name; NUL-terminated.
        let stdout = b"d\t4096\t1700000000.5\t755\tsub\0f\t12\t1700000001\t644\ta\nb\0";
        let entries = parse_dir_entries(stdout).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].name, "sub");
        assert_eq!(entries[0].entry_type, EntryType::Directory);
        assert_eq!(entries[0].size, 4096);
        assert_eq!(entries[0].modified_time, 1_700_000_000.5);
        assert_eq!(entries[0].mode, 0o755);
        // A newline in the name survives (records split on NUL, not newline).
        assert_eq!(entries[1].name, "a\nb");
        assert_eq!(entries[1].entry_type, EntryType::File);
        assert_eq!(entries[1].mode, 0o644);
    }

    #[test]
    fn keeps_a_tab_in_the_name() {
        let stdout = b"f\t3\t1700000000\t600\tta\tb\0";
        let entries = parse_dir_entries(stdout).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].name, "ta\tb");
    }

    #[test]
    fn skips_empty_records() {
        // The trailing NUL terminator produces an empty final split, and an
        // adjacent pair carries no data; neither is a malformed record.
        let stdout = b"d\t4096\t1\t755\tsub\0\0f\t3\t1\t644\tx\0";
        let entries = parse_dir_entries(stdout).unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].name, "sub");
        assert_eq!(entries[1].name, "x");
    }

    #[test]
    fn fails_the_listing_on_a_malformed_record() {
        // Too few fields.
        let err = parse_dir_entries(b"d\t4096\t1\t755\tok\0garbage\0").unwrap_err();
        assert!(err.contains("garbage"), "{err}");
        // A numeric field that does not parse (9 is not an octal digit).
        assert!(parse_dir_entries(b"f\t3\t1\t798\tx\0").is_err());
        assert!(parse_dir_entries(b"f\tbig\t1\t644\tx\0").is_err());
    }

    #[test]
    fn fails_the_listing_on_a_non_utf8_name() {
        // Distinct names like 0xFE and 0xFF would both decode lossily to
        // U+FFFD, making two real entries indistinguishable, and the string
        // path API could not address either; refuse the listing instead.
        let stdout = b"f\t3\t1700000000\t644\tb\xffad\0";
        let err = parse_dir_entries(stdout).unwrap_err();
        assert!(err.contains("not valid UTF-8"), "{err}");
    }

    #[test]
    fn symlink_type_is_reported() {
        let stdout = b"l\t7\t1700000000\t777\tlink\0";
        let entries = parse_dir_entries(stdout).unwrap();
        assert_eq!(entries[0].entry_type, EntryType::Symlink);
    }

    #[test]
    fn relative_paths_are_dot_prefixed() {
        assert_eq!(list_dir_argv("-weird")[2], "./-weird");
        assert_eq!(list_dir_argv("work")[2], "./work");
        assert_eq!(list_dir_argv("/abs")[2], "/abs");
    }
}