Skip to main content

blockworx/storage/
fs.rs

1//! [`Storage`] over a real directory.
2
3use std::path::{Path, PathBuf};
4
5use super::atomic::write_with;
6use super::{Durability, Storage};
7
8/// A container rooted at a directory on the filesystem.
9pub struct FsStorage {
10    root: PathBuf,
11}
12
13impl FsStorage {
14    pub fn new(root: impl Into<PathBuf>) -> Self {
15        Self { root: root.into() }
16    }
17
18    pub fn root(&self) -> &Path {
19        &self.root
20    }
21
22    /// `path` resolved under the root, refusing anything that would leave it.
23    /// Container paths are all minted by us, so a rejection means a bug or a
24    /// hand-edited container — either way not something to silently follow out
25    /// of the directory.
26    fn resolve(&self, path: &str) -> std::io::Result<PathBuf> {
27        let escapes = path.is_empty()
28            || Path::new(path)
29                .components()
30                .any(|c| !matches!(c, std::path::Component::Normal(_)));
31        if escapes {
32            return Err(std::io::Error::new(
33                std::io::ErrorKind::InvalidInput,
34                format!("{path:?} is not a path within the container"),
35            ));
36        }
37        Ok(self.root.join(path))
38    }
39}
40
41impl Storage for FsStorage {
42    fn read(&self, path: &str) -> std::io::Result<Vec<u8>> {
43        std::fs::read(self.resolve(path)?)
44    }
45
46    fn write(&self, path: &str, contents: &[u8], durability: Durability) -> std::io::Result<()> {
47        let full = self.resolve(path)?;
48        if let Some(parent) = full.parent() {
49            std::fs::create_dir_all(parent)?;
50        }
51        write_with(&full, contents, durability)
52    }
53
54    fn list(&self, dir: &str) -> std::io::Result<Vec<String>> {
55        // The container root itself, which `resolve` refuses as a path to read
56        // or write — there is no file there, but there is a directory.
57        let full = if dir.is_empty() {
58            self.root.clone()
59        } else {
60            self.resolve(dir)?
61        };
62        let entries = match std::fs::read_dir(&full) {
63            Ok(entries) => entries,
64            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
65            Err(e) => return Err(e),
66        };
67        entries
68            .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
69            .collect()
70    }
71
72    fn remove(&self, path: &str) -> std::io::Result<()> {
73        match std::fs::remove_file(self.resolve(path)?) {
74            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
75            other => other,
76        }
77    }
78
79    fn exists(&self, path: &str) -> bool {
80        self.resolve(path).is_ok_and(|full| full.exists())
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::storage::atomic::tests::TempDir;
88
89    fn storage(name: &str) -> (TempDir, FsStorage) {
90        let dir = TempDir::new(name);
91        let storage = FsStorage::new(dir.path());
92        (dir, storage)
93    }
94
95    #[test]
96    fn it_round_trips_through_nested_directories() {
97        let (_dir, s) = storage("fs-roundtrip");
98        s.write("assets/9f3a.png", b"bytes", Durability::Sync)
99            .unwrap();
100        assert_eq!(s.read("assets/9f3a.png").unwrap(), b"bytes");
101        assert!(s.exists("assets/9f3a.png"));
102    }
103
104    /// The tolerant-healing property the container leans on: writing into a
105    /// directory that has never existed creates it.
106    #[test]
107    fn writing_creates_the_directory_it_needs() {
108        let (_dir, s) = storage("fs-create-dirs");
109        assert!(!s.exists("history"));
110        s.write("history/000001.kdl", b"x", Durability::Sync)
111            .unwrap();
112        assert_eq!(s.read("history/000001.kdl").unwrap(), b"x");
113    }
114
115    #[test]
116    fn a_path_leaving_the_container_is_refused() {
117        let (dir, s) = storage("fs-escape");
118        for escape in ["../outside.kdl", "/etc/passwd", "assets/../../out.kdl", ""] {
119            assert!(s.read(escape).is_err(), "{escape:?} was allowed");
120            assert!(
121                s.write(escape, b"x", Durability::Sync).is_err(),
122                "{escape:?} was allowed"
123            );
124            assert!(!s.exists(escape), "{escape:?} was allowed");
125        }
126        // Proves the precondition: the escape would otherwise have landed
127        // outside, so the refusals above are load-bearing.
128        assert!(!dir.path().join("../outside.kdl").exists());
129    }
130}