Skip to main content

csaf_core/
fs.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Capability-scoped filesystem access (`DataDir`).
5//!
6//! Wraps a [`cap_std::fs::Dir`] so every read and write is confined to a
7//! base directory: `..`, absolute paths, drive / UNC prefixes, and
8//! symlink escapes are rejected at the syscall layer (Linux
9//! `openat2(RESOLVE_BENEATH)`; component-by-component resolution
10//! elsewhere). This closes the CWE-22 / CWE-59 / CWE-367 class for
11//! advisory import, export, dump, and audit-log writes.
12//!
13//! `ambient_authority()` — the point where process-wide filesystem
14//! authority crosses into the capability boundary — appears in exactly
15//! one place: [`DataDir::open`]. The base directory itself is the trust
16//! boundary (an operator-configured path, validated by
17//! [`csaf_models::settings::is_valid_storage_path`]); everything joined
18//! onto it afterwards is untrusted and stays confined.
19
20use std::io::{self, Write};
21use std::path::{Path, PathBuf};
22
23use cap_std::ambient_authority;
24use cap_std::fs::Dir;
25
26/// A capability handle to a base directory.
27///
28/// Every relative path passed to a method is resolved against the base
29/// and cannot escape it.
30pub struct DataDir {
31    dir: Dir,
32    base: PathBuf,
33}
34
35impl DataDir {
36    /// Open an existing base directory.
37    ///
38    /// This is the single place ambient filesystem authority enters the
39    /// capability boundary.
40    pub fn open(base: impl AsRef<Path>) -> io::Result<Self> {
41        let base = base.as_ref();
42        let dir = Dir::open_ambient_dir(base, ambient_authority())?;
43        Ok(Self {
44            dir,
45            base: base.to_path_buf(),
46        })
47    }
48
49    /// Open a base directory, first creating it (and any missing parents)
50    /// with ambient authority.
51    ///
52    /// The base path is the trust boundary, not attacker input, so
53    /// creating it ambiently is intentional; everything joined onto the
54    /// returned handle afterwards stays confined.
55    pub fn open_or_create(base: impl AsRef<Path>) -> io::Result<Self> {
56        let base = base.as_ref();
57        std::fs::create_dir_all(base)?;
58        Self::open(base)
59    }
60
61    /// Absolute path of a confined relative path, for logging and return
62    /// values only.
63    ///
64    /// The result is never re-opened through ambient authority — all
65    /// filesystem access goes through the capability-scoped `Dir`. This is
66    /// purely a display/identity helper.
67    #[must_use]
68    pub fn resolve(&self, rel: &str) -> PathBuf {
69        self.base.join(rel)
70    }
71
72    /// Read a confined relative path to a `String`.
73    pub fn read_to_string(&self, rel: &str) -> io::Result<String> {
74        self.dir.read_to_string(rel)
75    }
76
77    /// Read a confined relative path to bytes.
78    pub fn read(&self, rel: &str) -> io::Result<Vec<u8>> {
79        self.dir.read(rel)
80    }
81
82    /// Create all directories along a confined relative path.
83    pub fn create_dir_all(&self, rel: &str) -> io::Result<()> {
84        self.dir.create_dir_all(rel)
85    }
86
87    /// Whether a confined relative path exists.
88    #[must_use]
89    pub fn exists(&self, rel: &str) -> bool {
90        self.dir.exists(rel)
91    }
92
93    /// Length in bytes of a confined relative file.
94    pub fn file_len(&self, rel: &str) -> io::Result<u64> {
95        Ok(self.dir.metadata(rel)?.len())
96    }
97
98    /// Open a confined relative subdirectory as its own capability handle.
99    pub fn subdir(&self, rel: &str) -> io::Result<Self> {
100        let dir = self.dir.open_dir(rel)?;
101        Ok(Self {
102            dir,
103            base: self.base.join(rel),
104        })
105    }
106
107    /// Write bytes to a confined relative path, creating or truncating it.
108    pub fn write(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
109        let mut file = self.dir.create(rel)?;
110        file.write_all(bytes)
111    }
112
113    /// Atomically replace a confined relative path.
114    ///
115    /// Writes to a sibling temp file inside the same `Dir`, fsyncs, then
116    /// renames over the target. Both the temp file and the rename are
117    /// `*at`-relative to the open base fd, so neither can escape it.
118    pub fn write_atomic(&self, rel: &str, bytes: &[u8]) -> io::Result<()> {
119        let tmp = format!("{rel}.tmp");
120        {
121            let mut file = self.dir.create(&tmp)?;
122            file.write_all(bytes)?;
123            file.sync_all()?;
124        }
125        self.dir.rename(&tmp, &self.dir, rel)
126    }
127
128    /// Remove a confined relative file.
129    pub fn remove_file(&self, rel: &str) -> io::Result<()> {
130        self.dir.remove_file(rel)
131    }
132
133    /// Recursively collect the relative paths of every regular file under
134    /// the base directory.
135    ///
136    /// Each directory level is descended through its own re-opened `Dir`
137    /// handle, so the walk never round-trips a path back through ambient
138    /// authority. Symlinks, sockets, and devices are reported via
139    /// `file_type()` and never followed — the walk cannot escape the base.
140    pub fn walk_files(&self) -> io::Result<Vec<String>> {
141        let mut out = Vec::new();
142        walk_dir(&self.dir, "", &mut out)?;
143        Ok(out)
144    }
145}
146
147/// Depth-first walk over `dir`, pushing the base-relative path of every
148/// regular file into `out`.
149fn walk_dir(dir: &Dir, prefix: &str, out: &mut Vec<String>) -> io::Result<()> {
150    for entry in dir.entries()? {
151        let entry = entry?;
152        let name = entry.file_name();
153        let name = name.to_string_lossy();
154        let rel = if prefix.is_empty() {
155            name.to_string()
156        } else {
157            format!("{prefix}/{name}")
158        };
159        let file_type = entry.file_type()?;
160        if file_type.is_dir() {
161            let sub = entry.open_dir()?;
162            walk_dir(&sub, &rel, out)?;
163        } else if file_type.is_file() {
164            out.push(rel);
165        }
166    }
167    Ok(())
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used)]
172mod tests {
173    use super::*;
174
175    fn datadir() -> (tempfile::TempDir, DataDir) {
176        let tmp = tempfile::tempdir().expect("tmpdir");
177        let dd = DataDir::open(tmp.path()).expect("open base");
178        (tmp, dd)
179    }
180
181    #[test]
182    fn write_then_read_roundtrip() {
183        let (_t, dd) = datadir();
184        dd.create_dir_all("2026/001").unwrap();
185        dd.write("2026/001/a.json", b"hello").unwrap();
186        assert_eq!(dd.read_to_string("2026/001/a.json").unwrap(), "hello");
187        assert_eq!(dd.read("2026/001/a.json").unwrap(), b"hello");
188        assert!(dd.exists("2026/001/a.json"));
189        assert_eq!(dd.file_len("2026/001/a.json").unwrap(), 5);
190    }
191
192    #[test]
193    fn write_atomic_replaces_and_leaves_no_temp() {
194        let (_t, dd) = datadir();
195        dd.write_atomic("x.json", b"one").unwrap();
196        dd.write_atomic("x.json", b"two").unwrap();
197        assert_eq!(dd.read_to_string("x.json").unwrap(), "two");
198        assert!(!dd.exists("x.json.tmp"));
199    }
200
201    #[test]
202    fn traversal_is_refused() {
203        let (_t, dd) = datadir();
204        assert!(dd.write("../escape.json", b"x").is_err());
205        assert!(dd.read_to_string("../../etc/passwd").is_err());
206        assert!(dd.create_dir_all("../sneaky").is_err());
207        assert!(dd.read("/etc/passwd").is_err());
208    }
209
210    #[test]
211    fn subdir_scopes_further() {
212        let (_t, dd) = datadir();
213        dd.create_dir_all("sub").unwrap();
214        let sub = dd.subdir("sub").unwrap();
215        sub.write("f.json", b"v").unwrap();
216        assert!(dd.exists("sub/f.json"));
217        assert!(sub.write("../escape", b"x").is_err());
218    }
219
220    #[test]
221    fn walk_files_lists_nested_regular_files() {
222        let (_t, dd) = datadir();
223        dd.create_dir_all("2026/001").unwrap();
224        dd.write("2026/001/a.json", b"a").unwrap();
225        dd.write("top.json", b"t").unwrap();
226        let mut files = dd.walk_files().unwrap();
227        files.sort();
228        assert_eq!(
229            files,
230            vec!["2026/001/a.json".to_string(), "top.json".to_string(),]
231        );
232    }
233
234    #[test]
235    fn remove_file_works_within_base() {
236        let (_t, dd) = datadir();
237        dd.write("gone.json", b"x").unwrap();
238        assert!(dd.exists("gone.json"));
239        dd.remove_file("gone.json").unwrap();
240        assert!(!dd.exists("gone.json"));
241    }
242}