mimobox-vm 0.1.0-alpha

MimoBox microVM sandbox backend using Linux KVM
Documentation
use mimobox_core::{DirEntry, FileStat, FileType};

use crate::vm::{GuestCommandResult, GuestFileErrorKind, MicrovmError};

const GUEST_SANDBOX_PREFIX: &str = "/sandbox/";
const EXIT_PATH_NOT_FOUND: i32 = 10;
const EXIT_NOT_DIRECTORY: i32 = 11;

const LIST_DIR_SCRIPT: &str = r#"
dir=$1
if [ ! -e "$dir" ] && [ ! -L "$dir" ]; then
    echo "path not found" >&2
    exit 10
fi
if [ ! -d "$dir" ]; then
    echo "path is not a directory" >&2
    exit 11
fi

for entry in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do
    [ -e "$entry" ] || [ -L "$entry" ] || continue
    name=${entry##*/}
    if [ -L "$entry" ]; then
        kind=symlink
        is_symlink=1
    elif [ -d "$entry" ]; then
        kind=dir
        is_symlink=0
    elif [ -f "$entry" ]; then
        kind=file
        is_symlink=0
    else
        kind=other
        is_symlink=0
    fi
    size=$(stat -c %s "$entry") || exit 12
    printf '%s\t%s\t%s\t%s\n' "$kind" "$size" "$is_symlink" "$name"
done
"#;

const FILE_EXISTS_SCRIPT: &str = r#"
if [ -e "$1" ] || [ -L "$1" ]; then
    echo yes
else
    echo no
fi
"#;

const STAT_SCRIPT: &str = r#"
path=$1
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
    echo "path not found" >&2
    exit 10
fi

if [ -L "$path" ]; then
    kind=symlink
elif [ -d "$path" ]; then
    kind=dir
elif [ -f "$path" ]; then
    kind=file
else
    kind=other
fi

size=$(stat -c %s "$path") || exit 12
mode=$(stat -c %f "$path") || exit 12
modified=$(stat -c %Y "$path") || exit 12
printf '%s\t%s\t%s\t%s\n' "$kind" "$size" "$mode" "$modified"
"#;

pub(crate) fn list_dir<F>(path: &str, mut execute: F) -> Result<Vec<DirEntry>, MicrovmError>
where
    F: FnMut(&[String]) -> Result<GuestCommandResult, MicrovmError>,
{
    validate_guest_path(path)?;
    let result = execute(&shell_script_command(LIST_DIR_SCRIPT, &[path.to_string()]))?;
    ensure_success(result.clone(), "list_dir", path)?;
    let stdout = command_stdout_utf8(result.stdout)?;
    parse_list_dir_output(&stdout)
}

pub(crate) fn file_exists<F>(path: &str, mut execute: F) -> Result<bool, MicrovmError>
where
    F: FnMut(&[String]) -> Result<GuestCommandResult, MicrovmError>,
{
    validate_guest_path(path)?;
    let result = execute(&shell_script_command(
        FILE_EXISTS_SCRIPT,
        &[path.to_string()],
    ))?;
    ensure_success(result.clone(), "file_exists", path)?;
    let stdout = command_stdout_utf8(result.stdout)?;
    match stdout.trim() {
        "yes" => Ok(true),
        "no" => Ok(false),
        other => Err(MicrovmError::Backend(format!(
            "guest file_exists returned unexpected output for {path}: {other}"
        ))),
    }
}

pub(crate) fn remove_file<F>(path: &str, mut execute: F) -> Result<(), MicrovmError>
where
    F: FnMut(&[String]) -> Result<GuestCommandResult, MicrovmError>,
{
    validate_guest_path(path)?;
    let cmd = vec!["/bin/rm".to_string(), "-f".to_string(), path.to_string()];
    let result = execute(&cmd)?;
    ensure_success(result, "remove_file", path)
}

pub(crate) fn rename<F>(from: &str, to: &str, mut execute: F) -> Result<(), MicrovmError>
where
    F: FnMut(&[String]) -> Result<GuestCommandResult, MicrovmError>,
{
    validate_guest_path(from)?;
    validate_guest_path(to)?;
    let cmd = vec!["/bin/mv".to_string(), from.to_string(), to.to_string()];
    let result = execute(&cmd)?;
    ensure_success(result, "rename", from)
}

pub(crate) fn stat<F>(path: &str, mut execute: F) -> Result<FileStat, MicrovmError>
where
    F: FnMut(&[String]) -> Result<GuestCommandResult, MicrovmError>,
{
    validate_guest_path(path)?;
    let result = execute(&shell_script_command(STAT_SCRIPT, &[path.to_string()]))?;
    ensure_success(result.clone(), "stat", path)?;
    let stdout = command_stdout_utf8(result.stdout)?;
    parse_stat_output(path, &stdout)
}

fn shell_script_command(script: &str, args: &[String]) -> Vec<String> {
    let mut cmd = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()];
    cmd.push("mimobox-file-op".to_string());
    cmd.extend(args.iter().cloned());
    cmd
}

fn validate_guest_path(path: &str) -> Result<(), MicrovmError> {
    if path.is_empty() {
        return Err(MicrovmError::InvalidConfig(
            "guest file path must not be empty".to_string(),
        ));
    }
    if path.as_bytes().contains(&0) {
        return Err(MicrovmError::InvalidConfig(
            "guest file path must not contain NUL bytes".to_string(),
        ));
    }
    if path.contains('\n') || path.contains('\r') {
        return Err(MicrovmError::InvalidConfig(
            "guest file path must not contain newline or carriage return characters".to_string(),
        ));
    }
    if !path.starts_with(GUEST_SANDBOX_PREFIX) {
        return Err(MicrovmError::InvalidConfig(format!(
            "guest file path must start with {GUEST_SANDBOX_PREFIX}: {path}"
        )));
    }
    if path.split('/').any(|component| component == "..") {
        return Err(MicrovmError::InvalidConfig(format!(
            "guest file path must not contain '..' path traversal: {path}"
        )));
    }
    Ok(())
}

fn ensure_success(
    result: GuestCommandResult,
    operation: &str,
    path: &str,
) -> Result<(), MicrovmError> {
    if result.timed_out {
        return Err(MicrovmError::Backend(format!(
            "guest {operation} timed out for {path}"
        )));
    }

    match result.exit_code {
        Some(0) => Ok(()),
        Some(EXIT_PATH_NOT_FOUND) => Err(guest_file_error(GuestFileErrorKind::NotFound, path)),
        Some(EXIT_NOT_DIRECTORY) => Err(MicrovmError::Backend(format!(
            "guest {operation} target is not a directory: {path}"
        ))),
        Some(exit_code) => Err(MicrovmError::Backend(format!(
            "guest {operation} failed for {path}: exit_code={exit_code}, stderr={}",
            stderr_preview(&result.stderr)
        ))),
        None => Err(MicrovmError::Backend(format!(
            "guest {operation} did not return an exit code for {path}"
        ))),
    }
}

fn guest_file_error(kind: GuestFileErrorKind, path: &str) -> MicrovmError {
    MicrovmError::GuestFile {
        kind,
        path: path.to_string(),
    }
}

fn command_stdout_utf8(stdout: Vec<u8>) -> Result<String, MicrovmError> {
    String::from_utf8(stdout)
        .map_err(|error| MicrovmError::Backend(format!("guest output is not UTF-8: {error}")))
}

fn stderr_preview(stderr: &[u8]) -> String {
    let text = String::from_utf8_lossy(stderr);
    let mut preview = text.chars().take(512).collect::<String>();
    if text.chars().count() > 512 {
        preview.push_str("...");
    }
    preview
}

fn parse_list_dir_output(output: &str) -> Result<Vec<DirEntry>, MicrovmError> {
    output
        .lines()
        .filter(|line| !line.is_empty())
        .map(parse_list_dir_line)
        .collect()
}

fn parse_list_dir_line(line: &str) -> Result<DirEntry, MicrovmError> {
    let mut parts = line.splitn(4, '\t');
    let kind = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing type"))?;
    let size = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing size"))?
        .parse::<u64>()
        .map_err(|error| parse_error(line, &format!("invalid size: {error}")))?;
    let is_symlink = match parts
        .next()
        .ok_or_else(|| parse_error(line, "missing symlink flag"))?
    {
        "0" => false,
        "1" => true,
        value => return Err(parse_error(line, &format!("invalid symlink flag: {value}"))),
    };
    let name = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing name"))?
        .to_string();

    Ok(DirEntry::new(
        name,
        parse_file_type(kind)?,
        size,
        is_symlink,
    ))
}

fn parse_stat_output(path: &str, output: &str) -> Result<FileStat, MicrovmError> {
    let line = output
        .lines()
        .find(|line| !line.is_empty())
        .ok_or_else(|| MicrovmError::Backend("guest stat returned empty output".to_string()))?;
    let mut parts = line.splitn(4, '\t');
    let kind = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing type"))?;
    let size = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing size"))?
        .parse::<u64>()
        .map_err(|error| parse_error(line, &format!("invalid size: {error}")))?;
    let mode = u32::from_str_radix(
        parts
            .next()
            .ok_or_else(|| parse_error(line, "missing mode"))?,
        16,
    )
    .map_err(|error| parse_error(line, &format!("invalid mode: {error}")))?;
    let modified_ms = parts
        .next()
        .ok_or_else(|| parse_error(line, "missing modified timestamp"))?
        .parse::<u64>()
        .map(|seconds| seconds.saturating_mul(1000))
        .map(Some)
        .map_err(|error| parse_error(line, &format!("invalid modified timestamp: {error}")))?;
    let file_type = parse_file_type(kind)?;

    Ok(FileStat::new(
        path.to_string(),
        matches!(file_type, FileType::Dir),
        matches!(file_type, FileType::File),
        size,
        mode,
        modified_ms,
    ))
}

fn parse_file_type(kind: &str) -> Result<FileType, MicrovmError> {
    match kind {
        "file" => Ok(FileType::File),
        "dir" => Ok(FileType::Dir),
        "symlink" => Ok(FileType::Symlink),
        "other" => Ok(FileType::Other),
        other => Err(MicrovmError::Backend(format!(
            "unknown guest file type: {other}"
        ))),
    }
}

fn parse_error(line: &str, detail: &str) -> MicrovmError {
    MicrovmError::Backend(format!(
        "failed to parse guest file operation output: {detail}; line={line}"
    ))
}

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

    #[test]
    fn validate_guest_path_rejects_paths_outside_sandbox() {
        assert!(validate_guest_path("/tmp/file").is_err());
        assert!(validate_guest_path("/sandbox/../etc/passwd").is_err());
        assert!(validate_guest_path("/sandbox/file").is_ok());
    }

    #[test]
    fn validate_guest_path_rejects_newlines() {
        assert!(validate_guest_path("/sandbox/file\nname").is_err());
        assert!(validate_guest_path("/sandbox/file\rname").is_err());
        assert!(validate_guest_path("/sandbox/file\r\nname").is_err());
        assert!(validate_guest_path("/sandbox/normal-file").is_ok());
    }

    #[test]
    fn parse_list_dir_output_returns_core_entries() {
        let entries = parse_list_dir_output("file\t5\t0\ta.txt\ndir\t0\t0\td\nsymlink\t7\t1\tl\n")
            .expect("解析目录输出必须成功");

        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].name, "a.txt");
        assert_eq!(entries[0].file_type, FileType::File);
        assert_eq!(entries[0].size, 5);
        assert!(!entries[0].is_symlink);
        assert_eq!(entries[2].file_type, FileType::Symlink);
        assert!(entries[2].is_symlink);
    }

    #[test]
    fn parse_stat_output_returns_file_stat() {
        let stat = parse_stat_output("/sandbox/a.txt", "file\t4\t81a4\t1700000000\n")
            .expect("解析 stat 输出必须成功");

        assert_eq!(stat.path, "/sandbox/a.txt");
        assert!(stat.is_file);
        assert!(!stat.is_dir);
        assert_eq!(stat.size, 4);
        assert_eq!(stat.mode, 0x81a4);
        assert_eq!(stat.modified_ms, Some(1_700_000_000_000));
    }
}