// SPDX-License-Identifier: AGPL-3.0-only
//! The job's output directory.
//!
//! Heimdall is a defensive sandbox, and the output directory is the only place
//! a job may write. The rules (documented in `docs/RUN.md`):
//!
//! - the out dir must not exist yet; Heimdall creates it (mode 0700) and never
//! writes into an existing directory, so nothing is ever overwritten;
//! - the layout is `out/files/<name>` for the job's files and
//! `out/result.signed` for the signed result;
//! - an output name is one flat file name: 1 to 255 bytes of `[A-Za-z0-9._+-]`,
//! not starting with `.`, so no `/`, no `..`, no hidden files, no control
//! characters and no non-ASCII look-alikes;
//! - every file is created with `O_CREAT|O_EXCL|O_NOFOLLOW` (mode 0600): a
//! name used twice, or a symlink planted in the way, is refused;
//! - the file count, each file's size and the total size are capped;
//! - a refused job leaves no output files behind.
use std::collections::BTreeMap;
use std::fs::{DirBuilder, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// The subdirectory of the out dir that holds the job's files.
pub const FILES_DIR: &str = "files";
/// The signed result's file name inside the out dir.
pub const RESULT_FILE: &str = "result.signed";
/// The longest output name, in bytes.
pub const MAX_NAME_BYTES: usize = 255;
/// Why an out dir or an output file was refused.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum OutDirError {
#[error("out dir {0} already exists: Heimdall writes only into a fresh directory it creates")]
AlreadyExists(PathBuf),
#[error("out dir path {0} has no usable final component")]
BadPath(PathBuf),
#[error("the parent of out dir {0} is not an existing directory")]
ParentMissing(PathBuf),
#[error("cannot create out dir: {0}")]
Create(String),
#[error("output name is empty")]
NameEmpty,
#[error("output name is longer than {MAX_NAME_BYTES} bytes")]
NameTooLong,
#[error("output name is not valid UTF-8")]
NameNotUtf8,
#[error("output name {0:?} starts with '.'")]
NameLeadingDot(String),
#[error("output name {0:?} has a character outside [A-Za-z0-9._+-]")]
NameBadChar(String),
#[error("output name {0:?} was already written: outputs are never overwritten")]
Duplicate(String),
#[error("too many output files (limit {0})")]
TooManyFiles(u32),
#[error("output file {name:?} is larger than the limit {limit} bytes")]
FileTooLarge { name: String, limit: u64 },
#[error("outputs together are larger than the limit {0} bytes")]
TotalTooLarge(u64),
#[error("cannot write output {name:?}: {err}")]
Write { name: String, err: String },
#[error("out dir holds an entry Heimdall did not write: {0:?}")]
Foreign(String),
#[error("cannot list out dir: {0}")]
List(String),
}
/// Check one output name against the rules above.
pub fn validate_output_name(name: &str) -> Result<(), OutDirError> {
if name.is_empty() {
return Err(OutDirError::NameEmpty);
}
if name.len() > MAX_NAME_BYTES {
return Err(OutDirError::NameTooLong);
}
if name.starts_with('.') {
return Err(OutDirError::NameLeadingDot(name.to_string()));
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'+'))
{
return Err(OutDirError::NameBadChar(name.to_string()));
}
Ok(())
}
/// [`validate_output_name`] for a name that arrives as raw bytes.
pub fn validate_output_name_bytes(name: &[u8]) -> Result<&str, OutDirError> {
if name.len() > MAX_NAME_BYTES {
return Err(OutDirError::NameTooLong);
}
let s = std::str::from_utf8(name).map_err(|_| OutDirError::NameNotUtf8)?;
validate_output_name(s)?;
Ok(s)
}
/// The caps on what a job may write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputCaps {
pub max_files: u32,
pub max_file_bytes: u64,
pub max_total_bytes: u64,
}
/// One written output: its name and the sha256 of its bytes.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct WrittenOutput {
pub name: String,
pub sha256: [u8; 32],
pub len: u64,
}
/// A fresh output directory Heimdall created for one job.
#[derive(Debug)]
pub struct OutDir {
root: PathBuf,
files: PathBuf,
caps: OutputCaps,
written: BTreeMap<String, WrittenOutput>,
order: Vec<String>,
total: u64,
}
impl OutDir {
/// Create the out dir and its `files/` subdirectory. The path must not
/// exist (not even as a dangling symlink); its parent must be a directory.
pub fn create(path: &Path, caps: OutputCaps) -> Result<OutDir, OutDirError> {
match path.file_name() {
Some(n) if n != "." && n != ".." => {}
_ => return Err(OutDirError::BadPath(path.to_path_buf())),
}
if std::fs::symlink_metadata(path).is_ok() {
return Err(OutDirError::AlreadyExists(path.to_path_buf()));
}
let parent = match path.parent() {
Some(p) if p.as_os_str().is_empty() => Path::new("."),
Some(p) => p,
None => return Err(OutDirError::BadPath(path.to_path_buf())),
};
if !std::fs::metadata(parent)
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(OutDirError::ParentMissing(path.to_path_buf()));
}
// create_dir (not create_dir_all) fails if anything appeared at the
// path in the meantime, so a racing symlink is never followed.
DirBuilder::new().mode(0o700).create(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
OutDirError::AlreadyExists(path.to_path_buf())
} else {
OutDirError::Create(e.to_string())
}
})?;
let files = path.join(FILES_DIR);
DirBuilder::new()
.mode(0o700)
.create(&files)
.map_err(|e| OutDirError::Create(e.to_string()))?;
Ok(OutDir {
root: path.to_path_buf(),
files,
caps,
written: BTreeMap::new(),
order: Vec::new(),
total: 0,
})
}
/// The out dir itself.
pub fn root(&self) -> &Path {
&self.root
}
/// Where the signed result goes.
pub fn result_path(&self) -> PathBuf {
self.root.join(RESULT_FILE)
}
/// Write one output file. Checks the name, the caps and that the name is
/// new, then creates the file exclusively without following symlinks.
pub fn write_output(
&mut self,
name: &str,
bytes: &[u8],
) -> Result<&WrittenOutput, OutDirError> {
validate_output_name(name)?;
if self.written.contains_key(name) {
return Err(OutDirError::Duplicate(name.to_string()));
}
if self.written.len() as u64 >= u64::from(self.caps.max_files) {
return Err(OutDirError::TooManyFiles(self.caps.max_files));
}
let len = bytes.len() as u64;
if len > self.caps.max_file_bytes {
return Err(OutDirError::FileTooLarge {
name: name.to_string(),
limit: self.caps.max_file_bytes,
});
}
if self.total.saturating_add(len) > self.caps.max_total_bytes {
return Err(OutDirError::TotalTooLarge(self.caps.max_total_bytes));
}
let path = self.files.join(name);
let werr = |e: std::io::Error| OutDirError::Write {
name: name.to_string(),
err: e.to_string(),
};
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
OutDirError::Duplicate(name.to_string())
} else {
werr(e)
}
})?;
f.write_all(bytes).map_err(werr)?;
f.sync_all().map_err(werr)?;
self.total += len;
let out = WrittenOutput {
name: name.to_string(),
sha256: Sha256::digest(bytes).into(),
len,
};
self.order.push(name.to_string());
Ok(self.written.entry(name.to_string()).or_insert(out))
}
/// The outputs written so far, in emit order.
pub fn outputs(&self) -> Vec<WrittenOutput> {
self.order
.iter()
.filter_map(|n| self.written.get(n))
.cloned()
.collect()
}
/// Check that `files/` holds exactly the files Heimdall wrote, each a
/// regular file whose bytes still hash to what was recorded.
pub fn audit(&self) -> Result<(), OutDirError> {
let rd = std::fs::read_dir(&self.files).map_err(|e| OutDirError::List(e.to_string()))?;
let mut seen = 0usize;
for entry in rd {
let entry = entry.map_err(|e| OutDirError::List(e.to_string()))?;
let name = entry.file_name().to_string_lossy().into_owned();
let Some(w) = self.written.get(&name) else {
return Err(OutDirError::Foreign(name));
};
let ft = entry
.file_type()
.map_err(|e| OutDirError::List(e.to_string()))?;
if !ft.is_file() {
return Err(OutDirError::Foreign(name));
}
let bytes =
std::fs::read(entry.path()).map_err(|e| OutDirError::List(e.to_string()))?;
let h: [u8; 32] = Sha256::digest(&bytes).into();
if h != w.sha256 {
return Err(OutDirError::Foreign(name));
}
seen += 1;
}
if seen != self.written.len() {
return Err(OutDirError::List("an output file disappeared".into()));
}
Ok(())
}
/// Remove every output file (a refused job leaves no outputs behind).
pub fn discard_outputs(&mut self) -> Result<(), OutDirError> {
for name in self.written.keys() {
let p = self.files.join(name);
match std::fs::remove_file(&p) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(OutDirError::Write {
name: name.clone(),
err: e.to_string(),
})
}
}
}
self.written.clear();
self.order.clear();
self.total = 0;
Ok(())
}
/// Write the signed result file (exclusive create, no symlink follow).
pub fn write_result(&self, bytes: &[u8]) -> Result<PathBuf, OutDirError> {
let path = self.result_path();
let werr = |e: std::io::Error| OutDirError::Write {
name: RESULT_FILE.to_string(),
err: e.to_string(),
};
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
.map_err(werr)?;
f.write_all(bytes).map_err(werr)?;
f.sync_all().map_err(werr)?;
Ok(path)
}
}