Gunnar

nordisk/heimdall

crates/heimdall-run/src/outdir.rs

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