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