Gunnar

nordisk/heimdall

crates/heimdall-run/src/indir.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Reading the job's input directory.
//!
//! Heimdall is a defensive sandbox. In milestone 1 a wasm job takes exactly
//! one input: the input dir must hold exactly one regular file, and its bytes
//! become the guest's `input`. Symlinks, subdirectories, special files and
//! files above `max_input_bytes` are refused, and the file is opened with
//! `O_NOFOLLOW` so nothing planted after the listing is followed.

use std::fs::OpenOptions;
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};

/// The one input file of a job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputFile {
    /// Its file name inside the input dir (UTF-8 or lossy).
    pub name: String,
    /// Its bytes.
    pub bytes: Vec<u8>,
}

/// Why the input dir was refused.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InputError {
    #[error("input dir {0} does not exist")]
    Missing(PathBuf),
    #[error("input dir {0} is a symlink; give the real directory")]
    Symlink(PathBuf),
    #[error("input dir {0} is not a directory")]
    NotDir(PathBuf),
    #[error("input dir is empty: a wasm job needs exactly one input file")]
    Empty,
    #[error("input dir holds more than one entry: a wasm job takes exactly one input file in milestone 1")]
    MoreThanOne,
    #[error("input entry {0:?} is not a regular file (symlinks, directories and special files are refused)")]
    NotRegularFile(String),
    #[error("input file is larger than the limit {0} bytes")]
    TooLarge(u64),
    #[error("cannot read input: {0}")]
    Io(String),
}

/// Read the single input file of `dir`, at most `max_bytes` long.
pub fn read_single_input(dir: &Path, max_bytes: u64) -> Result<InputFile, InputError> {
    let meta = match std::fs::symlink_metadata(dir) {
        Ok(m) => m,
        Err(_) => return Err(InputError::Missing(dir.to_path_buf())),
    };
    if meta.file_type().is_symlink() {
        return Err(InputError::Symlink(dir.to_path_buf()));
    }
    if !meta.is_dir() {
        return Err(InputError::NotDir(dir.to_path_buf()));
    }
    let mut entries = Vec::new();
    for e in std::fs::read_dir(dir).map_err(|e| InputError::Io(e.to_string()))? {
        let e = e.map_err(|e| InputError::Io(e.to_string()))?;
        entries.push(e);
        if entries.len() > 1 {
            return Err(InputError::MoreThanOne);
        }
    }
    let Some(entry) = entries.pop() else {
        return Err(InputError::Empty);
    };
    let name = entry.file_name().to_string_lossy().into_owned();
    let ft = entry
        .file_type()
        .map_err(|e| InputError::Io(e.to_string()))?;
    if !ft.is_file() {
        return Err(InputError::NotRegularFile(name));
    }
    let file = OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
        .open(entry.path())
        .map_err(|e| {
            if e.raw_os_error() == Some(libc::ELOOP) {
                InputError::NotRegularFile(name.clone())
            } else {
                InputError::Io(e.to_string())
            }
        })?;
    // Re-check on the open fd: the entry may have been swapped after listing.
    let fmeta = file.metadata().map_err(|e| InputError::Io(e.to_string()))?;
    if !fmeta.is_file() {
        return Err(InputError::NotRegularFile(name));
    }
    if fmeta.len() > max_bytes {
        return Err(InputError::TooLarge(max_bytes));
    }
    let mut bytes = Vec::with_capacity(fmeta.len() as usize);
    file.take(max_bytes.saturating_add(1))
        .read_to_end(&mut bytes)
        .map_err(|e| InputError::Io(e.to_string()))?;
    if bytes.len() as u64 > max_bytes {
        return Err(InputError::TooLarge(max_bytes));
    }
    Ok(InputFile { name, bytes })
}