// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
// SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>

//! Lexical path-traversal defence.
//!
//! [`safe_join`] resolves a caller-supplied path against a base directory and
//! returns `Some(resolved)` only if the result provably stays under `base`.
//! Returns `None` for absolute paths, NUL-byte injection, and any sequence of
//! `..` components that would escape `base`.
//!
//! This is the string-layer companion to the [`cap-std`] capability boundary.
//! At runtime, `cap_std::fs::Dir` enforces the same invariant via
//! `openat2(RESOLVE_BENEATH)` / per-component re-canonicalisation; `safe_join`
//! gives callers an early, allocator-cheap reject for the obvious shapes plus
//! a deterministic acceptance Oracle for the regression suite.
//!
//! Threat sources covered (see `tests/test_path_security.rs`):
//!
//! - cap-std upstream test corpus (`bytecodealliance/cap-std`).
//! - OWASP "Path Traversal" cheat sheet (CWE-22), including NUL-truncation
//!   (CVE-2007-0883 family) and Windows / UNC / drive-letter shapes.
//! - Unicode normalisation cases that have produced sandbox escapes elsewhere
//!   (Node.js path-traversal advisories, Apache Tomcat's CVE-2014-0050 family).
//!
//! [`cap-std`]: https://docs.rs/cap-std

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

/// Resolve `path` against `base` if and only if the result stays under `base`.
///
/// Rejection rules (return `None`):
///
/// 1. `path` is absolute (Unix `/`-rooted, Windows drive-letter, or Windows
///    UNC `\\?\`-style prefix).
/// 2. `path` contains a NUL byte at any position.
/// 3. The lexical walk drops below zero depth at any point — that is, a `..`
///    component is processed while the running depth counter is already zero.
///
/// Acceptance:
///
/// - The result is `base.join(normalised_components)`, where `Component::CurDir`
///   is collapsed and `Component::ParentDir` is consumed against the running
///   depth counter (no residual `..` survives in the output).
/// - The result `.starts_with(base)` and contains no `Component::ParentDir`.
///
/// The `&str` input is already-validated UTF-8; this function does not decode
/// percent-encoding. Callers that URL-decode user input must do so before the
/// call and feed the decoded form here.
#[must_use]
pub fn safe_join(base: &Path, path: &str) -> Option<PathBuf> {
    if path.as_bytes().contains(&0) {
        return None;
    }
    let candidate = Path::new(path);
    if candidate.is_absolute() {
        return None;
    }

    let mut normalised: Vec<Component<'_>> = Vec::new();
    let mut depth: usize = 0;
    for component in candidate.components() {
        match component {
            Component::CurDir => {},
            Component::ParentDir => {
                depth = depth.checked_sub(1)?;
                normalised.pop();
            },
            Component::Normal(_) => {
                depth = depth.checked_add(1)?;
                normalised.push(component);
            },
            // RootDir / Prefix on a non-absolute path would have been caught
            // above; guard defensively in case of platform-specific encodings.
            Component::Prefix(_) | Component::RootDir => return None,
        }
    }

    let mut resolved = PathBuf::from(base);
    for c in normalised {
        resolved.push(c.as_os_str());
    }

    // Post-condition: invariant for the fuzz target and the test suite.
    if !resolved.starts_with(base) {
        return None;
    }
    if resolved
        .components()
        .any(|c| matches!(c, Component::ParentDir))
    {
        return None;
    }
    Some(resolved)
}
