1use 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(); 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}