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");
}
}