Skip to main content

submilli_engine/runtime/
default_filesystem.rs

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