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

//! Capability-scoped filesystem access (`DataDir`).
//!
//! Wraps a [`cap_std::fs::Dir`] so every read and write is confined to a
//! base directory: `..`, absolute paths, drive / UNC prefixes, and
//! symlink escapes are rejected at the syscall layer (Linux
//! `openat2(RESOLVE_BENEATH)`; component-by-component resolution
//! elsewhere). This closes the CWE-22 / CWE-59 / CWE-367 class for
//! advisory import, export, dump, and audit-log writes.
//!
//! `ambient_authority()` — the point where process-wide filesystem
//! authority crosses into the capability boundary — appears in exactly
//! one place: [`DataDir::open`]. The base directory itself is the trust
//! boundary (an operator-configured path, validated by
//! [`csaf_models::settings::is_valid_storage_path`]); everything joined
//! onto it afterwards is untrusted and stays confined.

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

use cap_std::ambient_authority;
use cap_std::fs::Dir;

/// A capability handle to a base directory.
///
/// Every relative path passed to a method is resolved against the base
/// and cannot escape it.
pub struct DataDir {
    dir: Dir,
    base: PathBuf,
}

impl DataDir {
    /// Open an existing base directory.
    ///
    /// This is the single place ambient filesystem authority enters the
    /// capability boundary.
    pub fn open(base: impl AsRef<Path>) -> io::Result<Self> {
        let base = base.as_ref();
        let dir = Dir::open_ambient_dir(base, ambient_authority())?;
        Ok(Self {
            dir,
            base: base.to_path_buf(),
        })
    }

    /// Open a base directory, first creating it (and any missing parents)
    /// with ambient authority.
    ///
    /// The base path is the trust boundary, not attacker input, so
    /// creating it ambiently is intentional; everything joined onto the
    /// returned handle afterwards stays confined.
    pub fn open_or_create(base: impl AsRef<Path>) -> io::Result<Self> {
        let base = base.as_ref();
        std::fs::create_dir_all(base)?;
        Self::open(base)
    }

    /// Absolute path of a confined relative path, for logging and return
    /// values only.
    ///
    /// The result is never re-opened through ambient authority — all
    /// filesystem access goes through the capability-scoped `Dir`. This is
    /// purely a display/identity helper.
    #[must_use]
    pub fn resolve(&self, rel: &str) -> PathBuf {
        self.base.join(rel)
    }

    /// Read a confined relative path to a `String`.
    pub fn read_to_string(&self, rel: &str) -> io::Result<String> {
        self.dir.read_to_string(rel)
    }

    /// Read a confined relative path to bytes.
    pub fn read(&self, rel: &str) -> io::Result<Vec<u8>> {
        self.dir.read(rel)
    }

    /// Create all directories along a confined relative path.
    pub fn create_dir_all(&self, rel: &str) -> io::Result<()> {
        self.dir.create_dir_all(rel)
    }

    /// Whether a confined relative path exists.
    #[must_use]
    pub fn exists(&self, rel: &str) -> bool {
        self.dir.exists(rel)
    }

    /// Length in bytes of a confined relative file.
    pub fn file_len(&self, rel: &str) -> io::Result<u64> {
        Ok(self.dir.metadata(rel)?.len())
    }

    /// Open a confined relative subdirectory as its own capability handle.
    pub fn subdir(&self, rel: &str) -> io::Result<Self> {
        let dir = self.dir.open_dir(rel)?;
        Ok(Self {
            dir,
            base: self.base.join(rel),
        })
    }

    /// Write bytes to a confined relative path, creating or truncating it.
    pub fn write(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
        let mut file = self.dir.create(rel)?;
        file.write_all(bytes)
    }

    /// Atomically replace a confined relative path.
    ///
    /// Writes to a sibling temp file inside the same `Dir`, fsyncs, then
    /// renames over the target. Both the temp file and the rename are
    /// `*at`-relative to the open base fd, so neither can escape it.
    pub fn write_atomic(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
        let tmp = format!("{rel}.tmp");
        {
            let mut file = self.dir.create(&tmp)?;
            file.write_all(bytes)?;
            file.sync_all()?;
        }
        self.dir.rename(&tmp, &self.dir, rel)
    }

    /// Remove a confined relative file.
    pub fn remove_file(&self, rel: &str) -> io::Result<()> {
        self.dir.remove_file(rel)
    }

    /// Recursively collect the relative paths of every regular file under
    /// the base directory.
    ///
    /// Each directory level is descended through its own re-opened `Dir`
    /// handle, so the walk never round-trips a path back through ambient
    /// authority. Symlinks, sockets, and devices are reported via
    /// `file_type()` and never followed — the walk cannot escape the base.
    pub fn walk_files(&self) -> io::Result<Vec<String>> {
        let mut out = Vec::new();
        walk_dir(&self.dir, "", &mut out)?;
        Ok(out)
    }
}

/// Depth-first walk over `dir`, pushing the base-relative path of every
/// regular file into `out`.
fn walk_dir(dir: &Dir, prefix: &str, out: &mut Vec<String>) -> io::Result<()> {
    for entry in dir.entries()? {
        let entry = entry?;
        let name = entry.file_name();
        let name = name.to_string_lossy();
        let rel = if prefix.is_empty() {
            name.to_string()
        } else {
            format!("{prefix}/{name}")
        };
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            let sub = entry.open_dir()?;
            walk_dir(&sub, &rel, out)?;
        } else if file_type.is_file() {
            out.push(rel);
        }
    }
    Ok(())
}

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

    fn datadir() -> (tempfile::TempDir, DataDir) {
        let tmp = tempfile::tempdir().expect("tmpdir");
        let dd = DataDir::open(tmp.path()).expect("open base");
        (tmp, dd)
    }

    #[test]
    fn write_then_read_roundtrip() {
        let (_t, dd) = datadir();
        dd.create_dir_all("2026/001").unwrap();
        dd.write("2026/001/a.json", b"hello").unwrap();
        assert_eq!(dd.read_to_string("2026/001/a.json").unwrap(), "hello");
        assert_eq!(dd.read("2026/001/a.json").unwrap(), b"hello");
        assert!(dd.exists("2026/001/a.json"));
        assert_eq!(dd.file_len("2026/001/a.json").unwrap(), 5);
    }

    #[test]
    fn write_atomic_replaces_and_leaves_no_temp() {
        let (_t, dd) = datadir();
        dd.write_atomic("x.json", b"one").unwrap();
        dd.write_atomic("x.json", b"two").unwrap();
        assert_eq!(dd.read_to_string("x.json").unwrap(), "two");
        assert!(!dd.exists("x.json.tmp"));
    }

    #[test]
    fn traversal_is_refused() {
        let (_t, dd) = datadir();
        assert!(dd.write("../escape.json", b"x").is_err());
        assert!(dd.read_to_string("../../etc/passwd").is_err());
        assert!(dd.create_dir_all("../sneaky").is_err());
        assert!(dd.read("/etc/passwd").is_err());
    }

    #[test]
    fn subdir_scopes_further() {
        let (_t, dd) = datadir();
        dd.create_dir_all("sub").unwrap();
        let sub = dd.subdir("sub").unwrap();
        sub.write("f.json", b"v").unwrap();
        assert!(dd.exists("sub/f.json"));
        assert!(sub.write("../escape", b"x").is_err());
    }

    #[test]
    fn walk_files_lists_nested_regular_files() {
        let (_t, dd) = datadir();
        dd.create_dir_all("2026/001").unwrap();
        dd.write("2026/001/a.json", b"a").unwrap();
        dd.write("top.json", b"t").unwrap();
        let mut files = dd.walk_files().unwrap();
        files.sort();
        assert_eq!(
            files,
            vec!["2026/001/a.json".to_string(), "top.json".to_string(),]
        );
    }

    #[test]
    fn remove_file_works_within_base() {
        let (_t, dd) = datadir();
        dd.write("gone.json", b"x").unwrap();
        assert!(dd.exists("gone.json"));
        dd.remove_file("gone.json").unwrap();
        assert!(!dd.exists("gone.json"));
    }
}