Skip to main content

hara_native/
file.rs

1//! Capability-backed logical filesystem providers.
2//!
3//! Public paths are always absolute paths in the mounted logical filesystem.
4//! Host paths are confined to provider implementations and never escape in
5//! return values or error data.
6
7use 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(&current) {
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            // Replace the directory entry itself. Opening a target symlink with
859            // truncate would otherwise mutate a file outside the mounted root.
860            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(&current, segment)?;
1021                let mut nodes = self.nodes.borrow_mut();
1022                match nodes.get(&current) {
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 { /* memory moves are atomic within one provider */ }
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}