// 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 })
}