Skip to main content

submilli_engine/runtime/
default_filesystem.rs

1//! Default filesystem implementation over the contained VFS.
2use super::durable::EffectId;
3use super::filesystem::{
4    DirectoryEntry, DirectoryReader, FileReader, FileWriter, Filesystem, FilesystemError,
5    FilesystemInfo, MutationBudget, ReadItem, ReadOutput, ReadPosition, ReaderOptions,
6    bounded_read,
7};
8use super::fs::{self, ContainError, ContentPath, FileIdentity, LinkPath, MAX_REMOVE_ENTRIES};
9use super::fuel::WorkBudget;
10use super::vfs::Placement;
11use super::{DiskQuota, Holder, OpenFileGuard, QuotaCharge, QuotaExceeded, Vfs};
12use crate::stdlib::shared::{contain_trap, quota_refusal, write_target_trap};
13use cap_std::fs::{Dir, DirEntry, ReadDir};
14use std::collections::VecDeque;
15use std::ffi::{OsStr, OsString};
16use std::fs::File;
17use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21pub struct DefaultFilesystem {
22    vfs: Vfs,
23}
24impl DefaultFilesystem {
25    pub fn new(vfs: Vfs) -> Self {
26        Self { vfs }
27    }
28}
29#[async_trait::async_trait]
30impl Filesystem for DefaultFilesystem {
31    fn info(&self) -> FilesystemInfo {
32        FilesystemInfo::from_vfs(&self.vfs)
33    }
34    fn cwd(&self) -> &str {
35        self.vfs.cwd()
36    }
37    fn placement(&self, path: &str) -> Result<Placement, FilesystemError> {
38        Ok(fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?
39            .placement()
40            .clone())
41    }
42    async fn metadata(
43        &self,
44        _effect_id: &EffectId,
45        _work: Option<Arc<WorkBudget>>,
46        path: &str,
47        follow: bool,
48    ) -> Result<cap_std::fs::Metadata, FilesystemError> {
49        if follow {
50            Ok(fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?.metadata()?)
51        } else {
52            Ok(fs::resolve_link(&self.vfs, self.vfs.cwd(), path)?.symlink_metadata()?)
53        }
54    }
55    async fn read(
56        &self,
57        _effect_id: &EffectId,
58        _work: Option<Arc<WorkBudget>>,
59        path: &str,
60        offset: u64,
61        maximum_bytes: u64,
62    ) -> Result<Vec<u8>, FilesystemError> {
63        let file = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?.open()?;
64        bounded_read(file, offset, maximum_bytes)
65    }
66    async fn write(
67        &self,
68        _effect_id: &EffectId,
69        _work: Option<Arc<WorkBudget>>,
70        path: &str,
71        bytes: Vec<u8>,
72    ) -> Result<(), FilesystemError> {
73        let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
74        path.writable()?;
75        if path.is_root() {
76            return Err(FilesystemError::Unsupported(
77                "cannot write to the VFS root or a mount point",
78            ));
79        }
80        let mut quota = QuotaCharge::new(path.placement().quota().cloned(), path.regular_file());
81        quota.reserve(bytes.len() as u64)?;
82        let temp = path.temp_sibling();
83        let mut file = temp.create_new()?;
84        let result = (|| {
85            file.write_all(&bytes)?;
86            file.sync_all()?;
87            drop(file);
88            temp.rename_to(&path)?;
89            Ok(())
90        })();
91        if result.is_err() {
92            let _ = temp.remove_file();
93        } else {
94            quota.commit();
95        }
96        result
97    }
98    async fn append(
99        &self,
100        _effect_id: &EffectId,
101        _work: Option<Arc<WorkBudget>>,
102        path: &str,
103        bytes: Vec<u8>,
104    ) -> Result<(), FilesystemError> {
105        let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
106        path.writable()?;
107        if path.is_root() {
108            return Err(FilesystemError::Unsupported(
109                "cannot write to the VFS root or a mount point",
110            ));
111        }
112        let count = bytes.len() as u64;
113        let before = path.file_len();
114        let mut quota = QuotaCharge::new(path.placement().quota().cloned(), None);
115        quota.reserve(count)?;
116        let result = path
117            .append()
118            .map_err(FilesystemError::from)
119            .and_then(|mut file| file.write_all(&bytes).map_err(Into::into));
120        if result.is_err() {
121            quota
122                .unreserve(count.saturating_sub(path.file_len().saturating_sub(before).min(count)));
123            quota.keep();
124        } else {
125            quota.commit();
126        }
127        result
128    }
129    async fn mkdir(
130        &self,
131        _effect_id: &EffectId,
132        _work: Option<Arc<WorkBudget>>,
133        path: &str,
134        recursive: bool,
135    ) -> Result<(), FilesystemError> {
136        let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
137        if recursive {
138            path.create_dir_all()?;
139        } else {
140            path.create_dir()?;
141        }
142        Ok(())
143    }
144    async fn remove(
145        &self,
146        _effect_id: &EffectId,
147        _work: Option<Arc<WorkBudget>>,
148        path: &str,
149        recursive: bool,
150        budget: &mut dyn MutationBudget,
151    ) -> Result<(), FilesystemError> {
152        default_remove(&self.vfs, path, recursive, budget).map_err(FilesystemError::Execution)
153    }
154    async fn move_entry(
155        &self,
156        _effect_id: &EffectId,
157        _work: Option<Arc<WorkBudget>>,
158        from: &str,
159        to: &str,
160        budget: &mut dyn MutationBudget,
161    ) -> Result<(), FilesystemError> {
162        default_move(&self.vfs, from, to, budget).map_err(FilesystemError::Execution)
163    }
164    async fn copy(
165        &self,
166        _effect_id: &EffectId,
167        _work: Option<Arc<WorkBudget>>,
168        from: &str,
169        to: &str,
170        recursive: bool,
171        budget: &mut dyn MutationBudget,
172    ) -> Result<(), FilesystemError> {
173        default_copy(&self.vfs, from, to, recursive, budget).map_err(FilesystemError::Execution)
174    }
175    async fn open_reader(
176        &self,
177        _effect_id: &EffectId,
178        _work: Option<Arc<WorkBudget>>,
179        path: &str,
180        options: ReaderOptions,
181    ) -> Result<Box<dyn FileReader>, FilesystemError> {
182        let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
183        let file = path.open()?;
184        let held = path.placement().quota().and_then(|quota| {
185            let metadata = file.metadata().ok()?;
186            Some(quota.hold(fs::FileIdentity::of(&metadata).ok()?, super::Holder::Reader))
187        });
188        Ok(Box::new(DefaultReader {
189            file: Some(file.into_std()),
190            options,
191            position: ReadPosition::default(),
192            _held: held,
193        }))
194    }
195    async fn open_directory(
196        &self,
197        _effect_id: &EffectId,
198        _work: Option<Arc<WorkBudget>>,
199        path: &str,
200        recursive: bool,
201        _: &super::limits::TenantLimits,
202    ) -> Result<Box<dyn DirectoryReader>, FilesystemError> {
203        let resolved = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
204        if !resolved.metadata()?.is_dir() {
205            return Err(std::io::Error::from(std::io::ErrorKind::NotADirectory).into());
206        }
207        let base = std::sync::Arc::new(resolved.open_dir()?);
208        let mounts = if recursive {
209            resolved.mounts_below()
210        } else {
211            Vec::new()
212        };
213        let normalized = fs::guest_normalize(self.vfs.cwd(), path).map_err(ContainError::from)?;
214        let prefix = if normalized == "/" {
215            String::new()
216        } else {
217            format!("{}/", normalized.trim_start_matches('/'))
218        };
219        let walk = ContainedWalk::new(base, prefix, recursive, mounts)?;
220        Ok(Box::new(DefaultDirectory(Some(walk))))
221    }
222    async fn open_writer(
223        &self,
224        _effect_id: &EffectId,
225        _work: Option<Arc<WorkBudget>>,
226        path: &str,
227    ) -> Result<Box<dyn FileWriter>, FilesystemError> {
228        let path = fs::resolve_content(&self.vfs, self.vfs.cwd(), path)?;
229        path.writable()?;
230        if path.is_root() {
231            return Err(FilesystemError::Unsupported(
232                "cannot write to the VFS root or a mount point",
233            ));
234        }
235        let temp_path = path.temp_sibling();
236        let file = temp_path.create_new()?;
237        let temp = TempFile::created(temp_path, &file)?;
238        let quota = path.placement().quota().cloned();
239        Ok(Box::new(DefaultWriter::new(
240            file.into_std(),
241            temp,
242            path,
243            quota,
244        )))
245    }
246}
247
248struct DefaultReader {
249    file: Option<std::fs::File>,
250    options: ReaderOptions,
251    position: ReadPosition,
252    _held: Option<super::OpenFileGuard>,
253}
254#[async_trait::async_trait]
255impl FileReader for DefaultReader {
256    async fn next(
257        &mut self,
258        _effect_id: &EffectId,
259        work: Option<Arc<WorkBudget>>,
260        limits: &super::limits::TenantLimits,
261    ) -> Result<ReadOutput, FilesystemError> {
262        let Some(file) = &mut self.file else {
263            return empty_read(limits);
264        };
265        read_item(
266            file,
267            &self.options,
268            &mut self.position,
269            work.as_deref(),
270            limits,
271        )
272    }
273    async fn close(
274        &mut self,
275        _effect_id: &EffectId,
276        _work: Option<Arc<WorkBudget>>,
277    ) -> Result<(), FilesystemError> {
278        self.file = None;
279        self._held = None;
280        self.position.closed = true;
281        Ok(())
282    }
283}
284
285/// Read one logical item, anchoring every attempt at the supplied position.
286/// Read-ahead is local only. Errors leave the logical position unchanged.
287/// Durable backends commit the returned position before exposing the result.
288pub fn read_item(
289    file: &mut (impl Read + Seek),
290    options: &ReaderOptions,
291    position: &mut ReadPosition,
292    work: Option<&WorkBudget>,
293    limits: &super::limits::TenantLimits,
294) -> Result<ReadOutput, FilesystemError> {
295    if position.closed {
296        return empty_read(limits);
297    }
298    file.seek(SeekFrom::Start(position.offset))?;
299    let mut next = position.clone();
300    let result = match options {
301        ReaderOptions::Bytes { chunk_size } => {
302            read_chunk(file, *chunk_size, &mut next, work, limits)
303        }
304        ReaderOptions::Lines { maximum_bytes } => {
305            read_line(file, *maximum_bytes, &mut next, work, limits)
306        }
307    }?;
308    *position = next;
309    Ok(result)
310}
311fn empty_read(limits: &super::limits::TenantLimits) -> Result<ReadOutput, FilesystemError> {
312    Ok(ReadOutput {
313        item: None,
314        _charge: memory(limits, 0)?,
315    })
316}
317fn memory(
318    limits: &super::limits::TenantLimits,
319    bytes: u64,
320) -> Result<super::limits::HostBytes, FilesystemError> {
321    super::limits::HostBytes::new(limits, bytes)
322        .map_err(|e| FilesystemError::Execution(wasmtime::Error::new(e)))
323}
324fn read_cost(
325    work: Option<&WorkBudget>,
326    rate: super::fuel::Rate,
327    bytes: usize,
328) -> Result<(), FilesystemError> {
329    if let Some(work) = work {
330        // The read has happened; exhaustion cannot discard its result.
331        work.before_effect();
332        work.charge(rate, bytes as u64)?;
333    }
334    Ok(())
335}
336fn read_chunk(
337    file: &mut impl Read,
338    size: usize,
339    position: &mut ReadPosition,
340    work: Option<&WorkBudget>,
341    limits: &super::limits::TenantLimits,
342) -> Result<ReadOutput, FilesystemError> {
343    if size == 0 {
344        return Err(FilesystemError::Rejected(
345            "chunk size must be positive".into(),
346        ));
347    }
348    if let Some(work) = work {
349        work.charge(super::fuel::COPY, size as u64)?;
350    }
351    let charge = memory(limits, size as u64)?;
352    let mut bytes = Vec::new();
353    bytes
354        .try_reserve_exact(size)
355        .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
356    bytes.resize(size, 0);
357    let mut filled = 0;
358    while filled < size {
359        let count = file.read(&mut bytes[filled..])?;
360        read_cost(work, super::fuel::IO, count)?;
361        if count == 0 {
362            break;
363        }
364        filled += count;
365    }
366    position.offset = position
367        .offset
368        .checked_add(filled as u64)
369        .ok_or_else(|| FilesystemError::Rejected("reader offset overflow".into()))?;
370    bytes.truncate(filled);
371    Ok(ReadOutput {
372        item: (filled > 0).then_some(ReadItem::Bytes(bytes)),
373        _charge: charge,
374    })
375}
376fn read_line(
377    file: &mut impl Read,
378    maximum: u64,
379    position: &mut ReadPosition,
380    work: Option<&WorkBudget>,
381    limits: &super::limits::TenantLimits,
382) -> Result<ReadOutput, FilesystemError> {
383    use std::io::BufRead;
384    let mut charge = memory(limits, 8192)?;
385    let mut reader = std::io::BufReader::new(file);
386    let mut bytes = Vec::new();
387    loop {
388        let needs_read = reader.buffer().is_empty();
389        let available = reader.fill_buf()?;
390        if needs_read {
391            read_cost(work, super::fuel::IO, available.len())?;
392        }
393        if available.is_empty() {
394            break;
395        }
396        let newline = available.iter().position(|&b| b == b'\n');
397        let count = newline.map_or(available.len(), |i| i + 1);
398        read_cost(work, super::fuel::SCAN, count)?;
399        let length = bytes.len().checked_add(count).ok_or_else(|| {
400            FilesystemError::Execution(super::host::range_error(
401                "fs.lines: line length exceeds host capacity",
402            ))
403        })?;
404        if length as u64 > maximum {
405            return Err(FilesystemError::Execution(super::host::range_error(
406                format!(
407                    "fs.lines: line exceeds maxReadSize of {maximum} bytes; read smaller chunks with fs.bytes"
408                ),
409            )));
410        }
411        if length > bytes.capacity() {
412            let capacity = length
413                .max(bytes.capacity().saturating_mul(2))
414                .min(usize::try_from(maximum).unwrap_or(usize::MAX));
415            charge
416                .grow(limits, (capacity - bytes.capacity()) as u64)
417                .map_err(|e| FilesystemError::Execution(wasmtime::Error::new(e)))?;
418            bytes
419                .try_reserve_exact(capacity - bytes.len())
420                .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
421        }
422        bytes.extend_from_slice(&available[..count]);
423        reader.consume(count);
424        if newline.is_some() {
425            break;
426        }
427    }
428    if bytes.is_empty() {
429        return empty_read(limits);
430    }
431    position.offset = position
432        .offset
433        .checked_add(bytes.len() as u64)
434        .ok_or_else(|| FilesystemError::Rejected("reader offset overflow".into()))?;
435    if bytes.last() == Some(&b'\n') {
436        bytes.pop();
437        if bytes.last() == Some(&b'\r') {
438            bytes.pop();
439        }
440    }
441    let capacity = bytes
442        .len()
443        .checked_mul(3)
444        .ok_or_else(|| FilesystemError::Rejected("line capacity overflow".into()))?;
445    let output_charge = memory(limits, capacity as u64)?;
446    let mut text = String::new();
447    text.try_reserve_exact(capacity)
448        .map_err(|e| FilesystemError::Backend(Box::new(e)))?;
449    for part in bytes.utf8_chunks() {
450        text.push_str(part.valid());
451        if !part.invalid().is_empty() {
452            text.push('\u{fffd}');
453        }
454    }
455    if !position.bom_handled {
456        position.bom_handled = true;
457        if text.starts_with('\u{feff}') {
458            text.drain(..'\u{feff}'.len_utf8());
459        }
460    }
461    Ok(ReadOutput {
462        item: Some(ReadItem::Line(text)),
463        _charge: output_charge,
464    })
465}
466
467struct DefaultDirectory(Option<ContainedWalk>);
468#[async_trait::async_trait]
469impl DirectoryReader for DefaultDirectory {
470    async fn next_entry(
471        &mut self,
472        _effect_id: &EffectId,
473        _work: Option<Arc<WorkBudget>>,
474        _: &super::limits::TenantLimits,
475    ) -> Result<Option<DirectoryEntry>, FilesystemError> {
476        Ok(self.0.as_mut().and_then(ContainedWalk::next_entry))
477    }
478    fn retained_bytes(&self) -> u64 {
479        8192
480    }
481    async fn close(
482        &mut self,
483        _effect_id: &EffectId,
484        _work: Option<Arc<WorkBudget>>,
485        _: &super::limits::TenantLimits,
486    ) -> Result<(), FilesystemError> {
487        self.0 = None;
488        Ok(())
489    }
490}
491
492/// Concurrently open directory handles per walk. A deep tree times the number of list
493/// handles a guest holds open consumes process-wide descriptors that the tenant limiter
494/// does not account for, since it charges a byte budget only. Subdirectories found past
495/// the cap are postponed and reopened from the base handle once a handle frees up.
496///
497/// Higher than `walkdir`'s `max_open` default of 10: `walkdir` pays only extra syscalls
498/// when it hits the cap (it closes the *shallowest* level and reopens it by path,
499/// preserving depth-first order), whereas postponing costs ordering as well — a postponed
500/// subtree lands after the rest of its parent's entries. Real checkouts (nested
501/// `node_modules`, deep monorepos) routinely pass 10 levels and rarely pass 32.
502///
503/// The `list` doc in `declaration.rs` states this depth as the guest-facing ordering
504/// contract — change both together.
505const MAX_OPEN_DIRS: usize = 32;
506
507/// Postponed subdirectory names a single walk will hold at once. Only levels past
508/// [`MAX_OPEN_DIRS`] postpone at all, so reaching this needs a directory 32 deep with a
509/// fan-out in the thousands; past it a subdirectory is skipped rather than remembered,
510/// which is the same best-effort outcome an unreadable one already gets. Without a
511/// ceiling the queue grows with the width of a directory the guest did not have to pay
512/// for in the byte cap.
513const MAX_DEFERRED: usize = 16_384;
514
515struct Level {
516    /// `None` once the directory is fully read: the handle is released at that point,
517    /// while the frame stays on the stack until [`Level::deferred`] drains.
518    iter: Option<ReadDir>,
519    /// Path of this level's directory relative to the listed directory — empty at the top.
520    /// Base-relative rather than guest-absolute so a postponed level can be reopened
521    /// against the base handle, and a `PathBuf` rather than a `String` so a name the
522    /// platform allows but UTF-8 does not still reopens — the lossy [`Level::prefix`]
523    /// would have replaced it with U+FFFD and lost the subtree.
524    path: PathBuf,
525    /// Lossy rendering of [`Level::path`] with guest `/` separators: `""` at the top,
526    /// `"sub/"` one down. Guest paths are built from this.
527    prefix: String,
528    /// Names of subdirectories of this level whose descent was postponed — because the
529    /// open-handle cap was reached, or because opening them from this level's handle
530    /// failed (most likely descriptor exhaustion, which is exactly what the cap manages).
531    /// Drained in order once this level is fully read, each rejoined to [`Level::path`]
532    /// and reopened from the base handle.
533    ///
534    /// Bare names, not paths: at the depth where postponement starts, the level's own
535    /// path is 32 components long, and storing it again per postponed child would make
536    /// the queue tens of times larger than the entry names it stands for.
537    deferred: VecDeque<OsString>,
538}
539
540impl Level {
541    fn new(path: PathBuf, iter: ReadDir) -> Self {
542        let mut prefix = String::new();
543        for component in &path {
544            prefix.push_str(&component.to_string_lossy());
545            prefix.push('/');
546        }
547        Self {
548            iter: Some(iter),
549            path,
550            prefix,
551            deferred: VecDeque::new(),
552        }
553    }
554}
555
556/// Directory walker (flat or recursive), rooted at a contained handle.
557///
558/// Lazy, pre-order, and non-following — matching `walkdir`'s default, so a checkout's
559/// internal links surface as entries rather than being descended and double-walked.
560/// Per-entry errors skip and continue rather than trapping the whole iteration: a
561/// recursive walk over a real checkout is the flagship use case, and one unreadable
562/// subtree should not end it.
563///
564/// Ordering: every directory is yielded before its descendants, and every subtree is
565/// yielded as one contiguous run — nothing outside a directory's subtree is interleaved
566/// into it. Strictly depth-first down to [`MAX_OPEN_DIRS`] levels of nesting; past that,
567/// a subdirectory's own contents follow the rest of its parent's entries instead of
568/// coming immediately after it, because the descent has to wait for a directory handle.
569struct ContainedWalk {
570    base: Arc<Dir>,
571    /// Mount points below the base, relative to it, with the volume handle a
572    /// descent opens in place of the empty directory the root holds there.
573    mounts: Vec<(PathBuf, Arc<Dir>)>,
574    /// Guest-relative path of the listed directory, `""` for the root, else `"tree/"`.
575    base_prefix: String,
576    stack: Vec<Level>,
577    /// Levels on `stack` still holding a `ReadDir`; the frames that have released theirs
578    /// stay on the stack while their postponed subdirectories drain.
579    open_dirs: usize,
580    /// Postponed names held across every level, against [`MAX_DEFERRED`].
581    deferred: usize,
582    recursive: bool,
583}
584
585impl ContainedWalk {
586    /// `base_prefix` is the guest-relative path of the listed directory, `""` for the root.
587    /// `mounts` are the mount points below it; see [`ContentPath::mounts_below`].
588    pub fn new(
589        base: Arc<Dir>,
590        base_prefix: String,
591        recursive: bool,
592        mounts: Vec<(PathBuf, Arc<Dir>)>,
593    ) -> std::io::Result<Self> {
594        let iter = base.entries()?;
595        Ok(Self {
596            base,
597            mounts,
598            base_prefix,
599            stack: vec![Level::new(PathBuf::new(), iter)],
600            open_dirs: 1,
601            deferred: 0,
602            recursive,
603        })
604    }
605
606    fn next_entry(&mut self) -> Option<DirectoryEntry> {
607        loop {
608            let level = self.stack.last_mut()?;
609            match level.iter.as_mut().map(Iterator::next) {
610                Some(Some(Ok(entry))) => {
611                    if let Some(found) = self.visit(entry) {
612                        return Some(found);
613                    }
614                }
615                Some(Some(Err(_))) => {}
616                Some(None) => {
617                    level.iter = None;
618                    self.open_dirs -= 1;
619                }
620                None => self.resume_deferred(),
621            }
622        }
623    }
624
625    /// Yields `entry`, descending into it first when it is a directory a recursive walk
626    /// should enter. `None` for an entry whose type cannot be determined at all.
627    fn visit(&mut self, entry: DirEntry) -> Option<DirectoryEntry> {
628        let raw_name = entry.file_name();
629        // `file_type` can fail where the directory entry carries no type; the entry's
630        // own metadata is the fallback before giving up on it.
631        let ft = entry
632            .file_type()
633            .ok()
634            .or_else(|| entry.metadata().ok().map(|m| m.file_type()))?;
635        let size = if ft.is_file() {
636            entry.metadata().map_or(0, |m| m.len())
637        } else {
638            0
639        };
640        let level = self.stack.last()?;
641        let name = raw_name.to_string_lossy().into_owned();
642        let guest_path = format!("/{}{}{}", self.base_prefix, level.prefix, name);
643        if self.recursive && ft.is_dir() && !ft.is_symlink() {
644            self.descend(&raw_name, &entry);
645        }
646        Some(DirectoryEntry {
647            name,
648            path: guest_path,
649            kind: kind_of(&ft),
650            bytes: size,
651        })
652    }
653
654    /// Opens the subdirectory named `name` as the new innermost level, or postpones it on
655    /// the current level — when there is no handle to spare, and equally when the open
656    /// fails, since a failure here is most likely descriptor exhaustion and dropping the
657    /// subtree over that would silently truncate the walk. Past [`MAX_DEFERRED`] it is
658    /// dropped after all, the walk having run out of room to remember it.
659    fn descend(&mut self, name: &OsStr, entry: &DirEntry) {
660        if self.open_dirs < MAX_OPEN_DIRS
661            && let Some(child) = self.stack.last().map(|level| level.path.join(name))
662            && let Ok(dir) = self.open_child(&child, entry)
663            && let Ok(iter) = dir.entries()
664        {
665            self.stack.push(Level::new(child, iter));
666            self.open_dirs += 1;
667            return;
668        }
669        if self.deferred < MAX_DEFERRED
670            && let Some(level) = self.stack.last_mut()
671        {
672            level.deferred.push_back(name.to_os_string());
673            self.deferred += 1;
674        }
675    }
676
677    /// The innermost level is fully read and has released its handle: descend into the
678    /// next subdirectory it postponed, or pop it once none are left. A postponed directory
679    /// that still won't open is skipped — same best-effort contract as an unreadable one,
680    /// and one retry keeps the walk finite.
681    fn resume_deferred(&mut self) {
682        let Some(level) = self.stack.last_mut() else {
683            return;
684        };
685        let Some(name) = level.deferred.pop_front() else {
686            self.stack.pop();
687            return;
688        };
689        self.deferred -= 1;
690        let path = level.path.join(name);
691        // The level draining its deferrals released its own handle first, so `open_dirs`
692        // is always below the cap here.
693        if let Ok(dir) = self.open_path(&path)
694            && let Ok(iter) = dir.entries()
695        {
696            self.stack.push(Level::new(path, iter));
697            self.open_dirs += 1;
698        }
699    }
700
701    /// The directory `entry` names at `path`, or the mounted volume when `path`
702    /// is a mount point.
703    fn open_child(&self, path: &Path, entry: &DirEntry) -> std::io::Result<Dir> {
704        match self.mounts.iter().find(|(rel, _)| rel == path) {
705            Some((_, volume)) => volume.try_clone(),
706            None => entry.open_dir(),
707        }
708    }
709
710    /// The directory at `path` below the base, through the volume mounted over
711    /// it if any.
712    fn open_path(&self, path: &Path) -> std::io::Result<Dir> {
713        for (rel, volume) in &self.mounts {
714            if let Ok(rest) = path.strip_prefix(rel) {
715                if rest.as_os_str().is_empty() {
716                    return volume.try_clone();
717                }
718                return volume.open_dir(rest);
719            }
720        }
721        self.base.open_dir(path)
722    }
723}
724
725pub fn kind_of(ft: &cap_std::fs::FileType) -> &'static str {
726    if ft.is_symlink() {
727        "symlink"
728    } else if ft.is_dir() {
729        "directory"
730    } else if ft.is_file() {
731        "file"
732    } else {
733        "other"
734    }
735}
736
737/// Buffered writer to a temp sibling file. Explicit [`close`](Self::close) fsyncs and
738/// atomically renames it onto `final_path`; `Drop` (the safety net for an un-`close`d
739/// writer) discards the temp file without renaming, removing it only while its name
740/// still refers to the writer's file.
741struct DefaultWriter {
742    /// `None` once the writer is closed.
743    open: Option<OpenWrite>,
744    /// Both sides stay contained paths for the whole lifetime of the writer. The commit
745    /// happens through the same handle the open resolved against, so a link swapped over
746    /// the parent between `writer()` and `close()` cannot redirect it — and the drop path
747    /// cannot unlink something under the server's working directory.
748    temp: TempFile,
749    final_path: ContentPath,
750}
751
752/// The temp file a writer streams into. The program can reach it by name while the
753/// writer is open, so before committing or discarding it, the writer checks that the
754/// name still refers to the file it created.
755struct TempFile {
756    path: ContentPath,
757    identity: FileIdentity,
758}
759
760impl TempFile {
761    /// The temp file just created at `path` and opened as `file`.
762    pub fn created(path: ContentPath, file: &cap_std::fs::File) -> std::io::Result<Self> {
763        Ok(Self {
764            path,
765            identity: FileIdentity::of(&file.metadata()?)?,
766        })
767    }
768
769    fn is_intact(&self) -> Result<bool, ContainError> {
770        self.path.refers_to(self.identity)
771    }
772}
773
774/// An open writer's buffer and its claim on the VFS's size limit. Program code runs
775/// between writes, so the claim replaces nothing up front: every byte written counts
776/// until `close` covers the file it replaces.
777struct OpenWrite {
778    writer: BufWriter<File>,
779    disk_charge: QuotaCharge,
780    /// Registers the temp file as the writer's own, so a removal of it by name
781    /// leaves its bytes for the writer to settle.
782    held: Option<OpenFileGuard>,
783}
784
785/// Why a write through a [`DefaultWriter`] failed.
786#[derive(Debug)]
787enum WriteError {
788    Io(std::io::Error),
789    Contain(ContainError),
790    Full(QuotaExceeded),
791}
792
793impl std::fmt::Display for WriteError {
794    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795        match self {
796            WriteError::Io(e) => e.fmt(f),
797            WriteError::Contain(e) => e.fmt(f),
798            WriteError::Full(e) => e.fmt(f),
799        }
800    }
801}
802
803impl DefaultWriter {
804    pub fn new(
805        file: File,
806        temp: TempFile,
807        final_path: ContentPath,
808        quota: Option<Arc<DiskQuota>>,
809    ) -> Self {
810        let held = quota
811            .as_ref()
812            .map(|quota| quota.hold(temp.identity, Holder::Writer));
813        Self {
814            open: Some(OpenWrite {
815                writer: BufWriter::new(file),
816                disk_charge: QuotaCharge::new(quota, None),
817                held,
818            }),
819            temp,
820            final_path,
821        }
822    }
823
824    pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), WriteError> {
825        let open = self.reserve(bytes.len() as u64)?;
826        open.writer.write_all(bytes).map_err(WriteError::Io)
827    }
828
829    fn reserve(&mut self, bytes: u64) -> Result<&mut OpenWrite, WriteError> {
830        let open = self
831            .open
832            .as_mut()
833            .ok_or_else(|| WriteError::Io(std::io::Error::other("writer already closed")))?;
834        open.disk_charge.reserve(bytes).map_err(WriteError::Full)?;
835        Ok(open)
836    }
837
838    /// Flush, fsync, and atomically rename temp→final. Idempotent: a second call after a
839    /// successful close is a no-op.
840    pub fn close(&mut self) -> Result<(), WriteError> {
841        let Some(open) = self.open.take() else {
842            return Ok(());
843        };
844        finalize(open, &self.temp, &self.final_path)
845    }
846}
847
848impl Drop for DefaultWriter {
849    fn drop(&mut self) {
850        if let Some(open) = self.open.take() {
851            // Discard the temp file rather than rename it: a GC-timed rename would be
852            // non-deterministic.
853            let unlinked = open
854                .writer
855                .into_inner()
856                .ok()
857                .is_some_and(|file| is_unlinked(&file));
858            discard_temp(&self.temp, open.disk_charge, unlinked);
859            drop(open.held);
860        }
861    }
862}
863
864/// Whether `file` has no name left, so its data goes once every handle on it
865/// closes.
866fn is_unlinked(file: &File) -> bool {
867    cap_std::fs::Metadata::from_file(file)
868        .is_ok_and(|meta| crate::runtime::fs::link_count(&meta) == Some(0))
869}
870
871/// Remove an uncommitted temp file and settle its disk charge. The program can reach
872/// the temp file by name while the writer is open. If the name no longer refers to
873/// the writer's file, it was moved or replaced: nothing is removed, and its bytes
874/// stay counted while they may still be on disk under another name. Once the file
875/// has no name at all, whether removed here or by the program, its bytes go when
876/// the last handle on it closes.
877fn discard_temp(temp: &TempFile, disk_charge: QuotaCharge, unlinked: bool) {
878    let removed = temp.is_intact().unwrap_or(false) && temp.path.remove_file().is_ok();
879    if removed || unlinked {
880        disk_charge.release_when_closed(temp.identity);
881    } else {
882        disk_charge.keep();
883    }
884}
885
886/// Commit the temp file over the final one. Any failure discards the temp file.
887fn finalize(open: OpenWrite, temp: &TempFile, final_path: &ContentPath) -> Result<(), WriteError> {
888    let OpenWrite {
889        writer,
890        mut disk_charge,
891        held,
892    } = open;
893    let committed = match flush_and_sync(writer) {
894        Ok(unlinked) => {
895            let renamed = rename_into_place(temp, final_path, &mut disk_charge);
896            match renamed {
897                Ok(()) => disk_charge.commit(),
898                Err(_) => discard_temp(temp, disk_charge, unlinked),
899            }
900            renamed
901        }
902        Err(err) => {
903            discard_temp(temp, disk_charge, false);
904            Err(err)
905        }
906    };
907    drop(held);
908    committed
909}
910
911/// Flush and sync the writer's file and close it, reporting whether the file has no
912/// name left. Closing before the rename lets Windows handles release; unix `rename`
913/// over an open file works either way.
914fn flush_and_sync(writer: BufWriter<File>) -> Result<bool, WriteError> {
915    let mut file = writer
916        .into_inner()
917        .map_err(|e| WriteError::Io(e.into_error()))?;
918    file.flush().map_err(WriteError::Io)?;
919    file.sync_all().map_err(WriteError::Io)?;
920    Ok(is_unlinked(&file))
921}
922
923/// Rename the temp file over the final one, first covering what it replaces.
924fn rename_into_place(
925    temp: &TempFile,
926    final_path: &ContentPath,
927    disk_charge: &mut QuotaCharge,
928) -> Result<(), WriteError> {
929    if !temp.is_intact().map_err(WriteError::Contain)? {
930        return Err(WriteError::Io(std::io::Error::other(
931            "the writer's temporary file was moved or replaced",
932        )));
933    }
934    disk_charge
935        .cover(final_path.regular_file())
936        .map_err(WriteError::Full)?;
937    temp.path.rename_to(final_path).map_err(WriteError::Contain)
938}
939
940#[async_trait::async_trait]
941impl FileWriter for DefaultWriter {
942    fn is_closed(&self) -> bool {
943        self.open.is_none()
944    }
945    async fn write(
946        &mut self,
947        _effect_id: &EffectId,
948        _work: Option<Arc<WorkBudget>>,
949        bytes: Vec<u8>,
950    ) -> Result<(), FilesystemError> {
951        self.write_bytes(&bytes).map_err(Into::into)
952    }
953    async fn close(
954        &mut self,
955        _effect_id: &EffectId,
956        _work: Option<Arc<WorkBudget>>,
957    ) -> Result<(), FilesystemError> {
958        DefaultWriter::close(self).map_err(Into::into)
959    }
960}
961impl From<WriteError> for FilesystemError {
962    fn from(error: WriteError) -> Self {
963        match error {
964            WriteError::Io(e) => Self::Io(e),
965            WriteError::Contain(e) => Self::Containment(e),
966            WriteError::Full(e) => Self::Quota(e),
967        }
968    }
969}
970
971fn default_remove(
972    vfs: &Vfs,
973    path: &str,
974    recursive: bool,
975    budget: &mut dyn MutationBudget,
976) -> wasmtime::Result<()> {
977    let resolved =
978        fs::resolve_link(vfs, vfs.cwd(), path).map_err(|e| contain_trap("fs.remove", path, &e))?;
979    let context = MutationContext {
980        op: "fs.remove",
981        path,
982        quota: resolved.placement().quota().cloned(),
983        changed: false,
984    };
985    meter_mutation(budget, context, |step| {
986        fs::removal::remove(&resolved, recursive, step)
987    })
988}
989fn default_move(
990    vfs: &Vfs,
991    from: &str,
992    to: &str,
993    budget: &mut dyn MutationBudget,
994) -> wasmtime::Result<()> {
995    let from_resolved =
996        fs::resolve_link(vfs, vfs.cwd(), from).map_err(|e| contain_trap("fs.move", from, &e))?;
997    let to_resolved =
998        fs::resolve_link(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.move", to, &e))?;
999    let pair = format!("{from} -> {to}");
1000    if from_resolved.is_root() || to_resolved.is_root() {
1001        wasmtime::bail!(
1002            "fs.move {pair}: the VFS root and mount points cannot be moved or replaced"
1003        );
1004    }
1005    if from_resolved
1006        .same_entry(&to_resolved)
1007        .map_err(|e| contain_trap("fs.move", &pair, &e))?
1008    {
1009        return Ok(());
1010    }
1011    if !from_resolved
1012        .placement()
1013        .same_volume(to_resolved.placement())
1014    {
1015        let mut work = CopyWork::default();
1016        let result = move_across(budget, &from_resolved, &to_resolved, &pair, &mut work);
1017        work.settle(budget)?;
1018        return result;
1019    }
1020    // A file moved over another frees the one it replaced; a move onto the same
1021    // file, under any spelling, frees nothing.
1022    let moved = from_resolved.regular_file().map(|(file, _)| file);
1023    let replaced = to_resolved
1024        .regular_file()
1025        .filter(|(file, _)| Some(*file) != moved);
1026    let context = MutationContext {
1027        op: "fs.move",
1028        path: &pair,
1029        quota: None,
1030        changed: false,
1031    };
1032    meter_mutation(budget, context, |step| {
1033        crate::runtime::fs::removal::rename(&from_resolved, &to_resolved, step)
1034    })?;
1035    if let (Some(quota), Some((file, bytes))) = (to_resolved.placement().quota(), replaced) {
1036        quota.release_file(file, bytes);
1037    }
1038    Ok(())
1039}
1040fn default_copy(
1041    vfs: &Vfs,
1042    from: &str,
1043    to: &str,
1044    recursive: bool,
1045    budget: &mut dyn MutationBudget,
1046) -> wasmtime::Result<()> {
1047    let from_resolved =
1048        fs::resolve_link(vfs, vfs.cwd(), from).map_err(|e| contain_trap("fs.copy", from, &e))?;
1049    let pair = format!("{from} -> {to}");
1050    let mount = from_resolved
1051        .contains_mount_point()
1052        .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
1053    if let Some(mount) = mount {
1054        wasmtime::bail!(
1055            "fs.copy {pair}: {from} contains the mount point {mount}; copy the \
1056                     mount's contents separately"
1057        );
1058    }
1059    // The content path routes to the same volume as the link path, and resolves
1060    // even when the destination's parent does not exist yet.
1061    let to_volume =
1062        fs::resolve_content(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.copy", to, &e))?;
1063    from_resolved
1064        .check_copy_destination(&to_volume)
1065        .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
1066    // A recursive copy used to reach `create_dir_all(to)`, which built the whole
1067    // destination chain. Resolving the destination now opens its parent, so that
1068    // chain has to exist first or a working call starts failing.
1069    if recursive
1070        && from_resolved
1071            .symlink_metadata()
1072            .is_ok_and(|meta| meta.is_dir())
1073    {
1074        to_volume
1075            .create_dir_all()
1076            .map_err(|e| write_target_trap("fs.copy", to, &e))?;
1077    }
1078    let to_resolved =
1079        fs::resolve_link(vfs, vfs.cwd(), to).map_err(|e| contain_trap("fs.copy", to, &e))?;
1080    let quota = to_resolved.placement().quota().cloned();
1081    let mut work = CopyWork::default();
1082    let result = copy_bounded(
1083        &from_resolved,
1084        &to_resolved,
1085        &CopyRun {
1086            recursive,
1087            links: LinkCopies::BestEffort,
1088            op: "fs.copy",
1089            pair: &pair,
1090            quota: quota.as_ref(),
1091        },
1092        &mut work,
1093    );
1094    work.settle(budget)?;
1095    result
1096}
1097/// Reproduce a symlink at `to` with `from`'s target stored verbatim.
1098///
1099/// Verbatim is the point: the target is never resolved, so a link that escapes the root is
1100/// reproduced as one that still refuses rather than as a working door out of it.
1101fn copy_link(from: &LinkPath, to: &LinkPath) -> Result<(), ContainError> {
1102    to.symlink(&from.read_link_contents()?, from.link_kind()?)
1103}
1104
1105/// A move between two volumes, which no rename can make: copy into a temporary
1106/// sibling of the destination (charged to the destination's limit), rename it into
1107/// place, then remove the source (released from the source's limit).
1108///
1109/// Until the rename, a failure removes the temporary copy and leaves the source
1110/// untouched. Once the destination is in place it is kept; if the source then
1111/// cannot be removed, the call fails saying both copies may exist. A link the copy
1112/// cannot reproduce fails the move, since the source is removed after.
1113fn move_across(
1114    budget: &mut dyn MutationBudget,
1115    from: &LinkPath,
1116    to: &LinkPath,
1117    pair: &str,
1118    work: &mut CopyWork,
1119) -> wasmtime::Result<()> {
1120    let meta = from
1121        .symlink_metadata()
1122        .map_err(|e| contain_trap("fs.move", pair, &e))?;
1123    let is_dir = meta.is_dir();
1124    // The source is removed last, so a tree too deep or too large to remove, or
1125    // one holding a mount point, is refused now, before a copy of it lands in the
1126    // destination. The destination gets the checks its rename will run, so a
1127    // move that must fail does no copying first.
1128    meter_mutation(
1129        budget,
1130        MutationContext {
1131            op: "fs.move",
1132            path: pair,
1133            quota: None,
1134            changed: false,
1135        },
1136        |step| {
1137            crate::runtime::fs::removal::validate(from, &mut *step)?;
1138            crate::runtime::fs::removal::validate(to, step)
1139        },
1140    )?;
1141    refuse_unfitting_destination(to, is_dir, pair)?;
1142    let to_quota = to.placement().quota().cloned();
1143    let staged = to.temp_sibling();
1144    let run = CopyRun {
1145        recursive: true,
1146        links: LinkCopies::Required,
1147        op: "fs.move",
1148        pair,
1149        quota: to_quota.as_ref(),
1150    };
1151    let copied = copy_bounded(from, &staged, &run, work).and_then(|()| {
1152        let replaced = to.regular_file();
1153        meter_mutation(
1154            budget,
1155            MutationContext {
1156                op: "fs.move",
1157                path: pair,
1158                quota: None,
1159                changed: true,
1160            },
1161            |step| crate::runtime::fs::removal::rename(&staged, to, step),
1162        )?;
1163        if let (Some(quota), Some((file, bytes))) = (to_quota.as_ref(), replaced) {
1164            quota.release_file(file, bytes);
1165        }
1166        Ok(())
1167    });
1168    if let Err(err) = copied {
1169        // Best effort: what cannot be removed stays charged, so the count errs
1170        // toward refusing a later write.
1171        let _ = meter_mutation(
1172            budget,
1173            MutationContext {
1174                op: "fs.move",
1175                path: pair,
1176                quota: to_quota,
1177                changed: true,
1178            },
1179            |step| crate::runtime::fs::removal::remove(&staged, true, step),
1180        );
1181        return Err(err);
1182    }
1183    meter_mutation(
1184        budget,
1185        MutationContext {
1186            op: "fs.move",
1187            path: pair,
1188            quota: from.placement().quota().cloned(),
1189            changed: true,
1190        },
1191        |step| {
1192            crate::runtime::fs::removal::remove(from, true, step).map_err(|error| {
1193            if crate::runtime::host::ends_the_run(&error) { return error; }
1194            wasmtime::Error::msg(format!(
1195                "fs.move {pair}: moved to the destination but could not remove the source, so both may now exist: {error}"
1196            ))
1197        })
1198        },
1199    )
1200}
1201
1202/// Refuse a destination a rename of the source could not replace — a directory
1203/// in place of a file, a file in place of a directory, or a directory that isn't
1204/// empty — as a rename within one volume would, before any copying.
1205fn refuse_unfitting_destination(
1206    to: &LinkPath,
1207    moving_dir: bool,
1208    pair: &str,
1209) -> wasmtime::Result<()> {
1210    let existing = match to.symlink_metadata() {
1211        Ok(existing) => existing,
1212        Err(ContainError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
1213            return Ok(());
1214        }
1215        Err(err) => return Err(contain_trap("fs.move", pair, &err)),
1216    };
1217    let replacing_dir = existing.is_dir();
1218    if replacing_dir != moving_dir {
1219        let (what, onto) = if moving_dir {
1220            ("a directory", "something that is not a directory")
1221        } else {
1222            ("a file", "a directory")
1223        };
1224        wasmtime::bail!("fs.move {pair}: cannot move {what} onto {onto}");
1225    }
1226    if !replacing_dir {
1227        return Ok(());
1228    }
1229    let has_entries = to
1230        .entries()
1231        .map_err(|e| contain_trap("fs.move", pair, &e))?
1232        .next()
1233        .is_some();
1234    if has_entries {
1235        wasmtime::bail!("fs.move {pair}: the destination directory is not empty");
1236    }
1237    Ok(())
1238}
1239
1240struct MutationContext<'a> {
1241    op: &'static str,
1242    path: &'a str,
1243    quota: Option<Arc<DiskQuota>>,
1244    changed: bool,
1245}
1246
1247fn meter_mutation(
1248    budget: &mut dyn MutationBudget,
1249    context: MutationContext<'_>,
1250    run: impl FnOnce(
1251        &mut dyn FnMut(crate::runtime::fs::removal::Step) -> wasmtime::Result<()>,
1252    ) -> wasmtime::Result<()>,
1253) -> wasmtime::Result<()> {
1254    use crate::runtime::fs::removal::Step;
1255    budget.begin()?;
1256    let mut changed = context.changed;
1257    let result = run(&mut |step| {
1258        match step {
1259            Step::Inspect(units) | Step::Mutate(units) if changed => {
1260                budget.syscalls(units, true)?;
1261            }
1262            Step::Inspect(units) | Step::Mutate(units) => {
1263                budget.syscalls(units, false)?;
1264            }
1265            Step::Reserve(bytes) => budget.reserve(bytes)?,
1266            Step::Unlinked(file) => {
1267                changed = true;
1268                budget.effect();
1269                if let (Some(quota), Some((file, bytes))) = (&context.quota, file) {
1270                    quota.release_file(file, bytes);
1271                }
1272            }
1273        }
1274        Ok(())
1275    });
1276    budget.end();
1277    result.map_err(|error| match error.downcast::<ContainError>() {
1278        Ok(error) => contain_trap(context.op, context.path, &error),
1279        Err(error) => error,
1280    })
1281}
1282
1283/// How one copy walk treats its tree.
1284#[derive(Clone, Copy)]
1285struct CopyRun<'a> {
1286    recursive: bool,
1287    links: LinkCopies,
1288    /// `fs.copy` or `fs.move`, for diagnostics.
1289    op: &'a str,
1290    /// `from -> to` as the program wrote them, for diagnostics.
1291    pair: &'a str,
1292    /// The destination volume's limit.
1293    quota: Option<&'a Arc<DiskQuota>>,
1294}
1295
1296/// What a copy does with a link inside the tree that the platform will not
1297/// reproduce, such as an absolute one.
1298#[derive(Clone, Copy, PartialEq, Eq)]
1299enum LinkCopies {
1300    /// Skip it: a copy leaves the source in place, so nothing is lost.
1301    BestEffort,
1302    /// Fail: a move removes the source afterwards, which would lose the link.
1303    Required,
1304}
1305
1306/// Matches the entry-count limit used by removal.
1307const MAX_COPY_ENTRIES: u64 = MAX_REMOVE_ENTRIES as u64;
1308const MAX_COPY_DEPTH: usize = 64;
1309
1310#[derive(Default)]
1311struct CopyWork {
1312    entries: u64,
1313    bytes: u64,
1314}
1315
1316impl CopyWork {
1317    fn visit(&mut self, op: &str, pair: &str) -> wasmtime::Result<()> {
1318        if self.entries >= MAX_COPY_ENTRIES {
1319            return Err(crate::runtime::host::range_error(format!(
1320                "{op} {pair}: exceeds {MAX_COPY_ENTRIES} entries; copy smaller subtrees"
1321            )));
1322        }
1323        self.entries += 1;
1324        Ok(())
1325    }
1326
1327    fn settle(&self, budget: &mut dyn MutationBudget) -> wasmtime::Result<()> {
1328        budget.copied(self.entries, self.bytes)
1329    }
1330}
1331
1332struct CopyFrame {
1333    from: LinkPath,
1334    to: LinkPath,
1335    from_dir: Arc<cap_std::fs::Dir>,
1336    to_dir: Arc<cap_std::fs::Dir>,
1337    entries: cap_std::fs::ReadDir,
1338}
1339
1340/// Depth-first traversal retains directory iterators instead of recursing or
1341/// collecting every sibling path. Symlinks are copied as links, never followed.
1342fn copy_bounded(
1343    from: &LinkPath,
1344    to: &LinkPath,
1345    run: &CopyRun<'_>,
1346    work: &mut CopyWork,
1347) -> wasmtime::Result<()> {
1348    let mut frames = Vec::new();
1349    if let Some(frame) = copy_entry(from, to, run, work, 0)? {
1350        frames
1351            .try_reserve(1)
1352            .map_err(crate::runtime::host::fatal_host_error)?;
1353        frames.push(frame);
1354    }
1355    while let Some(frame) = frames.last_mut() {
1356        let Some(entry) = frame.entries.next() else {
1357            frames.pop();
1358            continue;
1359        };
1360        let entry = entry.map_err(|e| contain_trap(run.op, run.pair, &ContainError::from(e)))?;
1361        let name = entry.file_name();
1362        let from = frame.from.child(Arc::clone(&frame.from_dir), name.clone());
1363        let to = frame.to.child(Arc::clone(&frame.to_dir), name);
1364        if let Some(child) = copy_entry(&from, &to, run, work, frames.len())? {
1365            frames
1366                .try_reserve(1)
1367                .map_err(crate::runtime::host::fatal_host_error)?;
1368            frames.push(child);
1369        }
1370    }
1371    Ok(())
1372}
1373
1374fn copy_entry(
1375    from: &LinkPath,
1376    to: &LinkPath,
1377    run: &CopyRun<'_>,
1378    work: &mut CopyWork,
1379    depth: usize,
1380) -> wasmtime::Result<Option<CopyFrame>> {
1381    let CopyRun {
1382        recursive,
1383        links,
1384        op,
1385        pair,
1386        quota,
1387    } = *run;
1388    work.visit(op, pair)?;
1389    let meta = from
1390        .symlink_metadata()
1391        .map_err(|e| contain_trap(op, pair, &e))?;
1392    let ft = meta.file_type();
1393    if ft.is_symlink() {
1394        let result = copy_link(from, to);
1395        // Only nested unrepresentable links are best-effort. Root failures and
1396        // every move failure retain their existing error behavior.
1397        if depth == 0 || links == LinkCopies::Required {
1398            result.map_err(|e| contain_trap(op, pair, &e))?;
1399        }
1400        return Ok(None);
1401    }
1402    if ft.is_dir() {
1403        if !recursive {
1404            wasmtime::bail!("{op} {pair}: source is a directory (use {{ recursive: true }})");
1405        }
1406        if depth >= MAX_COPY_DEPTH {
1407            return Err(crate::runtime::host::range_error(format!(
1408                "{op} {pair}: exceeds {MAX_COPY_DEPTH} directory levels; copy smaller subtrees"
1409            )));
1410        }
1411        to.create_dir_all()
1412            .map_err(|e| contain_trap(op, pair, &e))?;
1413        let from_dir = Arc::new(from.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1414        let to_dir = Arc::new(to.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1415        let entries = from_dir
1416            .entries()
1417            .map_err(|e| contain_trap(op, pair, &ContainError::from(e)))?;
1418        return Ok(Some(CopyFrame {
1419            from: from.clone(),
1420            to: to.clone(),
1421            from_dir,
1422            to_dir,
1423            entries,
1424        }));
1425    }
1426    // A copy may leave earlier files in place on error. Preserve quota claims
1427    // for whatever landed, and settle known completed bytes on either outcome.
1428    let mut disk_charge = QuotaCharge::in_place(quota.cloned(), to.copied_onto_file());
1429    disk_charge
1430        .reserve(meta.len())
1431        .map_err(|exceeded| quota_refusal(op, pair, exceeded))?;
1432    match from.copy_to_counted(to) {
1433        Ok(bytes) => {
1434            work.bytes = work.bytes.saturating_add(bytes);
1435            disk_charge.commit();
1436        }
1437        Err(e) => {
1438            disk_charge.settle_at(to.copied_onto_file().map_or(0, |(_, bytes)| bytes));
1439            return Err(contain_trap(op, pair, &e));
1440        }
1441    }
1442    Ok(None)
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    #[test]
1448    fn logical_lines_bound_memory_and_restore_without_buffered_read_ahead() {
1449        use super::*;
1450        use crate::runtime::limits::TenantLimits;
1451        let limits = TenantLimits::new(1 << 20);
1452        let work = Arc::new(WorkBudget::new(u64::MAX));
1453        let mut file = std::io::Cursor::new(b"\xef\xbb\xbfhi\r\n\xff\n");
1454        let options = ReaderOptions::Lines { maximum_bytes: 32 };
1455        let mut position = ReadPosition::default();
1456        let output = read_item(
1457            &mut file,
1458            &options,
1459            &mut position,
1460            Some(work.as_ref()),
1461            &limits,
1462        )
1463        .unwrap();
1464        assert!(matches!(&output.item, Some(ReadItem::Line(line)) if line == "hi"));
1465        assert_eq!(position.offset, 7);
1466        assert!(limits.host_attached_bytes() > 0);
1467        drop(output);
1468        let output = read_item(
1469            &mut file,
1470            &options,
1471            &mut position,
1472            Some(work.as_ref()),
1473            &limits,
1474        )
1475        .unwrap();
1476        assert!(matches!(&output.item, Some(ReadItem::Line(line)) if line == "\u{fffd}"));
1477        drop(output);
1478        assert!(
1479            read_item(
1480                &mut file,
1481                &options,
1482                &mut position,
1483                Some(work.as_ref()),
1484                &limits
1485            )
1486            .unwrap()
1487            .item
1488            .is_none()
1489        );
1490        assert_eq!(limits.host_attached_bytes(), 0);
1491    }
1492
1493    #[test]
1494    fn line_limit_and_memory_refusal_leave_progress_retryable() {
1495        use super::*;
1496        use crate::runtime::limits::TenantLimits;
1497        let limits = TenantLimits::new(1 << 20);
1498        let mut position = ReadPosition::default();
1499        let mut file = std::io::Cursor::new(b"abcde");
1500        let options = ReaderOptions::Lines { maximum_bytes: 4 };
1501        for _ in 0..2 {
1502            let error = read_item(&mut file, &options, &mut position, None, &limits)
1503                .err()
1504                .unwrap();
1505            assert!(error.to_string().contains("maxReadSize"));
1506            assert_eq!(position.offset, 0);
1507            assert_eq!(limits.host_attached_bytes(), 0);
1508        }
1509        let limits = TenantLimits::new(8192 + 4);
1510        let mut file = std::io::Cursor::new(b"abcd");
1511        let error = read_item(&mut file, &options, &mut position, None, &limits)
1512            .err()
1513            .unwrap();
1514        assert!(
1515            matches!(error, FilesystemError::Execution(ref e) if e.is::<crate::runtime::limits::MemoryCapExceeded>())
1516        );
1517        assert_eq!(position.offset, 0);
1518        assert_eq!(limits.host_attached_bytes(), 0);
1519    }
1520
1521    #[test]
1522    fn read_failures_refund_buffers_and_charge_consumed_io_without_advancing_progress() {
1523        use super::*;
1524        use crate::runtime::limits::TenantLimits;
1525        struct FailsAfterOne(std::io::Cursor<[u8; 2]>);
1526        impl Read for FailsAfterOne {
1527            fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
1528                if self.0.position() > 0 {
1529                    return Err(std::io::Error::other("injected read failure"));
1530                }
1531                self.0.read(&mut bytes[..1])
1532            }
1533        }
1534        impl Seek for FailsAfterOne {
1535            fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
1536                self.0.seek(position)
1537            }
1538        }
1539        let limits = TenantLimits::new(1 << 20);
1540        let work = Arc::new(crate::runtime::fuel::WorkBudget::new(u64::MAX));
1541        let mut file = FailsAfterOne(std::io::Cursor::new(*b"ab"));
1542        let mut position = ReadPosition::default();
1543        assert!(
1544            read_item(
1545                &mut file,
1546                &ReaderOptions::Bytes { chunk_size: 2 },
1547                &mut position,
1548                Some(work.as_ref()),
1549                &limits
1550            )
1551            .is_err()
1552        );
1553        assert_eq!(position.offset, 0);
1554        assert_eq!(limits.host_attached_bytes(), 0);
1555        assert!(work.spent() >= crate::runtime::fuel::IO.cost(1));
1556    }
1557
1558    #[test]
1559    fn unknown_read_cost_settles_after_read_even_when_fuel_is_spent() {
1560        use super::*;
1561        let limits = crate::runtime::limits::TenantLimits::new(1 << 20);
1562        let work = Arc::new(crate::runtime::fuel::WorkBudget::new(0));
1563        let mut file = std::io::Cursor::new(b"line\n");
1564        let mut position = ReadPosition::default();
1565        assert!(
1566            read_item(
1567                &mut file,
1568                &ReaderOptions::Lines { maximum_bytes: 32 },
1569                &mut position,
1570                Some(work.as_ref()),
1571                &limits
1572            )
1573            .unwrap()
1574            .item
1575            .is_some()
1576        );
1577        assert_eq!(position.offset, 5);
1578        assert!(work.spent() > 0);
1579    }
1580
1581    use super::*;
1582    use crate::runtime::fs::resolve_link;
1583    #[test]
1584    fn copy_entry_and_depth_limits_preserve_completed_work() {
1585        let root = tempfile::tempdir().unwrap();
1586        std::fs::create_dir(root.path().join("source")).unwrap();
1587        std::fs::write(root.path().join("source/file"), "content").unwrap();
1588        let vfs = Vfs::external(root.path().to_path_buf()).unwrap();
1589        let from = resolve_link(&vfs, "/", "/source").unwrap();
1590        let to = resolve_link(&vfs, "/", "/target").unwrap();
1591        let run = CopyRun {
1592            recursive: true,
1593            links: LinkCopies::Required,
1594            op: "fs.copy",
1595            pair: "source -> target",
1596            quota: None,
1597        };
1598        let mut work = CopyWork {
1599            entries: MAX_COPY_ENTRIES - 1,
1600            bytes: 0,
1601        };
1602        let error = copy_bounded(&from, &to, &run, &mut work).unwrap_err();
1603        assert!(error.to_string().contains("entries"));
1604        assert!(root.path().join("target").is_dir());
1605        assert!(!root.path().join("target/file").exists());
1606        assert_eq!(
1607            std::fs::read(root.path().join("source/file")).unwrap(),
1608            b"content"
1609        );
1610        let to = resolve_link(&vfs, "/", "/too-deep").unwrap();
1611        let error = copy_entry(&from, &to, &run, &mut CopyWork::default(), MAX_COPY_DEPTH)
1612            .err()
1613            .unwrap();
1614        assert!(error.to_string().contains("directory levels"));
1615        assert!(!root.path().join("too-deep").exists());
1616    }
1617
1618    /// Builds `root/<branch>` as a chain `l1/l2/…/l{depth}` with an `f.txt` in every
1619    /// directory. Returns every path the walk should yield, relative to `root`.
1620    fn build_chain(root: &std::path::Path, branch: &str, depth: usize) -> Vec<String> {
1621        let mut path = root.join(branch);
1622        let mut rel = branch.to_string();
1623        std::fs::create_dir_all(&path).unwrap();
1624        std::fs::write(path.join("f.txt"), "x").unwrap();
1625        let mut expected = vec![rel.clone(), format!("{rel}/f.txt")];
1626        for level in 1..=depth {
1627            path = path.join(format!("l{level}"));
1628            rel = format!("{rel}/l{level}");
1629            std::fs::create_dir(&path).unwrap();
1630            std::fs::write(path.join("f.txt"), "x").unwrap();
1631            expected.push(rel.clone());
1632            expected.push(format!("{rel}/f.txt"));
1633        }
1634        expected
1635    }
1636
1637    fn walk_over(root: &std::path::Path, listed: &str, recursive: bool) -> ContainedWalk {
1638        let vfs = crate::runtime::Vfs::external(root.to_path_buf()).unwrap();
1639        let resolved = crate::runtime::fs::resolve_content(&vfs, "/", listed).unwrap();
1640        let base = Arc::new(resolved.open_dir().unwrap());
1641        let base_prefix = if listed == "/" {
1642            String::new()
1643        } else {
1644            format!("{}/", listed.trim_start_matches('/'))
1645        };
1646        ContainedWalk::new(base, base_prefix, recursive, Vec::new()).unwrap()
1647    }
1648
1649    fn walk_all(root: &std::path::Path, listed: &str) -> Vec<DirectoryEntry> {
1650        let mut walk = walk_over(root, listed, true);
1651        std::iter::from_fn(|| walk.next_entry()).collect()
1652    }
1653
1654    /// Every subtree is one consecutive run: for each directory, its descendants are
1655    /// yielded after it with nothing from outside the subtree interleaved.
1656    fn assert_subtrees_contiguous(entries: &[DirectoryEntry]) {
1657        for (i, dir) in entries.iter().enumerate() {
1658            if dir.kind != "directory" {
1659                continue;
1660            }
1661            let prefix = format!("{}/", dir.path);
1662            let inside: Vec<usize> = entries
1663                .iter()
1664                .enumerate()
1665                .filter(|(_, e)| e.path.starts_with(&prefix))
1666                .map(|(j, _)| j)
1667                .collect();
1668            let (Some(&first), Some(&last)) = (inside.first(), inside.last()) else {
1669                continue;
1670            };
1671            assert!(
1672                first > i,
1673                "{} yielded before its parent",
1674                entries[first].path
1675            );
1676            assert_eq!(
1677                last - first + 1,
1678                inside.len(),
1679                "entries from outside {} are interleaved into its subtree",
1680                dir.path,
1681            );
1682        }
1683    }
1684
1685    #[test]
1686    fn deep_tree_yields_every_entry_exactly_once() {
1687        let dir = tempfile::tempdir().unwrap();
1688        let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1689        let mut got: Vec<String> = walk_all(dir.path(), "/")
1690            .into_iter()
1691            .map(|e| e.path.trim_start_matches('/').to_string())
1692            .collect();
1693        got.sort();
1694        expected.sort();
1695        assert_eq!(
1696            got, expected,
1697            "deep walk must yield every entry exactly once"
1698        );
1699    }
1700
1701    /// Past the open-handle cap the queue holds bare names, rejoined to the level's
1702    /// own path when the descent resumes. A wide level down there is where a mistake
1703    /// in that rejoin shows up — either as a doubled prefix or as a lost subtree.
1704    #[test]
1705    fn a_wide_level_past_the_open_dir_cap_walks_completely() {
1706        let dir = tempfile::tempdir().unwrap();
1707        let mut expected = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 2);
1708        let deep = expected
1709            .iter()
1710            .filter(|p| !p.ends_with("f.txt"))
1711            .max_by_key(|p| p.matches('/').count())
1712            .expect("the deepest directory")
1713            .clone();
1714        for i in 0..64 {
1715            let child = format!("{deep}/w{i}");
1716            std::fs::create_dir(dir.path().join(&child)).unwrap();
1717            std::fs::write(dir.path().join(&child).join("f.txt"), "x").unwrap();
1718            expected.push(child.clone());
1719            expected.push(format!("{child}/f.txt"));
1720        }
1721
1722        let mut got: Vec<String> = walk_all(dir.path(), "/")
1723            .into_iter()
1724            .map(|e| e.path.trim_start_matches('/').to_string())
1725            .collect();
1726        got.sort();
1727        expected.sort();
1728        assert_eq!(
1729            got, expected,
1730            "every postponed subtree must still be walked"
1731        );
1732    }
1733
1734    #[test]
1735    fn deep_tree_under_a_nested_base_keeps_the_base_prefix() {
1736        let dir = tempfile::tempdir().unwrap();
1737        let expected: Vec<String> = build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5)
1738            .into_iter()
1739            .filter(|p| p != "a")
1740            .map(|p| format!("/{p}"))
1741            .collect();
1742        let mut got: Vec<String> = walk_all(dir.path(), "/a")
1743            .into_iter()
1744            .map(|e| e.path)
1745            .collect();
1746        let mut expected = expected;
1747        got.sort();
1748        expected.sort();
1749        assert_eq!(
1750            got, expected,
1751            "guest paths keep the listed directory's prefix"
1752        );
1753    }
1754
1755    #[test]
1756    fn deep_subtrees_stay_contiguous_past_the_open_dir_cap() {
1757        let dir = tempfile::tempdir().unwrap();
1758        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1759        build_chain(dir.path(), "b", MAX_OPEN_DIRS + 5);
1760        assert_subtrees_contiguous(&walk_all(dir.path(), "/"));
1761    }
1762
1763    #[test]
1764    fn open_directory_handles_stay_within_the_cap() {
1765        let dir = tempfile::tempdir().unwrap();
1766        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 5);
1767        let mut walk = walk_over(dir.path(), "/", true);
1768        let mut deepest = 0;
1769        while walk.next_entry().is_some() {
1770            deepest = deepest.max(walk.open_dirs);
1771            assert!(
1772                walk.open_dirs <= MAX_OPEN_DIRS,
1773                "walk held {} directory handles",
1774                walk.open_dirs,
1775            );
1776        }
1777        assert_eq!(deepest, MAX_OPEN_DIRS, "a chain past the cap must reach it");
1778    }
1779
1780    #[cfg(unix)]
1781    #[test]
1782    fn a_subdirectory_that_would_not_open_is_retried_from_the_base() {
1783        use std::os::unix::fs::PermissionsExt;
1784
1785        let dir = tempfile::tempdir().unwrap();
1786        let blocked = dir.path().join("w");
1787        std::fs::create_dir(&blocked).unwrap();
1788        std::fs::write(blocked.join("inside.txt"), "x").unwrap();
1789        std::fs::write(dir.path().join("sibling.txt"), "x").unwrap();
1790        std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
1791        if std::fs::read_dir(&blocked).is_ok() {
1792            eprintln!("skipping: this user can read a 0o000 directory");
1793            return;
1794        }
1795
1796        let mut walk = walk_over(dir.path(), "/", true);
1797        let mut paths = Vec::new();
1798        while let Some(entry) = walk.next_entry() {
1799            paths.push(entry.path);
1800            // The descent into `/w` has already failed by the time it is yielded; the walk
1801            // must retry it when it drains, not drop the subtree.
1802            if entry.name == "w" {
1803                std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
1804            }
1805        }
1806        assert!(
1807            paths.iter().any(|p| p == "/w/inside.txt"),
1808            "a subdirectory that failed to open must be retried, got {paths:?}",
1809        );
1810    }
1811
1812    #[cfg(unix)]
1813    #[test]
1814    fn a_deferred_directory_with_a_non_utf8_name_still_walks() {
1815        use std::os::unix::ffi::OsStrExt;
1816
1817        let dir = tempfile::tempdir().unwrap();
1818        build_chain(dir.path(), "a", MAX_OPEN_DIRS + 3);
1819        let mut deepest = dir.path().join("a");
1820        for level in 1..=(MAX_OPEN_DIRS + 3) {
1821            deepest = deepest.join(format!("l{level}"));
1822        }
1823        let odd = deepest.join(std::ffi::OsStr::from_bytes(b"bad\xffname"));
1824        // APFS (and any other filesystem enforcing UTF-8 names) rejects this outright,
1825        // which makes the case unreachable there rather than untested.
1826        if std::fs::create_dir(&odd).is_err() {
1827            eprintln!("skipping: this filesystem rejects non-UTF-8 names");
1828            return;
1829        }
1830        std::fs::write(odd.join("in.txt"), "x").unwrap();
1831
1832        let entries = walk_all(dir.path(), "/");
1833        assert_eq!(
1834            entries.iter().filter(|e| e.name == "in.txt").count(),
1835            1,
1836            "a deferred directory whose name is not UTF-8 must still be reopened",
1837        );
1838    }
1839}