Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/pathsafe.rs

//! Member-path safety. Every bundle is hostile input; a member path must be a
//! relative, forward-slash, traversal-free path with no ambiguity that could
//! escape an extraction root or collide on a case-insensitive filesystem.
//!
//! This mirrors the server's `raw::safe_relpath` component rules (empty / `.` /
//! `..` / backslash / NUL) and adds what none of the existing helpers cover:
//! duplicate-normalized-path and case-folding-collision detection across the set
//! of members (both are manifest-forgery signals). Symlink / hard-link members
//! cannot occur — a znippy archive stores file *bytes*, not link entries — and
//! extraction always creates the parent directories itself into a fresh empty
//! directory, so no pre-existing symlink is ever followed.

use std::collections::HashSet;

use crate::error::{BundleError, Result};

/// Validate a single member path in isolation. Rejects absolute paths, `..`,
/// empty components, backslashes, and NUL bytes.
pub fn validate_member_path(path: &str) -> Result<()> {
    if path.is_empty() {
        return Err(BundleError::UnsafePath("empty path".into()));
    }
    if path.contains('\0') {
        return Err(BundleError::UnsafePath(format!("NUL byte in {path:?}")));
    }
    if path.contains('\\') {
        // Backslash is a path separator on Windows and platform-ambiguous elsewhere.
        return Err(BundleError::UnsafePath(format!("backslash in {path:?}")));
    }
    if path.starts_with('/') || std::path::Path::new(path).is_absolute() {
        return Err(BundleError::UnsafePath(format!("absolute path {path:?}")));
    }
    for seg in path.split('/') {
        if seg.is_empty() {
            // covers leading, trailing, and doubled separators
            return Err(BundleError::UnsafePath(format!(
                "empty path component in {path:?}"
            )));
        }
        if seg == "." || seg == ".." {
            return Err(BundleError::UnsafePath(format!(
                "traversal component {seg:?} in {path:?}"
            )));
        }
    }
    Ok(())
}

/// Accumulates the set of accepted member paths and 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()
    }

    /// Validate `path` and register it. Returns an error if the path is unsafe,
    /// a byte-identical duplicate, or collides case-insensitively with a member
    /// already seen.
    pub fn check(&mut self, path: &str) -> Result<()> {
        validate_member_path(path)?;
        if !self.seen.insert(path.to_string()) {
            return Err(BundleError::UnsafePath(format!(
                "duplicate normalized path {path:?}"
            )));
        }
        let folded = path.to_ascii_lowercase();
        if !self.folded.insert(folded) {
            return Err(BundleError::UnsafePath(format!(
                "case-folding collision on {path:?}"
            )));
        }
        Ok(())
    }
}

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

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

    #[test]
    fn rejects_hostile_paths() {
        for p in [
            "",
            "/etc/passwd",
            "../secret",
            "payload/../../escape",
            "a//b",
            "trailing/",
            "back\\slash",
            "nul\0byte",
            ".",
            "a/./b",
        ] {
            assert!(validate_member_path(p).is_err(), "should reject {p:?}");
        }
    }

    #[test]
    fn guard_detects_duplicate_and_casefold() {
        let mut g = PathGuard::new();
        g.check("payload/a.bin").unwrap();
        assert!(g.check("payload/a.bin").is_err(), "duplicate");
        let mut g2 = PathGuard::new();
        g2.check("payload/App.bin").unwrap();
        assert!(g2.check("payload/app.bin").is_err(), "case-fold collision");
    }
}