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

//! Capability-secure filesystem wrapper.
//!
//! [`DataDir`] is the one place in `mimetype-cli` that calls
//! [`cap_std::ambient_authority`]. Every other module receives a `&DataDir`
//! (or a value derived from it) and gets boundary enforcement for free:
//! absolute paths, `..`-traversal, and symlink crossings are all rejected at
//! the syscall layer (Linux: `openat2(RESOLVE_BENEATH)`; other platforms:
//! per-component re-canonicalisation).
//!
//! Reviewers can `grep -RnE 'ambient_authority|open_ambient_dir' src/` and
//! see a handful of call sites — they should all be inside this module.

use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

use cap_std::ambient_authority;
use cap_std::fs::{Dir, FileType};

use crate::path_security::safe_join;

/// Capability-scoped data directory.
///
/// Wraps a [`cap_std::fs::Dir`]. All operations take relative paths and are
/// constrained to the directory's open file descriptor.
pub struct DataDir(Dir);

impl DataDir {
    /// Open `root` as the capability boundary. This is the only public entry
    /// point that uses [`ambient_authority`].
    ///
    /// # Errors
    /// Propagates [`io::Error`] from the underlying `openat`/`open` syscall
    /// (missing directory, permission denied, symlink-loop, etc.).
    pub fn open<P: AsRef<Path>>(root: P) -> io::Result<Self> {
        Dir::open_ambient_dir(root.as_ref(), ambient_authority()).map(Self)
    }

    /// Open the same directory `root` but create it (and any missing parents)
    /// first if it does not already exist. Use this for `--output-dir`-style
    /// targets that the operator named but may not have pre-created.
    ///
    /// # Errors
    /// Propagates [`io::Error`] from `mkdir_p` or the subsequent open.
    pub fn open_or_create<P: AsRef<Path>>(root: P) -> io::Result<Self> {
        let p = root.as_ref();
        if !p.exists() {
            std::fs::create_dir_all(p)?;
        }
        Self::open(p)
    }

    /// Read the entire contents of the file at `rel` (which MUST stay under
    /// the boundary).
    ///
    /// # Errors
    /// Returns [`io::ErrorKind::InvalidInput`] if `rel` would escape the
    /// boundary lexically (`..`, absolute path, NUL); otherwise propagates
    /// the kernel's response (`PermissionDenied` if `openat2` refused the
    /// traversal, etc.).
    pub fn read(&self, rel: &str) -> io::Result<Vec<u8>> {
        let rel = lexical_check(rel)?;
        let mut f = self.0.open(&rel)?;
        let mut buf = Vec::new();
        f.read_to_end(&mut buf)?;
        Ok(buf)
    }

    /// Write `body` to the file at `rel`. Creates the file with the
    /// default mode and truncates an existing file. Refuses to follow a
    /// pre-existing symlink at `rel`.
    ///
    /// # Errors
    /// As [`Self::read`].
    pub fn write(&self, rel: &str, body: &[u8]) -> io::Result<()> {
        let rel = lexical_check(rel)?;
        let mut f = self.0.create(&rel)?;
        f.write_all(body)
    }

    /// Iterate non-recursively over the entries of this directory and apply
    /// `visit` to every entry that is a regular file. Symlinks, sockets and
    /// device nodes are reported via `FileType` and skipped silently.
    ///
    /// # Errors
    /// Propagates [`io::Error`] from `read_dir` or per-entry `file_type`.
    pub fn for_each_file<F>(&self, mut visit: F) -> io::Result<()>
    where
        F: FnMut(&Path, FileType) -> io::Result<()>,
    {
        for entry in self.0.entries()? {
            let entry = entry?;
            let ft = entry.file_type()?;
            if ft.is_file() {
                let name = entry.file_name();
                let p = PathBuf::from(&name);
                visit(&p, ft)?;
            }
        }
        Ok(())
    }

    /// Walk this directory recursively, yielding every regular file's path
    /// relative to the boundary. Symlinks are NOT followed. The relative
    /// paths are intended for use with [`Self::read`] and friends.
    ///
    /// # Errors
    /// Propagates [`io::Error`] from any underlying syscall.
    pub fn walk(&self) -> io::Result<Vec<PathBuf>> {
        let mut out = Vec::new();
        walk_inner(&self.0, &PathBuf::new(), &mut out)?;
        Ok(out)
    }
}

/// Internal recursive helper for [`DataDir::walk`].
///
/// # Errors
/// Propagates any [`io::Error`] from `entries`, `file_type`, or `open_dir`.
fn walk_inner(dir: &Dir, prefix: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
    for entry in dir.entries()? {
        let entry = entry?;
        let ft = entry.file_type()?;
        let name = entry.file_name();
        // `cap_std` already refuses symlinks crossing the boundary at open
        // time; we additionally do not follow symlinks at all during recursive
        // walks so the result set matches operator expectations.
        if ft.is_symlink() {
            continue;
        }
        let mut rel = PathBuf::from(prefix);
        rel.push(&name);
        if ft.is_file() {
            out.push(rel);
        } else if ft.is_dir() {
            let sub = entry.open_dir()?;
            walk_inner(&sub, &rel, out)?;
        }
    }
    Ok(())
}

/// String-level safety net: refuse the obvious traversal shapes before
/// handing the path to the kernel. Gives a clearer error than the syscall's
/// `EPERM` and short-circuits a round-trip.
///
/// # Errors
/// Returns [`io::ErrorKind::InvalidInput`] when [`safe_join`] rejects the
/// input (absolute path, NUL byte, or `..`-escape).
fn lexical_check(rel: &str) -> io::Result<PathBuf> {
    // `safe_join` uses an arbitrary anchor; the value is irrelevant — we use
    // it purely as a lexical safety net (accept => return the relative path).
    let anchor = Path::new(".");
    match safe_join(anchor, rel) {
        Some(_) => Ok(PathBuf::from(rel)),
        None => Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("path {rel:?} escapes the capability boundary"),
        )),
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::missing_panics_doc,
    clippy::missing_errors_doc,
    clippy::missing_const_for_fn
)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn tmp() -> (TempDir, DataDir) {
        // `tempfile::TempDir` is fine for the scaffold here: it exposes a
        // path we can hand to `DataDir::open`, and the capability boundary
        // is established by `DataDir` itself once the path is opened.
        let tmp = TempDir::new().expect("tempdir");
        let data = DataDir::open(tmp.path()).expect("open");
        (tmp, data)
    }

    #[test]
    fn read_write_round_trip() {
        let (_tmp, dir) = tmp();
        dir.write("hello.txt", b"world").unwrap();
        let got = dir.read("hello.txt").unwrap();
        assert_eq!(got, b"world");
    }

    #[test]
    fn absolute_path_rejected() {
        let (_tmp, dir) = tmp();
        let err = dir.read("/etc/passwd").expect_err("must reject");
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn parent_traversal_rejected() {
        let (_tmp, dir) = tmp();
        let err = dir.read("../etc/passwd").expect_err("must reject");
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn nul_injection_rejected() {
        let (_tmp, dir) = tmp();
        let err = dir.write("foo\0bar.txt", b"x").expect_err("must reject");
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn walk_finds_files_no_symlinks() {
        let (_tmp, dir) = tmp();
        dir.write("a.txt", b"a").unwrap();
        dir.write("b.txt", b"b").unwrap();
        let entries = dir.walk().unwrap();
        let names: Vec<String> = entries
            .into_iter()
            .map(|p| p.display().to_string())
            .collect();
        assert!(names.contains(&"a.txt".to_owned()));
        assert!(names.contains(&"b.txt".to_owned()));
    }
}
