Skip to main content

sova_fs/
fs.rs

1//! [`Fs`] handle + [`FsPlugin`] + [`FsExt`].
2
3use 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/// Cloneable filesystem handle rooted at a jail directory.
38#[derive(Clone)]
39pub struct Fs {
40    inner: Arc<FsInner>,
41}
42
43impl Fs {
44    /// Builder for [`FsPlugin`] (installed via `app.install`).
45    #[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    /// Build from `SOVA_FS_ROOT` (default `./data`).
56    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
304/// Plugin builder installed via [`Plugin::install`].
305pub 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    /// Build handle after ensuring root exists and is canonical (for tests).
324    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
341/// `req.fs()`.
342pub 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        // Sync create + canonicalize so state is ready before accept.
391        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}