// 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>

//! String-level path-traversal defences — the lexical companion to the
//! capability-scoped `cap_std::fs::Dir` boundary used for exports.
//!
//! Implements the ndaal `rust-path-security` skill's primitives:
//!
//! * [`safe_join`] resolves a caller-supplied relative path against a base
//!   directory, rejecting absolute paths, `..` escapes and NUL bytes. The
//!   result is guaranteed to stay under `base` and to contain no `..`
//!   component. This is the string-layer twin of `cap-std`'s
//!   `openat2(RESOLVE_BENEATH)` enforcement, exercised by the regression
//!   corpus in `tests/test_path_security.rs`.
//! * [`is_safe_segment`] validates a single flat filename (no separators, not
//!   `.`/`..`, no NUL) — used as belt-and-braces before a `cap-std` write so a
//!   refused name yields a clear application error instead of a kernel `EPERM`.

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

/// Resolve `rel` against `base`, returning `Some(path)` only when the result
/// stays at or below `base`. Returns `None` for absolute paths, `..` escapes,
/// drive/UNC prefixes and any input containing a NUL byte.
///
/// The returned path is `base` plus the normalised `Normal` components of
/// `rel`; it always [`starts_with`](Path::starts_with) `base` and never
/// contains a [`ParentDir`](Component::ParentDir) component.
#[must_use]
pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
    // A NUL anywhere is a flat reject (CVE-2007-0883-class truncation).
    if rel.as_bytes().contains(&0) {
        return None;
    }
    let mut segments: Vec<&std::ffi::OsStr> = Vec::new();
    for component in Path::new(rel).components() {
        match component {
            // Absolute paths, drive letters and UNC roots cannot be joined.
            Component::Prefix(_) | Component::RootDir => return None,
            Component::CurDir => {}
            Component::ParentDir => {
                // Pop a previously-descended segment; an empty stack means the
                // path is trying to climb above `base` — reject.
                if segments.pop().is_none() {
                    return None;
                }
            }
            Component::Normal(segment) => segments.push(segment),
        }
    }
    let mut out = base.to_path_buf();
    for segment in segments {
        out.push(segment);
    }
    Some(out)
}

/// Whether `name` is a single safe path segment: non-empty, not `.`/`..`, and
/// free of path separators and NUL bytes. Use before writing `name` into a
/// capability-scoped directory.
#[must_use]
pub fn is_safe_segment(name: &str) -> bool {
    !name.is_empty()
        && name != "."
        && name != ".."
        && !name.contains('/')
        && !name.contains('\\')
        && !name.contains('\0')
}

#[cfg(test)]
mod tests {
    use super::{is_safe_segment, safe_join};
    use std::path::{Component, Path};

    const BASE: &str = "/var/lib/sarif";

    fn rejected(input: &str) {
        assert!(
            safe_join(Path::new(BASE), input).is_none(),
            "expected rejection for {input:?}"
        );
    }

    fn accepted(input: &str) {
        let got = safe_join(Path::new(BASE), input)
            .unwrap_or_else(|| panic!("expected accept for {input:?}"));
        assert!(got.starts_with(BASE), "{got:?} escapes base for {input:?}");
        assert!(
            !got.components().any(|c| matches!(c, Component::ParentDir)),
            "{got:?} still has `..` for {input:?}"
        );
    }

    #[test]
    fn accepts_well_formed_relative_paths() {
        accepted("report.sarif");
        accepted("2026/001/scan.sarif");
        accepted("./a/./b/./c.json");
        accepted(".hidden");
        accepted("");
        accepted(".");
        accepted("2026/.."); // descend then pop — stays at base
    }

    #[test]
    fn rejects_traversal_and_absolute() {
        rejected("/etc/passwd");
        rejected("//etc/passwd");
        rejected("../etc/passwd");
        rejected("..");
        rejected("2026/../../etc/passwd");
        rejected(&("../".repeat(32) + "etc/passwd"));
    }

    #[test]
    fn rejects_nul_anywhere() {
        rejected("\0a");
        rejected("a\0");
        rejected("2026/001\0.json");
    }

    #[test]
    fn safe_segment_rules() {
        assert!(is_safe_segment("report.sarif"));
        assert!(is_safe_segment("a-b_c.2.1.0.pdf"));
        assert!(!is_safe_segment(""));
        assert!(!is_safe_segment("."));
        assert!(!is_safe_segment(".."));
        assert!(!is_safe_segment("a/b"));
        assert!(!is_safe_segment("a\\b"));
        assert!(!is_safe_segment("a\0b"));
    }
}
