Gunnar

nordisk/heimdall

crates/heimdall-core/src/outputs.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The caller accepts only the outputs a verified result lists, with matching
//! SHA-256 and length (spec §7). Anything else in out/ is refused: a missing
//! file, a changed file, a symlink or directory, or a file the result does not list.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::types::*;

/// Why an output is refused.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum OutputError {
    #[error("output `{name}`: {err}")]
    BadName { name: String, err: OutputNameError },
    #[error("listed output `{0}` is missing")]
    Missing(String),
    #[error("output `{0}` is not a regular file")]
    NotRegularFile(String),
    #[error("output `{name}` is {got} bytes, the result says {expected}")]
    LengthMismatch {
        name: String,
        expected: u64,
        got: u64,
    },
    #[error("output `{name}` has sha256 {got}, the result says {expected}")]
    HashMismatch {
        name: String,
        expected: Sha256Digest,
        got: Sha256Digest,
    },
    #[error("`{0}` is in the output directory but not in the result")]
    Unlisted(String),
    #[error("output `{name}`: {err}")]
    Io { name: String, err: String },
}

fn check_name(entry: &Output) -> Result<(), OutputError> {
    validate_output_name(&entry.name).map_err(|err| OutputError::BadName {
        name: entry.name.clone(),
        err,
    })
}

/// Check one output's bytes against its entry.
pub fn check_output_bytes(entry: &Output, bytes: &[u8]) -> Result<(), OutputError> {
    check_name(entry)?;
    let got = bytes.len() as u64;
    if got != entry.bytes {
        return Err(OutputError::LengthMismatch {
            name: entry.name.clone(),
            expected: entry.bytes,
            got,
        });
    }
    let h = Sha256Digest::of(bytes);
    if h != entry.sha256 {
        return Err(OutputError::HashMismatch {
            name: entry.name.clone(),
            expected: entry.sha256,
            got: h,
        });
    }
    Ok(())
}

/// Hash one regular file without following a symlink at its name, reading at
/// most `expected + 1` bytes so a huge file cannot exhaust memory or time.
fn check_file(entry: &Output, path: &Path) -> Result<(), OutputError> {
    use sha2::{Digest, Sha256};
    use std::io::Read;

    let io = |err: std::io::Error| OutputError::Io {
        name: entry.name.clone(),
        err: err.to_string(),
    };
    let meta = match std::fs::symlink_metadata(path) {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(OutputError::Missing(entry.name.clone()))
        }
        Err(e) => return Err(io(e)),
    };
    if !meta.file_type().is_file() {
        return Err(OutputError::NotRegularFile(entry.name.clone()));
    }
    let mut opts = std::fs::OpenOptions::new();
    opts.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        // Refuse a symlink swapped in after the metadata check.
        opts.custom_flags(libc::O_NOFOLLOW);
    }
    let f = opts.open(path).map_err(io)?;
    if !f.metadata().map_err(io)?.file_type().is_file() {
        return Err(OutputError::NotRegularFile(entry.name.clone()));
    }
    let mut h = Sha256::new();
    let mut got: u64 = 0;
    let mut buf = vec![0u8; 64 * 1024];
    let mut limited = f.take(entry.bytes.saturating_add(1));
    loop {
        let n = limited.read(&mut buf).map_err(io)?;
        if n == 0 {
            break;
        }
        got += n as u64;
        h.update(&buf[..n]);
    }
    if got != entry.bytes {
        return Err(OutputError::LengthMismatch {
            name: entry.name.clone(),
            expected: entry.bytes,
            got,
        });
    }
    let digest = Sha256Digest(h.finalize().into());
    if digest != entry.sha256 {
        return Err(OutputError::HashMismatch {
            name: entry.name.clone(),
            expected: entry.sha256,
            got: digest,
        });
    }
    Ok(())
}

/// Check `dir` against `result.outputs`: every listed file present, regular,
/// with matching size and SHA-256, and nothing unlisted. Run it on a result
/// that [`crate::verify`] accepted.
pub fn check_outputs(result: &JobResult, dir: &Path) -> Result<(), crate::VerifyError> {
    check_output_files(result, dir)?;
    Ok(())
}

/// [`check_outputs`], returning the checked paths in list order.
pub fn check_output_files(result: &JobResult, out_dir: &Path) -> Result<Vec<PathBuf>, OutputError> {
    let mut listed = std::collections::HashSet::with_capacity(result.outputs.len());
    for e in &result.outputs {
        check_name(e)?;
        listed.insert(e.name.as_str());
    }
    let dir_io = |err: std::io::Error| OutputError::Io {
        name: out_dir.display().to_string(),
        err: err.to_string(),
    };
    for entry in std::fs::read_dir(out_dir).map_err(dir_io)? {
        let entry = entry.map_err(dir_io)?;
        let name = entry.file_name();
        match name.to_str() {
            Some(n) if listed.contains(n) => {}
            _ => return Err(OutputError::Unlisted(name.to_string_lossy().into_owned())),
        }
    }
    let mut paths = Vec::with_capacity(result.outputs.len());
    for e in &result.outputs {
        let p = out_dir.join(&e.name);
        check_file(e, &p)?;
        paths.push(p);
    }
    Ok(paths)
}