1use std::io::{self, Write};
21use std::path::{Path, PathBuf};
22
23use cap_std::ambient_authority;
24use cap_std::fs::Dir;
25
26pub struct DataDir {
31 dir: Dir,
32 base: PathBuf,
33}
34
35impl DataDir {
36 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 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 #[must_use]
68 pub fn resolve(&self, rel: &str) -> PathBuf {
69 self.base.join(rel)
70 }
71
72 pub fn read_to_string(&self, rel: &str) -> io::Result<String> {
74 self.dir.read_to_string(rel)
75 }
76
77 pub fn read(&self, rel: &str) -> io::Result<Vec<u8>> {
79 self.dir.read(rel)
80 }
81
82 pub fn create_dir_all(&self, rel: &str) -> io::Result<()> {
84 self.dir.create_dir_all(rel)
85 }
86
87 #[must_use]
89 pub fn exists(&self, rel: &str) -> bool {
90 self.dir.exists(rel)
91 }
92
93 pub fn file_len(&self, rel: &str) -> io::Result<u64> {
95 Ok(self.dir.metadata(rel)?.len())
96 }
97
98 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 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 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 pub fn remove_file(&self, rel: &str) -> io::Result<()> {
130 self.dir.remove_file(rel)
131 }
132
133 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
147fn 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}