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    capability::{
12        table_fs_capability, table_fs_mkdir_capability, table_fs_read_capability,
13        table_fs_rmdir_capability, table_fs_write_capability,
14    },
15    id::CORE_TABLE_CLASS_ID,
16    object::ClassRef,
17    table::{Dir, Table},
18};
19
20use crate::citizen::fs_dir_class_symbol;
21use crate::roadmap11::{decode_expr_for_ext, encode_expr_for_ext, infer_ext_from_expr, known_exts};
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    fn leaf_candidates(&self, name: &Symbol) -> Result<Vec<(PathBuf, &'static str)>> {
74        let base = self.segment(name)?;
75        let mut matches = Vec::new();
76        for ext in known_exts() {
77            let path = base.with_extension(ext);
78            self.ensure_within_root(&path)?;
79            if path.is_file() {
80                matches.push((path, ext));
81            }
82        }
83        Ok(matches)
84    }
85
86    fn leaf_path_for_read(&self, name: &Symbol) -> Result<Option<(PathBuf, &'static str)>> {
87        let matches = self.leaf_candidates(name)?;
88        match matches.len() {
89            0 => Ok(None),
90            1 => Ok(matches.into_iter().next()),
91            _ => Err(Error::Eval(format!(
92                "table/fs: multiple leaf files found for key {name}"
93            ))),
94        }
95    }
96
97    fn codec_for_ext(ext: &str) -> Result<Symbol> {
98        match ext {
99            "siml" => Ok(Symbol::qualified("codec", "lisp")),
100            "simb" => Ok(Symbol::qualified("codec", "binary")),
101            "simb64" => Ok(Symbol::qualified("codec", "binary-base64")),
102            "simj" => Ok(Symbol::qualified("codec", "json")),
103            "sima" => Ok(Symbol::qualified("codec", "algol")),
104            other => Err(Error::Eval(format!("table/fs: unknown extension {other}"))),
105        }
106    }
107
108    fn decode_expr_bytes(cx: &mut Cx, codec: &Symbol, bytes: &[u8]) -> Result<Expr> {
109        decode_with_codec(
110            cx,
111            codec,
112            Input::Bytes(bytes.to_vec()),
113            ReadPolicy::default(),
114        )
115    }
116
117    fn encode_expr_bytes(cx: &mut Cx, codec: &Symbol, expr: &Expr) -> Result<Vec<u8>> {
118        match encode_with_codec(cx, codec, expr, EncodeOptions::default())? {
119            Output::Text(text) => Ok(text.into_bytes()),
120            Output::Bytes(bytes) => Ok(bytes),
121        }
122    }
123}
124
125impl Object for FsDir {
126    fn display(&self, _cx: &mut Cx) -> Result<String> {
127        Ok(format!("table/fs[{}]", self.root.display()))
128    }
129
130    fn as_any(&self) -> &dyn std::any::Any {
131        self
132    }
133}
134
135impl sim_kernel::ObjectCompat for FsDir {
136    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
137        let symbol = fs_dir_class_symbol();
138        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
139            return Ok(value.clone());
140        }
141        let symbol = Symbol::qualified("core", "Table");
142        if let Some(value) = cx.registry().class_by_symbol(&symbol) {
143            return Ok(value.clone());
144        }
145        cx.factory().class_stub(CORE_TABLE_CLASS_ID, symbol)
146    }
147    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
148        self.as_table_expr(cx)
149    }
150    fn truth(&self, cx: &mut Cx) -> Result<bool> {
151        Ok(!self.is_empty(cx)?)
152    }
153    fn as_table_impl(&self) -> Option<&dyn Table> {
154        Some(self)
155    }
156    fn as_dir(&self) -> Option<&dyn Dir> {
157        Some(self)
158    }
159    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
160        Some(self)
161    }
162}
163
164impl ObjectEncode for FsDir {
165    fn object_encoding(&self, _cx: &mut Cx) -> Result<ObjectEncoding> {
166        Ok(ObjectEncoding::Constructor {
167            class: fs_dir_class_symbol(),
168            args: vec![
169                Expr::Symbol(Symbol::new("v0")),
170                Expr::String(self.root.display().to_string()),
171            ],
172        })
173    }
174}
175
176impl sim_citizen::Citizen for FsDir {
177    fn citizen_symbol() -> Symbol {
178        fs_dir_class_symbol()
179    }
180
181    fn citizen_version() -> u32 {
182        0
183    }
184
185    fn citizen_arity() -> usize {
186        1
187    }
188
189    fn citizen_fields() -> &'static [&'static str] {
190        &["root"]
191    }
192}
193
194impl Table for FsDir {
195    fn backend_symbol(&self) -> Symbol {
196        Symbol::qualified("table", "fs")
197    }
198
199    fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
200        cx.require(&table_fs_read_capability())?;
201        match self.leaf_path_for_read(&key)? {
202            Some((path, ext)) => {
203                let bytes = std::fs::read(&path)
204                    .map_err(|err| Error::Eval(format!("table/fs: read {err}")))?;
205                let expr = match decode_expr_for_ext(ext, &bytes) {
206                    Some(expr) => expr?,
207                    None => {
208                        let codec = Self::codec_for_ext(ext)?;
209                        Self::decode_expr_bytes(cx, &codec, &bytes)?
210                    }
211                };
212                cx.factory().expr(expr)
213            }
214            None => cx.factory().nil(),
215        }
216    }
217
218    fn set(&self, cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
219        cx.require(&table_fs_write_capability())?;
220        let base = self.segment(&key)?;
221        if base.is_dir() {
222            return Err(Error::Eval(format!("table/fs: {key} is a directory")));
223        }
224        let existing_leaf = self.leaf_path_for_read(&key)?;
225        for (path, _) in self.leaf_candidates(&key)? {
226            if Some(path.clone()) != existing_leaf.as_ref().map(|(path, _)| path.clone())
227                && path.extension().and_then(|ext| ext.to_str()) != Some(DEFAULT_EXT)
228            {
229                std::fs::remove_file(&path)
230                    .map_err(|err| Error::Eval(format!("table/fs: write {err}")))?;
231            }
232        }
233        let expr = value.object().as_expr(cx)?;
234        let ext = existing_leaf
235            .as_ref()
236            .map(|(_, ext)| *ext)
237            .or_else(|| infer_ext_from_expr(&expr))
238            .unwrap_or(DEFAULT_EXT);
239        let path = base.with_extension(ext);
240        self.ensure_within_root(&path)?;
241        let bytes = match encode_expr_for_ext(ext, &expr) {
242            Some(bytes) => bytes?,
243            None => {
244                let codec = Symbol::qualified("codec", "lisp");
245                Self::encode_expr_bytes(cx, &codec, &expr)?
246            }
247        };
248        std::fs::write(&path, bytes)
249            .map_err(|err| Error::Eval(format!("table/fs: write {err}")))?;
250        Ok(())
251    }
252
253    fn has(&self, cx: &mut Cx, key: Symbol) -> Result<bool> {
254        cx.require(&table_fs_read_capability())?;
255        let path = self.segment(&key)?;
256        Ok(path.is_dir() || self.leaf_path_for_read(&key)?.is_some())
257    }
258
259    fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
260        cx.require(&table_fs_write_capability())?;
261        match self.leaf_path_for_read(&key)? {
262            Some((path, ext)) => {
263                let bytes = std::fs::read(&path).unwrap_or_default();
264                std::fs::remove_file(&path)
265                    .map_err(|err| Error::Eval(format!("table/fs: del {err}")))?;
266                let expr = match decode_expr_for_ext(ext, &bytes) {
267                    Some(expr) => expr,
268                    None => {
269                        let codec = Self::codec_for_ext(ext)?;
270                        Self::decode_expr_bytes(cx, &codec, &bytes)
271                    }
272                };
273                match expr {
274                    Ok(expr) => cx.factory().expr(expr),
275                    Err(_) => cx.factory().nil(),
276                }
277            }
278            None => cx.factory().nil(),
279        }
280    }
281
282    fn keys(&self, cx: &mut Cx) -> Result<Vec<Symbol>> {
283        cx.require(&table_fs_read_capability())?;
284        let mut keys = BTreeSet::new();
285        let entries = std::fs::read_dir(&self.root)
286            .map_err(|err| Error::Eval(format!("table/fs: read_dir {err}")))?;
287        for entry in entries {
288            let entry = entry.map_err(|err| Error::Eval(format!("table/fs: {err}")))?;
289            let path = entry.path();
290            self.ensure_within_root(&path)?;
291            let name = entry.file_name().to_string_lossy().to_string();
292            if name.starts_with('.') {
293                continue;
294            }
295            if path.is_dir() {
296                keys.insert(Symbol::new(name));
297                continue;
298            }
299            let Some(stem) = known_exts().into_iter().find_map(|ext| {
300                name.strip_suffix(&format!(".{ext}"))
301                    .map(std::borrow::ToOwned::to_owned)
302            }) else {
303                continue;
304            };
305            keys.insert(Symbol::new(stem));
306        }
307        Ok(keys.into_iter().collect())
308    }
309
310    fn entries(&self, cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
311        cx.require(&table_fs_read_capability())?;
312        let mut entries = Vec::new();
313        for key in self.keys(cx)? {
314            if self.is_dir(cx, key.clone())? {
315                continue;
316            }
317            entries.push((key.clone(), self.get(cx, key)?));
318        }
319        Ok(entries)
320    }
321
322    fn len(&self, cx: &mut Cx) -> Result<usize> {
323        Ok(self.entries(cx)?.len())
324    }
325
326    fn clear(&self, cx: &mut Cx) -> Result<()> {
327        cx.require(&table_fs_write_capability())?;
328        for key in self.keys(cx)? {
329            if !self.is_dir(cx, key.clone())? {
330                let _ = self.del(cx, key)?;
331            }
332        }
333        Ok(())
334    }
335}
336
337impl Dir for FsDir {
338    fn mkdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
339        cx.require(&table_fs_mkdir_capability())?;
340        let path = self.segment(&name)?;
341        if self.leaf_path_for_read(&name)?.is_some() {
342            return Err(Error::Eval(format!("table/fs: {name} is a file")));
343        }
344        std::fs::create_dir_all(&path)
345            .map_err(|err| Error::Eval(format!("table/fs: mkdir {err}")))?;
346        cx.factory().opaque(Arc::new(Self::open(path)?))
347    }
348
349    fn opendir(&self, cx: &mut Cx, name: Symbol) -> Result<Option<Value>> {
350        cx.require(&table_fs_read_capability())?;
351        let path = self.segment(&name)?;
352        if path.is_dir() {
353            Ok(Some(cx.factory().opaque(Arc::new(Self::open(path)?))?))
354        } else if path.exists() || self.leaf_path_for_read(&name)?.is_some() {
355            Err(Error::Eval(format!("table/fs: {name} is not a directory")))
356        } else {
357            Ok(None)
358        }
359    }
360
361    fn rmdir(&self, cx: &mut Cx, name: Symbol) -> Result<Value> {
362        cx.require(&table_fs_rmdir_capability())?;
363        let path = self.segment(&name)?;
364        if !path.is_dir() {
365            return Err(Error::Eval(format!("table/fs: {name} is not a directory")));
366        }
367        std::fs::remove_dir_all(&path)
368            .map_err(|err| Error::Eval(format!("table/fs: rmdir {err}")))?;
369        cx.factory().nil()
370    }
371
372    fn is_dir(&self, cx: &mut Cx, name: Symbol) -> Result<bool> {
373        cx.require(&table_fs_read_capability())?;
374        Ok(self.segment(&name)?.is_dir())
375    }
376}
377
378/// Opens a filesystem table at `root` and returns it as a runtime table value.
379///
380/// Requires the table-fs capability; the returned value wraps an [`FsDir`].
381pub fn install_fs_dir_lib(cx: &mut Cx, root: &str) -> Result<Value> {
382    cx.require(&table_fs_capability())?;
383    let dir = FsDir::open(PathBuf::from(root))?;
384    cx.factory().opaque(Arc::new(dir))
385}