Gunnar

nordisk/heimdall

guests/verify-bundle/src/pathsafe.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Member-path safety: Holger's `pathsafe.rs` rules (no absolute path, no `..`
//! or `.`, no empty component, no backslash, no NUL, no duplicate, no
//! case-folding collision), plus two the guest adds because it, not znippy, is
//! the index reader: no control character and a length cap.

use std::collections::HashSet;

use crate::limits::MAX_PATH_BYTES;
use crate::refusal::{Refusal, Result};

pub fn validate_member_path(path: &str) -> Result<()> {
    if path.is_empty() {
        return Err(Refusal::UnsafePath("empty path".into()));
    }
    if path.len() > MAX_PATH_BYTES {
        return Err(Refusal::UnsafePath(format!(
            "path of {} bytes is longer than {MAX_PATH_BYTES}",
            path.len()
        )));
    }
    if path.contains('\0') {
        return Err(Refusal::UnsafePath(format!("NUL byte in {path:?}")));
    }
    if path.chars().any(|c| c.is_control()) {
        return Err(Refusal::UnsafePath(format!(
            "control character in {path:?}"
        )));
    }
    if path.contains('\\') {
        return Err(Refusal::UnsafePath(format!("backslash in {path:?}")));
    }
    if path.starts_with('/') {
        return Err(Refusal::UnsafePath(format!("absolute path {path:?}")));
    }
    // A Windows drive prefix is absolute on the host that may extract this.
    let b = path.as_bytes();
    if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() {
        return Err(Refusal::UnsafePath(format!("drive-prefixed path {path:?}")));
    }
    for seg in path.split('/') {
        if seg.is_empty() {
            return Err(Refusal::UnsafePath(format!(
                "empty path component in {path:?}"
            )));
        }
        if seg == "." || seg == ".." {
            return Err(Refusal::UnsafePath(format!(
                "traversal component {seg:?} in {path:?}"
            )));
        }
    }
    Ok(())
}

/// Rejects duplicates and case-folding collisions across the whole bundle.
#[derive(Default)]
pub struct PathGuard {
    seen: HashSet<String>,
    folded: HashSet<String>,
}

impl PathGuard {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn check(&mut self, path: &str) -> Result<()> {
        validate_member_path(path)?;
        if !self.seen.insert(path.to_string()) {
            return Err(Refusal::UnsafePath(format!(
                "duplicate normalized path {path:?}"
            )));
        }
        if !self.folded.insert(path.to_ascii_lowercase()) {
            return Err(Refusal::UnsafePath(format!(
                "case-folding collision on {path:?}"
            )));
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ordinary_bundle_paths_are_accepted() {
        for p in [
            "core.json",
            "payload/firmware.bin",
            "evidence/sbom/app.cdx.json",
            "signatures/release.sig",
        ] {
            validate_member_path(p).expect(p);
        }
    }

    #[test]
    fn every_hostile_path_shape_is_refused() {
        for p in [
            "",
            "/etc/passwd",
            "../secret",
            "payload/../../escape",
            "a//b",
            "trailing/",
            "back\\slash",
            "nul\0byte",
            ".",
            "a/./b",
            "C:/windows",
            "c:evil",
            "new\nline",
            "esc\u{1b}[2J",
        ] {
            assert!(validate_member_path(p).is_err(), "should refuse {p:?}");
        }
        assert!(validate_member_path(&"a".repeat(MAX_PATH_BYTES + 1)).is_err());
    }

    #[test]
    fn the_guard_refuses_duplicates_and_case_fold_collisions() {
        let mut g = PathGuard::new();
        g.check("payload/a.bin").unwrap();
        assert!(g.check("payload/a.bin").is_err());
        let mut g2 = PathGuard::new();
        g2.check("payload/App.bin").unwrap();
        assert!(g2.check("payload/app.bin").is_err());
    }
}