csaf-core 0.3.4

CSAF storage, validation, sidecar generation, import/export
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Path-traversal-safe join primitive (CWE-22 guard).
//!
//! `safe_join` resolves a caller-supplied relative path against a base
//! directory at the string layer, rejecting any input that would escape
//! the base via `..`, an absolute path, a drive / UNC prefix, or a NUL
//! byte. It is the companion to the capability-based `cap-std` migration
//! described in `skills/rust-path-security.md`; until that lands it gives
//! every external-input path junction a single, audited choke point.

use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};

/// Join `input` onto `base`, returning `None` if `input` would escape
/// `base`.
///
/// Accepts only relative inputs that stay within `base`. The returned
/// path is guaranteed to start with `base` and to contain no `..`
/// ([`Component::ParentDir`]) component.
///
/// Rejected (returns `None`):
/// - any input containing a NUL byte,
/// - absolute paths and Windows drive / UNC prefixes
///   ([`Component::RootDir`], [`Component::Prefix`]),
/// - any `..` that pops above `base`.
///
/// Accepted (returns `Some`):
/// - `.` and the empty string resolve to `base` itself,
/// - balanced `a/../b` walks that stay at or below `base`.
///
/// The check is purely lexical — it does not touch the filesystem and
/// does not resolve symlinks. Symlink-boundary enforcement belongs to
/// the `cap-std` layer.
#[must_use]
pub fn safe_join(base: &Path, input: &str) -> Option<PathBuf> {
    // A NUL byte cannot appear in any legitimate path; reject outright
    // so a truncation attack (CVE-2007-0883 class) never reaches a
    // syscall.
    if input.contains('\0') {
        return None;
    }

    let mut stack: Vec<&OsStr> = Vec::new();
    for component in Path::new(input).components() {
        match component {
            Component::CurDir => {},
            Component::Normal(segment) => stack.push(segment),
            // A `..` with nothing left to pop would escape `base`.
            Component::ParentDir => {
                stack.pop()?;
            },
            // Absolute roots and drive / UNC prefixes are never allowed
            // against a confined base.
            Component::RootDir | Component::Prefix(_) => return None,
        }
    }

    let mut resolved = base.to_path_buf();
    for segment in stack {
        resolved.push(segment);
    }
    Some(resolved)
}

/// Whether `input` is a safe relative subpath: no `..` traversal, not
/// absolute, no drive / UNC prefix, no NUL byte.
///
/// Equivalent to `safe_join(base, input).is_some()` for any `base`; use
/// it where the caller only needs a yes/no decision rather than the
/// joined path (e.g. validating an HTTP path parameter before matching
/// it against a static whitelist).
#[must_use]
pub fn is_safe_relative_path(input: &str) -> bool {
    safe_join(Path::new(""), input).is_some()
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn is_safe_relative_path_accepts_plain_subpaths() {
        assert!(is_safe_relative_path("css/custom.css"));
        assert!(is_safe_relative_path("js/theme.js"));
        assert!(is_safe_relative_path("fonts/roboto/roboto-regular.ttf"));
        assert!(is_safe_relative_path("."));
        assert!(is_safe_relative_path(""));
    }

    #[test]
    fn is_safe_relative_path_rejects_traversal_and_absolute() {
        assert!(!is_safe_relative_path("../secret"));
        assert!(!is_safe_relative_path("css/../../etc/passwd"));
        assert!(!is_safe_relative_path("/etc/passwd"));
        assert!(!is_safe_relative_path("a\0b"));
    }

    #[test]
    fn safe_join_confines_to_base() {
        let base = Path::new("/srv/data");
        assert_eq!(
            safe_join(base, "2026/001/a.json"),
            Some(PathBuf::from("/srv/data/2026/001/a.json"))
        );
        assert_eq!(safe_join(base, "2026/.."), Some(PathBuf::from("/srv/data")));
        assert_eq!(safe_join(base, "../escape"), None);
        assert_eq!(safe_join(base, "/etc/passwd"), None);
    }
}