Skip to main content

geop_cad_base/
disk.rs

1//! A workspace on disk: the folder of programs and data files an editor
2//! edits ([`Folder`]), the folder a path names ([`workspace_of`]), and
3//! where what the files are made into is kept beside them ([`DiskCache`]).
4//! What `geop open` serves to a browser and the desktop app edits alike.
5
6use geop_core_math::geop_error::{GeopError, GeopResult};
7use geop_core_program::cache::{CACHE_EXTENSION, Cache, CacheStore};
8use std::{
9    collections::BTreeMap,
10    path::{Path, PathBuf},
11};
12
13/// The most files, and bytes in all, of a folder the editor is sent: a
14/// folder much larger is no workspace, but a home directory opened by
15/// mistake.
16pub const MOST_FILES: usize = 10_000;
17pub const MOST_BYTES: u64 = 512 << 20;
18
19/// The folder edited: the files under it of the extensions the explorer
20/// shows — programs, and the data files operations read (see
21/// `OperationInfo::reads`).
22pub struct Folder {
23    /// Canonical: what every file's own canonical path must start with.
24    root: PathBuf,
25    /// In lower case, `geop` among them.
26    extensions: Vec<String>,
27}
28
29impl Folder {
30    pub fn new(root: &Path, extensions: Vec<String>) -> GeopResult<Self> {
31        let root = std::fs::canonicalize(root)
32            .map_err(|e| GeopError::new(format!("opening {}: {e}", root.display())))?;
33        if !root.is_dir() {
34            return Err(GeopError::new(format!("{} is no folder", root.display())));
35        }
36        Ok(Self { root, extensions })
37    }
38
39    /// The folder, canonical.
40    pub fn root(&self) -> &Path {
41        &self.root
42    }
43
44    /// Whether `path` — relative, `/`-separated — can name a file of the
45    /// folder: no empty, `.` or `..` segment, nothing a platform reads as
46    /// a root, a drive or a stream (`/`, `\`, `:`), and an extension shown.
47    pub fn names_file(&self, path: &str) -> bool {
48        let segments_ok = (path.split('/'))
49            .all(|s| !s.is_empty() && s != "." && s != ".." && !s.contains(['\\', ':', '\0']));
50        let lower = path.to_ascii_lowercase();
51        let extension_ok = (self.extensions.iter()).any(|e| lower.ends_with(&format!(".{e}")));
52        segments_ok && extension_ok
53    }
54
55    /// The file `path` names under the folder — checked, before it is read
56    /// or written, to stay in it: where it is already, its own canonical
57    /// path; where it is not yet, that of the nearest folder above it
58    /// that is. A link out of the folder is refused like `..`.
59    pub fn file(&self, path: &str) -> GeopResult<PathBuf> {
60        if !self.names_file(path) {
61            return Err(GeopError::new(format!(
62                "{path:?} names no file of the workspace"
63            )));
64        }
65        let file = path.split('/').fold(self.root.clone(), |p, s| p.join(s));
66        let existing = (file.ancestors())
67            .find(|p| p.symlink_metadata().is_ok())
68            .ok_or_else(|| GeopError::new(format!("{path:?}: nothing of it exists")))?;
69        let canonical = std::fs::canonicalize(existing)
70            .map_err(|e| GeopError::new(format!("{path:?}: {e}")))?;
71        if !canonical.starts_with(&self.root) {
72            return Err(GeopError::new(format!(
73                "{path:?} lies outside the workspace"
74            )));
75        }
76        Ok(file)
77    }
78
79    /// Every file of the folder, by path, its content as a workspace holds
80    /// it: its text, or a `data:` URL of bytes that are not (see
81    /// [`geop_core_program::workspace::content`]). Hidden folders, `node_modules` and
82    /// `target` are left out, and so is a link out of the folder.
83    pub fn files(&self) -> GeopResult<BTreeMap<String, String>> {
84        let mut files = BTreeMap::new();
85        let mut bytes = 0;
86        let mut folders = vec![(self.root.clone(), String::new())];
87        while let Some((dir, prefix)) = folders.pop() {
88            let entries = std::fs::read_dir(&dir)
89                .map_err(|e| GeopError::new(format!("listing {}: {e}", dir.display())))?;
90            for entry in entries.flatten() {
91                let Some(name) = entry.file_name().to_str().map(String::from) else {
92                    continue;
93                };
94                let path = format!("{prefix}{name}");
95                let Ok(kind) = entry.file_type() else {
96                    continue;
97                };
98                if kind.is_dir() {
99                    if !name.starts_with('.') && name != "node_modules" && name != "target" {
100                        folders.push((entry.path(), format!("{path}/")));
101                    }
102                    continue;
103                }
104                // A link to a file is followed only within the folder; to
105                // a folder, never (it could lead round in a circle).
106                let Ok(file) = self.file(&path) else { continue };
107                if !file.is_file() {
108                    continue;
109                }
110                let text = std::fs::read(&file)
111                    .map_err(|e| GeopError::new(format!("reading {}: {e}", file.display())))?;
112                bytes += text.len() as u64;
113                files.insert(path, geop_core_program::workspace::content(text));
114                if files.len() > MOST_FILES || bytes > MOST_BYTES {
115                    return Err(GeopError::new(format!(
116                        "{} holds more than {MOST_FILES} files or {} MB of programs and data: open a folder of one project",
117                        self.root.display(),
118                        MOST_BYTES >> 20
119                    )));
120                }
121            }
122        }
123        Ok(files)
124    }
125
126    /// Write the file `path`, its content `content` as a workspace holds
127    /// it (see [`Folder::files`]): its bytes, the decoded ones of a `data:`
128    /// URL. Written whole, then put in place: a reader meanwhile sees the
129    /// old file or the new, never half of one.
130    pub fn write(&self, path: &str, content: &str) -> GeopResult<()> {
131        let text = geop_core_program::workspace::bytes(content)?;
132        let file = self.file(path)?;
133        let io = |e: std::io::Error| GeopError::new(format!("writing {}: {e}", file.display()));
134        let dir = file.parent().expect("a file of the folder is in it");
135        std::fs::create_dir_all(dir).map_err(io)?;
136        let name = file
137            .file_name()
138            .expect("a file has a name")
139            .to_string_lossy();
140        let partial = dir.join(format!(".{name}.{}.partial", std::process::id()));
141        let written =
142            std::fs::write(&partial, &text).and_then(|()| std::fs::rename(&partial, &file));
143        if written.is_err() {
144            let _ = std::fs::remove_file(&partial);
145        }
146        written.map_err(io)
147    }
148
149    /// Remove the file `path`; one already gone is no error.
150    pub fn remove(&self, path: &str) -> GeopResult<()> {
151        let file = self.file(path)?;
152        match std::fs::remove_file(&file) {
153            Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
154                Err(GeopError::new(format!("removing {}: {e}", file.display())))
155            }
156            _ => Ok(()),
157        }
158    }
159}
160
161/// The folder `path` names, and the program in it to edit first if it
162/// names one.
163pub fn workspace_of(path: &Path) -> GeopResult<(PathBuf, Option<String>)> {
164    if path.is_dir() {
165        return Ok((path.to_path_buf(), None));
166    }
167    let is_program = path.extension().is_some_and(|e| e == "geop");
168    let name = path.file_name().and_then(|n| n.to_str());
169    match (is_program && path.is_file(), name) {
170        (true, Some(name)) => {
171            let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
172            Ok((
173                dir.unwrap_or(Path::new(".")).to_path_buf(),
174                Some(name.to_string()),
175            ))
176        }
177        _ => Err(GeopError::new(format!(
178            "{} is neither a folder nor a .geop program",
179            path.display()
180        ))),
181    }
182}
183
184/// Cache files on disk: `path.geopc` under `root` for the file `path`, of
185/// this build of the kernel (see `geop_core_program::cache`).
186pub struct DiskCache {
187    root: PathBuf,
188    build: String,
189}
190
191impl DiskCache {
192    /// The cache files under `root`, of the executable running: its version,
193    /// and its size and time on disk, so that a kernel built again makes
194    /// again what an older one kept. None if the executable cannot be told
195    /// apart: nothing is then kept on disk.
196    pub fn of(root: PathBuf) -> Option<Self> {
197        let exe = std::fs::metadata(std::env::current_exe().ok()?).ok()?;
198        let modified = exe
199            .modified()
200            .ok()?
201            .duration_since(std::time::UNIX_EPOCH)
202            .ok()?;
203        let build = format!(
204            "{} {} {}",
205            env!("CARGO_PKG_VERSION"),
206            exe.len(),
207            modified.as_nanos()
208        );
209        Some(Self { root, build })
210    }
211
212    /// What `self` makes of files: kept in it, if there is one, and in
213    /// memory.
214    pub fn cache(this: Option<Self>) -> Cache {
215        match this {
216            Some(store) => Cache::stored(Box::new(store)),
217            None => Cache::new(),
218        }
219    }
220}
221
222impl CacheStore for DiskCache {
223    fn build(&self) -> &str {
224        &self.build
225    }
226
227    fn read(&self, path: &str) -> Option<String> {
228        std::fs::read_to_string(self.root.join(path)).ok()
229    }
230
231    /// Written whole, then put in place: a run reading it meanwhile, or one
232    /// cut short, sees the old file or the new, never half of one. Not
233    /// written is only made again next time, so it is said on stderr and
234    /// nothing else.
235    fn write(&self, path: &str, text: &str) {
236        let target = self.root.join(path);
237        let partial = target.with_extension(format!("{CACHE_EXTENSION}.{}", std::process::id()));
238        // A shared library's cache files are in a folder of the cache's own.
239        let folder = target.parent().map_or(Ok(()), std::fs::create_dir_all);
240        let written = (folder.and_then(|()| std::fs::write(&partial, text)))
241            .and_then(|()| std::fs::rename(&partial, &target));
242        if let Err(e) = written {
243            let _ = std::fs::remove_file(&partial);
244            eprintln!("geop: could not keep {}: {e}", target.display());
245        }
246    }
247}
248
249/// The extensions of the files an editor of `registry` shows in a
250/// workspace, in lower case: programs (`geop`), and the data files its
251/// operations read (see `OperationInfo::reads`).
252pub fn extensions(registry: &geop_core_editor::registry::Registry) -> Vec<String> {
253    let mut extensions: Vec<String> = vec!["geop".into()];
254    for listed in registry.infos() {
255        extensions.extend(listed.info.reads.iter().map(|e| e.to_ascii_lowercase()));
256    }
257    extensions.sort();
258    extensions.dedup();
259    extensions
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn scratch(test: &str) -> PathBuf {
267        let dir = std::env::temp_dir().join(format!("geop-disk-{test}-{}", std::process::id()));
268        let _ = std::fs::remove_dir_all(&dir);
269        std::fs::create_dir_all(&dir).unwrap();
270        dir
271    }
272
273    fn folder(dir: &Path) -> Folder {
274        Folder::new(dir, vec!["geop".into(), "step".into(), "stp".into()]).unwrap()
275    }
276
277    #[test]
278    fn a_path_names_a_file_of_the_folder_only_relatively_and_by_a_shown_extension() {
279        let dir = scratch("names");
280        let f = folder(&dir);
281        for ok in ["part.geop", "parts/bolt.geop", "a b/c.STEP", "x.stp"] {
282            assert!(f.names_file(ok), "{ok}");
283        }
284        for bad in [
285            "",
286            "/etc/passwd.geop",
287            "../up.geop",
288            "a/../../up.geop",
289            "./part.geop",
290            "a//b.geop",
291            "a\\..\\b.geop",
292            "C:/x.geop",
293            "c:x.geop",
294            "part.geop:stream",
295            "notes.txt",
296            "part.geop/",
297            "part.geopc",
298        ] {
299            assert!(!f.names_file(bad), "{bad}");
300            assert!(f.file(bad).is_err(), "{bad}");
301        }
302    }
303
304    #[test]
305    fn bytes_that_are_no_text_travel_as_a_data_url() {
306        let dir = scratch("bytes");
307        let f = folder(&dir);
308        let content = geop_core_program::workspace::content(vec![0xff, 0x00, 0x80]);
309        f.write("mesh.step", &content).unwrap();
310        assert_eq!(
311            std::fs::read(dir.join("mesh.step")).unwrap(),
312            [0xff, 0x00, 0x80]
313        );
314        assert_eq!(f.files().unwrap()["mesh.step"], content);
315    }
316
317    #[test]
318    fn files_are_read_and_written_in_the_folder() {
319        let dir = scratch("rw");
320        let f = folder(&dir);
321        f.write("parts/bolt.geop", "{\"steps\": []}\n").unwrap();
322        f.write("part.geop", "{}").unwrap();
323        std::fs::write(dir.join("notes.txt"), "not shown").unwrap();
324        std::fs::create_dir_all(dir.join(".git")).unwrap();
325        std::fs::write(dir.join(".git/hidden.geop"), "{}").unwrap();
326        let files = f.files().unwrap();
327        assert_eq!(
328            files.keys().collect::<Vec<_>>(),
329            ["part.geop", "parts/bolt.geop"]
330        );
331        assert_eq!(files["parts/bolt.geop"], "{\"steps\": []}\n");
332        f.remove("part.geop").unwrap();
333        f.remove("part.geop").unwrap();
334        assert!(!dir.join("part.geop").exists());
335        // No partial file is left behind.
336        let left: Vec<_> = std::fs::read_dir(dir.join("parts")).unwrap().collect();
337        assert_eq!(left.len(), 1);
338    }
339
340    #[cfg(unix)]
341    #[test]
342    fn a_link_out_of_the_folder_is_refused() {
343        let dir = scratch("links");
344        let outside = scratch("links-outside");
345        std::fs::write(outside.join("secret.geop"), "secret").unwrap();
346        let root = dir.join("root");
347        std::fs::create_dir_all(&root).unwrap();
348        std::fs::write(root.join("inside.geop"), "inside").unwrap();
349        std::os::unix::fs::symlink(&outside, root.join("out")).unwrap();
350        std::os::unix::fs::symlink(outside.join("secret.geop"), root.join("secret.geop")).unwrap();
351        std::os::unix::fs::symlink(root.join("inside.geop"), root.join("alias.geop")).unwrap();
352        let f = folder(&root);
353        assert!(f.file("out/secret.geop").is_err());
354        assert!(f.file("out/new/new.geop").is_err());
355        assert!(f.file("secret.geop").is_err());
356        assert!(f.write("out/written.geop", "x").is_err());
357        assert!(!outside.join("written.geop").exists());
358        assert!(f.write("secret.geop", "x").is_err());
359        assert_eq!(
360            std::fs::read_to_string(outside.join("secret.geop")).unwrap(),
361            "secret"
362        );
363        assert!(f.remove("secret.geop").is_err());
364        assert!(outside.join("secret.geop").exists());
365        // Listed: what is in the folder, a link within it included.
366        let files = f.files().unwrap();
367        assert_eq!(
368            files.keys().collect::<Vec<_>>(),
369            ["alias.geop", "inside.geop"]
370        );
371    }
372
373    #[test]
374    fn a_program_named_is_opened_in_its_folder() {
375        let dir = scratch("workspace-of");
376        std::fs::write(dir.join("a.geop"), "{}").unwrap();
377        assert_eq!(workspace_of(&dir).unwrap(), (dir.clone(), None));
378        assert_eq!(
379            workspace_of(&dir.join("a.geop")).unwrap(),
380            (dir.clone(), Some("a.geop".into()))
381        );
382        assert!(workspace_of(&dir.join("missing.geop")).is_err());
383        assert!(workspace_of(&dir.join("missing")).is_err());
384    }
385}