Skip to main content

twine_core/
files.rs

1//! Lazy directory listings and a bounded text preview. A dedicated worker watches only expanded
2//! directories and the selected file, with a single latest snapshot for the client to poll.
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use thiserror::Error;
7
8pub(crate) mod access;
9mod editing;
10mod git;
11mod text;
12mod watcher;
13pub(crate) use editing::save;
14pub use editing::{FileSaveOutcome, FileSaveRequest};
15pub(crate) use text::read_preview;
16pub(crate) use watcher::FileWatcher;
17
18pub const TEXT_LIMIT: u64 = 2 * 1024 * 1024;
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct FileEntry {
22    pub path: PathBuf,
23    pub name: String,
24    pub kind: FileKind,
25    pub git: Option<crate::git::status::GitDecoration>,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum FileKind {
30    Directory,
31    File,
32    Symlink,
33    Other,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct DirectoryListing {
38    pub path: PathBuf,
39    pub entries: Vec<FileEntry>,
40    pub error: Option<String>,
41}
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum FileContent {
45    Text(String),
46    Binary,
47    TooLarge,
48    Missing,
49    Unsupported,
50    Unavailable(String),
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct FilePreview {
55    pub path: PathBuf,
56    pub content: FileContent,
57    pub version: Option<FileVersion>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct FileVersion {
62    pub fingerprint: String,
63    pub utf8_bom: bool,
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct FileSnapshot {
68    pub revision: u64,
69    pub folder: PathBuf,
70    pub directories: Vec<DirectoryListing>,
71    pub file: Option<FilePreview>,
72    pub git: crate::git::status::GitSnapshot,
73}
74
75#[derive(Default)]
76pub(crate) struct FileBrowser {
77    snapshot: Option<FileSnapshot>,
78    revision: u64,
79    pub(super) git: crate::git::status::GitSnapshot,
80}
81
82impl FileBrowser {
83    pub(crate) fn clear(&mut self) {
84        self.snapshot = None;
85    }
86
87    pub(crate) fn poll(
88        &mut self,
89        folder: &Path,
90        directories: &[PathBuf],
91        file: Option<&Path>,
92        revision: Option<u64>,
93    ) -> Result<Option<FileSnapshot>, FileError> {
94        validate_request(folder, directories, file)?;
95        let mut paths = directories.to_vec();
96        paths.push(folder.to_owned());
97        paths.sort();
98        paths.dedup();
99        let mut next = FileSnapshot {
100            revision: self.revision,
101            folder: folder.to_owned(),
102            directories: paths
103                .iter()
104                .map(|path| list_directory(folder, path))
105                .collect(),
106            file: file.map(|path| read_preview(folder, path)),
107            git: self.git.clone(),
108        };
109        git::decorate(folder, &mut next.directories, &next.git);
110        if self.snapshot.as_ref() != Some(&next) {
111            self.revision = self
112                .revision
113                .checked_add(1)
114                .ok_or(FileError::RevisionOverflow)?;
115            self.snapshot = Some(FileSnapshot {
116                revision: self.revision,
117                ..next
118            });
119        }
120        Ok(if revision == Some(self.revision) {
121            None
122        } else {
123            self.snapshot.clone()
124        })
125    }
126}
127
128fn validate_request(
129    folder: &Path,
130    directories: &[PathBuf],
131    file: Option<&Path>,
132) -> Result<(), FileError> {
133    if !folder.is_absolute() || directories.len() > 256 {
134        return Err(FileError::InvalidRequest);
135    }
136    for path in directories
137        .iter()
138        .map(PathBuf::as_path)
139        .chain(file)
140        .chain(std::iter::once(folder))
141    {
142        if !path.starts_with(folder)
143            || path
144                .components()
145                .any(|part| matches!(part, Component::ParentDir))
146        {
147            return Err(FileError::InvalidRequest);
148        }
149    }
150    Ok(())
151}
152
153fn list_directory(folder: &Path, path: &Path) -> DirectoryListing {
154    let result = access::entries(folder, path);
155    match result {
156        Ok(entries) => DirectoryListing {
157            path: path.to_owned(),
158            entries,
159            error: None,
160        },
161        Err(error) => DirectoryListing {
162            path: path.to_owned(),
163            entries: Vec::new(),
164            error: Some(error.to_string()),
165        },
166    }
167}
168
169#[derive(Debug, Error)]
170pub enum FileError {
171    #[error("The folder is no longer open.")]
172    FolderChanged,
173    #[error("Could not start file watcher: {0}")]
174    StartWatcher(#[source] io::Error),
175    #[error("File paths must stay inside the open folder; at most 256 folders can be expanded.")]
176    InvalidRequest,
177    #[error("File revision counter exhausted.")]
178    RevisionOverflow,
179    #[error("The edited file exceeds the 2 MiB text limit.")]
180    TextTooLarge,
181    #[error("Only regular UTF-8 text files can be saved.")]
182    UnsupportedSave,
183    #[error("Could not save the file: {0}")]
184    Save(#[from] io::Error),
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::fs::{self, OpenOptions};
191    use std::io::Read;
192
193    fn read_text(folder: &Path, path: &Path) -> FileContent {
194        read_preview(folder, path).content
195    }
196
197    #[test]
198    fn replacement_links_cannot_redirect_root_or_ancestor_reads() {
199        let parent = tempfile::tempdir().unwrap();
200        let root = parent.path().join("root");
201        let outside = parent.path().join("outside");
202        fs::create_dir_all(root.join("child")).unwrap();
203        fs::create_dir_all(&outside).unwrap();
204        fs::write(root.join("child/file"), "inside").unwrap();
205        fs::write(outside.join("file"), "outside").unwrap();
206        let mut opened = access::open_file(&root, &root.join("child/file"), false).unwrap();
207        fs::rename(root.join("child"), root.join("moved")).unwrap();
208        std::os::unix::fs::symlink(&outside, root.join("child")).unwrap();
209        let mut text = String::new();
210        opened.read_to_string(&mut text).unwrap();
211        assert_eq!(text, "inside");
212        assert_eq!(
213            read_text(&root, &root.join("child/file")),
214            FileContent::Unsupported
215        );
216        fs::rename(&root, parent.path().join("old-root")).unwrap();
217        std::os::unix::fs::symlink(&outside, &root).unwrap();
218        assert!(list_directory(&root, &root).error.is_some());
219        assert!(!matches!(
220            read_text(&root, &root.join("file")),
221            FileContent::Text(_)
222        ));
223    }
224
225    #[test]
226    fn lists_only_requested_children_and_keeps_empty_directories() {
227        let root = tempfile::tempdir().unwrap();
228        let nested = root.path().join("nested");
229        fs::create_dir(&nested).unwrap();
230        fs::write(nested.join("hidden-until-expanded.txt"), "hello").unwrap();
231        fs::create_dir(root.path().join("empty")).unwrap();
232        fs::write(root.path().join("a.txt"), "root").unwrap();
233        let mut browser = FileBrowser::default();
234        let initial = browser.poll(root.path(), &[], None, None).unwrap().unwrap();
235        assert_eq!(initial.directories.len(), 1);
236        let entries = &initial.directories[0].entries;
237        assert_eq!(
238            entries
239                .iter()
240                .map(|entry| entry.name.as_str())
241                .collect::<Vec<_>>(),
242            ["empty", "nested", "a.txt"]
243        );
244        let expanded = browser
245            .poll(root.path(), &[nested], None, Some(initial.revision))
246            .unwrap()
247            .unwrap();
248        assert_eq!(expanded.directories.len(), 2);
249        assert_eq!(
250            expanded.directories[1].entries[0].name,
251            "hidden-until-expanded.txt"
252        );
253        assert!(
254            browser
255                .poll(
256                    root.path(),
257                    &[root.path().join("nested")],
258                    None,
259                    Some(expanded.revision)
260                )
261                .unwrap()
262                .is_none()
263        );
264        let empty = list_directory(root.path(), &root.path().join("empty"));
265        assert_eq!(empty.entries, []);
266        assert!(empty.error.is_none());
267    }
268
269    #[test]
270    fn polls_creation_edits_atomic_replacement_rename_deletion_and_recreation() {
271        let root = tempfile::tempdir().unwrap();
272        let path = root.path().join("file.txt");
273        let mut browser = FileBrowser::default();
274        let initial = browser
275            .poll(root.path(), &[], Some(&path), None)
276            .unwrap()
277            .unwrap();
278        assert_eq!(initial.file.unwrap().content, FileContent::Missing);
279        fs::write(&path, "first").unwrap();
280        let created = browser
281            .poll(root.path(), &[], Some(&path), Some(initial.revision))
282            .unwrap()
283            .unwrap();
284        assert_eq!(
285            created.file.unwrap().content,
286            FileContent::Text("first".into())
287        );
288        fs::write(&path, "edits").unwrap(); // Same length, including rapid successive saves.
289        let edited = browser
290            .poll(root.path(), &[], Some(&path), Some(created.revision))
291            .unwrap()
292            .unwrap();
293        assert_eq!(
294            edited.file.unwrap().content,
295            FileContent::Text("edits".into())
296        );
297        let replacement = root.path().join("replacement");
298        fs::write(&replacement, "saved").unwrap();
299        fs::rename(&replacement, &path).unwrap();
300        let replaced = browser
301            .poll(root.path(), &[], Some(&path), Some(edited.revision))
302            .unwrap()
303            .unwrap();
304        assert_eq!(
305            replaced.file.unwrap().content,
306            FileContent::Text("saved".into())
307        );
308        fs::rename(&path, root.path().join("renamed.txt")).unwrap();
309        let renamed = browser
310            .poll(root.path(), &[], Some(&path), Some(replaced.revision))
311            .unwrap()
312            .unwrap();
313        assert_eq!(renamed.file.unwrap().content, FileContent::Missing);
314        assert_eq!(renamed.directories[0].entries[0].name, "renamed.txt");
315        fs::remove_file(root.path().join("renamed.txt")).unwrap();
316        let deleted = browser
317            .poll(root.path(), &[], Some(&path), Some(renamed.revision))
318            .unwrap()
319            .unwrap();
320        assert_eq!(deleted.directories[0].entries, []);
321        fs::write(&path, "back").unwrap();
322        assert_eq!(
323            browser
324                .poll(root.path(), &[], Some(&path), Some(deleted.revision))
325                .unwrap()
326                .unwrap()
327                .file
328                .unwrap()
329                .content,
330            FileContent::Text("back".into())
331        );
332    }
333
334    #[test]
335    fn text_preview_has_bounded_reads_and_explicit_non_text_states() {
336        let root = tempfile::tempdir().unwrap();
337        let path = root.path().join("file");
338        for bytes in [b"hello\0world".as_slice(), &[0xff, 0xfe], &[1, 2, 3]] {
339            fs::write(&path, bytes).unwrap();
340            assert_eq!(read_text(root.path(), &path), FileContent::Binary);
341        }
342        fs::write(&path, "\u{feff}hello\r\n🌲\n").unwrap();
343        assert_eq!(
344            read_text(root.path(), &path),
345            FileContent::Text("hello\r\n🌲\n".into())
346        );
347        fs::write(&path, []).unwrap();
348        assert_eq!(
349            read_text(root.path(), &path),
350            FileContent::Text(String::new())
351        );
352        fs::write(&path, vec![b'a'; usize::try_from(TEXT_LIMIT).unwrap()]).unwrap();
353        assert!(matches!(
354            read_text(root.path(), &path),
355            FileContent::Text(_)
356        ));
357        OpenOptions::new()
358            .write(true)
359            .open(&path)
360            .unwrap()
361            .set_len(TEXT_LIMIT + 1)
362            .unwrap();
363        assert_eq!(read_text(root.path(), &path), FileContent::TooLarge);
364        assert_eq!(
365            read_text(root.path(), root.path()),
366            FileContent::Unsupported
367        );
368    }
369
370    #[test]
371    fn refuses_path_escape_links_and_special_files() {
372        let root = tempfile::tempdir().unwrap();
373        let outside = tempfile::tempdir().unwrap();
374        let link = root.path().join("link");
375        std::os::unix::fs::symlink(outside.path(), &link).unwrap();
376        assert_eq!(read_text(root.path(), &link), FileContent::Unsupported);
377        assert!(list_directory(root.path(), &link).error.is_some());
378        let socket = root.path().join("socket");
379        let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
380        assert_eq!(read_text(root.path(), &socket), FileContent::Unsupported);
381        let mut browser = FileBrowser::default();
382        for path in [outside.path().to_owned(), root.path().join("../escape")] {
383            assert!(matches!(
384                browser.poll(root.path(), &[], Some(&path), None),
385                Err(FileError::InvalidRequest)
386            ));
387        }
388    }
389}