1use crate::core::{ExceptionInfo, Value};
8use crate::task::Promise;
9use std::cell::RefCell;
10use std::collections::{BTreeMap, HashMap};
11use std::rc::Rc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
16use std::fs::{self, File, OpenOptions};
17#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
18use std::io::Write;
19#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
20use std::path::{Path, PathBuf};
21
22static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum FileError {
26 NotFound,
27 AlreadyExists,
28 InvalidPath(String),
29 OutsideRoot,
30 Denied,
31 NotDirectory,
32 IsDirectory,
33 DirectoryNotEmpty,
34 PermissionDenied,
35 Unsupported,
36 Io(String),
37}
38
39impl FileError {
40 pub fn code(&self) -> &'static str {
41 match self {
42 Self::NotFound => "not-found",
43 Self::AlreadyExists => "already-exists",
44 Self::InvalidPath(_) => "invalid-path",
45 Self::OutsideRoot => "outside-root",
46 Self::Denied => "denied",
47 Self::NotDirectory => "not-directory",
48 Self::IsDirectory => "is-directory",
49 Self::DirectoryNotEmpty => "directory-not-empty",
50 Self::PermissionDenied => "permission-denied",
51 Self::Unsupported => "unsupported",
52 Self::Io(_) => "io",
53 }
54 }
55
56 pub fn message(&self) -> String {
57 match self {
58 Self::InvalidPath(message) | Self::Io(message) => message.clone(),
59 _ => format!("file/{}", self.code()),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum FileType {
66 File,
67 Directory,
68 Symlink,
69 Other,
70}
71
72impl FileType {
73 pub fn keyword(self) -> &'static str {
74 match self {
75 Self::File => "file",
76 Self::Directory => "directory",
77 Self::Symlink => "symlink",
78 Self::Other => "other",
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct FileEntry {
85 pub path: String,
86 pub name: String,
87 pub kind: FileType,
88 pub size: Option<u64>,
89 pub modified_at: i64,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WriteMode {
94 Create,
95 Replace,
96 Append,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct WriteOptions {
101 pub mode: WriteMode,
102 pub parents: bool,
103}
104
105impl Default for WriteOptions {
106 fn default() -> Self {
107 Self {
108 mode: WriteMode::Create,
109 parents: false,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct MkdirOptions {
116 pub parents: bool,
117 pub exists_ok: bool,
118}
119
120impl Default for MkdirOptions {
121 fn default() -> Self {
122 Self {
123 parents: true,
124 exists_ok: true,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub struct DeleteOptions {
131 pub missing_ok: bool,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub struct CopyOptions {
136 pub replace: bool,
137 pub parents: bool,
138 pub preserve_modified: bool,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub struct MoveOptions {
143 pub replace: bool,
144 pub parents: bool,
145 pub atomic: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct TempFileOptions {
150 pub prefix: String,
151 pub suffix: String,
152}
153
154impl Default for TempFileOptions {
155 fn default() -> Self {
156 Self {
157 prefix: "tmp".into(),
158 suffix: String::new(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct TempDirectoryOptions {
165 pub prefix: String,
166}
167
168impl Default for TempDirectoryOptions {
169 fn default() -> Self {
170 Self {
171 prefix: "tmp".into(),
172 }
173 }
174}
175
176pub fn logical_normalise(path: &str) -> Result<String, FileError> {
177 if path.contains('\0') {
178 return Err(FileError::InvalidPath("logical path contains NUL".into()));
179 }
180 if path.contains('\\') {
181 return Err(FileError::InvalidPath(
182 "logical paths use '/' rather than host separators".into(),
183 ));
184 }
185 let mut segments: Vec<&str> = Vec::new();
186 for segment in path.split('/') {
187 match segment {
188 "" | "." => {}
189 ".." => {
190 if segments.pop().is_none() {
191 return Err(FileError::OutsideRoot);
192 }
193 }
194 value
195 if value.len() >= 2
196 && value.as_bytes()[0].is_ascii_alphabetic()
197 && value.as_bytes()[1] == b':' =>
198 {
199 return Err(FileError::InvalidPath(
200 "logical paths do not accept host drive prefixes".into(),
201 ));
202 }
203 value => segments.push(value),
204 }
205 }
206 if segments.is_empty() {
207 Ok("/".into())
208 } else {
209 Ok(format!("/{}", segments.join("/")))
210 }
211}
212
213pub fn logical_join(base: &str, path: &str) -> Result<String, FileError> {
214 let base = logical_normalise(base)?;
215 let path = path.trim_start_matches('/');
216 logical_normalise(&format!("{}/{}", base.trim_end_matches('/'), path))
217}
218
219pub fn logical_resolve(base: &str, path: &str) -> Result<String, FileError> {
220 if path.starts_with('/') {
221 logical_normalise(path)
222 } else {
223 logical_join(base, path)
224 }
225}
226
227pub fn logical_parent(path: &str) -> Result<Option<String>, FileError> {
228 let path = logical_normalise(path)?;
229 if path == "/" {
230 return Ok(None);
231 }
232 let index = path.rfind('/').unwrap_or(0);
233 Ok(Some(if index == 0 {
234 "/".into()
235 } else {
236 path[..index].into()
237 }))
238}
239
240pub fn logical_name(path: &str) -> Result<String, FileError> {
241 let path = logical_normalise(path)?;
242 Ok(if path == "/" {
243 String::new()
244 } else {
245 path.rsplit('/').next().unwrap_or_default().into()
246 })
247}
248
249pub fn file_error_value(
250 operation: &str,
251 path: &str,
252 target: Option<&str>,
253 error: &FileError,
254) -> Value {
255 let path = logical_normalise(path).unwrap_or_else(|_| path.to_owned());
256 let target = target.map(|value| logical_normalise(value).unwrap_or_else(|_| value.to_owned()));
257 Value::ExceptionInfo(Rc::new(ExceptionInfo {
258 message: format!("{operation} failed: {}", error.message()),
259 data: Box::new(Value::Map(
260 [
261 (
262 Value::Keyword("ex/code".into()),
263 Value::Keyword(format!("file/{}", error.code()).into()),
264 ),
265 (
266 Value::Keyword("ex/class".into()),
267 Value::Keyword(file_error_class(error).into()),
268 ),
269 (
270 Value::Keyword("file/operation".into()),
271 Value::Keyword(operation.trim_start_matches("file/").into()),
272 ),
273 (Value::Keyword("file/path".into()), Value::String(path)),
274 (
275 Value::Keyword("file/target".into()),
276 target.map(Value::String).unwrap_or(Value::Nil),
277 ),
278 ]
279 .into_iter()
280 .collect(),
281 )),
282 cause: None,
283 provenance: Rc::new(RefCell::new(Default::default())),
284 }))
285}
286
287fn file_error_class(error: &FileError) -> &'static str {
288 match error {
289 FileError::NotFound => "ex.class/not-found",
290 FileError::AlreadyExists | FileError::DirectoryNotEmpty => "ex.class/conflict",
291 FileError::InvalidPath(_) => "ex.class/argument",
292 FileError::OutsideRoot | FileError::Denied | FileError::PermissionDenied => {
293 "ex.class/security"
294 }
295 FileError::NotDirectory
296 | FileError::IsDirectory
297 | FileError::Unsupported
298 | FileError::Io(_) => "ex.class/io",
299 }
300}
301
302fn resolved(value: Value) -> Promise {
303 let promise = Promise::new();
304 promise.resolve(value);
305 promise
306}
307
308fn rejected(operation: &str, path: &str, target: Option<&str>, error: FileError) -> Promise {
309 let promise = Promise::new();
310 promise.reject_value(file_error_value(operation, path, target, &error));
311 promise
312}
313
314fn entries_value(entries: Vec<FileEntry>) -> Value {
315 Value::Vector(
316 entries
317 .into_iter()
318 .map(|entry| {
319 Value::Map(
320 [
321 (Value::Keyword("path".into()), Value::String(entry.path)),
322 (Value::Keyword("name".into()), Value::String(entry.name)),
323 (
324 Value::Keyword("type".into()),
325 Value::Keyword(entry.kind.keyword().into()),
326 ),
327 (
328 Value::Keyword("size".into()),
329 entry
330 .size
331 .and_then(|size| i64::try_from(size).ok())
332 .map(Value::Number)
333 .unwrap_or(Value::Nil),
334 ),
335 (
336 Value::Keyword("modified-at".into()),
337 Value::Number(entry.modified_at),
338 ),
339 (
340 Value::Keyword("extensions".into()),
341 Value::Map(Default::default()),
342 ),
343 ]
344 .into_iter()
345 .collect(),
346 )
347 })
348 .collect(),
349 )
350}
351
352fn list_value(entries: Vec<FileEntry>) -> Value {
353 Value::Vector(
354 entries
355 .into_iter()
356 .map(|entry| Value::String(entry.path))
357 .collect(),
358 )
359}
360
361fn walk_collect<P: FileProvider + ?Sized>(
362 provider: &P,
363 path: &str,
364 output: &mut Vec<String>,
365) -> Result<(), FileError> {
366 let stat = provider.stat_entry(path)?;
367 match stat.kind {
368 FileType::Directory => {
369 for entry in provider.entries_values(path)? {
370 if entry.kind == FileType::Directory {
371 walk_collect(provider, &entry.path, output)?;
372 } else {
373 output.push(entry.path);
374 }
375 }
376 }
377 FileType::File | FileType::Symlink | FileType::Other => output.push(stat.path),
378 }
379 Ok(())
380}
381
382pub trait FileProvider {
383 fn resolve(&self, root: &str, path: &str) -> Result<String, FileError> {
384 logical_resolve(root, path)
385 }
386
387 fn read_bytes(&self, path: &str) -> Result<Vec<u8>, FileError>;
388 fn write_bytes(
389 &self,
390 path: &str,
391 bytes: Vec<u8>,
392 options: WriteOptions,
393 ) -> Result<String, FileError>;
394 fn exists_value(&self, path: &str) -> Result<bool, FileError>;
395 fn stat_entry(&self, path: &str) -> Result<FileEntry, FileError>;
396 fn entries_values(&self, path: &str) -> Result<Vec<FileEntry>, FileError>;
397 fn mkdir_path(&self, path: &str, options: MkdirOptions) -> Result<String, FileError>;
398 fn delete_path(&self, path: &str, options: DeleteOptions) -> Result<String, FileError>;
399 fn copy_path(
400 &self,
401 source: &str,
402 target: &str,
403 options: CopyOptions,
404 ) -> Result<String, FileError>;
405 fn move_path(
406 &self,
407 source: &str,
408 target: &str,
409 options: MoveOptions,
410 ) -> Result<String, FileError>;
411 fn temp_file_path(&self, parent: &str, options: TempFileOptions) -> Result<String, FileError>;
412 fn temp_directory_path(
413 &self,
414 parent: &str,
415 options: TempDirectoryOptions,
416 ) -> Result<String, FileError>;
417
418 fn read(&self, path: &str) -> Result<Promise, FileError> {
419 let logical = logical_normalise(path)?;
420 Ok(match self.read_bytes(&logical) {
421 Ok(bytes) => resolved(Value::Bytes(bytes)),
422 Err(error) => rejected("file/read", &logical, None, error),
423 })
424 }
425
426 fn write(&self, path: &str, bytes: Vec<u8>) -> Result<Promise, FileError> {
427 self.write_with_options(path, bytes, WriteOptions::default())
428 }
429
430 fn write_with_options(
431 &self,
432 path: &str,
433 bytes: Vec<u8>,
434 options: WriteOptions,
435 ) -> Result<Promise, FileError> {
436 let logical = logical_normalise(path)?;
437 Ok(match self.write_bytes(&logical, bytes, options) {
438 Ok(path) => resolved(Value::String(path)),
439 Err(error) => rejected("file/write", &logical, None, error),
440 })
441 }
442
443 fn exists(&self, path: &str) -> Result<Promise, FileError> {
444 let logical = logical_normalise(path)?;
445 Ok(match self.exists_value(&logical) {
446 Ok(value) => resolved(Value::Bool(value)),
447 Err(FileError::NotFound) => resolved(Value::Bool(false)),
448 Err(error) => rejected("file/exists?", &logical, None, error),
449 })
450 }
451
452 fn stat(&self, path: &str) -> Result<Promise, FileError> {
453 let logical = logical_normalise(path)?;
454 Ok(match self.stat_entry(&logical) {
455 Ok(entry) => resolved(entries_value(vec![entry]).into_single_entry()),
456 Err(error) => rejected("file/stat", &logical, None, error),
457 })
458 }
459
460 fn entries(&self, path: &str) -> Result<Promise, FileError> {
461 let logical = logical_normalise(path)?;
462 Ok(match self.entries_values(&logical) {
463 Ok(entries) => resolved(entries_value(entries)),
464 Err(error) => rejected("file/entries", &logical, None, error),
465 })
466 }
467
468 fn list(&self, path: &str) -> Result<Promise, FileError> {
469 let logical = logical_normalise(path)?;
470 Ok(match self.entries_values(&logical) {
471 Ok(entries) => resolved(list_value(entries)),
472 Err(error) => rejected("file/list", &logical, None, error),
473 })
474 }
475
476 fn walk(&self, path: &str) -> Result<Promise, FileError> {
477 let logical = logical_normalise(path)?;
478 let mut values = Vec::new();
479 Ok(match walk_collect(self, &logical, &mut values) {
480 Ok(()) => {
481 values.sort();
482 resolved(Value::Vector(
483 values.into_iter().map(Value::String).collect(),
484 ))
485 }
486 Err(error) => rejected("file/walk", &logical, None, error),
487 })
488 }
489
490 fn mkdir(&self, path: &str) -> Result<Promise, FileError> {
491 self.mkdir_with_options(path, MkdirOptions::default())
492 }
493
494 fn mkdir_with_options(&self, path: &str, options: MkdirOptions) -> Result<Promise, FileError> {
495 let logical = logical_normalise(path)?;
496 Ok(match self.mkdir_path(&logical, options) {
497 Ok(path) => resolved(Value::String(path)),
498 Err(error) => rejected("file/mkdir", &logical, None, error),
499 })
500 }
501
502 fn delete(&self, path: &str) -> Result<Promise, FileError> {
503 self.delete_with_options(path, DeleteOptions::default())
504 }
505
506 fn delete_with_options(
507 &self,
508 path: &str,
509 options: DeleteOptions,
510 ) -> Result<Promise, FileError> {
511 let logical = logical_normalise(path)?;
512 Ok(match self.delete_path(&logical, options) {
513 Ok(path) => resolved(Value::String(path)),
514 Err(error) => rejected("file/delete", &logical, None, error),
515 })
516 }
517
518 fn copy(&self, source: &str, target: &str, options: CopyOptions) -> Result<Promise, FileError> {
519 let source = logical_normalise(source)?;
520 let target = logical_normalise(target)?;
521 Ok(match self.copy_path(&source, &target, options) {
522 Ok(path) => resolved(Value::String(path)),
523 Err(error) => rejected("file/copy", &source, Some(&target), error),
524 })
525 }
526
527 fn move_entry(
528 &self,
529 source: &str,
530 target: &str,
531 options: MoveOptions,
532 ) -> Result<Promise, FileError> {
533 let source = logical_normalise(source)?;
534 let target = logical_normalise(target)?;
535 Ok(match self.move_path(&source, &target, options) {
536 Ok(path) => resolved(Value::String(path)),
537 Err(error) => rejected("file/move", &source, Some(&target), error),
538 })
539 }
540
541 fn temp_file(&self, parent: &str, options: TempFileOptions) -> Result<Promise, FileError> {
542 let parent = logical_normalise(parent)?;
543 Ok(match self.temp_file_path(&parent, options) {
544 Ok(path) => resolved(Value::String(path)),
545 Err(error) => rejected("file/temp-file", &parent, None, error),
546 })
547 }
548
549 fn temp_directory(
550 &self,
551 parent: &str,
552 options: TempDirectoryOptions,
553 ) -> Result<Promise, FileError> {
554 let parent = logical_normalise(parent)?;
555 Ok(match self.temp_directory_path(&parent, options) {
556 Ok(path) => resolved(Value::String(path)),
557 Err(error) => rejected("file/temp-directory", &parent, None, error),
558 })
559 }
560}
561
562trait SingleEntryValue {
563 fn into_single_entry(self) -> Value;
564}
565
566impl SingleEntryValue for Value {
567 fn into_single_entry(self) -> Value {
568 match self {
569 Value::Vector(values) => values.iter().next().cloned().unwrap_or(Value::Nil),
570 value => value,
571 }
572 }
573}
574
575fn now_millis() -> i64 {
576 SystemTime::now()
577 .duration_since(UNIX_EPOCH)
578 .ok()
579 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
580 .unwrap_or(0)
581}
582
583#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
584fn io_error(error: std::io::Error) -> FileError {
585 use std::io::ErrorKind;
586 match error.kind() {
587 ErrorKind::NotFound => FileError::NotFound,
588 ErrorKind::AlreadyExists => FileError::AlreadyExists,
589 ErrorKind::PermissionDenied => FileError::PermissionDenied,
590 ErrorKind::NotADirectory => FileError::NotDirectory,
591 ErrorKind::IsADirectory => FileError::IsDirectory,
592 ErrorKind::DirectoryNotEmpty => FileError::DirectoryNotEmpty,
593 ErrorKind::Unsupported => FileError::Unsupported,
594 _ => FileError::Io(error.to_string()),
595 }
596}
597
598#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
599fn modified_millis(metadata: &fs::Metadata) -> i64 {
600 metadata
601 .modified()
602 .ok()
603 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
604 .and_then(|duration| i64::try_from(duration.as_millis()).ok())
605 .unwrap_or(0)
606}
607
608fn validate_temp_name(prefix: &str, suffix: Option<&str>) -> Result<(), FileError> {
609 for value in std::iter::once(prefix).chain(suffix) {
610 if value.contains('/') || value.contains('\\') || value.contains('\0') {
611 return Err(FileError::InvalidPath(
612 "temporary entry prefix and suffix must be single logical path fragments".into(),
613 ));
614 }
615 }
616 Ok(())
617}
618
619#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
620#[derive(Debug, Clone)]
621pub struct NativeFileProvider {
622 root: PathBuf,
623}
624
625#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
626impl NativeFileProvider {
627 pub fn new(root: impl AsRef<Path>) -> Self {
628 let root = root.as_ref();
629 let root = if root.is_absolute() {
630 root.to_path_buf()
631 } else {
632 std::env::current_dir()
633 .unwrap_or_else(|_| PathBuf::from("."))
634 .join(root)
635 };
636 Self { root }
637 }
638
639 fn scoped(&self, logical: &str) -> Result<PathBuf, FileError> {
640 let logical = logical_normalise(logical)?;
641 let logical_path = Path::new(&logical);
642 let relative = logical_path
643 .strip_prefix(&self.root)
644 .unwrap_or_else(|_| Path::new(logical.trim_start_matches('/')));
645 let candidate = self.root.join(relative);
646 let mut current = self.root.clone();
647 let relative = candidate
648 .strip_prefix(&self.root)
649 .map_err(|_| FileError::OutsideRoot)?;
650 let components: Vec<_> = relative.components().collect();
651 for component in components.iter().take(components.len().saturating_sub(1)) {
652 current.push(component.as_os_str());
653 match fs::symlink_metadata(¤t) {
654 Ok(metadata) if metadata.file_type().is_symlink() => {
655 return Err(FileError::OutsideRoot)
656 }
657 Ok(metadata) if !metadata.is_dir() => return Err(FileError::NotDirectory),
658 Ok(_) => {}
659 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
660 Err(error) => return Err(io_error(error)),
661 }
662 }
663 Ok(candidate)
664 }
665
666 fn entry(&self, logical: &str, host: &Path) -> Result<FileEntry, FileError> {
667 let metadata = fs::symlink_metadata(host).map_err(io_error)?;
668 let kind = if metadata.file_type().is_symlink() {
669 FileType::Symlink
670 } else if metadata.is_file() {
671 FileType::File
672 } else if metadata.is_dir() {
673 FileType::Directory
674 } else {
675 FileType::Other
676 };
677 Ok(FileEntry {
678 path: logical_normalise(logical)?,
679 name: logical_name(logical)?,
680 kind,
681 size: (kind == FileType::File).then_some(metadata.len()),
682 modified_at: modified_millis(&metadata),
683 })
684 }
685
686 fn ensure_parent(&self, path: &Path, parents: bool) -> Result<(), FileError> {
687 let parent = path
688 .parent()
689 .ok_or_else(|| FileError::InvalidPath("path has no parent".into()))?;
690 if parents {
691 fs::create_dir_all(parent).map_err(io_error)
692 } else {
693 let metadata = fs::symlink_metadata(parent).map_err(io_error)?;
694 if metadata.is_dir() {
695 Ok(())
696 } else {
697 Err(FileError::NotDirectory)
698 }
699 }
700 }
701}
702
703#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
704impl FileProvider for NativeFileProvider {
705 fn read_bytes(&self, path: &str) -> Result<Vec<u8>, FileError> {
706 let host = self.scoped(path)?;
707 let metadata = fs::symlink_metadata(&host).map_err(io_error)?;
708 if metadata.file_type().is_symlink() {
709 return Err(FileError::Unsupported);
710 }
711 if metadata.is_dir() {
712 return Err(FileError::IsDirectory);
713 }
714 if !metadata.is_file() {
715 return Err(FileError::Unsupported);
716 }
717 fs::read(host).map_err(io_error)
718 }
719
720 fn write_bytes(
721 &self,
722 path: &str,
723 bytes: Vec<u8>,
724 options: WriteOptions,
725 ) -> Result<String, FileError> {
726 let logical = logical_normalise(path)?;
727 let host = self.scoped(&logical)?;
728 self.ensure_parent(&host, options.parents)?;
729 if let Ok(metadata) = fs::symlink_metadata(&host) {
730 if metadata.file_type().is_symlink() {
731 return Err(FileError::Unsupported);
732 }
733 if metadata.is_dir() {
734 return Err(FileError::IsDirectory);
735 }
736 }
737 let mut builder = OpenOptions::new();
738 builder.write(true);
739 match options.mode {
740 WriteMode::Create => {
741 builder.create_new(true);
742 }
743 WriteMode::Replace => {
744 builder.create(true).truncate(true);
745 }
746 WriteMode::Append => {
747 builder.create(true).append(true);
748 }
749 }
750 let mut file = builder.open(host).map_err(io_error)?;
751 file.write_all(&bytes).map_err(io_error)?;
752 Ok(logical)
753 }
754
755 fn exists_value(&self, path: &str) -> Result<bool, FileError> {
756 let host = self.scoped(path)?;
757 match fs::symlink_metadata(host) {
758 Ok(_) => Ok(true),
759 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
760 Err(error) => Err(io_error(error)),
761 }
762 }
763
764 fn stat_entry(&self, path: &str) -> Result<FileEntry, FileError> {
765 let logical = logical_normalise(path)?;
766 let host = self.scoped(&logical)?;
767 self.entry(&logical, &host)
768 }
769
770 fn entries_values(&self, path: &str) -> Result<Vec<FileEntry>, FileError> {
771 let logical = logical_normalise(path)?;
772 let host = self.scoped(&logical)?;
773 let metadata = fs::symlink_metadata(&host).map_err(io_error)?;
774 if metadata.file_type().is_symlink() || !metadata.is_dir() {
775 return Err(FileError::NotDirectory);
776 }
777 let mut entries = Vec::new();
778 for value in fs::read_dir(host).map_err(io_error)? {
779 let value = value.map_err(io_error)?;
780 let name = value.file_name().into_string().map_err(|_| {
781 FileError::InvalidPath("filesystem entry is not valid UTF-8".into())
782 })?;
783 let child = logical_resolve(&logical, &name)?;
784 entries.push(self.entry(&child, &value.path())?);
785 }
786 entries.sort_by(|left, right| left.path.cmp(&right.path));
787 Ok(entries)
788 }
789
790 fn mkdir_path(&self, path: &str, options: MkdirOptions) -> Result<String, FileError> {
791 let logical = logical_normalise(path)?;
792 let host = self.scoped(&logical)?;
793 if let Ok(metadata) = fs::symlink_metadata(&host) {
794 if metadata.is_dir() && options.exists_ok {
795 return Ok(logical);
796 }
797 return Err(FileError::AlreadyExists);
798 }
799 if options.parents {
800 fs::create_dir_all(host).map_err(io_error)?;
801 } else {
802 self.ensure_parent(&host, false)?;
803 fs::create_dir(host).map_err(io_error)?;
804 }
805 Ok(logical)
806 }
807
808 fn delete_path(&self, path: &str, options: DeleteOptions) -> Result<String, FileError> {
809 let logical = logical_normalise(path)?;
810 if logical == "/" {
811 return Err(FileError::Denied);
812 }
813 let host = self.scoped(&logical)?;
814 let metadata = match fs::symlink_metadata(&host) {
815 Ok(value) => value,
816 Err(error) if error.kind() == std::io::ErrorKind::NotFound && options.missing_ok => {
817 return Ok(logical)
818 }
819 Err(error) => return Err(io_error(error)),
820 };
821 if metadata.is_dir() && !metadata.file_type().is_symlink() {
822 fs::remove_dir(host).map_err(io_error)?;
823 } else {
824 fs::remove_file(host).map_err(io_error)?;
825 }
826 Ok(logical)
827 }
828
829 fn copy_path(
830 &self,
831 source: &str,
832 target: &str,
833 options: CopyOptions,
834 ) -> Result<String, FileError> {
835 let source = logical_normalise(source)?;
836 let target = logical_normalise(target)?;
837 if source == target {
838 return Err(FileError::AlreadyExists);
839 }
840 let source_host = self.scoped(&source)?;
841 let target_host = self.scoped(&target)?;
842 let metadata = fs::symlink_metadata(&source_host).map_err(io_error)?;
843 if metadata.file_type().is_symlink() || !metadata.is_file() {
844 return Err(if metadata.is_dir() {
845 FileError::IsDirectory
846 } else {
847 FileError::Unsupported
848 });
849 }
850 self.ensure_parent(&target_host, options.parents)?;
851 if let Ok(target_metadata) = fs::symlink_metadata(&target_host) {
852 if !options.replace {
853 return Err(FileError::AlreadyExists);
854 }
855 if target_metadata.is_dir() && !target_metadata.file_type().is_symlink() {
856 return Err(FileError::IsDirectory);
857 }
858 fs::remove_file(&target_host).map_err(io_error)?;
861 }
862 let mut copy_options = fs::OpenOptions::new();
863 copy_options.write(true).create_new(true);
864 let mut input = File::open(&source_host).map_err(io_error)?;
865 let mut output = copy_options.open(&target_host).map_err(io_error)?;
866 std::io::copy(&mut input, &mut output).map_err(io_error)?;
867 if options.preserve_modified {
868 let modified = metadata.modified().map_err(io_error)?;
869 output
870 .set_times(fs::FileTimes::new().set_modified(modified))
871 .map_err(io_error)?;
872 }
873 Ok(target)
874 }
875
876 fn move_path(
877 &self,
878 source: &str,
879 target: &str,
880 options: MoveOptions,
881 ) -> Result<String, FileError> {
882 let source = logical_normalise(source)?;
883 let target = logical_normalise(target)?;
884 if source == "/" || target == "/" {
885 return Err(FileError::Denied);
886 }
887 if source == target {
888 self.stat_entry(&source)?;
889 return Ok(target);
890 }
891 if target.starts_with(&format!("{source}/")) {
892 return Err(FileError::InvalidPath(
893 "cannot move a directory beneath itself".into(),
894 ));
895 }
896 let source_host = self.scoped(&source)?;
897 let target_host = self.scoped(&target)?;
898 let source_metadata = fs::symlink_metadata(&source_host).map_err(io_error)?;
899 if source_metadata.file_type().is_symlink() {
900 return Err(FileError::Unsupported);
901 }
902 self.ensure_parent(&target_host, options.parents)?;
903 if let Ok(target_metadata) = fs::symlink_metadata(&target_host) {
904 if !options.replace {
905 return Err(FileError::AlreadyExists);
906 }
907 if target_metadata.is_dir() && !target_metadata.file_type().is_symlink() {
908 fs::remove_dir(&target_host).map_err(io_error)?;
909 } else {
910 fs::remove_file(&target_host).map_err(io_error)?;
911 }
912 }
913 fs::rename(source_host, target_host).map_err(|error| {
914 if options.atomic {
915 FileError::Unsupported
916 } else {
917 io_error(error)
918 }
919 })?;
920 Ok(target)
921 }
922
923 fn temp_file_path(&self, parent: &str, options: TempFileOptions) -> Result<String, FileError> {
924 validate_temp_name(&options.prefix, Some(&options.suffix))?;
925 let parent = logical_normalise(parent)?;
926 let parent_host = self.scoped(&parent)?;
927 let metadata = fs::symlink_metadata(&parent_host).map_err(io_error)?;
928 if !metadata.is_dir() {
929 return Err(FileError::NotDirectory);
930 }
931 for _ in 0..1024 {
932 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
933 let name = format!("{}-{:016x}{}", options.prefix, sequence, options.suffix);
934 let logical = logical_resolve(&parent, &name)?;
935 let host = self.scoped(&logical)?;
936 match OpenOptions::new().write(true).create_new(true).open(host) {
937 Ok(_) => return Ok(logical),
938 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
939 Err(error) => return Err(io_error(error)),
940 }
941 }
942 Err(FileError::Io(
943 "unable to allocate unique temporary file".into(),
944 ))
945 }
946
947 fn temp_directory_path(
948 &self,
949 parent: &str,
950 options: TempDirectoryOptions,
951 ) -> Result<String, FileError> {
952 validate_temp_name(&options.prefix, None)?;
953 let parent = logical_normalise(parent)?;
954 let parent_host = self.scoped(&parent)?;
955 let metadata = fs::symlink_metadata(&parent_host).map_err(io_error)?;
956 if !metadata.is_dir() {
957 return Err(FileError::NotDirectory);
958 }
959 for _ in 0..1024 {
960 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
961 let name = format!("{}-{:016x}", options.prefix, sequence);
962 let logical = logical_resolve(&parent, &name)?;
963 let host = self.scoped(&logical)?;
964 match fs::create_dir(host) {
965 Ok(()) => return Ok(logical),
966 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
967 Err(error) => return Err(io_error(error)),
968 }
969 }
970 Err(FileError::Io(
971 "unable to allocate unique temporary directory".into(),
972 ))
973 }
974}
975
976#[derive(Debug, Clone)]
977enum MemoryNode {
978 Directory { modified_at: i64 },
979 File { bytes: Vec<u8>, modified_at: i64 },
980}
981
982#[derive(Debug, Clone)]
983pub struct MemoryFileProvider {
984 nodes: Rc<RefCell<HashMap<String, MemoryNode>>>,
985}
986
987impl MemoryFileProvider {
988 pub fn new(_root: impl Into<String>) -> Self {
989 Self {
990 nodes: Rc::new(RefCell::new(HashMap::from([(
991 "/".into(),
992 MemoryNode::Directory {
993 modified_at: now_millis(),
994 },
995 )]))),
996 }
997 }
998
999 pub fn insert(&self, path: &str, bytes: Vec<u8>) -> Result<(), FileError> {
1000 self.write_bytes(
1001 path,
1002 bytes,
1003 WriteOptions {
1004 mode: WriteMode::Replace,
1005 parents: true,
1006 },
1007 )?;
1008 Ok(())
1009 }
1010
1011 fn ensure_parent(&self, path: &str, parents: bool) -> Result<(), FileError> {
1012 let parent = logical_parent(path)?.ok_or(FileError::Denied)?;
1013 if parents {
1014 let mut current = String::from("/");
1015 for segment in parent
1016 .trim_start_matches('/')
1017 .split('/')
1018 .filter(|value| !value.is_empty())
1019 {
1020 current = logical_resolve(¤t, segment)?;
1021 let mut nodes = self.nodes.borrow_mut();
1022 match nodes.get(¤t) {
1023 Some(MemoryNode::Directory { .. }) => {}
1024 Some(_) => return Err(FileError::NotDirectory),
1025 None => {
1026 nodes.insert(
1027 current.clone(),
1028 MemoryNode::Directory {
1029 modified_at: now_millis(),
1030 },
1031 );
1032 }
1033 }
1034 }
1035 Ok(())
1036 } else {
1037 match self.nodes.borrow().get(&parent) {
1038 Some(MemoryNode::Directory { .. }) => Ok(()),
1039 Some(_) => Err(FileError::NotDirectory),
1040 None => Err(FileError::NotFound),
1041 }
1042 }
1043 }
1044
1045 fn entry_for(&self, path: &str, node: &MemoryNode) -> Result<FileEntry, FileError> {
1046 let (kind, size, modified_at) = match node {
1047 MemoryNode::Directory { modified_at } => (FileType::Directory, None, *modified_at),
1048 MemoryNode::File { bytes, modified_at } => {
1049 (FileType::File, Some(bytes.len() as u64), *modified_at)
1050 }
1051 };
1052 Ok(FileEntry {
1053 path: logical_normalise(path)?,
1054 name: logical_name(path)?,
1055 kind,
1056 size,
1057 modified_at,
1058 })
1059 }
1060}
1061
1062impl FileProvider for MemoryFileProvider {
1063 fn read_bytes(&self, path: &str) -> Result<Vec<u8>, FileError> {
1064 let path = logical_normalise(path)?;
1065 match self.nodes.borrow().get(&path) {
1066 Some(MemoryNode::File { bytes, .. }) => Ok(bytes.clone()),
1067 Some(MemoryNode::Directory { .. }) => Err(FileError::IsDirectory),
1068 None => Err(FileError::NotFound),
1069 }
1070 }
1071
1072 fn write_bytes(
1073 &self,
1074 path: &str,
1075 mut bytes: Vec<u8>,
1076 options: WriteOptions,
1077 ) -> Result<String, FileError> {
1078 let path = logical_normalise(path)?;
1079 if path == "/" {
1080 return Err(FileError::IsDirectory);
1081 }
1082 self.ensure_parent(&path, options.parents)?;
1083 let mut nodes = self.nodes.borrow_mut();
1084 match (nodes.get(&path), options.mode) {
1085 (Some(MemoryNode::Directory { .. }), _) => return Err(FileError::IsDirectory),
1086 (Some(_), WriteMode::Create) => return Err(FileError::AlreadyExists),
1087 (
1088 Some(MemoryNode::File {
1089 bytes: existing, ..
1090 }),
1091 WriteMode::Append,
1092 ) => {
1093 let mut output = existing.clone();
1094 output.extend(bytes);
1095 bytes = output;
1096 }
1097 _ => {}
1098 }
1099 nodes.insert(
1100 path.clone(),
1101 MemoryNode::File {
1102 bytes,
1103 modified_at: now_millis(),
1104 },
1105 );
1106 Ok(path)
1107 }
1108
1109 fn exists_value(&self, path: &str) -> Result<bool, FileError> {
1110 Ok(self.nodes.borrow().contains_key(&logical_normalise(path)?))
1111 }
1112
1113 fn stat_entry(&self, path: &str) -> Result<FileEntry, FileError> {
1114 let path = logical_normalise(path)?;
1115 let nodes = self.nodes.borrow();
1116 let node = nodes.get(&path).ok_or(FileError::NotFound)?;
1117 self.entry_for(&path, node)
1118 }
1119
1120 fn entries_values(&self, path: &str) -> Result<Vec<FileEntry>, FileError> {
1121 let path = logical_normalise(path)?;
1122 match self.nodes.borrow().get(&path) {
1123 Some(MemoryNode::Directory { .. }) => {}
1124 Some(_) => return Err(FileError::NotDirectory),
1125 None => return Err(FileError::NotFound),
1126 }
1127 let mut output = BTreeMap::new();
1128 let prefix = if path == "/" {
1129 "/".into()
1130 } else {
1131 format!("{path}/")
1132 };
1133 let nodes = self.nodes.borrow();
1134 for (candidate, node) in nodes.iter() {
1135 if candidate == &path || !candidate.starts_with(&prefix) {
1136 continue;
1137 }
1138 let remainder = &candidate[prefix.len()..];
1139 if remainder.is_empty() || remainder.contains('/') {
1140 continue;
1141 }
1142 output.insert(candidate.clone(), self.entry_for(candidate, node)?);
1143 }
1144 Ok(output.into_values().collect())
1145 }
1146
1147 fn mkdir_path(&self, path: &str, options: MkdirOptions) -> Result<String, FileError> {
1148 let path = logical_normalise(path)?;
1149 if let Some(node) = self.nodes.borrow().get(&path) {
1150 return if matches!(node, MemoryNode::Directory { .. }) && options.exists_ok {
1151 Ok(path)
1152 } else {
1153 Err(FileError::AlreadyExists)
1154 };
1155 }
1156 self.ensure_parent(&path, options.parents)?;
1157 self.nodes.borrow_mut().insert(
1158 path.clone(),
1159 MemoryNode::Directory {
1160 modified_at: now_millis(),
1161 },
1162 );
1163 Ok(path)
1164 }
1165
1166 fn delete_path(&self, path: &str, options: DeleteOptions) -> Result<String, FileError> {
1167 let path = logical_normalise(path)?;
1168 if path == "/" {
1169 return Err(FileError::Denied);
1170 }
1171 let mut nodes = self.nodes.borrow_mut();
1172 let Some(node) = nodes.get(&path) else {
1173 return if options.missing_ok {
1174 Ok(path)
1175 } else {
1176 Err(FileError::NotFound)
1177 };
1178 };
1179 if matches!(node, MemoryNode::Directory { .. }) {
1180 let prefix = format!("{path}/");
1181 if nodes.keys().any(|candidate| candidate.starts_with(&prefix)) {
1182 return Err(FileError::DirectoryNotEmpty);
1183 }
1184 }
1185 nodes.remove(&path);
1186 Ok(path)
1187 }
1188
1189 fn copy_path(
1190 &self,
1191 source: &str,
1192 target: &str,
1193 options: CopyOptions,
1194 ) -> Result<String, FileError> {
1195 let source = logical_normalise(source)?;
1196 let target = logical_normalise(target)?;
1197 if source == target {
1198 return Err(FileError::AlreadyExists);
1199 }
1200 let (bytes, modified_at) = match self.nodes.borrow().get(&source) {
1201 Some(MemoryNode::File { bytes, modified_at }) => (bytes.clone(), *modified_at),
1202 Some(MemoryNode::Directory { .. }) => return Err(FileError::IsDirectory),
1203 None => return Err(FileError::NotFound),
1204 };
1205 let result = self.write_bytes(
1206 &target,
1207 bytes,
1208 WriteOptions {
1209 mode: if options.replace {
1210 WriteMode::Replace
1211 } else {
1212 WriteMode::Create
1213 },
1214 parents: options.parents,
1215 },
1216 )?;
1217 if options.preserve_modified {
1218 if let Some(MemoryNode::File {
1219 modified_at: target_modified,
1220 ..
1221 }) = self.nodes.borrow_mut().get_mut(&target)
1222 {
1223 *target_modified = modified_at;
1224 }
1225 }
1226 Ok(result)
1227 }
1228
1229 fn move_path(
1230 &self,
1231 source: &str,
1232 target: &str,
1233 options: MoveOptions,
1234 ) -> Result<String, FileError> {
1235 if options.atomic { }
1236 let source = logical_normalise(source)?;
1237 let target = logical_normalise(target)?;
1238 if source == "/" || target == "/" {
1239 return Err(FileError::Denied);
1240 }
1241 if source == target {
1242 self.stat_entry(&source)?;
1243 return Ok(target);
1244 }
1245 if target.starts_with(&format!("{source}/")) {
1246 return Err(FileError::InvalidPath(
1247 "cannot move a directory beneath itself".into(),
1248 ));
1249 }
1250 self.ensure_parent(&target, options.parents)?;
1251 let mut nodes = self.nodes.borrow_mut();
1252 if !nodes.contains_key(&source) {
1253 return Err(FileError::NotFound);
1254 }
1255 if nodes.contains_key(&target) && !options.replace {
1256 return Err(FileError::AlreadyExists);
1257 }
1258 if options.replace {
1259 let target_prefix = format!("{target}/");
1260 if matches!(nodes.get(&target), Some(MemoryNode::Directory { .. }))
1261 && nodes
1262 .keys()
1263 .any(|candidate| candidate.starts_with(&target_prefix))
1264 {
1265 return Err(FileError::DirectoryNotEmpty);
1266 }
1267 nodes.remove(&target);
1268 }
1269 let prefix = format!("{source}/");
1270 let moving: Vec<(String, MemoryNode)> = nodes
1271 .iter()
1272 .filter(|(path, _)| *path == &source || path.starts_with(&prefix))
1273 .map(|(path, node)| (path.clone(), node.clone()))
1274 .collect();
1275 for (path, _) in &moving {
1276 nodes.remove(path);
1277 }
1278 for (path, node) in moving {
1279 let suffix = path.strip_prefix(&source).unwrap_or_default();
1280 nodes.insert(format!("{target}{suffix}"), node);
1281 }
1282 Ok(target)
1283 }
1284
1285 fn temp_file_path(&self, parent: &str, options: TempFileOptions) -> Result<String, FileError> {
1286 validate_temp_name(&options.prefix, Some(&options.suffix))?;
1287 for _ in 0..1024 {
1288 let name = format!(
1289 "{}-{:016x}{}",
1290 options.prefix,
1291 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed),
1292 options.suffix
1293 );
1294 let path = logical_resolve(parent, &name)?;
1295 match self.write_bytes(&path, Vec::new(), WriteOptions::default()) {
1296 Ok(value) => return Ok(value),
1297 Err(FileError::AlreadyExists) => {}
1298 Err(error) => return Err(error),
1299 }
1300 }
1301 Err(FileError::Io(
1302 "unable to allocate unique temporary file".into(),
1303 ))
1304 }
1305
1306 fn temp_directory_path(
1307 &self,
1308 parent: &str,
1309 options: TempDirectoryOptions,
1310 ) -> Result<String, FileError> {
1311 validate_temp_name(&options.prefix, None)?;
1312 for _ in 0..1024 {
1313 let name = format!(
1314 "{}-{:016x}",
1315 options.prefix,
1316 TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1317 );
1318 let path = logical_resolve(parent, &name)?;
1319 match self.mkdir_path(
1320 &path,
1321 MkdirOptions {
1322 parents: false,
1323 exists_ok: false,
1324 },
1325 ) {
1326 Ok(value) => return Ok(value),
1327 Err(FileError::AlreadyExists) => {}
1328 Err(error) => return Err(error),
1329 }
1330 }
1331 Err(FileError::Io(
1332 "unable to allocate unique temporary directory".into(),
1333 ))
1334 }
1335}
1336
1337#[derive(Debug, Default, Clone, Copy)]
1338pub struct UnsupportedFileProvider;
1339
1340impl FileProvider for UnsupportedFileProvider {
1341 fn read_bytes(&self, _path: &str) -> Result<Vec<u8>, FileError> {
1342 Err(FileError::Unsupported)
1343 }
1344 fn write_bytes(
1345 &self,
1346 _path: &str,
1347 _bytes: Vec<u8>,
1348 _options: WriteOptions,
1349 ) -> Result<String, FileError> {
1350 Err(FileError::Unsupported)
1351 }
1352 fn exists_value(&self, _path: &str) -> Result<bool, FileError> {
1353 Err(FileError::Unsupported)
1354 }
1355 fn stat_entry(&self, _path: &str) -> Result<FileEntry, FileError> {
1356 Err(FileError::Unsupported)
1357 }
1358 fn entries_values(&self, _path: &str) -> Result<Vec<FileEntry>, FileError> {
1359 Err(FileError::Unsupported)
1360 }
1361 fn mkdir_path(&self, _path: &str, _options: MkdirOptions) -> Result<String, FileError> {
1362 Err(FileError::Unsupported)
1363 }
1364 fn delete_path(&self, _path: &str, _options: DeleteOptions) -> Result<String, FileError> {
1365 Err(FileError::Unsupported)
1366 }
1367 fn copy_path(
1368 &self,
1369 _source: &str,
1370 _target: &str,
1371 _options: CopyOptions,
1372 ) -> Result<String, FileError> {
1373 Err(FileError::Unsupported)
1374 }
1375 fn move_path(
1376 &self,
1377 _source: &str,
1378 _target: &str,
1379 _options: MoveOptions,
1380 ) -> Result<String, FileError> {
1381 Err(FileError::Unsupported)
1382 }
1383 fn temp_file_path(
1384 &self,
1385 _parent: &str,
1386 _options: TempFileOptions,
1387 ) -> Result<String, FileError> {
1388 Err(FileError::Unsupported)
1389 }
1390 fn temp_directory_path(
1391 &self,
1392 _parent: &str,
1393 _options: TempDirectoryOptions,
1394 ) -> Result<String, FileError> {
1395 Err(FileError::Unsupported)
1396 }
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402 use crate::task::{PromiseRejection, PromiseState};
1403
1404 fn rejection_data(promise: Promise) -> Value {
1405 match promise.wait_state() {
1406 PromiseState::Rejected(PromiseRejection::Value(Value::ExceptionInfo(info))) => {
1407 (*info.data).clone()
1408 }
1409 state => panic!("expected structured filesystem rejection, got {state:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn logical_paths_are_absolute_and_cannot_escape() {
1415 assert_eq!(
1416 logical_normalise("src//./main.hal").unwrap(),
1417 "/src/main.hal"
1418 );
1419 assert_eq!(
1420 logical_join("/src", "/test/main.hal").unwrap(),
1421 "/src/test/main.hal"
1422 );
1423 assert_eq!(
1424 logical_resolve("/src", "/test/main.hal").unwrap(),
1425 "/test/main.hal"
1426 );
1427 assert_eq!(
1428 logical_resolve("/src/lib", "../main.hal").unwrap(),
1429 "/src/main.hal"
1430 );
1431 assert_eq!(
1432 logical_parent("/src/main.hal").unwrap(),
1433 Some("/src".into())
1434 );
1435 assert_eq!(
1436 logical_normalise("../escape").unwrap_err(),
1437 FileError::OutsideRoot
1438 );
1439 assert!(matches!(
1440 logical_normalise(r"src\main.hal"),
1441 Err(FileError::InvalidPath(_))
1442 ));
1443 assert!(matches!(
1444 logical_normalise("C:/host/path"),
1445 Err(FileError::InvalidPath(_))
1446 ));
1447 }
1448
1449 #[test]
1450 fn memory_provider_honours_safe_defaults_and_sorted_entries() {
1451 let files = MemoryFileProvider::new("ignored");
1452 files.mkdir_path("/src", MkdirOptions::default()).unwrap();
1453 files
1454 .write_bytes("/src/b", vec![2], WriteOptions::default())
1455 .unwrap();
1456 files
1457 .write_bytes("/src/a", vec![1], WriteOptions::default())
1458 .unwrap();
1459 assert_eq!(
1460 files
1461 .write_bytes("/src/a", vec![3], WriteOptions::default())
1462 .unwrap_err(),
1463 FileError::AlreadyExists
1464 );
1465 files
1466 .write_bytes(
1467 "/src/a",
1468 vec![3],
1469 WriteOptions {
1470 mode: WriteMode::Append,
1471 parents: false,
1472 },
1473 )
1474 .unwrap();
1475 assert_eq!(files.read_bytes("/src/a").unwrap(), vec![1, 3]);
1476 let entries = files.entries_values("/src").unwrap();
1477 assert_eq!(
1478 entries
1479 .iter()
1480 .map(|entry| entry.path.as_str())
1481 .collect::<Vec<_>>(),
1482 vec!["/src/a", "/src/b"]
1483 );
1484 assert!(entries.iter().all(|entry| entry.name.len() == 1));
1485 }
1486
1487 #[test]
1488 fn provider_failures_are_structured_promise_rejections() {
1489 let files = MemoryFileProvider::new("ignored");
1490 let data = rejection_data(files.read("/missing").unwrap());
1491 let Value::Map(data) = data else {
1492 panic!("filesystem rejection data was not a map");
1493 };
1494 assert_eq!(
1495 data.get(&Value::Keyword("ex/code".into())),
1496 Some(&Value::Keyword("file/not-found".into()))
1497 );
1498 assert_eq!(
1499 data.get(&Value::Keyword("file/operation".into())),
1500 Some(&Value::Keyword("read".into()))
1501 );
1502 assert_eq!(
1503 data.get(&Value::Keyword("file/path".into())),
1504 Some(&Value::String("/missing".into()))
1505 );
1506 assert_eq!(
1507 data.get(&Value::Keyword("file/target".into())),
1508 Some(&Value::Nil)
1509 );
1510 }
1511
1512 #[test]
1513 fn memory_move_rejects_descendants_and_non_empty_replacement() {
1514 let files = MemoryFileProvider::new("ignored");
1515 files
1516 .write_bytes(
1517 "/source/child",
1518 vec![1],
1519 WriteOptions {
1520 mode: WriteMode::Create,
1521 parents: true,
1522 },
1523 )
1524 .unwrap();
1525 assert!(matches!(
1526 files.move_path("/source", "/source/child/nested", MoveOptions::default()),
1527 Err(FileError::InvalidPath(_))
1528 ));
1529
1530 files
1531 .write_bytes(
1532 "/target/existing",
1533 vec![2],
1534 WriteOptions {
1535 mode: WriteMode::Create,
1536 parents: true,
1537 },
1538 )
1539 .unwrap();
1540 assert_eq!(
1541 files
1542 .move_path(
1543 "/source",
1544 "/target",
1545 MoveOptions {
1546 replace: true,
1547 ..MoveOptions::default()
1548 },
1549 )
1550 .unwrap_err(),
1551 FileError::DirectoryNotEmpty
1552 );
1553 assert_eq!(
1554 files
1555 .move_path("/source", "/source", MoveOptions::default())
1556 .unwrap(),
1557 "/source"
1558 );
1559 }
1560
1561 #[test]
1562 fn memory_copy_preserves_modified_time_only_when_requested() {
1563 let files = MemoryFileProvider::new("ignored");
1564 files
1565 .write_bytes("/source", vec![1, 2], WriteOptions::default())
1566 .unwrap();
1567 if let Some(MemoryNode::File { modified_at, .. }) =
1568 files.nodes.borrow_mut().get_mut("/source")
1569 {
1570 *modified_at = 1234;
1571 }
1572 files
1573 .copy_path(
1574 "/source",
1575 "/target",
1576 CopyOptions {
1577 preserve_modified: true,
1578 ..CopyOptions::default()
1579 },
1580 )
1581 .unwrap();
1582 assert_eq!(files.stat_entry("/target").unwrap().modified_at, 1234);
1583 assert_eq!(
1584 files
1585 .copy_path(
1586 "/source",
1587 "/source",
1588 CopyOptions {
1589 replace: true,
1590 ..CopyOptions::default()
1591 },
1592 )
1593 .unwrap_err(),
1594 FileError::AlreadyExists
1595 );
1596 }
1597
1598 #[test]
1599 fn temporary_names_must_remain_beneath_the_explicit_parent() {
1600 let files = MemoryFileProvider::new("ignored");
1601 files.mkdir_path("/tmp", MkdirOptions::default()).unwrap();
1602 assert!(matches!(
1603 files.temp_file_path(
1604 "/tmp",
1605 TempFileOptions {
1606 prefix: "../escape".into(),
1607 suffix: String::new(),
1608 },
1609 ),
1610 Err(FileError::InvalidPath(_))
1611 ));
1612 let first = files
1613 .temp_file_path("/tmp", TempFileOptions::default())
1614 .unwrap();
1615 let second = files
1616 .temp_file_path("/tmp", TempFileOptions::default())
1617 .unwrap();
1618 assert_ne!(first, second);
1619 assert!(first.starts_with("/tmp/tmp-"));
1620 assert!(second.starts_with("/tmp/tmp-"));
1621 }
1622
1623 #[cfg(all(unix, not(target_arch = "wasm32")))]
1624 #[test]
1625 fn native_provider_accepts_a_host_absolute_path_within_its_mount() {
1626 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1627 let root = std::env::temp_dir().join(format!(
1628 "hara-native-file-mounted-path-{}-{sequence}",
1629 std::process::id()
1630 ));
1631 fs::create_dir_all(&root).unwrap();
1632 fs::write(root.join("project.edn"), b"{}").unwrap();
1633 let root = root.canonicalize().unwrap();
1634 let files = NativeFileProvider::new(&root);
1635 let mounted = root.join("project.edn").to_string_lossy().into_owned();
1636 let generated = root.join("generated.hal").to_string_lossy().into_owned();
1637
1638 assert_eq!(files.read_bytes(&mounted).unwrap(), b"{}");
1639 assert_eq!(files.stat_entry(&mounted).unwrap().path, mounted);
1640 assert_eq!(
1641 files
1642 .write_bytes(
1643 &generated,
1644 b"(ns generated)\n".to_vec(),
1645 WriteOptions::default()
1646 )
1647 .unwrap(),
1648 generated
1649 );
1650 assert_eq!(
1651 fs::read(root.join("generated.hal")).unwrap(),
1652 b"(ns generated)\n"
1653 );
1654
1655 fs::remove_dir_all(root).unwrap();
1656 }
1657
1658 #[cfg(all(unix, not(target_arch = "wasm32")))]
1659 #[test]
1660 fn native_copy_replaces_a_symlink_entry_without_following_it() {
1661 use std::os::unix::fs::symlink;
1662
1663 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1664 let root = std::env::temp_dir().join(format!(
1665 "hara-native-file-test-{}-{sequence}",
1666 std::process::id()
1667 ));
1668 let outside = std::env::temp_dir().join(format!(
1669 "hara-native-file-outside-{}-{sequence}",
1670 std::process::id()
1671 ));
1672 fs::create_dir_all(&root).unwrap();
1673 fs::write(root.join("source"), b"inside").unwrap();
1674 fs::write(&outside, b"outside").unwrap();
1675 symlink(&outside, root.join("target")).unwrap();
1676
1677 let files = NativeFileProvider::new(&root);
1678 files
1679 .copy_path(
1680 "/source",
1681 "/target",
1682 CopyOptions {
1683 replace: true,
1684 ..CopyOptions::default()
1685 },
1686 )
1687 .unwrap();
1688
1689 assert_eq!(fs::read(root.join("target")).unwrap(), b"inside");
1690 assert_eq!(fs::read(&outside).unwrap(), b"outside");
1691 assert!(!fs::symlink_metadata(root.join("target"))
1692 .unwrap()
1693 .file_type()
1694 .is_symlink());
1695
1696 fs::remove_dir_all(root).unwrap();
1697 fs::remove_file(outside).unwrap();
1698 }
1699}