Skip to main content

sim_table_fs/
fs_dir.rs

1use std::{
2    collections::BTreeSet,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use sim_codec::{Input, Output, decode_with_codec, encode_with_codec};
8use sim_kernel::{
9    Cx, EncodeOptions, Error, Expr, Object, ObjectEncode, ObjectEncoding, ReadPolicy, Result,
10    Symbol, Value,
11    id::CORE_TABLE_CLASS_ID,
12    object::ClassRef,
13    table::{Dir, Table},
14};
15
16use crate::{
17    capabilities::{require_table_fs_read, require_table_fs_write},
18    citizen::fs_dir_class_symbol,
19    roadmap11::{decode_expr_for_ext, encode_expr_for_ext, infer_ext_from_expr, known_exts},
20    table_fs_capability,
21};
22
23const DEFAULT_EXT: &str = "siml";
24
25/// A SIM table backed by a host directory rooted at a canonical path.
26#[derive(Clone)]
27pub struct FsDir {
28    root: PathBuf,
29}
30
31impl FsDir {
32    /// Opens (creating if needed) the directory at `root` as a filesystem table.
33    ///
34    /// The root is created if it does not exist and then canonicalized; an I/O
35    /// failure on either step is reported as an error.
36    pub fn open(root: PathBuf) -> Result<Self> {
37        std::fs::create_dir_all(&root)
38            .map_err(|err| Error::Eval(format!("table/fs: cannot open root: {err}")))?;
39        let root = std::fs::canonicalize(&root)
40            .map_err(|err| Error::Eval(format!("table/fs: cannot open root: {err}")))?;
41        Ok(Self { root })
42    }
43
44    fn segment(&self, name: &Symbol) -> Result<PathBuf> {
45        let segment = name.name.as_ref();
46        // The shared predicate rejects empty/`.`/`..`/`/`/`\`; table-fs keeps the
47        // additional, stricter `is_absolute()` guard on top of it.
48        if !sim_table_core::is_legal_table_segment(segment) || Path::new(segment).is_absolute() {
49            return Err(Error::Eval(format!("table/fs: illegal name {segment:?}")));
50        }
51        let path = self.root.join(segment);
52        self.ensure_within_root(&path)?;
53        Ok(path)
54    }
55
56    fn ensure_within_root(&self, path: &Path) -> Result<()> {
57        let candidate = if path.exists() {
58            std::fs::canonicalize(path)
59                .map_err(|err| Error::Eval(format!("table/fs: path check {err}")))?
60        } else {
61            path.to_path_buf()
62        };
63        if candidate.starts_with(&self.root) {
64            Ok(())
65        } else {
66            Err(Error::Eval(format!(
67                "table/fs: path escapes root: {}",
68                path.display()
69            )))
70        }
71    }
72
73    pub(crate) fn ensure_internal_path(&self, path: &Path) -> Result<()> {
74        self.ensure_within_root(path)
75    }
76
77    pub(crate) fn root_path(&self) -> &Path {
78        &self.root
79    }
80
81    fn leaf_candidates(&self, name: &Symbol) -> Result<Vec<(PathBuf, &'static str)>> {
82        let base = self.segment(name)?;
83        let mut matches = Vec::new();
84        for ext in known_exts() {
85            let path = base.with_extension(ext);
86            self.ensure_within_root(&path)?;
87            if path.is_file() {
88                matches.push((path, ext));
89            }
90        }
91        Ok(matches)
92    }
93
94    fn leaf_path_for_read(&self, name: &Symbol) -> Result<Option<(PathBuf, &'static str)>> {
95        let matches = self.leaf_candidates(name)?;
96        match matches.len() {
97            0 => Ok(None),
98            1 => Ok(matches.into_iter().next()),
99            _ => Err(Error::Eval(format!(
100                "table/fs: multiple leaf files found for key {name}"
101            ))),
102        }
103    }
104
105    pub(crate) fn read_leaf_expr(
106        &self,
107        cx: &mut Cx,
108        key: &Symbol,
109    ) -> Result<(PathBuf, &'static str, Expr)> {
110        let Some((path, ext)) = self.leaf_path_for_read(key)? else {
111            return Err(Error::Eval(format!("table/fs: {key} is not a file")));
112        };
113        let expr = self.read_leaf_path(cx, &path, ext)?;
114        Ok((path, ext, expr))
115    }
116
117    pub(crate) fn read_leaf_path(&self, cx: &mut Cx, path: &Path, ext: &str) -> Result<Expr> {
118        self.ensure_internal_path(path)?;
119        let bytes =
120            std::fs::read(path).map_err(|err| Error::Eval(format!("table/fs: read {err}")))?;
121        Ok(match decode_expr_for_ext(ext, &bytes) {
122            Some(expr) => expr?,
123            None => {
124                let codec = Self::codec_for_ext(ext)?;
125                Self::decode_expr_bytes(cx, &codec, &bytes)?
126            }
127        })
128    }
129
130    fn codec_for_ext(ext: &str) -> Result<Symbol> {
131        match ext {
132            "siml" => Ok(Symbol::qualified("codec", "lisp")),
133            "simb" => Ok(Symbol::qualified("codec", "binary")),
134            "simb64" => Ok(Symbol::qualified("codec", "binary-base64")),
135            "simj" => Ok(Symbol::qualified("codec", "json")),
136            "sima" => Ok(Symbol::qualified("codec", "algol")),
137            other => Err(Error::Eval(format!("table/fs: unknown extension {other}"))),
138        }
139    }
140
141    fn decode_expr_bytes(cx: &mut Cx, codec: &Symbol, bytes: &[u8]) -> Result<Expr> {
142        decode_with_codec(
143            cx,
144            codec,
145            Input::Bytes(bytes.to_vec()),
146            ReadPolicy::default(),
147        )
148    }
149
150    fn encode_expr_bytes(cx: &mut Cx, codec: &Symbol, expr: &Expr) -> Result<Vec<u8>> {
151        match encode_with_codec(cx, codec, expr, EncodeOptions::default())? {
152            Output::Text(text) => Ok(text.into_bytes()),
153            Output::Bytes(bytes) => Ok(bytes),
154        }
155    }
156
157    pub(crate) fn encode_leaf_expr(cx: &mut Cx, ext: &str, expr: &Expr) -> Result<Vec<u8>> {
158        match encode_expr_for_ext(ext, expr) {
159            Some(bytes) => bytes,
160            None => {
161                let codec = Symbol::qualified("codec", "lisp");
162                Self::encode_expr_bytes(cx, &codec, expr)
163            }
164        }
165    }
166}
167
168impl Object for FsDir {
169    fn display(&self, _cx: &mut Cx) -> Result<String> {
170        Ok(format!("table/fs[{}]", self.root.display()))
171    }
172
173    fn as_any(&self) -> &dyn std::any::Any {
174        self
175    }
176}
177
178impl sim_kernel::ObjectCompat for FsDir {
179    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
180        let symbol = fs_dir_class_symbol();
181        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
182            return Ok(value.clone());
183        }
184        let symbol = Symbol::qualified("core", "Table");
185        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
186            return Ok(value.clone());
187        }
188        cx.factory().class_stub(CORE_TABLE_CLASS_ID, symbol)
189    }
190    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
191        self.as_table_expr(cx)
192    }
193    fn truth(&self, cx: &mut Cx) -> Result<bool> {
194        Ok(!self.is_empty(cx)?)
195    }
196    fn as_table_impl(&self) -> Option<&dyn Table> {
197        Some(self)
198    }
199    fn as_dir(&self) -> Option<&dyn Dir> {
200        Some(self)
201    }
202    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
203        Some(self)
204    }
205}
206
207impl ObjectEncode for FsDir {
208    fn object_encoding(&self, _cx: &mut Cx) -> Result<ObjectEncoding> {
209        Ok(ObjectEncoding::Constructor {
210            class: fs_dir_class_symbol(),
211            args: vec![
212                Expr::Symbol(Symbol::new("v0")),
213                Expr::String(self.root.display().to_string()),
214            ],
215        })
216    }
217}
218
219impl sim_citizen::Citizen for FsDir {
220    fn citizen_symbol() -> Symbol {
221        fs_dir_class_symbol()
222    }
223
224    fn citizen_version() -> u32 {
225        0
226    }
227
228    fn citizen_arity() -> usize {
229        1
230    }
231
232    fn citizen_fields() -> &'static [&'static str] {
233        &["root"]
234    }
235}
236
237impl Table for FsDir {
238    fn backend_symbol(&self) -> Symbol {
239        Symbol::qualified("table", "fs")
240    }
241
242    fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
243        require_table_fs_read(cx)?;
244        match self.leaf_path_for_read(&key)? {
245            Some(_) => {
246                let (_, _, expr) = self.read_leaf_expr(cx, &key)?;
247                cx.factory().expr(expr)
248            }
249            None => cx.factory().nil(),
250        }
251    }
252
253    fn set(&self, cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
254        require_table_fs_write(cx)?;
255        let base = self.segment(&key)?;
256        if base.is_dir() {
257            return Err(Error::Eval(format!("table/fs: {key} is a directory")));
258        }
259        let existing_leaf = self.leaf_path_for_read(&key)?;
260        for (path, _) in self.leaf_candidates(&key)? {
261            if Some(path.clone()) != existing_leaf.as_ref().map(|(path, _)| path.clone())
262                && path.extension().and_then(|ext| ext.to_str()) != Some(DEFAULT_EXT)
263            {
264                std::fs::remove_file(&path)
265                    .map_err(|err| Error::Eval(format!("table/fs: write {err}")))?;
266            }
267        }
268        let expr = value.object().as_expr(cx)?;
269        let ext = existing_leaf
270            .as_ref()
271            .map(|(_, ext)| *ext)
272            .or_else(|| infer_ext_from_expr(&expr))
273            .unwrap_or(DEFAULT_EXT);
274        let path = base.with_extension(ext);
275        self.ensure_within_root(&path)?;
276        let bytes = Self::encode_leaf_expr(cx, ext, &expr)?;
277        std::fs::write(&path, bytes)
278            .map_err(|err| Error::Eval(format!("table/fs: write {err}")))?;
279        Ok(())
280    }
281
282    fn has(&self, cx: &mut Cx, key: Symbol) -> Result<bool> {
283        require_table_fs_read(cx)?;
284        let path = self.segment(&key)?;
285        Ok(path.is_dir() || self.leaf_path_for_read(&key)?.is_some())
286    }
287
288    fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
289        require_table_fs_write(cx)?;
290        match self.leaf_path_for_read(&key)? {
291            Some((path, ext)) => {
292                let bytes = std::fs::read(&path).unwrap_or_default();
293                std::fs::remove_file(&path)
294                    .map_err(|err| Error::Eval(format!("table/fs: del {err}")))?;
295                let expr = match decode_expr_for_ext(ext, &bytes) {
296                    Some(expr) => expr,
297                    None => {
298                        let codec = Self::codec_for_ext(ext)?;
299                        Self::decode_expr_bytes(cx, &codec, &bytes)
300                    }
301                };
302                match expr {
303                    Ok(expr) => cx.factory().expr(expr),
304                    Err(_) => cx.factory().nil(),
305                }
306            }
307            None => cx.factory().nil(),
308        }
309    }
310
311    fn keys(&self, cx: &mut Cx) -> Result<Vec<Symbol>> {
312        require_table_fs_read(cx)?;
313        let mut keys = BTreeSet::new();
314        let entries = std::fs::read_dir(&self.root)
315            .map_err(|err| Error::Eval(format!("table/fs: read_dir {err}")))?;
316        for entry in entries {
317            let entry = entry.map_err(|err| Error::Eval(format!("table/fs: {err}")))?;
318            let path = entry.path();
319            self.ensure_within_root(&path)?;
320            let name = entry.file_name().to_string_lossy().to_string();
321            if name.starts_with('.') {
322                continue;
323            }
324            if path.is_dir() {
325                keys.insert(Symbol::new(name));
326                continue;
327            }
328            let Some(stem) = known_exts().into_iter().find_map(|ext| {
329                name.strip_suffix(&format!(".{ext}"))
330                    .map(std::borrow::ToOwned::to_owned)
331            }) else {
332                continue;
333            };
334            keys.insert(Symbol::new(stem));
335        }
336        Ok(keys.into_iter().collect())
337    }
338
339    fn entries(&self, cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
340        require_table_fs_read(cx)?;
341        let mut entries = Vec::new();
342        for key in self.keys(cx)? {
343            if self.is_dir(cx, key.clone())? {
344                continue;
345            }
346            entries.push((key.clone(), self.get(cx, key)?));
347        }
348        Ok(entries)
349    }
350
351    fn len(&self, cx: &mut Cx) -> Result<usize> {
352        Ok(self.entries(cx)?.len())
353    }
354
355    fn clear(&self, cx: &mut Cx) -> Result<()> {
356        require_table_fs_write(cx)?;
357        for key in self.keys(cx)? {
358            if !self.is_dir(cx, key.clone())? {
359                let _ = self.del(cx, key)?;
360            }
361        }
362        Ok(())
363    }
364}
365
366impl Dir for FsDir {
367    fn mkdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
368        require_table_fs_write(cx)?;
369        let path = self.segment(&name)?;
370        if self.leaf_path_for_read(&name)?.is_some() {
371            return Err(Error::Eval(format!("table/fs: {name} is a file")));
372        }
373        std::fs::create_dir_all(&path)
374            .map_err(|err| Error::Eval(format!("table/fs: mkdir {err}")))?;
375        cx.factory().opaque(Arc::new(Self::open(path)?))
376    }
377
378    fn opendir(&self, cx: &mut Cx, name: Symbol) -> Result<Option<Value>> {
379        require_table_fs_read(cx)?;
380        let path = self.segment(&name)?;
381        if path.is_dir() {
382            Ok(Some(cx.factory().opaque(Arc::new(Self::open(path)?))?))
383        } else if path.exists() || self.leaf_path_for_read(&name)?.is_some() {
384            Err(Error::Eval(format!("table/fs: {name} is not a directory")))
385        } else {
386            Ok(None)
387        }
388    }
389
390    fn rmdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
391        require_table_fs_write(cx)?;
392        let path = self.segment(&name)?;
393        if !path.is_dir() {
394            return Err(Error::Eval(format!("table/fs: {name} is not a directory")));
395        }
396        std::fs::remove_dir_all(&path)
397            .map_err(|err| Error::Eval(format!("table/fs: rmdir {err}")))?;
398        cx.factory().nil()
399    }
400
401    fn is_dir(&self, cx: &mut Cx, name: Symbol) -> Result<bool> {
402        require_table_fs_read(cx)?;
403        Ok(self.segment(&name)?.is_dir())
404    }
405}
406
407/// Opens a filesystem table at `root` and returns it as a runtime table value.
408///
409/// Requires the table-fs capability; the returned value wraps an [`FsDir`].
410pub fn install_fs_dir_lib(cx: &mut Cx, root: &str) -> Result<Value> {
411    cx.require(&table_fs_capability())?;
412    let dir = FsDir::open(PathBuf::from(root))?;
413    cx.factory().opaque(Arc::new(dir))
414}