1use crate::events::{DirCreated, FileRemoved, FileWritten};
4use crate::path::{rel_display, resolve};
5use crate::FsError;
6use sova_core::{App, EventBus, Plugin, Request};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::SystemTime;
10
11#[derive(Debug, Clone)]
12pub struct FsEntry {
13 pub path: String,
14 pub name: String,
15 pub is_file: bool,
16 pub is_dir: bool,
17 pub len: u64,
18 pub modified: Option<SystemTime>,
19}
20
21#[derive(Debug, Clone)]
22pub struct FsMeta {
23 pub is_file: bool,
24 pub is_dir: bool,
25 pub len: u64,
26 pub modified: Option<SystemTime>,
27}
28
29#[derive(Clone)]
30struct FsInner {
31 root: PathBuf,
32 max_walk_depth: usize,
33 max_walk_entries: usize,
34 events: Option<EventBus>,
35}
36
37#[derive(Clone)]
39pub struct Fs {
40 inner: Arc<FsInner>,
41}
42
43impl Fs {
44 #[allow(clippy::new_ret_no_self)]
46 pub fn new(root: impl Into<PathBuf>) -> FsPlugin {
47 FsPlugin {
48 root: root.into(),
49 root_explicit: true,
50 max_walk_depth: 32,
51 max_walk_entries: 10_000,
52 }
53 }
54
55 pub fn from_env() -> FsPlugin {
57 let root = std::env::var("SOVA_FS_ROOT").unwrap_or_else(|_| "./data".into());
58 FsPlugin {
59 root: PathBuf::from(root),
60 root_explicit: std::env::var_os("SOVA_FS_ROOT").is_some(),
61 max_walk_depth: 32,
62 max_walk_entries: 10_000,
63 }
64 }
65
66 pub fn root(&self) -> &Path {
67 &self.inner.root
68 }
69
70 fn emit_written(&self, path: &Path) {
71 if let Some(bus) = &self.inner.events {
72 bus.dispatch(FileWritten {
73 path: rel_display(&self.inner.root, path),
74 });
75 }
76 }
77
78 fn emit_removed(&self, path: &Path) {
79 if let Some(bus) = &self.inner.events {
80 bus.dispatch(FileRemoved {
81 path: rel_display(&self.inner.root, path),
82 });
83 }
84 }
85
86 fn emit_dir(&self, path: &Path) {
87 if let Some(bus) = &self.inner.events {
88 bus.dispatch(DirCreated {
89 path: rel_display(&self.inner.root, path),
90 });
91 }
92 }
93
94 async fn resolve(&self, relative: &str) -> Result<PathBuf, FsError> {
95 resolve(&self.inner.root, relative).await
96 }
97
98 pub async fn exists(&self, path: &str) -> Result<bool, FsError> {
99 let p = self.resolve(path).await?;
100 Ok(tokio::fs::try_exists(&p).await?)
101 }
102
103 pub async fn metadata(&self, path: &str) -> Result<FsMeta, FsError> {
104 let p = self.resolve(path).await?;
105 let meta = tokio::fs::metadata(&p).await.map_err(map_io)?;
106 Ok(meta_to_fs(&meta))
107 }
108
109 pub async fn read(&self, path: &str) -> Result<Vec<u8>, FsError> {
110 let p = self.resolve(path).await?;
111 tokio::fs::read(&p).await.map_err(map_io)
112 }
113
114 pub async fn read_to_string(&self, path: &str) -> Result<String, FsError> {
115 let p = self.resolve(path).await?;
116 tokio::fs::read_to_string(&p).await.map_err(map_io)
117 }
118
119 pub async fn write(&self, path: &str, data: impl AsRef<[u8]>) -> Result<(), FsError> {
120 let p = self.resolve(path).await?;
121 if let Some(parent) = p.parent() {
122 if parent != self.inner.root.as_path() && !parent.as_os_str().is_empty() {
123 tokio::fs::create_dir_all(parent).await?;
124 }
125 }
126 tokio::fs::write(&p, data).await?;
127 self.emit_written(&p);
128 Ok(())
129 }
130
131 pub async fn write_string(&self, path: &str, data: impl AsRef<str>) -> Result<(), FsError> {
132 self.write(path, data.as_ref().as_bytes()).await
133 }
134
135 pub async fn append(&self, path: &str, data: impl AsRef<[u8]>) -> Result<(), FsError> {
136 use tokio::io::AsyncWriteExt;
137 let p = self.resolve(path).await?;
138 if let Some(parent) = p.parent() {
139 if parent != self.inner.root.as_path() && !parent.as_os_str().is_empty() {
140 tokio::fs::create_dir_all(parent).await?;
141 }
142 }
143 let mut file = tokio::fs::OpenOptions::new()
144 .create(true)
145 .append(true)
146 .open(&p)
147 .await?;
148 file.write_all(data.as_ref()).await?;
149 self.emit_written(&p);
150 Ok(())
151 }
152
153 pub async fn create_dir(&self, path: &str) -> Result<(), FsError> {
154 let p = self.resolve(path).await?;
155 tokio::fs::create_dir_all(&p).await?;
156 self.emit_dir(&p);
157 Ok(())
158 }
159
160 pub async fn remove_file(&self, path: &str) -> Result<(), FsError> {
161 let p = self.resolve(path).await?;
162 tokio::fs::remove_file(&p).await.map_err(map_io)?;
163 self.emit_removed(&p);
164 Ok(())
165 }
166
167 pub async fn remove_dir(&self, path: &str) -> Result<(), FsError> {
168 let p = self.resolve(path).await?;
169 if p == self.inner.root {
170 return Err(FsError::Forbidden);
171 }
172 tokio::fs::remove_dir_all(&p).await.map_err(map_io)?;
173 self.emit_removed(&p);
174 Ok(())
175 }
176
177 pub async fn copy(&self, from: &str, to: &str) -> Result<(), FsError> {
178 let src = self.resolve(from).await?;
179 let dst = self.resolve(to).await?;
180 if let Some(parent) = dst.parent() {
181 if parent != self.inner.root.as_path() && !parent.as_os_str().is_empty() {
182 tokio::fs::create_dir_all(parent).await?;
183 }
184 }
185 tokio::fs::copy(&src, &dst).await.map_err(map_io)?;
186 self.emit_written(&dst);
187 Ok(())
188 }
189
190 pub async fn rename(&self, from: &str, to: &str) -> Result<(), FsError> {
191 let src = self.resolve(from).await?;
192 let dst = self.resolve(to).await?;
193 if let Some(parent) = dst.parent() {
194 if parent != self.inner.root.as_path() && !parent.as_os_str().is_empty() {
195 tokio::fs::create_dir_all(parent).await?;
196 }
197 }
198 tokio::fs::rename(&src, &dst).await.map_err(map_io)?;
199 self.emit_removed(&src);
200 self.emit_written(&dst);
201 Ok(())
202 }
203
204 pub async fn read_dir(&self, path: &str) -> Result<Vec<FsEntry>, FsError> {
205 let p = self.resolve(path).await?;
206 let mut rd = tokio::fs::read_dir(&p).await.map_err(map_io)?;
207 let mut out = Vec::new();
208 while let Some(entry) = rd.next_entry().await.map_err(map_io)? {
209 out.push(entry_to_fs(&self.inner.root, &entry).await?);
210 }
211 out.sort_by(|a, b| a.name.cmp(&b.name));
212 Ok(out)
213 }
214
215 pub async fn walk(&self, path: &str) -> Result<Vec<FsEntry>, FsError> {
216 let p = self.resolve(path).await?;
217 let mut out = Vec::new();
218 walk_dir(
219 &self.inner.root,
220 &p,
221 0,
222 self.inner.max_walk_depth,
223 self.inner.max_walk_entries,
224 &mut out,
225 )
226 .await?;
227 Ok(out)
228 }
229}
230
231async fn walk_dir(
232 root: &Path,
233 dir: &Path,
234 depth: usize,
235 max_depth: usize,
236 max_entries: usize,
237 out: &mut Vec<FsEntry>,
238) -> Result<(), FsError> {
239 if depth > max_depth {
240 return Err(FsError::Msg(format!(
241 "walk max_depth={max_depth} exceeded"
242 )));
243 }
244 let mut rd = tokio::fs::read_dir(dir).await.map_err(map_io)?;
245 while let Some(entry) = rd.next_entry().await.map_err(map_io)? {
246 if out.len() >= max_entries {
247 return Err(FsError::Msg(format!(
248 "walk max_entries={max_entries} exceeded"
249 )));
250 }
251 let fs_entry = entry_to_fs(root, &entry).await?;
252 let is_dir = fs_entry.is_dir;
253 let child = entry.path();
254 out.push(fs_entry);
255 if is_dir {
256 Box::pin(walk_dir(
257 root,
258 &child,
259 depth + 1,
260 max_depth,
261 max_entries,
262 out,
263 ))
264 .await?;
265 }
266 }
267 Ok(())
268}
269
270async fn entry_to_fs(
271 root: &Path,
272 entry: &tokio::fs::DirEntry,
273) -> Result<FsEntry, FsError> {
274 let path = entry.path();
275 let meta = entry.metadata().await.map_err(map_io)?;
276 let name = entry.file_name().to_string_lossy().into_owned();
277 Ok(FsEntry {
278 path: rel_display(root, &path),
279 name,
280 is_file: meta.is_file(),
281 is_dir: meta.is_dir(),
282 len: meta.len(),
283 modified: meta.modified().ok(),
284 })
285}
286
287fn meta_to_fs(meta: &std::fs::Metadata) -> FsMeta {
288 FsMeta {
289 is_file: meta.is_file(),
290 is_dir: meta.is_dir(),
291 len: meta.len(),
292 modified: meta.modified().ok(),
293 }
294}
295
296fn map_io(e: std::io::Error) -> FsError {
297 if e.kind() == std::io::ErrorKind::NotFound {
298 FsError::NotFound
299 } else {
300 FsError::Io(e)
301 }
302}
303
304pub struct FsPlugin {
306 root: PathBuf,
307 root_explicit: bool,
308 max_walk_depth: usize,
309 max_walk_entries: usize,
310}
311
312impl FsPlugin {
313 pub fn max_walk_depth(mut self, n: usize) -> Self {
314 self.max_walk_depth = n;
315 self
316 }
317
318 pub fn max_walk_entries(mut self, n: usize) -> Self {
319 self.max_walk_entries = n;
320 self
321 }
322
323 pub async fn into_fs(self) -> Result<Fs, FsError> {
325 prepare_root(&self.root).await.map(|root| Fs {
326 inner: Arc::new(FsInner {
327 root,
328 max_walk_depth: self.max_walk_depth,
329 max_walk_entries: self.max_walk_entries,
330 events: None,
331 }),
332 })
333 }
334}
335
336async fn prepare_root(root: &Path) -> Result<PathBuf, FsError> {
337 tokio::fs::create_dir_all(root).await?;
338 Ok(tokio::fs::canonicalize(root).await?)
339}
340
341pub trait FsExt {
343 fn fs(&self) -> Fs;
344}
345
346impl FsExt for Request {
347 fn fs(&self) -> Fs {
348 self.try_state::<Fs>()
349 .map(|a| (*a).clone())
350 .expect("Fs plugin is not installed (missing req.fs())")
351 }
352}
353
354impl Plugin for FsPlugin {
355 fn id(&self) -> &'static str {
356 "fs"
357 }
358
359 fn meta(&self) -> sova_core::PluginMeta {
360 sova_core::PluginMeta::new("Fs")
361 .description("Local filesystem with jail root (async CRUD + walk)")
362 .version(env!("CARGO_PKG_VERSION"))
363 }
364
365 fn install(self, app: &mut App) {
366 let mut root = self.root;
367 let mut explicit = self.root_explicit;
368
369 if !explicit {
370 if let Some(doc) = app.config_doc() {
371 if let Some(section) = doc.section("fs") {
372 if let Some(r) = section.get("root").and_then(|v| v.as_str()) {
373 root = PathBuf::from(r);
374 explicit = true;
375 let _ = explicit;
376 }
377 }
378 }
379 if let Ok(env) = std::env::var("SOVA_FS_ROOT") {
380 if !env.is_empty() {
381 root = PathBuf::from(env);
382 }
383 }
384 }
385
386 let max_walk_depth = self.max_walk_depth;
387 let max_walk_entries = self.max_walk_entries;
388 let events = Some(app.events());
389
390 std::fs::create_dir_all(&root).unwrap_or_else(|e| {
392 panic!("sova-fs: create root {}: {e}", root.display());
393 });
394 let root = std::fs::canonicalize(&root).unwrap_or_else(|e| {
395 panic!("sova-fs: canonicalize root {}: {e}", root.display());
396 });
397
398 app.state(Fs {
399 inner: Arc::new(FsInner {
400 root,
401 max_walk_depth,
402 max_walk_entries,
403 events,
404 }),
405 });
406 }
407}