1use std::path::{Path, PathBuf};
4
5use super::atomic::write_with;
6use super::{Durability, Storage};
7
8pub 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 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 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 #[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 assert!(!dir.path().join("../outside.kdl").exists());
129 }
130}