Skip to main content

submilli_engine/stdlib/fs/
mod.rs

1//! `submilli:fs` — agent-facing filesystem library.
2//!
3//! Pure Rust host functions registered directly under the package name. Each
4//! gated op runs `check_security` before touching the disk; the set of gated
5//! capabilities is cataloged in [`crate::stdlib::capabilities`] — keep it in
6//! sync when adding or removing a gate (see AGENTS.md).
7//!
8//! `Stat` / `Peek` / `DirEntry` / `Info` are host-built backing structs the
9//! guest holds opaquely and reads through the registered getters. `FileWriter`
10//! and the `lines` / `bytes` / `list` iterators wrap [`handles`] payloads in
11//! `externref`s; the iterators are closable (`for…of` releases the OS handle
12//! on every loop exit) and GC reclaim is the backstop.
13
14use crate::runtime::host::{abi_arg, abi_result};
15pub mod declaration;
16pub mod handles;
17
18use std::io::{BufReader, Read, Seek, SeekFrom, Write};
19use std::sync::Arc;
20
21use wasmtime::{
22    Caller, ExternRef, FieldType, Finality, FuncType, HeapType, Linker, Mutability, RefType,
23    Rooted, StorageType, StructRef, StructRefPre, StructType, Val, ValType,
24};
25
26use crate::runtime::call_log::{Payload, Side, record_payload};
27use crate::runtime::decision::CallTicket;
28use crate::runtime::fs::{
29    ContainError, ContentPath, FileIdentity, LinkPath, MAX_REMOVE_ENTRIES, guest_normalize,
30    resolve_link,
31};
32use crate::runtime::fuel::{self, host_func};
33use crate::runtime::gc_singleton::singleton_struct;
34use crate::runtime::host::{
35    quota_exceeded_error, read_string_arg, read_uint8_array_arg, register_host_fn,
36    write_submilli_string_struct, write_submilli_uint8array_struct,
37};
38use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types, intrinsic_types};
39use crate::runtime::prelude::iterator::{
40    as_struct, build_closable_iterator, iter_done, iter_yield, next_closure_type, void_closure_type,
41};
42use crate::runtime::{DiskQuota, Holder, OpenFileGuard, QuotaCharge, StoreData};
43use crate::stdlib::abi::{
44    self, backing_struct, externref_field, f64_field, install_field_getters, string_field,
45};
46use crate::stdlib::shared::{
47    atomic_write, check_security_call, contain_trap, quota_refusal, refuse_volume_root,
48    require_writable, resolve_content_or_trap, resolve_link_or_trap, write_target_trap,
49};
50use handles::{
51    ChargedByteReader, ChargedDirIter, ChargedFileWriter, ChargedLineReader, ContainedWalk,
52    TempFile, WriteError, kind_of,
53};
54
55pub const MODULE_NAME: &str = "submilli:fs";
56
57pub use declaration::package_declaration;
58
59// `$StatBacking` field indices (0 is the vtable).
60const STAT_KIND: usize = 1;
61const STAT_SIZE: usize = 2;
62const STAT_MODIFIED_AT: usize = 3;
63
64// `$PeekBacking` field indices.
65const PEEK_PREVIEW: usize = 1;
66const PEEK_ENCODING: usize = 2;
67const PEEK_LINE_ENDING: usize = 3;
68const PEEK_SIZE: usize = 4;
69
70// `$DirEntryBacking` field indices.
71const ENTRY_KIND: usize = 1;
72const ENTRY_NAME: usize = 2;
73const ENTRY_PATH: usize = 3;
74const ENTRY_SIZE: usize = 4;
75
76// `$InfoBacking` field indices.
77const INFO_MODE: usize = 1;
78const INFO_SIZE_LIMIT: usize = 2;
79const INFO_ACCESS: usize = 3;
80const INFO_VOLUME: usize = 4;
81const INFO_MOUNTS: usize = 5;
82
83// `$MountInfoBacking` field indices.
84const MOUNT_PATH: usize = 1;
85const MOUNT_MODE: usize = 2;
86const MOUNT_VOLUME: usize = 3;
87const MOUNT_ACCESS: usize = 4;
88const MOUNT_SIZE_LIMIT: usize = 5;
89
90// `$FileWriterBacking`: vtable + the externref handle.
91const WRITER_HANDLE: usize = 1;
92
93fn stat_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
94    let intr = build_intrinsic_types(engine)?;
95    backing_struct(
96        engine,
97        &intr,
98        vec![
99            string_field(&intr), // kind
100            f64_field(),         // size
101            f64_field(),         // modifiedAt
102        ],
103    )
104}
105
106fn peek_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
107    let intr = build_intrinsic_types(engine)?;
108    backing_struct(
109        engine,
110        &intr,
111        vec![
112            string_field(&intr), // preview
113            string_field(&intr), // encoding
114            string_field(&intr), // lineEnding
115            f64_field(),         // size
116        ],
117    )
118}
119
120fn dir_entry_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
121    let intr = build_intrinsic_types(engine)?;
122    backing_struct(
123        engine,
124        &intr,
125        vec![
126            string_field(&intr), // kind
127            string_field(&intr), // name
128            string_field(&intr), // path
129            f64_field(),         // size
130            wasmtime::FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)), // nominal marker
131        ],
132    )
133}
134
135fn info_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
136    let intr = build_intrinsic_types(engine)?;
137    backing_struct(
138        engine,
139        &intr,
140        vec![
141            string_field(&intr),     // mode
142            f64_field(),             // sizeLimit
143            string_field(&intr),     // access
144            string_field(&intr),     // volume
145            abi::array_field(&intr), // mounts
146            wasmtime::FieldType::new(Mutability::Const, StorageType::ValType(ValType::I64)), // nominal marker
147        ],
148    )
149}
150
151fn mount_info_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
152    let intr = build_intrinsic_types(engine)?;
153    backing_struct(
154        engine,
155        &intr,
156        vec![
157            string_field(&intr), // path
158            string_field(&intr), // mode
159            string_field(&intr), // volume
160            string_field(&intr), // access
161            f64_field(),         // sizeLimit
162            wasmtime::FieldType::new(Mutability::Const, StorageType::ValType(ValType::I32)), // nominal marker
163        ],
164    )
165}
166
167/// `$FileWriterBacking` — vtable + the `externref` holding the
168/// [`ChargedFileWriter`].
169fn file_writer_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
170    let intr = build_intrinsic_types(engine)?;
171    backing_struct(engine, &intr, vec![externref_field()])
172}
173
174/// The iterator env: a host-private single-field struct carrying the handle
175/// `externref` through the closure ABI's `(ref any)` slot.
176fn handle_env_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
177    singleton_struct(
178        engine,
179        Finality::Final,
180        None::<StructType>,
181        vec![FieldType::new(
182            Mutability::Const,
183            StorageType::ValType(ValType::EXTERNREF),
184        )],
185    )
186}
187
188pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
189    let engine = linker.engine().clone();
190    let intr = build_intrinsic_types(&engine)?;
191    let string = ValType::Ref(RefType::new(
192        false,
193        HeapType::ConcreteStruct(intr.string.clone()),
194    ));
195    let uint8 = ValType::Ref(RefType::new(
196        false,
197        HeapType::ConcreteStruct(intr.uint8_array.clone()),
198    ));
199    let object = ValType::Ref(RefType::new(
200        false,
201        HeapType::ConcreteStruct(intr.object.clone()),
202    ));
203    let nullable_object = ValType::Ref(RefType::new(
204        true,
205        HeapType::ConcreteStruct(intr.object.clone()),
206    ));
207
208    let fs_fn = |name: &str| crate::mangle::package_symbol(MODULE_NAME, name);
209
210    register_host_fn(
211        linker,
212        MODULE_NAME,
213        fs_fn("maxReadSize"),
214        FuncType::new(&engine, [], [ValType::F64]),
215        /* deterministic = */ true,
216        |caller, _params, results| {
217            *abi_result(results, 0)? = Val::F64((caller.data().fs_max_read_size as f64).to_bits());
218            Ok(())
219        },
220    )?;
221
222    register_host_fn(
223        linker,
224        MODULE_NAME,
225        fs_fn("cwd"),
226        FuncType::new(&engine, [], [string.clone()]),
227        true,
228        |caller, _params, results| {
229            let cwd = caller.data().vfs.cwd().to_owned();
230            let value = write_submilli_string_struct(&mut *caller, &cwd)?;
231            let result = results.first_mut().ok_or_else(|| {
232                crate::runtime::host::fatal_host_error("fs.cwd: missing result slot")
233            })?;
234            *result = Val::AnyRef(Some(value.to_anyref()));
235            Ok(())
236        },
237    )?;
238
239    register_host_fn(
240        linker,
241        MODULE_NAME,
242        fs_fn("info"),
243        FuncType::new(&engine, [], [nullable_object.clone()]),
244        /* deterministic = */ true,
245        |caller, _params, results| {
246            *abi_result(results, 0)? = build_info(caller)?;
247            Ok(())
248        },
249    )?;
250
251    register_host_fn(
252        linker,
253        MODULE_NAME,
254        fs_fn("exists"),
255        FuncType::new(&engine, [string.clone()], [ValType::I32]),
256        /* deterministic = */ false,
257        |caller, params, results| {
258            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.exists")?;
259            let ticket = gate(
260                &mut *caller,
261                "fs.stat",
262                serde_json::json!({ "path": &path }),
263            )?;
264            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.exists")?;
265            // A path the sandbox cannot reach is reported absent, not trapped: that
266            // preserves the documented broken-link behaviour and denies the guest an
267            // existence oracle over host layout.
268            // `exists` is declared to mirror `std::path::exists` — a total predicate that
269            // never throws. Every way a path can be unreachable answers `false`: outside the
270            // sandbox, missing, a file used as a directory component, a symlink cycle. A
271            // guard clause written as `if (!exists(p))` is exactly what an LLM writes, and it
272            // must not abort on the malformed-path case it exists to handle.
273            let found = match resolved.try_exists() {
274                Ok(found) => found,
275                Err(ContainError::Escape | ContainError::Io(_)) => false,
276                Err(e) => return Err(contain_trap("fs.exists", &path, &e)),
277            };
278            record_payload(&*caller, ticket, Side::Response, || {
279                Payload::meta(serde_json::json!({ "exists": found }))
280            });
281            *abi_result(results, 0)? = Val::I32(i32::from(found));
282            Ok(())
283        },
284    )?;
285
286    register_host_fn(
287        linker,
288        MODULE_NAME,
289        fs_fn("size"),
290        FuncType::new(&engine, [string.clone()], [ValType::F64]),
291        /* deterministic = */ false,
292        |caller, params, results| {
293            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.size")?;
294            let ticket = gate(
295                &mut *caller,
296                "fs.stat",
297                serde_json::json!({ "path": &path }),
298            )?;
299            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.size")?;
300            let meta = resolved
301                .metadata()
302                .map_err(|e| contain_trap("fs.size", &path, &e))?;
303            if meta.is_dir() {
304                wasmtime::bail!("fs.size {}: path is a directory", path);
305            }
306            record_payload(&*caller, ticket, Side::Response, || {
307                Payload::meta(serde_json::json!({ "size": meta.len() }))
308            });
309            *abi_result(results, 0)? = Val::F64((meta.len() as f64).to_bits());
310            Ok(())
311        },
312    )?;
313
314    register_host_fn(
315        linker,
316        MODULE_NAME,
317        fs_fn("stat"),
318        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
319        /* deterministic = */ false,
320        |caller, params, results| {
321            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.stat")?;
322            gate(
323                &mut *caller,
324                "fs.stat",
325                serde_json::json!({ "path": &path }),
326            )?;
327            *abi_result(results, 0)? = stat_entry(caller, &path)?;
328            Ok(())
329        },
330    )?;
331
332    register_host_fn(
333        linker,
334        MODULE_NAME,
335        fs_fn("peek"),
336        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
337        /* deterministic = */ false,
338        |caller, params, results| {
339            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.peek")?;
340            gate(
341                &mut *caller,
342                "fs.stat",
343                serde_json::json!({ "path": &path }),
344            )?;
345            *abi_result(results, 0)? = peek_file(caller, &path)?;
346            Ok(())
347        },
348    )?;
349
350    register_host_fn(
351        linker,
352        MODULE_NAME,
353        fs_fn("read"),
354        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
355        /* deterministic = */ false,
356        |caller, params, results| {
357            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.read")?;
358            *abi_result(results, 0)? = match read_whole_capped(caller, &path, "read")? {
359                Some(bytes) => {
360                    let arr = write_submilli_uint8array_struct(caller, &bytes)?;
361                    Val::AnyRef(Some(arr.to_anyref()))
362                }
363                None => crate::runtime::prelude::undefined::value(caller)?,
364            };
365            Ok(())
366        },
367    )?;
368
369    register_host_fn(
370        linker,
371        MODULE_NAME,
372        fs_fn("readText"),
373        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
374        /* deterministic = */ false,
375        |caller, params, results| {
376            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.readText")?;
377            *abi_result(results, 0)? = match read_whole_capped(caller, &path, "readText")? {
378                Some(bytes) => {
379                    // Lossy UTF-8: invalid bytes become U+FFFD rather than trapping.
380                    let mut text = String::from_utf8_lossy(&bytes).into_owned();
381                    if text.starts_with('\u{FEFF}') {
382                        text.remove(0);
383                    }
384                    let st = write_submilli_string_struct(caller, &text)?;
385                    Val::AnyRef(Some(st.to_anyref()))
386                }
387                None => crate::runtime::prelude::undefined::value(caller)?,
388            };
389            Ok(())
390        },
391    )?;
392
393    register_host_fn(
394        linker,
395        MODULE_NAME,
396        fs_fn("readBytes"),
397        FuncType::new(
398            &engine,
399            [string.clone(), ValType::F64, ValType::F64],
400            [uint8.clone()],
401        ),
402        /* deterministic = */ false,
403        |caller, params, results| {
404            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.readBytes")?;
405            let offset = f64_arg(abi_arg(params, 1)?, "fs.readBytes (offset)")? as i64;
406            let length = f64_arg(abi_arg(params, 2)?, "fs.readBytes (length)")? as i64;
407            let ticket = gate(
408                &mut *caller,
409                "fs.read",
410                serde_json::json!({
411                    "path": &path,
412                    "length": length,
413                }),
414            )?;
415            let bytes = read_byte_range(caller, &path, offset, length)?;
416            record_payload(&*caller, ticket, Side::Response, || {
417                Payload::meta(serde_json::json!({ "offset": offset })).with_body(&bytes)
418            });
419            let arr = write_submilli_uint8array_struct(caller, &bytes)?;
420            *abi_result(results, 0)? = Val::AnyRef(Some(arr.to_anyref()));
421            Ok(())
422        },
423    )?;
424
425    // write / writeText / append / appendText share the shape
426    // `(path, content) -> void`; only the op name, encoding, and disk action differ.
427    for (name, op, text_content, atomic) in [
428        ("write", "write", false, true),
429        ("writeText", "writeText", true, true),
430        ("append", "append", false, false),
431        ("appendText", "appendText", true, false),
432    ] {
433        let content_ty = if text_content {
434            string.clone()
435        } else {
436            uint8.clone()
437        };
438        register_host_fn(
439            linker,
440            MODULE_NAME,
441            fs_fn(name),
442            FuncType::new(&engine, [string.clone(), content_ty], []),
443            /* deterministic = */ false,
444            move |caller, params, _results| {
445                let ctx = format!("fs.{op}");
446                let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, &ctx)?;
447                let bytes = if text_content {
448                    read_string_arg(&mut *caller, abi_arg(params, 1)?, &ctx)?.into_bytes()
449                } else {
450                    read_uint8_array_arg(&mut *caller, abi_arg(params, 1)?, &ctx)?
451                };
452                let ticket = gate(
453                    &mut *caller,
454                    "fs.write",
455                    serde_json::json!({
456                        "path": &path,
457                        "length": bytes.len(),
458                    }),
459                )?;
460                let resolved = resolve_content_or_trap(caller.data(), &path, &ctx)?;
461                require_writable(&*caller, ticket, resolved.placement(), "fs.write", &path)?;
462                refuse_volume_root(&resolved, &ctx, &path)?;
463                let quota = resolved.placement().quota().cloned();
464                fuel::charge(&mut *caller, fuel::IO, bytes.len() as u64)?;
465                if atomic {
466                    atomic_write(&resolved, &bytes, None, &path, &ctx, quota)
467                } else {
468                    append_bytes(&resolved, &bytes, &path, &ctx, quota)
469                }
470            },
471        )?;
472    }
473
474    register_host_fn(
475        linker,
476        MODULE_NAME,
477        fs_fn("mkdir"),
478        FuncType::new(&engine, [string.clone(), ValType::I32], []),
479        /* deterministic = */ false,
480        |caller, params, _results| {
481            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.mkdir")?;
482            let recursive = i32_flag(abi_arg(params, 1)?, "fs.mkdir (recursive)")?;
483            let ticket = gate(
484                &mut *caller,
485                "fs.mkdir",
486                serde_json::json!({ "path": &path, "recursive": recursive }),
487            )?;
488            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.mkdir")?;
489            if !(recursive && resolved.is_existing_dir()) {
490                require_writable(&*caller, ticket, resolved.placement(), "fs.mkdir", &path)?;
491            }
492            let res = if recursive {
493                resolved.create_dir_all()
494            } else {
495                resolved.create_dir()
496            };
497            res.map_err(|e| write_target_trap("fs.mkdir", &path, &e))?;
498            Ok(())
499        },
500    )?;
501
502    register_host_fn(
503        linker,
504        MODULE_NAME,
505        fs_fn("remove"),
506        FuncType::new(&engine, [string.clone(), ValType::I32], []),
507        /* deterministic = */ false,
508        |caller, params, _results| {
509            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.remove")?;
510            let recursive = i32_flag(abi_arg(params, 1)?, "fs.remove (recursive)")?;
511            let ticket = gate(
512                &mut *caller,
513                "fs.remove",
514                serde_json::json!({ "path": &path, "recursive": recursive }),
515            )?;
516            let resolved = resolve_link_or_trap(caller.data(), &path, "fs.remove")?;
517            require_writable(&*caller, ticket, resolved.placement(), "fs.remove", &path)?;
518            // `remove_dir_all(".")` drains the root and only then fails on the self-unlink,
519            // so without this the guest destroys the operator's volume and is told the call
520            // failed. Refuse before touching anything. A mount point is the root of its
521            // volume and is refused the same way.
522            if resolved.is_root() {
523                wasmtime::bail!(
524                    "fs.remove {}: cannot remove the VFS root or a mount point itself; \
525                     remove its entries instead",
526                    path
527                );
528            }
529            let context = MutationContext {
530                op: "fs.remove",
531                path: &path,
532                quota: resolved.placement().quota().cloned(),
533                changed: false,
534            };
535            meter_mutation(caller, context, |step| {
536                crate::runtime::fs::removal::remove(&resolved, recursive, step)
537            })
538        },
539    )?;
540
541    register_host_fn(
542        linker,
543        MODULE_NAME,
544        fs_fn("move"),
545        FuncType::new(&engine, [string.clone(), string.clone()], []),
546        /* deterministic = */ false,
547        |caller, params, _results| {
548            let from = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.move (from)")?;
549            let to = read_string_arg(&mut *caller, abi_arg(params, 1)?, "fs.move (to)")?;
550            let ticket = gate(
551                &mut *caller,
552                "fs.move",
553                serde_json::json!({ "from": &from, "to": &to }),
554            )?;
555            let from_resolved = resolve_link_or_trap(caller.data(), &from, "fs.move")?;
556            let to_resolved = resolve_link_or_trap(caller.data(), &to, "fs.move")?;
557            require_writable(
558                &*caller,
559                ticket,
560                from_resolved.placement(),
561                "fs.move",
562                &from,
563            )?;
564            require_writable(&*caller, ticket, to_resolved.placement(), "fs.move", &to)?;
565            let pair = format!("{from} -> {to}");
566            if from_resolved.is_root() || to_resolved.is_root() {
567                wasmtime::bail!(
568                    "fs.move {pair}: the VFS root and mount points cannot be moved or replaced"
569                );
570            }
571            if from_resolved
572                .same_entry(&to_resolved)
573                .map_err(|e| contain_trap("fs.move", &pair, &e))?
574            {
575                return Ok(());
576            }
577            if !from_resolved
578                .placement()
579                .same_volume(to_resolved.placement())
580            {
581                let mut work = CopyWork::default();
582                let result = move_across(caller, &from_resolved, &to_resolved, &pair, &mut work);
583                work.settle(caller)?;
584                return fuel::settle_result(caller, |caller| {
585                    result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
586                });
587            }
588            // A file moved over another frees the one it replaced; a move onto the same
589            // file, under any spelling, frees nothing.
590            let moved = from_resolved.regular_file().map(|(file, _)| file);
591            let replaced = to_resolved
592                .regular_file()
593                .filter(|(file, _)| Some(*file) != moved);
594            let context = MutationContext {
595                op: "fs.move",
596                path: &pair,
597                quota: None,
598                changed: false,
599            };
600            meter_mutation(caller, context, |step| {
601                crate::runtime::fs::removal::rename(&from_resolved, &to_resolved, step)
602            })?;
603            if let (Some(quota), Some((file, bytes))) = (to_resolved.placement().quota(), replaced)
604            {
605                quota.release_file(file, bytes);
606            }
607            Ok(())
608        },
609    )?;
610
611    register_host_fn(
612        linker,
613        MODULE_NAME,
614        fs_fn("copy"),
615        FuncType::new(&engine, [string.clone(), string.clone(), ValType::I32], []),
616        /* deterministic = */ false,
617        |caller, params, _results| {
618            let from = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.copy (from)")?;
619            let to = read_string_arg(&mut *caller, abi_arg(params, 1)?, "fs.copy (to)")?;
620            let recursive = i32_flag(abi_arg(params, 2)?, "fs.copy (recursive)")?;
621            let ticket = gate(
622                &mut *caller,
623                "fs.copy",
624                serde_json::json!({
625                    "from": &from,
626                    "to": &to,
627                    "recursive": recursive,
628                }),
629            )?;
630            let from_resolved = resolve_link_or_trap(caller.data(), &from, "fs.copy")?;
631            let pair = format!("{from} -> {to}");
632            let mount = from_resolved
633                .contains_mount_point()
634                .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
635            if let Some(mount) = mount {
636                wasmtime::bail!(
637                    "fs.copy {pair}: {from} contains the mount point {mount}; copy the \
638                     mount's contents separately"
639                );
640            }
641            // The content path routes to the same volume as the link path, and resolves
642            // even when the destination's parent does not exist yet.
643            let to_volume = resolve_content_or_trap(caller.data(), &to, "fs.copy")?;
644            require_writable(&*caller, ticket, to_volume.placement(), "fs.copy", &to)?;
645            from_resolved
646                .check_copy_destination(&to_volume)
647                .map_err(|e| contain_trap("fs.copy", &pair, &e))?;
648            // A recursive copy used to reach `create_dir_all(to)`, which built the whole
649            // destination chain. Resolving the destination now opens its parent, so that
650            // chain has to exist first or a working call starts failing.
651            if recursive
652                && from_resolved
653                    .symlink_metadata()
654                    .is_ok_and(|meta| meta.is_dir())
655            {
656                to_volume
657                    .create_dir_all()
658                    .map_err(|e| write_target_trap("fs.copy", &to, &e))?;
659            }
660            let to_resolved = resolve_link_or_trap(caller.data(), &to, "fs.copy")?;
661            let quota = to_resolved.placement().quota().cloned();
662            let mut work = CopyWork::default();
663            let result = copy_bounded(
664                &from_resolved,
665                &to_resolved,
666                &CopyRun {
667                    recursive,
668                    links: LinkCopies::BestEffort,
669                    op: "fs.copy",
670                    pair: &pair,
671                    quota: quota.as_ref(),
672                },
673                &mut work,
674            );
675            work.settle(caller)?;
676            fuel::settle_result(caller, |caller| {
677                result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
678            })
679        },
680    )?;
681
682    register_host_fn(
683        linker,
684        MODULE_NAME,
685        fs_fn("writer"),
686        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
687        /* deterministic = */ false,
688        |caller, params, results| {
689            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.writer (path)")?;
690            let ticket = gate(
691                &mut *caller,
692                "fs.write",
693                serde_json::json!({ "path": &path }),
694            )?;
695            *abi_result(results, 0)? = open_writer(caller, ticket, &path)?;
696            Ok(())
697        },
698    )?;
699
700    register_host_fn(
701        linker,
702        MODULE_NAME,
703        fs_fn("lines"),
704        FuncType::new(&engine, [string.clone()], [nullable_object.clone()]),
705        /* deterministic = */ false,
706        |caller, params, results| {
707            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.lines (path)")?;
708            gate(
709                &mut *caller,
710                "fs.read",
711                serde_json::json!({ "path": &path }),
712            )?;
713            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.lines")?;
714            let file = resolved
715                .open()
716                .map_err(|e| contain_trap("fs.lines", &path, &e))?;
717            let held = hold_for_reading(resolved.placement().quota(), &file);
718            let reader = ChargedLineReader::new(
719                BufReader::new(file.into_std()),
720                &caller.data().tenant_limits,
721                held,
722            )
723            .map_err(|e| wasmtime::Error::new(e).context(format!("fs.lines {path}")))?;
724            *abi_result(results, 0)? = make_handle_iterator(
725                caller,
726                Some(reader),
727                lines_next,
728                close_handle_of::<Option<ChargedLineReader>>,
729            )?;
730            Ok(())
731        },
732    )?;
733
734    register_host_fn(
735        linker,
736        MODULE_NAME,
737        fs_fn("bytes"),
738        FuncType::new(
739            &engine,
740            [string.clone(), ValType::F64],
741            [nullable_object.clone()],
742        ),
743        /* deterministic = */ false,
744        |caller, params, results| {
745            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.bytes (path)")?;
746            let chunk_size = f64_arg(abi_arg(params, 1)?, "fs.bytes (chunkSize)")? as i64;
747            if chunk_size <= 0 {
748                return Err(crate::runtime::host::range_error(format!(
749                    "fs.bytes: chunkSize must be > 0, got {chunk_size}"
750                )));
751            }
752            gate(
753                &mut *caller,
754                "fs.read",
755                serde_json::json!({ "path": &path, "chunkSize": chunk_size }),
756            )?;
757            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.bytes")?;
758            let file = resolved
759                .open()
760                .map_err(|e| contain_trap("fs.bytes", &path, &e))?;
761            let held = hold_for_reading(resolved.placement().quota(), &file);
762            let reader = ChargedByteReader::new(
763                file.into_std(),
764                chunk_size as usize,
765                &caller.data().tenant_limits,
766                held,
767            )
768            .map_err(|e| wasmtime::Error::new(e).context(format!("fs.bytes {path}")))?;
769            *abi_result(results, 0)? = make_handle_iterator(
770                caller,
771                reader,
772                bytes_next,
773                close_handle_of::<ChargedByteReader>,
774            )?;
775            Ok(())
776        },
777    )?;
778
779    register_host_fn(
780        linker,
781        MODULE_NAME,
782        fs_fn("list"),
783        FuncType::new(
784            &engine,
785            [string.clone(), ValType::I32],
786            [nullable_object.clone()],
787        ),
788        /* deterministic = */ false,
789        |caller, params, results| {
790            let path = read_string_arg(&mut *caller, abi_arg(params, 0)?, "fs.list (path)")?;
791            let recursive = i32_flag(abi_arg(params, 1)?, "fs.list (recursive)")?;
792            gate(
793                &mut *caller,
794                "fs.list",
795                serde_json::json!({ "path": &path, "recursive": recursive }),
796            )?;
797            let resolved = resolve_content_or_trap(caller.data(), &path, "fs.list")?;
798            let meta = resolved
799                .metadata()
800                .map_err(|e| contain_trap("fs.list", &path, &e))?;
801            if !meta.is_dir() {
802                wasmtime::bail!("fs.list {}: path is not a directory", path);
803            }
804            let base = Arc::new(
805                resolved
806                    .open_dir()
807                    .map_err(|e| contain_trap("fs.list", &path, &e))?,
808            );
809            let mounts = if recursive {
810                resolved.mounts_below()
811            } else {
812                Vec::new()
813            };
814            let walk = ContainedWalk::new(
815                base,
816                list_prefix(caller.data().vfs.cwd(), &path)?,
817                recursive,
818                mounts,
819            )
820            .map_err(|e| contain_trap("fs.list", &path, &ContainError::from(e)))?;
821            let dir = ChargedDirIter::new(walk, &caller.data().tenant_limits)
822                .map_err(|e| wasmtime::Error::new(e).context(format!("fs.list {path}")))?;
823            *abi_result(results, 0)? =
824                make_handle_iterator(caller, dir, list_next, close_handle_of::<ChargedDirIter>)?;
825            Ok(())
826        },
827    )?;
828
829    install_getters(linker, &engine, &intr, object.clone())?;
830    install_file_writer_methods(linker, &engine, &intr, object)?;
831    Ok(())
832}
833
834// ---------------------------------------------------------------------------
835// Extracted op bodies
836// ---------------------------------------------------------------------------
837
838/// `info()`: the active VFS configuration as an `$InfoBacking`.
839fn build_info(caller: &mut Caller<'_, StoreData>) -> wasmtime::Result<Val> {
840    let info = caller.data().vfs_info.clone();
841    let mode = match info.mode {
842        crate::runtime::VfsMode::None => "none",
843        crate::runtime::VfsMode::Ephemeral => "ephemeral",
844        crate::runtime::VfsMode::PerSession => "per_session",
845        crate::runtime::VfsMode::Named => "named",
846    };
847    let vfs = &caller.data().vfs;
848    let root_limit = vfs.quota().map(|quota| quota.limit());
849    let access = vfs.access().as_str();
850    let volume = vfs.volume().unwrap_or_default().to_string();
851    // Cloned out of the store, which the string writes below borrow mutably.
852    let mounts = vfs.mounts().to_vec();
853    let mount_ty = mount_info_backing_struct(caller.engine())?;
854    let mut built = Vec::with_capacity(mounts.len());
855    for mount in &mounts {
856        built.push(build_mount_info(caller, &mount_ty, mount)?);
857    }
858    let mounts = abi::new_array(caller, &built)?;
859    // A named root's limit is the volume's, which the server shares in rather
860    // than writes into the info.
861    let size_limit = size_limit_number(info.size_limit.or(root_limit));
862    let mode = write_submilli_string_struct(caller, mode)?.to_anyref();
863    let access = write_submilli_string_struct(caller, access)?.to_anyref();
864    let volume = write_submilli_string_struct(caller, &volume)?.to_anyref();
865    let ty = info_backing_struct(caller.engine())?;
866    abi::new_backing(
867        caller,
868        ty,
869        &[
870            Val::AnyRef(Some(mode)),
871            Val::F64(size_limit.to_bits()),
872            Val::AnyRef(Some(access)),
873            Val::AnyRef(Some(volume)),
874            mounts,
875            Val::I64(0),
876        ],
877    )
878}
879
880/// One `info().mounts` entry as a `$MountInfoBacking`, fields in `MOUNT_*` order.
881fn build_mount_info(
882    caller: &mut Caller<'_, StoreData>,
883    ty: &StructType,
884    mount: &crate::runtime::vfs::Mount,
885) -> wasmtime::Result<Val> {
886    let path = write_submilli_string_struct(caller, mount.guest_path())?.to_anyref();
887    let mode = write_submilli_string_struct(caller, "named")?.to_anyref();
888    let volume = write_submilli_string_struct(caller, mount.volume())?.to_anyref();
889    let access = write_submilli_string_struct(caller, mount.access().as_str())?.to_anyref();
890    let size_limit = size_limit_number(mount.quota().map(|quota| quota.limit()));
891    abi::new_backing(
892        caller,
893        ty.clone(),
894        &[
895            Val::AnyRef(Some(path)),
896            Val::AnyRef(Some(mode)),
897            Val::AnyRef(Some(volume)),
898            Val::AnyRef(Some(access)),
899            Val::F64(size_limit.to_bits()),
900            Val::I32(0),
901        ],
902    )
903}
904
905/// `-1` stands for "no limit": the language has no `undefined`, so the field
906/// is always present.
907fn size_limit_number(limit: Option<u64>) -> f64 {
908    limit.map_or(-1.0, |v| v as f64)
909}
910
911/// `stat(path)`: a `$StatBacking`, or `undefined` when the path does not exist.
912fn stat_entry(caller: &mut Caller<'_, StoreData>, path: &str) -> wasmtime::Result<Val> {
913    // `stat` is declared `Stat | undefined` so a program can probe for absence. Resolution now
914    // opens the parent, so a missing *ancestor* fails here rather than at the metadata call
915    // below — and answering that with a trap would break the contract every caller branches
916    // on. Escapes still trap: unreachable-because-outside is not the same as absent.
917    let resolved = match resolve_link(&caller.data().vfs, caller.data().vfs.cwd(), path) {
918        Ok(resolved) => resolved,
919        Err(ContainError::Io(e)) if is_absent(&e) => {
920            return crate::runtime::prelude::undefined::value(caller);
921        }
922        Err(e) => return Err(contain_trap("fs.stat", path, &e)),
923    };
924    let meta = match resolved.symlink_metadata() {
925        Ok(m) => m,
926        Err(ContainError::Io(e)) if is_absent(&e) => {
927            return crate::runtime::prelude::undefined::value(caller);
928        }
929        Err(e) => return Err(contain_trap("fs.stat", path, &e)),
930    };
931    let ft = meta.file_type();
932    let size = if ft.is_file() { meta.len() as f64 } else { 0.0 };
933    let modified_ms = meta
934        .modified()
935        .ok()
936        .and_then(|t| t.into_std().duration_since(std::time::UNIX_EPOCH).ok())
937        .map_or(0.0, |d| d.as_millis() as f64);
938    let kind = write_submilli_string_struct(caller, kind_of(&ft))?.to_anyref();
939    let ty = stat_backing_struct(caller.engine())?;
940    abi::new_backing(
941        caller,
942        ty,
943        &[
944            Val::AnyRef(Some(kind)),
945            Val::F64(size.to_bits()),
946            Val::F64(modified_ms.to_bits()),
947        ],
948    )
949}
950
951/// `peek(path)`: sniff the first ~256 bytes into a `$PeekBacking`.
952fn peek_file(caller: &mut Caller<'_, StoreData>, path: &str) -> wasmtime::Result<Val> {
953    let resolved = resolve_content_or_trap(caller.data(), path, "fs.peek")?;
954    let meta = resolved
955        .metadata()
956        .map_err(|e| contain_trap("fs.peek", path, &e))?;
957    if !meta.is_file() {
958        wasmtime::bail!("fs.peek {}: path is not a regular file", path);
959    }
960    let mut f = resolved
961        .open()
962        .map_err(|e| contain_trap("fs.peek", path, &e))?;
963    let mut buf = vec![0u8; 256];
964    let n = f
965        .read(&mut buf)
966        .map_err(|e| wasmtime::Error::msg(format!("fs.peek {path}: {e}")))?;
967    buf.truncate(n);
968
969    let (encoding, content_start) = detect_encoding(&buf);
970    let content = &buf[content_start..];
971    let line_ending = if content.windows(2).any(|w| w == b"\r\n") {
972        "crlf"
973    } else {
974        "lf"
975    };
976    let preview = String::from_utf8_lossy(content).into_owned();
977
978    let preview = write_submilli_string_struct(caller, &preview)?.to_anyref();
979    let encoding = write_submilli_string_struct(caller, encoding)?.to_anyref();
980    let line_ending = write_submilli_string_struct(caller, line_ending)?.to_anyref();
981    let ty = peek_backing_struct(caller.engine())?;
982    abi::new_backing(
983        caller,
984        ty,
985        &[
986            Val::AnyRef(Some(preview)),
987            Val::AnyRef(Some(encoding)),
988            Val::AnyRef(Some(line_ending)),
989            Val::F64((meta.len() as f64).to_bits()),
990        ],
991    )
992}
993
994/// BOM-first encoding sniff; falls back to a UTF-8 validity check, then latin1.
995/// Returns the detected label and the byte offset where content starts.
996fn detect_encoding(buf: &[u8]) -> (&'static str, usize) {
997    if buf.starts_with(&[0xEF, 0xBB, 0xBF]) {
998        ("utf-8", 3)
999    } else if buf.starts_with(&[0xFF, 0xFE]) {
1000        ("utf-16le", 2)
1001    } else if buf.starts_with(&[0xFE, 0xFF]) {
1002        ("utf-16be", 2)
1003    } else if std::str::from_utf8(buf).is_ok() {
1004        ("utf-8", 0)
1005    } else {
1006        ("latin1", 0)
1007    }
1008}
1009
1010/// The shared `read`/`readText` path: gate, resolve, refuse directories, and
1011/// load the whole file — or `None` when it exceeds `fs.maxReadSize` (the
1012/// caller surfaces that as a guest `null`).
1013fn read_whole_capped(
1014    caller: &mut Caller<'_, StoreData>,
1015    path: &str,
1016    op: &str,
1017) -> wasmtime::Result<Option<Vec<u8>>> {
1018    let ctx = format!("fs.{op}");
1019    let ticket = gate(&mut *caller, "fs.read", serde_json::json!({ "path": path }))?;
1020    let resolved = resolve_content_or_trap(caller.data(), path, &ctx)?;
1021    let meta = resolved
1022        .metadata()
1023        .map_err(|e| contain_trap(&ctx, path, &e))?;
1024    if meta.is_dir() {
1025        wasmtime::bail!("{ctx} {}: path is a directory", path);
1026    }
1027    if meta.len() > caller.data().fs_max_read_size {
1028        record_payload(&*caller, ticket, Side::Response, || {
1029            Payload::meta(serde_json::json!({ "over_max_read_size": meta.len() })).with_size(0)
1030        });
1031        return Ok(None);
1032    }
1033    // The size is known before the read, so the charge comes first.
1034    fuel::charge(&mut *caller, fuel::IO, meta.len())?;
1035    let bytes = resolved.read().map_err(|e| contain_trap(&ctx, path, &e))?;
1036    record_payload(&*caller, ticket, Side::Response, || {
1037        Payload::meta(serde_json::Value::Null).with_body(&bytes)
1038    });
1039    Ok(Some(bytes))
1040}
1041
1042/// `readBytes(path, offset, length)`: validated random-access range read; may
1043/// come back short at EOF.
1044fn read_byte_range(
1045    caller: &mut Caller<'_, StoreData>,
1046    path: &str,
1047    offset: i64,
1048    length: i64,
1049) -> wasmtime::Result<Vec<u8>> {
1050    if offset < 0 {
1051        return Err(crate::runtime::host::range_error(
1052            "fs.readBytes: negative offset",
1053        ));
1054    }
1055    if length < 0 {
1056        return Err(crate::runtime::host::range_error(
1057            "fs.readBytes: negative length",
1058        ));
1059    }
1060    if (length as u64) > caller.data().fs_max_read_size {
1061        return Err(crate::runtime::host::range_error(format!(
1062            "fs.readBytes: length {length} exceeds fs.maxReadSize"
1063        )));
1064    }
1065    let resolved = resolve_content_or_trap(caller.data(), path, "fs.readBytes")?;
1066    let mut f = resolved
1067        .open()
1068        .map_err(|e| contain_trap("fs.readBytes", path, &e))?;
1069    f.seek(SeekFrom::Start(offset as u64))
1070        .map_err(|e| wasmtime::Error::msg(format!("fs.readBytes {path}: seek: {e}")))?;
1071    // The length is known before the read, so the charge comes first.
1072    fuel::charge(&mut *caller, fuel::IO, length as u64)?;
1073    let mut buf = vec![0u8; length as usize];
1074    let mut filled = 0;
1075    while filled < buf.len() {
1076        let n = f
1077            .read(&mut buf[filled..])
1078            .map_err(|e| wasmtime::Error::msg(format!("fs.readBytes {path}: {e}")))?;
1079        if n == 0 {
1080            break;
1081        }
1082        filled += n;
1083    }
1084    buf.truncate(filled);
1085    Ok(buf)
1086}
1087
1088/// `writer(path)`: open the temp sibling and wrap the [`ChargedFileWriter`] in
1089/// a `$FileWriterBacking`.
1090fn open_writer(
1091    caller: &mut Caller<'_, StoreData>,
1092    ticket: Option<CallTicket>,
1093    path: &str,
1094) -> wasmtime::Result<Val> {
1095    let resolved = resolve_content_or_trap(caller.data(), path, "fs.writer")?;
1096    require_writable(&*caller, ticket, resolved.placement(), "fs.write", path)?;
1097    refuse_volume_root(&resolved, "fs.writer", path)?;
1098    let tmp = resolved.temp_sibling();
1099    let file = tmp
1100        .create_new()
1101        .map_err(|e| write_target_trap("fs.writer", path, &e))?;
1102    let temp = TempFile::created(tmp, &file)
1103        .map_err(|e| wasmtime::Error::msg(format!("fs.writer {path}: {e}")))?;
1104    let quota = resolved.placement().quota().cloned();
1105    let writer = ChargedFileWriter::new(
1106        file.into_std(),
1107        temp,
1108        resolved,
1109        &caller.data().tenant_limits,
1110        quota,
1111    )
1112    .map_err(|e| wasmtime::Error::new(e).context(format!("fs.writer {path}")))?;
1113    let handle = ExternRef::new(&mut *caller, writer)?;
1114    let ty = file_writer_backing_struct(caller.engine())?;
1115    abi::new_backing(caller, ty, &[Val::ExternRef(Some(handle))])
1116}
1117
1118// ---------------------------------------------------------------------------
1119// Iterators
1120// ---------------------------------------------------------------------------
1121
1122type IterCallback = fn(&mut Caller<'_, StoreData>, &[Val], &mut [Val]) -> wasmtime::Result<()>;
1123
1124/// Wrap a charged handle in a closable iterator: the handle rides an
1125/// `externref` inside a host-private env struct; `next_step` / `close_step`
1126/// are the per-kind callbacks.
1127fn make_handle_iterator(
1128    caller: &mut Caller<'_, StoreData>,
1129    payload: impl Send + Sync + 'static,
1130    next_step: IterCallback,
1131    close_step: IterCallback,
1132) -> wasmtime::Result<Val> {
1133    let handle = ExternRef::new(&mut *caller, payload)?;
1134    let env_ty = handle_env_struct(caller.engine())?;
1135    let pre = StructRefPre::new(&mut *caller, env_ty);
1136    let env = StructRef::new(&mut *caller, &pre, &[Val::ExternRef(Some(handle))])?;
1137
1138    let intr = intrinsic_types(&mut *caller)?;
1139    let (next_ty, _) = next_closure_type(caller.engine(), &intr)?;
1140    let (close_ty, _) = void_closure_type(caller.engine(), &intr)?;
1141    let next_fn = host_func(&mut *caller, next_ty, next_step);
1142    let close_fn = host_func(&mut *caller, close_ty, close_step);
1143    build_closable_iterator(
1144        caller,
1145        next_fn,
1146        close_fn,
1147        Val::AnyRef(Some(env.to_anyref())),
1148    )
1149}
1150
1151/// Pull the handle `externref` back out of the iterator env struct.
1152fn env_handle(
1153    caller: &mut Caller<'_, StoreData>,
1154    env: &Val,
1155) -> wasmtime::Result<Option<Rooted<ExternRef>>> {
1156    let st = as_struct(caller, env, "fs iterator env")?;
1157    match st.field(&mut *caller, 0)? {
1158        Val::ExternRef(handle) => Ok(handle),
1159        other => Err(wasmtime::Error::msg(format!(
1160            "fs iterator env: expected externref, got {other:?}"
1161        ))),
1162    }
1163}
1164
1165/// Borrow the typed handle payload out of the env, erroring with `ctx` when
1166/// the externref was reclaimed or holds an unexpected payload.
1167fn handle_payload<'a, T: 'static>(
1168    caller: &'a mut Caller<'_, StoreData>,
1169    handle: &Rooted<ExternRef>,
1170    ctx: &str,
1171) -> wasmtime::Result<&'a mut T> {
1172    handle
1173        .data_mut(&mut *caller)?
1174        .ok_or_else(|| wasmtime::Error::msg(format!("{ctx}: handle externref reclaimed")))?
1175        .downcast_mut::<T>()
1176        .ok_or_else(|| wasmtime::Error::msg(format!("{ctx}: unexpected externref payload")))
1177}
1178
1179fn lines_next(
1180    caller: &mut Caller<'_, StoreData>,
1181    params: &[Val],
1182    results: &mut [Val],
1183) -> wasmtime::Result<()> {
1184    let [environment] = params else {
1185        return Err(crate::runtime::host::fatal_host_error(
1186            "fs.lines.next: invalid argument count",
1187        ));
1188    };
1189    let [result] = results else {
1190        return Err(crate::runtime::host::fatal_host_error(
1191            "fs.lines.next: invalid result count",
1192        ));
1193    };
1194    let Some(handle) = env_handle(caller, environment)? else {
1195        *result = iter_done(caller)?;
1196        return Ok(());
1197    };
1198    // Take the payload temporarily so memory admission can read the current
1199    // store limits without aliasing the externref's mutable borrow.
1200    let Some(mut reader) =
1201        handle_payload::<Option<ChargedLineReader>>(caller, &handle, "fs.lines.next")?.take()
1202    else {
1203        *result = iter_done(caller)?;
1204        return Ok(());
1205    };
1206    let before = reader.bytes_read;
1207    let scanned_before = reader.bytes_scanned;
1208    let line = reader.read_next(&caller.data().tenant_limits, caller.data().fs_max_read_size);
1209    let received = reader.bytes_read.saturating_sub(before);
1210    let scanned = reader.bytes_scanned.saturating_sub(scanned_before);
1211    *handle_payload::<Option<ChargedLineReader>>(caller, &handle, "fs.lines.next")? = Some(reader);
1212    // Reading advances the iterator even if a later chunk or allocation fails.
1213    fuel::settle(&mut *caller, fuel::IO, received)?;
1214    fuel::settle(&mut *caller, fuel::SCAN, scanned)?;
1215    *result = fuel::settle_result(caller, |caller| {
1216        let line = line.map_err(|error| crate::runtime::host::throw_host_error(caller, error))?;
1217        match line {
1218            Some(line) => {
1219                let st = write_submilli_string_struct(caller, &line.text)?;
1220                iter_yield(caller, Val::AnyRef(Some(st.to_anyref())))
1221            }
1222            None => iter_done(caller),
1223        }
1224    })?;
1225    Ok(())
1226}
1227
1228fn bytes_next(
1229    caller: &mut Caller<'_, StoreData>,
1230    params: &[Val],
1231    results: &mut [Val],
1232) -> wasmtime::Result<()> {
1233    let Some(handle) = env_handle(caller, abi_arg(params, 0)?)? else {
1234        *abi_result(results, 0)? = iter_done(caller)?;
1235        return Ok(());
1236    };
1237    let chunk = handle_payload::<ChargedByteReader>(caller, &handle, "fs.bytes.next")?
1238        .read_next()
1239        .map_err(|e| wasmtime::Error::msg(format!("fs.bytes.next: {e}")))?;
1240    *abi_result(results, 0)? = match chunk {
1241        Some(bytes) => {
1242            fuel::charge(&mut *caller, fuel::IO, bytes.len() as u64)?;
1243            let arr = write_submilli_uint8array_struct(caller, &bytes)?;
1244            iter_yield(caller, Val::AnyRef(Some(arr.to_anyref())))?
1245        }
1246        None => iter_done(caller)?,
1247    };
1248    Ok(())
1249}
1250
1251fn list_next(
1252    caller: &mut Caller<'_, StoreData>,
1253    params: &[Val],
1254    results: &mut [Val],
1255) -> wasmtime::Result<()> {
1256    let Some(handle) = env_handle(caller, abi_arg(params, 0)?)? else {
1257        *abi_result(results, 0)? = iter_done(caller)?;
1258        return Ok(());
1259    };
1260    fuel::charge(&mut *caller, fuel::SYSCALL, 1)?;
1261    let entry = handle_payload::<ChargedDirIter>(caller, &handle, "fs.list.next")?.next_entry();
1262    let Some(entry) = entry else {
1263        *abi_result(results, 0)? = iter_done(caller)?;
1264        return Ok(());
1265    };
1266    let size = entry.size as f64;
1267    let kind = write_submilli_string_struct(caller, entry.kind)?.to_anyref();
1268    let name = write_submilli_string_struct(caller, &entry.name)?.to_anyref();
1269    let path = write_submilli_string_struct(caller, &entry.guest_path)?.to_anyref();
1270    let ty = dir_entry_backing_struct(caller.engine())?;
1271    let entry = abi::new_backing(
1272        caller,
1273        ty,
1274        &[
1275            Val::AnyRef(Some(kind)),
1276            Val::AnyRef(Some(name)),
1277            Val::AnyRef(Some(path)),
1278            Val::F64(size.to_bits()),
1279            Val::I64(0),
1280        ],
1281    )?;
1282    *abi_result(results, 0)? = iter_yield(caller, entry)?;
1283    Ok(())
1284}
1285
1286/// Eager close: drops the OS handle and refunds bytes now. A reclaimed
1287/// externref is a no-op — `for…of` fires `close` unconditionally, so a
1288/// double close must not trap.
1289fn close_handle_of<T: handles::Closable + 'static>(
1290    caller: &mut Caller<'_, StoreData>,
1291    params: &[Val],
1292    results: &mut [Val],
1293) -> wasmtime::Result<()> {
1294    if let Some(handle) = env_handle(caller, abi_arg(params, 0)?)?
1295        && let Some(payload) = handle
1296            .data_mut(&mut *caller)?
1297            .and_then(|d| d.downcast_mut::<T>())
1298    {
1299        payload.close();
1300    }
1301    *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
1302    Ok(())
1303}
1304
1305// ---------------------------------------------------------------------------
1306// Getters and FileWriter methods
1307// ---------------------------------------------------------------------------
1308
1309fn install_getters(
1310    linker: &mut Linker<StoreData>,
1311    engine: &wasmtime::Engine,
1312    intr: &IntrinsicTypes,
1313    receiver: ValType,
1314) -> wasmtime::Result<()> {
1315    let string = || {
1316        ValType::Ref(RefType::new(
1317            false,
1318            HeapType::ConcreteStruct(intr.string.clone()),
1319        ))
1320    };
1321    let array = ValType::Ref(RefType::new(
1322        false,
1323        HeapType::ConcreteStruct(intr.array.clone()),
1324    ));
1325    install_field_getters(
1326        linker,
1327        MODULE_NAME,
1328        "Stat",
1329        engine,
1330        &receiver,
1331        &[
1332            ("kind", STAT_KIND, string()),
1333            ("size", STAT_SIZE, ValType::F64),
1334            ("modifiedAt", STAT_MODIFIED_AT, ValType::F64),
1335        ],
1336    )?;
1337    install_field_getters(
1338        linker,
1339        MODULE_NAME,
1340        "Peek",
1341        engine,
1342        &receiver,
1343        &[
1344            ("preview", PEEK_PREVIEW, string()),
1345            ("encoding", PEEK_ENCODING, string()),
1346            ("lineEnding", PEEK_LINE_ENDING, string()),
1347            ("size", PEEK_SIZE, ValType::F64),
1348        ],
1349    )?;
1350    install_field_getters(
1351        linker,
1352        MODULE_NAME,
1353        "DirEntry",
1354        engine,
1355        &receiver,
1356        &[
1357            ("kind", ENTRY_KIND, string()),
1358            ("name", ENTRY_NAME, string()),
1359            ("path", ENTRY_PATH, string()),
1360            ("size", ENTRY_SIZE, ValType::F64),
1361        ],
1362    )?;
1363    install_field_getters(
1364        linker,
1365        MODULE_NAME,
1366        "Info",
1367        engine,
1368        &receiver,
1369        &[
1370            ("mode", INFO_MODE, string()),
1371            ("sizeLimit", INFO_SIZE_LIMIT, ValType::F64),
1372            ("access", INFO_ACCESS, string()),
1373            ("volume", INFO_VOLUME, string()),
1374            ("mounts", INFO_MOUNTS, array),
1375        ],
1376    )?;
1377    install_field_getters(
1378        linker,
1379        MODULE_NAME,
1380        "MountInfo",
1381        engine,
1382        &receiver,
1383        &[
1384            ("path", MOUNT_PATH, string()),
1385            ("mode", MOUNT_MODE, string()),
1386            ("volume", MOUNT_VOLUME, string()),
1387            ("access", MOUNT_ACCESS, string()),
1388            ("sizeLimit", MOUNT_SIZE_LIMIT, ValType::F64),
1389        ],
1390    )?;
1391    Ok(())
1392}
1393
1394fn install_file_writer_methods(
1395    linker: &mut Linker<StoreData>,
1396    engine: &wasmtime::Engine,
1397    intr: &IntrinsicTypes,
1398    receiver: ValType,
1399) -> wasmtime::Result<()> {
1400    let string = ValType::Ref(RefType::new(
1401        false,
1402        HeapType::ConcreteStruct(intr.string.clone()),
1403    ));
1404    let uint8 = ValType::Ref(RefType::new(
1405        false,
1406        HeapType::ConcreteStruct(intr.uint8_array.clone()),
1407    ));
1408    let writer_key = crate::mangle::package_symbol(MODULE_NAME, "FileWriter");
1409
1410    register_host_fn(
1411        linker,
1412        MODULE_NAME,
1413        crate::mangle::extend(&writer_key, "close"),
1414        FuncType::new(engine, [receiver.clone()], []),
1415        /* deterministic = */ false,
1416        |caller, params, _results| {
1417            writer_payload(caller, abi_arg(params, 0)?, "fs.writer.close")?
1418                .close()
1419                .map_err(|e| write_error("fs.writer.close", e))
1420        },
1421    )?;
1422
1423    register_host_fn(
1424        linker,
1425        MODULE_NAME,
1426        crate::mangle::extend(&writer_key, "writeLine"),
1427        FuncType::new(engine, [receiver.clone(), string], []),
1428        /* deterministic = */ false,
1429        |caller, params, _results| {
1430            let line = read_string_arg(&mut *caller, abi_arg(params, 1)?, "fs.writer.writeLine")?;
1431            fuel::charge(&mut *caller, fuel::IO, line.len() as u64 + 1)?;
1432            writer_payload(caller, abi_arg(params, 0)?, "fs.writer.writeLine")?
1433                .write_line(&line)
1434                .map_err(|e| write_error("fs.writer.writeLine", e))
1435        },
1436    )?;
1437
1438    register_host_fn(
1439        linker,
1440        MODULE_NAME,
1441        crate::mangle::extend(&writer_key, "writeBytes"),
1442        FuncType::new(engine, [receiver, uint8], []),
1443        /* deterministic = */ false,
1444        |caller, params, _results| {
1445            let bytes =
1446                read_uint8_array_arg(&mut *caller, abi_arg(params, 1)?, "fs.writer.writeBytes")?;
1447            fuel::charge(&mut *caller, fuel::IO, bytes.len() as u64)?;
1448            writer_payload(caller, abi_arg(params, 0)?, "fs.writer.writeBytes")?
1449                .write_bytes(&bytes)
1450                .map_err(|e| write_error("fs.writer.writeBytes", e))
1451        },
1452    )?;
1453
1454    Ok(())
1455}
1456
1457/// A size-limit refusal is a `QuotaExceededError` the program can catch, like every other
1458/// write that would pass it.
1459fn write_error(op: &str, err: WriteError) -> wasmtime::Error {
1460    match err {
1461        WriteError::Full(exceeded) => quota_exceeded_error(format!("{op}: {exceeded}")),
1462        WriteError::Io(e) => wasmtime::Error::msg(format!("{op}: {e}")),
1463        WriteError::Contain(e) => wasmtime::Error::msg(format!("{op}: {e}")),
1464    }
1465}
1466
1467/// Borrow the [`ChargedFileWriter`] out of a `$FileWriterBacking` receiver.
1468fn writer_payload<'a>(
1469    caller: &'a mut Caller<'_, StoreData>,
1470    receiver: &Val,
1471    ctx: &str,
1472) -> wasmtime::Result<&'a mut ChargedFileWriter> {
1473    let st = abi::backing_receiver(caller, receiver)?;
1474    let handle = match st.field(&mut *caller, WRITER_HANDLE)? {
1475        Val::ExternRef(Some(handle)) => handle,
1476        Val::ExternRef(None) => {
1477            return Err(wasmtime::Error::msg(format!(
1478                "{ctx}: writer handle is null"
1479            )));
1480        }
1481        other => {
1482            return Err(wasmtime::Error::msg(format!(
1483                "{ctx}: expected externref handle, got {other:?}"
1484            )));
1485        }
1486    };
1487    handle_payload::<ChargedFileWriter>(caller, &handle, ctx)
1488}
1489
1490// ---------------------------------------------------------------------------
1491// Shared helpers
1492// ---------------------------------------------------------------------------
1493
1494/// Reproduce a symlink at `to` with `from`'s target stored verbatim.
1495///
1496/// Verbatim is the point: the target is never resolved, so a link that escapes the root is
1497/// reproduced as one that still refuses rather than as a working door out of it.
1498fn copy_link(from: &LinkPath, to: &LinkPath) -> Result<(), ContainError> {
1499    to.symlink(&from.read_link_contents()?, from.link_kind()?)
1500}
1501
1502/// Whether an I/O error means "this path is not there", for the two operations declared
1503/// to answer that without trapping. `NotADirectory` counts: a file used as a path
1504/// component is a path that cannot exist, not an error to propagate.
1505fn is_absent(err: &std::io::Error) -> bool {
1506    matches!(
1507        err.kind(),
1508        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1509    )
1510}
1511
1512/// The capability check every fs call starts with, and the one metadata
1513/// syscall it costs at least (open, stat, readdir, unlink, rename); reads and
1514/// writes add their bytes on top.
1515fn gate(
1516    caller: &mut Caller<'_, StoreData>,
1517    capability: &str,
1518    context: serde_json::Value,
1519) -> wasmtime::Result<Option<CallTicket>> {
1520    let ticket = check_security_call(&mut *caller, capability, context)?;
1521    fuel::charge(&mut *caller, fuel::SYSCALL, 1)?;
1522    Ok(ticket)
1523}
1524
1525/// The guest-relative prefix a listing's entries carry: `""` at the root, else `"tree/"`.
1526fn list_prefix(cwd: &str, path: &str) -> wasmtime::Result<String> {
1527    let normalized = guest_normalize(cwd, path)
1528        .map_err(|e| contain_trap("fs.list", path, &ContainError::from(e)))?;
1529    Ok(if normalized == "/" {
1530        String::new()
1531    } else {
1532        format!("{}/", &normalized[1..])
1533    })
1534}
1535
1536fn f64_arg(val: &Val, ctx: &str) -> wasmtime::Result<f64> {
1537    match val {
1538        Val::F64(bits) => Ok(f64::from_bits(*bits)),
1539        other => Err(crate::runtime::host::type_error(format!(
1540            "{ctx}: expected f64, got {other:?}"
1541        ))),
1542    }
1543}
1544
1545fn i32_flag(val: &Val, ctx: &str) -> wasmtime::Result<bool> {
1546    match val {
1547        Val::I32(v) => Ok(*v != 0),
1548        other => Err(crate::runtime::host::type_error(format!(
1549            "{ctx}: expected i32 flag, got {other:?}"
1550        ))),
1551    }
1552}
1553
1554fn append_bytes(
1555    final_path: &ContentPath,
1556    bytes: &[u8],
1557    guest_path: &str,
1558    op: &str,
1559    quota: Option<Arc<DiskQuota>>,
1560) -> wasmtime::Result<()> {
1561    let appended = bytes.len() as u64;
1562    let before = final_path.file_len();
1563    let mut disk_charge = QuotaCharge::new(quota, None);
1564    disk_charge
1565        .reserve(appended)
1566        .map_err(|exceeded| quota_refusal(op, guest_path, exceeded))?;
1567    let result = final_path
1568        .append()
1569        .map_err(|e| write_target_trap(op, guest_path, &e))
1570        .and_then(|mut f| {
1571            f.write_all(bytes)
1572                .map_err(|e| wasmtime::Error::msg(format!("{op} {guest_path}: {e}")))
1573        });
1574    if result.is_err() {
1575        // A failed append may still have landed part of its bytes; keep those counted.
1576        let landed = final_path.file_len().saturating_sub(before).min(appended);
1577        disk_charge.unreserve(appended.saturating_sub(landed));
1578        disk_charge.keep();
1579        return result;
1580    }
1581    disk_charge.commit();
1582    result
1583}
1584
1585/// Register a file a reader opens, so a removal of its name keeps its bytes
1586/// counted until the reader closes.
1587fn hold_for_reading(
1588    quota: Option<&Arc<DiskQuota>>,
1589    file: &cap_std::fs::File,
1590) -> Option<OpenFileGuard> {
1591    let quota = quota?;
1592    let metadata = file.metadata().ok()?;
1593    Some(quota.hold(FileIdentity::of(&metadata).ok()?, Holder::Reader))
1594}
1595
1596/// A move between two volumes, which no rename can make: copy into a temporary
1597/// sibling of the destination (charged to the destination's limit), rename it into
1598/// place, then remove the source (released from the source's limit).
1599///
1600/// Until the rename, a failure removes the temporary copy and leaves the source
1601/// untouched. Once the destination is in place it is kept; if the source then
1602/// cannot be removed, the call fails saying both copies may exist. A link the copy
1603/// cannot reproduce fails the move, since the source is removed after.
1604fn move_across(
1605    caller: &mut Caller<'_, StoreData>,
1606    from: &LinkPath,
1607    to: &LinkPath,
1608    pair: &str,
1609    work: &mut CopyWork,
1610) -> wasmtime::Result<()> {
1611    let meta = from
1612        .symlink_metadata()
1613        .map_err(|e| contain_trap("fs.move", pair, &e))?;
1614    let is_dir = meta.is_dir();
1615    // The source is removed last, so a tree too deep or too large to remove, or
1616    // one holding a mount point, is refused now, before a copy of it lands in the
1617    // destination. The destination gets the checks its rename will run, so a
1618    // move that must fail does no copying first.
1619    meter_mutation(
1620        caller,
1621        MutationContext {
1622            op: "fs.move",
1623            path: pair,
1624            quota: None,
1625            changed: false,
1626        },
1627        |step| {
1628            crate::runtime::fs::removal::validate(from, &mut *step)?;
1629            crate::runtime::fs::removal::validate(to, step)
1630        },
1631    )?;
1632    refuse_unfitting_destination(to, is_dir, pair)?;
1633    let to_quota = to.placement().quota().cloned();
1634    let staged = to.temp_sibling();
1635    let run = CopyRun {
1636        recursive: true,
1637        links: LinkCopies::Required,
1638        op: "fs.move",
1639        pair,
1640        quota: to_quota.as_ref(),
1641    };
1642    let copied = copy_bounded(from, &staged, &run, work).and_then(|()| {
1643        let replaced = to.regular_file();
1644        meter_mutation(
1645            caller,
1646            MutationContext {
1647                op: "fs.move",
1648                path: pair,
1649                quota: None,
1650                changed: true,
1651            },
1652            |step| crate::runtime::fs::removal::rename(&staged, to, step),
1653        )?;
1654        if let (Some(quota), Some((file, bytes))) = (to_quota.as_ref(), replaced) {
1655            quota.release_file(file, bytes);
1656        }
1657        Ok(())
1658    });
1659    if let Err(err) = copied {
1660        // Best effort: what cannot be removed stays charged, so the count errs
1661        // toward refusing a later write.
1662        let _ = meter_mutation(
1663            caller,
1664            MutationContext {
1665                op: "fs.move",
1666                path: pair,
1667                quota: to_quota,
1668                changed: true,
1669            },
1670            |step| crate::runtime::fs::removal::remove(&staged, true, step),
1671        );
1672        return Err(err);
1673    }
1674    meter_mutation(
1675        caller,
1676        MutationContext {
1677            op: "fs.move",
1678            path: pair,
1679            quota: from.placement().quota().cloned(),
1680            changed: true,
1681        },
1682        |step| {
1683            crate::runtime::fs::removal::remove(from, true, step).map_err(|error| {
1684            if crate::runtime::host::ends_the_run(&error) { return error; }
1685            wasmtime::Error::msg(format!(
1686                "fs.move {pair}: moved to the destination but could not remove the source, so both may now exist: {error}"
1687            ))
1688        })
1689        },
1690    )
1691}
1692
1693/// Refuse a destination a rename of the source could not replace — a directory
1694/// in place of a file, a file in place of a directory, or a directory that isn't
1695/// empty — as a rename within one volume would, before any copying.
1696fn refuse_unfitting_destination(
1697    to: &LinkPath,
1698    moving_dir: bool,
1699    pair: &str,
1700) -> wasmtime::Result<()> {
1701    let existing = match to.symlink_metadata() {
1702        Ok(existing) => existing,
1703        Err(ContainError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
1704            return Ok(());
1705        }
1706        Err(err) => return Err(contain_trap("fs.move", pair, &err)),
1707    };
1708    let replacing_dir = existing.is_dir();
1709    if replacing_dir != moving_dir {
1710        let (what, onto) = if moving_dir {
1711            ("a directory", "something that is not a directory")
1712        } else {
1713            ("a file", "a directory")
1714        };
1715        wasmtime::bail!("fs.move {pair}: cannot move {what} onto {onto}");
1716    }
1717    if !replacing_dir {
1718        return Ok(());
1719    }
1720    let has_entries = to
1721        .entries()
1722        .map_err(|e| contain_trap("fs.move", pair, &e))?
1723        .next()
1724        .is_some();
1725    if has_entries {
1726        wasmtime::bail!("fs.move {pair}: the destination directory is not empty");
1727    }
1728    Ok(())
1729}
1730
1731struct MutationContext<'a> {
1732    op: &'static str,
1733    path: &'a str,
1734    quota: Option<Arc<DiskQuota>>,
1735    changed: bool,
1736}
1737
1738fn meter_mutation(
1739    caller: &mut Caller<'_, StoreData>,
1740    context: MutationContext<'_>,
1741    run: impl FnOnce(
1742        &mut dyn FnMut(crate::runtime::fs::removal::Step) -> wasmtime::Result<()>,
1743    ) -> wasmtime::Result<()>,
1744) -> wasmtime::Result<()> {
1745    use crate::runtime::fs::removal::Step;
1746    let mut memory = handles::ByteCharge::new(&caller.data().tenant_limits, 0)?;
1747    let mut changed = context.changed;
1748    let result = run(&mut |step| {
1749        match step {
1750            Step::Inspect(units) | Step::Mutate(units) if changed => {
1751                fuel::settle(&mut *caller, fuel::SYSCALL, units)?;
1752            }
1753            Step::Inspect(units) | Step::Mutate(units) => {
1754                fuel::charge(&mut *caller, fuel::SYSCALL, units)?;
1755            }
1756            Step::Reserve(bytes) => memory.grow(&caller.data().tenant_limits, bytes)?,
1757            Step::Unlinked(file) => {
1758                changed = true;
1759                if let (Some(quota), Some((file, bytes))) = (&context.quota, file) {
1760                    quota.release_file(file, bytes);
1761                }
1762            }
1763        }
1764        Ok(())
1765    });
1766    drop(memory);
1767    let result = result.map_err(|error| match error.downcast::<ContainError>() {
1768        Ok(error) => contain_trap(context.op, context.path, &error),
1769        Err(error) => error,
1770    });
1771    if changed {
1772        return fuel::settle_result(caller, |caller| {
1773            result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
1774        });
1775    }
1776    result
1777}
1778
1779/// How one copy walk treats its tree.
1780#[derive(Clone, Copy)]
1781struct CopyRun<'a> {
1782    recursive: bool,
1783    links: LinkCopies,
1784    /// `fs.copy` or `fs.move`, for diagnostics.
1785    op: &'a str,
1786    /// `from -> to` as the program wrote them, for diagnostics.
1787    pair: &'a str,
1788    /// The destination volume's limit.
1789    quota: Option<&'a Arc<DiskQuota>>,
1790}
1791
1792/// What a copy does with a link inside the tree that the platform will not
1793/// reproduce, such as an absolute one.
1794#[derive(Clone, Copy, PartialEq, Eq)]
1795enum LinkCopies {
1796    /// Skip it: a copy leaves the source in place, so nothing is lost.
1797    BestEffort,
1798    /// Fail: a move removes the source afterwards, which would lose the link.
1799    Required,
1800}
1801
1802/// Matches the entry-count limit used by removal.
1803const MAX_COPY_ENTRIES: u64 = MAX_REMOVE_ENTRIES as u64;
1804const MAX_COPY_DEPTH: usize = 64;
1805
1806#[derive(Default)]
1807struct CopyWork {
1808    entries: u64,
1809    bytes: u64,
1810}
1811
1812impl CopyWork {
1813    fn visit(&mut self, op: &str, pair: &str) -> wasmtime::Result<()> {
1814        if self.entries >= MAX_COPY_ENTRIES {
1815            return Err(crate::runtime::host::range_error(format!(
1816                "{op} {pair}: exceeds {MAX_COPY_ENTRIES} entries; copy smaller subtrees"
1817            )));
1818        }
1819        self.entries += 1;
1820        Ok(())
1821    }
1822
1823    fn settle(&self, caller: &mut Caller<'_, StoreData>) -> wasmtime::Result<()> {
1824        fuel::settle(&mut *caller, fuel::SYSCALL, self.entries)?;
1825        fuel::settle(caller, fuel::IO, self.bytes)
1826    }
1827}
1828
1829struct CopyFrame {
1830    from: LinkPath,
1831    to: LinkPath,
1832    from_dir: Arc<cap_std::fs::Dir>,
1833    to_dir: Arc<cap_std::fs::Dir>,
1834    entries: cap_std::fs::ReadDir,
1835}
1836
1837/// Depth-first traversal retains directory iterators instead of recursing or
1838/// collecting every sibling path. Symlinks are copied as links, never followed.
1839fn copy_bounded(
1840    from: &LinkPath,
1841    to: &LinkPath,
1842    run: &CopyRun<'_>,
1843    work: &mut CopyWork,
1844) -> wasmtime::Result<()> {
1845    let mut frames = Vec::new();
1846    if let Some(frame) = copy_entry(from, to, run, work, 0)? {
1847        frames
1848            .try_reserve(1)
1849            .map_err(crate::runtime::host::fatal_host_error)?;
1850        frames.push(frame);
1851    }
1852    while let Some(frame) = frames.last_mut() {
1853        let Some(entry) = frame.entries.next() else {
1854            frames.pop();
1855            continue;
1856        };
1857        let entry = entry.map_err(|e| contain_trap(run.op, run.pair, &ContainError::from(e)))?;
1858        let name = entry.file_name();
1859        let from = frame.from.child(Arc::clone(&frame.from_dir), name.clone());
1860        let to = frame.to.child(Arc::clone(&frame.to_dir), name);
1861        if let Some(child) = copy_entry(&from, &to, run, work, frames.len())? {
1862            frames
1863                .try_reserve(1)
1864                .map_err(crate::runtime::host::fatal_host_error)?;
1865            frames.push(child);
1866        }
1867    }
1868    Ok(())
1869}
1870
1871fn copy_entry(
1872    from: &LinkPath,
1873    to: &LinkPath,
1874    run: &CopyRun<'_>,
1875    work: &mut CopyWork,
1876    depth: usize,
1877) -> wasmtime::Result<Option<CopyFrame>> {
1878    let CopyRun {
1879        recursive,
1880        links,
1881        op,
1882        pair,
1883        quota,
1884    } = *run;
1885    work.visit(op, pair)?;
1886    let meta = from
1887        .symlink_metadata()
1888        .map_err(|e| contain_trap(op, pair, &e))?;
1889    let ft = meta.file_type();
1890    if ft.is_symlink() {
1891        let result = copy_link(from, to);
1892        // Only nested unrepresentable links are best-effort. Root failures and
1893        // every move failure retain their existing error behavior.
1894        if depth == 0 || links == LinkCopies::Required {
1895            result.map_err(|e| contain_trap(op, pair, &e))?;
1896        }
1897        return Ok(None);
1898    }
1899    if ft.is_dir() {
1900        if !recursive {
1901            wasmtime::bail!("{op} {pair}: source is a directory (use {{ recursive: true }})");
1902        }
1903        if depth >= MAX_COPY_DEPTH {
1904            return Err(crate::runtime::host::range_error(format!(
1905                "{op} {pair}: exceeds {MAX_COPY_DEPTH} directory levels; copy smaller subtrees"
1906            )));
1907        }
1908        to.create_dir_all()
1909            .map_err(|e| contain_trap(op, pair, &e))?;
1910        let from_dir = Arc::new(from.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1911        let to_dir = Arc::new(to.open_dir().map_err(|e| contain_trap(op, pair, &e))?);
1912        let entries = from_dir
1913            .entries()
1914            .map_err(|e| contain_trap(op, pair, &ContainError::from(e)))?;
1915        return Ok(Some(CopyFrame {
1916            from: from.clone(),
1917            to: to.clone(),
1918            from_dir,
1919            to_dir,
1920            entries,
1921        }));
1922    }
1923    // A copy may leave earlier files in place on error. Preserve quota claims
1924    // for whatever landed, and settle known completed bytes on either outcome.
1925    let mut disk_charge = QuotaCharge::in_place(quota.cloned(), to.copied_onto_file());
1926    disk_charge
1927        .reserve(meta.len())
1928        .map_err(|exceeded| quota_refusal(op, pair, exceeded))?;
1929    match from.copy_to_counted(to) {
1930        Ok(bytes) => {
1931            work.bytes = work.bytes.saturating_add(bytes);
1932            disk_charge.commit();
1933        }
1934        Err(e) => {
1935            disk_charge.settle_at(to.copied_onto_file().map_or(0, |(_, bytes)| bytes));
1936            return Err(contain_trap(op, pair, &e));
1937        }
1938    }
1939    Ok(None)
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944    use std::sync::Arc;
1945
1946    use crate::compile_script;
1947    use crate::runtime::security::{CheckOutcome, SecurityCheck};
1948    use crate::runtime::{
1949        RuntimeConfig, StoreData, Vfs, VfsInfo, VfsMode, dispatch_main_async, install_runtime_async,
1950    };
1951
1952    #[tokio::test]
1953    async fn subpath_aliases_move_copy_and_cwd() {
1954        use crate::runtime::{Access, DiskQuota, MountSpec};
1955        let volume = tempfile::tempdir().unwrap();
1956        let quota = Arc::new(DiskQuota::new(8, 0));
1957        let mut vfs = Vfs::tempdir().unwrap();
1958        for (guest, sub_path) in [
1959            ("/all", None),
1960            ("/personal", Some("users/ada")),
1961            ("/alias", Some("users/ada")),
1962        ] {
1963            vfs = vfs
1964                .with_mount_subpath(
1965                    MountSpec {
1966                        guest_path: guest.into(),
1967                        host: volume.path().into(),
1968                        volume: "notes".into(),
1969                        access: Access::ReadWrite,
1970                        quota: Some(Arc::clone(&quota)),
1971                    },
1972                    sub_path,
1973                )
1974                .unwrap();
1975        }
1976        let vfs = vfs.with_cwd("/personal").unwrap();
1977        let source = r#"
1978            import { cwd, writeText, readText, move, copy, remove, mkdir, list } from "submilli:fs";
1979            function fails(f: () => void): boolean { try { f(); return false; } catch { return true; } }
1980            function main(): void {
1981                assert(cwd() === "/personal", "cwd");
1982                writeText("a", "12345678");
1983                move("a", "/alias/a");
1984                assert(readText("a") === "12345678", "same-file move keeps content");
1985                assert(fails(() => copy("a", "/alias/a", false)), "self-copy refused");
1986                move("a", "/all/b");
1987                assert(readText("/all/b") === "12345678", "same-volume move at quota");
1988                assert(fails(() => remove("/all/users", true)), "aliased mount ancestor protected");
1989                mkdir("tree", true);
1990                assert(fails(() => copy("tree", "/alias/tree/child", true)), "descendant copy refused");
1991                let found = false;
1992                for (const entry of list(".", false)) { if (entry.name === "tree") found = entry.path === "/personal/tree"; }
1993                assert(found, "listing uses cwd");
1994            }
1995        "#;
1996        run_with(source, StoreData::with_vfs(vfs)).await.unwrap();
1997        assert_eq!(quota.used(), 8);
1998    }
1999
2000    #[tokio::test]
2001    async fn copy_merges_into_existing_mount_root() {
2002        let volume = tempfile::tempdir().unwrap();
2003        let vfs = mount_volume(
2004            Vfs::tempdir().unwrap(),
2005            "/notes",
2006            volume.path(),
2007            "notes",
2008            crate::runtime::Access::ReadWrite,
2009            None,
2010        );
2011        run_with(
2012            r#"
2013            import { mkdir, writeText, readText, copy } from "submilli:fs";
2014            function main(): void {
2015                mkdir("/tree", true);
2016                writeText("/tree/a", "hello");
2017                copy("/tree", "/notes", true);
2018                assert(readText("/notes/a") === "hello", "copy merges into mount root");
2019            }
2020        "#,
2021            StoreData::with_vfs(vfs),
2022        )
2023        .await
2024        .unwrap();
2025    }
2026
2027    #[test]
2028    fn copy_entry_and_depth_limits_preserve_completed_work() {
2029        use super::*;
2030        let root = tempfile::tempdir().unwrap();
2031        std::fs::create_dir(root.path().join("source")).unwrap();
2032        std::fs::write(root.path().join("source/file"), "content").unwrap();
2033        let vfs = Vfs::external(root.path().to_path_buf()).unwrap();
2034        let from = resolve_link(&vfs, "/", "/source").unwrap();
2035        let to = resolve_link(&vfs, "/", "/target").unwrap();
2036        let run = CopyRun {
2037            recursive: true,
2038            links: LinkCopies::Required,
2039            op: "fs.copy",
2040            pair: "source -> target",
2041            quota: None,
2042        };
2043        let mut work = CopyWork {
2044            entries: MAX_COPY_ENTRIES - 1,
2045            bytes: 0,
2046        };
2047        let error = copy_bounded(&from, &to, &run, &mut work).unwrap_err();
2048        assert!(error.to_string().contains("entries"));
2049        assert!(root.path().join("target").is_dir());
2050        assert!(!root.path().join("target/file").exists());
2051        assert_eq!(
2052            std::fs::read(root.path().join("source/file")).unwrap(),
2053            b"content"
2054        );
2055        let to = resolve_link(&vfs, "/", "/too-deep").unwrap();
2056        let error = copy_entry(&from, &to, &run, &mut CopyWork::default(), MAX_COPY_DEPTH)
2057            .err()
2058            .unwrap();
2059        assert!(error.to_string().contains("directory levels"));
2060        assert!(!root.path().join("too-deep").exists());
2061    }
2062
2063    #[tokio::test]
2064    async fn line_limit_is_catchable_and_following_reads_work() {
2065        let source = r#"
2066            import { writeText, lines } from "submilli:fs";
2067            function main(): void {
2068                writeText("/long", "12345"); writeText("/short", "ok\n");
2069                let caught = false;
2070                try { for (const line of lines("/long")) { assert(false, "over-limit line"); } }
2071                catch (e: RangeError) { caught = e.message.includes("maxReadSize"); }
2072                assert(caught, "line cap raises a typed error");
2073                let text = "";
2074                for (const line of lines("/short")) { text += line; }
2075                assert(text === "ok", "a later reader still works");
2076            }
2077        "#;
2078        let mut data = StoreData::with_vfs(Vfs::tempdir().unwrap());
2079        data.fs_max_read_size = 4;
2080        run_with(source, data).await.unwrap();
2081    }
2082
2083    struct DenyAllFs;
2084    impl SecurityCheck for DenyAllFs {
2085        fn check(
2086            &self,
2087            _caller: &str,
2088            capability: &str,
2089            _context: &serde_json::Value,
2090        ) -> CheckOutcome {
2091            if capability.starts_with("fs.") {
2092                CheckOutcome::Deny {
2093                    rule: None,
2094                    reason: format!("denied {capability} in test"),
2095                }
2096            } else {
2097                CheckOutcome::Allow { rule: None }
2098            }
2099        }
2100    }
2101
2102    #[tokio::test]
2103    async fn narrowed_host_carriers() {
2104        let source = r#"
2105import { info, stat, peek, list, writer, writeText, Info, MountInfo, Stat, Peek, DirEntry, FileWriter } from "submilli:fs";
2106
2107class Parent { value: unknown = null; reset(value: unknown): void { this.value = value; } }
2108function rejects(read: () => void): void {
2109 let caught = false;
2110 try { read(); } catch (e) { caught = e instanceof TypeError; }
2111 assert(caught, "unrelated carrier must throw TypeError");
2112}
2113
2114class InfoField extends Parent { value: Info = info(); }
2115class StatField extends Parent { value: Stat | null = null; }
2116class PeekField extends Parent { value: Peek | null = null; }
2117class EntryField extends Parent { value: DirEntry | null = null; }
2118class WriterField extends Parent { value: FileWriter | null = null; }
2119class MountField extends Parent { value: MountInfo | null = null; }
2120function main(): void {
2121 writeText("/input.txt", "text");
2122 const i = new InfoField(); assert(i.value.mode.length > 0, "Info");
2123 const s = new StatField(); s.reset(stat("/input.txt")); assert(s.value!.size === 4, "Stat");
2124 i.reset(s.value); rejects(() => { const v = i.value; });
2125 s.reset(info()); rejects(() => { const v = s.value; });
2126 const p = new PeekField(); p.reset(peek("/input.txt")); assert(p.value!.size === 4, "Peek");
2127 const e = new EntryField();
2128 for (const entry of list("/", false)) { if (entry.name === "input.txt") e.reset(entry); }
2129 assert(e.value!.name === "input.txt", "DirEntry");
2130 p.reset(e.value); rejects(() => { const v = p.value; });
2131 e.reset(peek("/input.txt")); rejects(() => { const v = e.value; });
2132 const w = new WriterField(); w.reset(writer("/output.txt")); w.value!.close();
2133 w.reset(info()); rejects(() => { const v = w.value; });
2134 const m = new MountField(); m.reset(info().mounts[0]); assert(m.value!.path === "/m", "MountInfo");
2135 m.reset(info()); rejects(() => { const v = m.value; });
2136 i.reset(info().mounts[0]); rejects(() => { const v = i.value; });
2137}
2138"#;
2139        let volume = tempfile::tempdir().unwrap();
2140        let vfs = mount_volume(
2141            Vfs::tempdir().unwrap(),
2142            "/m",
2143            volume.path(),
2144            "m",
2145            crate::runtime::vfs::Access::ReadWrite,
2146            None,
2147        );
2148        run_with(source, StoreData::with_vfs(vfs))
2149            .await
2150            .expect("host guards");
2151    }
2152
2153    #[tokio::test]
2154    async fn lines_iterator_closes_handle_on_loop_exit() {
2155        let source = r#"
2156            import { writeText, lines } from "submilli:fs";
2157            function main(): void {
2158                writeText("/lines.txt", "a\nb\nc\n");
2159                let count: number = 0;
2160                for (const line of lines("/lines.txt")) {
2161                    count = count + 1;
2162                }
2163                assert(count === 3, "iterated all 3 lines");
2164            }
2165        "#;
2166        run_and_assert_handles_empty(source).await;
2167    }
2168
2169    #[tokio::test]
2170    async fn lines_iterator_closes_handle_on_break() {
2171        let source = r#"
2172            import { writeText, lines } from "submilli:fs";
2173            function main(): void {
2174                writeText("/lines.txt", "a\nb\nc\nd\ne\n");
2175                let count: number = 0;
2176                for (const line of lines("/lines.txt")) {
2177                    count = count + 1;
2178                    if (count === 2) {
2179                        break;
2180                    }
2181                }
2182                assert(count === 2, "broke after 2 lines");
2183            }
2184        "#;
2185        run_and_assert_handles_empty(source).await;
2186    }
2187
2188    #[tokio::test]
2189    async fn bytes_iterator_closes_handle_on_break() {
2190        let source = r#"
2191            import { write, bytes } from "submilli:fs";
2192            function main(): void {
2193                const payload: Uint8Array = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
2194                write("/blob.bin", payload);
2195                let chunks: number = 0;
2196                for (const chunk of bytes("/blob.bin", 2)) {
2197                    chunks = chunks + 1;
2198                    if (chunks === 2) {
2199                        break;
2200                    }
2201                }
2202                assert(chunks === 2, "broke after 2 chunks");
2203            }
2204        "#;
2205        run_and_assert_handles_empty(source).await;
2206    }
2207
2208    #[tokio::test]
2209    async fn list_iterator_closes_handle_on_break() {
2210        let source = r#"
2211            import { mkdir, writeText, list, DirEntry } from "submilli:fs";
2212            function main(): void {
2213                mkdir("/d", false);
2214                writeText("/d/a.txt", "a");
2215                writeText("/d/b.txt", "b");
2216                writeText("/d/c.txt", "c");
2217                let count: number = 0;
2218                for (const entry of list("/d", false)) {
2219                    count = count + 1;
2220                    if (count === 1) {
2221                        break;
2222                    }
2223                }
2224                assert(count === 1, "broke after first entry");
2225            }
2226        "#;
2227        run_and_assert_handles_empty(source).await;
2228    }
2229
2230    #[tokio::test]
2231    async fn lines_iterator_closes_handle_on_throw() {
2232        let source = r#"
2233            import { writeText, lines } from "submilli:fs";
2234            function main(): void {
2235                writeText("/lines.txt", "alpha\nbeta\ngamma\n");
2236                let caught: boolean = false;
2237                try {
2238                    for (const line of lines("/lines.txt")) {
2239                        if (line === "beta") {
2240                            throw new Error("unwind from loop");
2241                        }
2242                    }
2243                } catch (e: Error) {
2244                    caught = true;
2245                }
2246                assert(caught, "outer catch saw the throw");
2247            }
2248        "#;
2249        run_and_assert_handles_empty(source).await;
2250    }
2251
2252    /// The for-of `finally` closes every iterator, and eager `close()` refunds the
2253    /// reader's charged bytes immediately. So after a clean run the store's
2254    /// host-attached counter must be back to zero — the externref-era equivalent of
2255    /// "the handle map is empty".
2256    async fn run_and_assert_handles_empty(source: &str) {
2257        let compiled =
2258            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2259        let cfg = RuntimeConfig::default();
2260        let engine = cfg.engine().expect("engine");
2261        let data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2262        let mut store = cfg.store(&engine, data).expect("store");
2263        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2264        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2265        install_runtime_async(&mut linker, &mut store)
2266            .await
2267            .expect("install");
2268        let inst = linker
2269            .instantiate_async(&mut store, &module)
2270            .await
2271            .expect("instantiate");
2272        dispatch_main_async(&mut store, &inst)
2273            .await
2274            .expect("main ran without trap");
2275        let live = store.data().tenant_limits.host_attached_bytes();
2276        assert_eq!(
2277            live, 0,
2278            "host-attached bytes should be refunded after for-of finally — got {live}",
2279        );
2280    }
2281
2282    struct RecordingCheck {
2283        seen: std::sync::Mutex<Vec<(String, String)>>,
2284    }
2285    impl SecurityCheck for RecordingCheck {
2286        fn check(
2287            &self,
2288            caller: &str,
2289            capability: &str,
2290            _context: &serde_json::Value,
2291        ) -> CheckOutcome {
2292            self.seen
2293                .lock()
2294                .unwrap()
2295                .push((caller.to_string(), capability.to_string()));
2296            CheckOutcome::Allow { rule: None }
2297        }
2298    }
2299
2300    struct ReadContextCheck {
2301        seen: std::sync::Mutex<Vec<serde_json::Value>>,
2302    }
2303    impl SecurityCheck for ReadContextCheck {
2304        fn check(
2305            &self,
2306            _caller: &str,
2307            capability: &str,
2308            context: &serde_json::Value,
2309        ) -> CheckOutcome {
2310            if capability == "fs.read" {
2311                self.seen.lock().unwrap().push(context.clone());
2312            }
2313            CheckOutcome::Allow { rule: None }
2314        }
2315    }
2316
2317    #[tokio::test]
2318    async fn read_bytes_capability_omits_offset() {
2319        let source = r#"
2320            import { writeText, readBytes } from "submilli:fs";
2321            function main(): void {
2322                writeText("/x.txt", "abcdef");
2323                const bytes = readBytes("/x.txt", 2, 3);
2324                assert(bytes.length === 3, "read requested range");
2325            }
2326        "#;
2327        let recording = Arc::new(ReadContextCheck {
2328            seen: std::sync::Mutex::new(Vec::new()),
2329        });
2330        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2331        data.security_check = recording.clone();
2332        run_with(source, data).await.expect("main ran");
2333
2334        let seen = recording.seen.lock().unwrap();
2335        let context = seen
2336            .iter()
2337            .find(|context| context.get("length").is_some())
2338            .expect("readBytes capability context");
2339        assert_eq!(context["path"], "/x.txt");
2340        assert_eq!(context["length"], 3);
2341        assert!(
2342            context.get("offset").is_none(),
2343            "read offset is transport mechanics, not capability context: {context}"
2344        );
2345    }
2346
2347    #[tokio::test]
2348    async fn a_script_is_attributed_to_main() {
2349        let source = r#"
2350            import { writeText, readText } from "submilli:fs";
2351            function main(): void {
2352                writeText("/x.txt", "hi");
2353                const _back = readText("/x.txt");
2354            }
2355        "#;
2356        let recording = Arc::new(RecordingCheck {
2357            seen: std::sync::Mutex::new(Vec::new()),
2358        });
2359        let compiled =
2360            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2361        let cfg = RuntimeConfig::default();
2362        let engine = cfg.engine().expect("engine");
2363        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2364        data.security_check = recording.clone();
2365        let mut store = cfg.store(&engine, data).expect("store");
2366        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2367        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2368        install_runtime_async(&mut linker, &mut store)
2369            .await
2370            .expect("install");
2371        let inst = linker
2372            .instantiate_async(&mut store, &module)
2373            .await
2374            .expect("instantiate");
2375        dispatch_main_async(&mut store, &inst)
2376            .await
2377            .expect("main ran");
2378
2379        let seen = recording.seen.lock().unwrap().clone();
2380        assert!(
2381            !seen.is_empty(),
2382            "RecordingCheck should have captured at least one fs.* call"
2383        );
2384        for (caller, capability) in &seen {
2385            assert_eq!(
2386                caller, "main",
2387                "expected caller=main for capability {capability}; got {caller}"
2388            );
2389        }
2390    }
2391
2392    /// Simulates a library-above-main call to verify entry-below-top caller semantics.
2393    #[tokio::test]
2394    async fn a_package_is_attributed_to_the_package() {
2395        let source = r#"
2396            import { writeText, readText } from "submilli:fs";
2397            function main(): void {
2398                writeText("/x.txt", "hi");
2399                const _back = readText("/x.txt");
2400            }
2401        "#;
2402        let recording = Arc::new(RecordingCheck {
2403            seen: std::sync::Mutex::new(Vec::new()),
2404        });
2405        // Compiled under the package name so the identity rides the wasm frame the runtime
2406        // reads, rather than being declared out-of-band.
2407        let compiled = crate::compile::compile_script_owned_by(
2408            "submilli:url",
2409            source,
2410            "test.subm",
2411            crate::FileId(0),
2412            &[],
2413            &[],
2414        )
2415        .expect("compile clean");
2416        let cfg = RuntimeConfig::default();
2417        let engine = cfg.engine().expect("engine");
2418        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2419        data.security_check = recording.clone();
2420        let mut store = cfg.store(&engine, data).expect("store");
2421        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2422        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2423        install_runtime_async(&mut linker, &mut store)
2424            .await
2425            .expect("install");
2426        let inst = linker
2427            .instantiate_async(&mut store, &module)
2428            .await
2429            .expect("instantiate");
2430        dispatch_main_async(&mut store, &inst)
2431            .await
2432            .expect("main ran");
2433
2434        let seen = recording.seen.lock().unwrap().clone();
2435        assert!(
2436            !seen.is_empty(),
2437            "RecordingCheck should have captured calls"
2438        );
2439        for (caller, capability) in &seen {
2440            assert_eq!(
2441                caller, "submilli:url",
2442                "expected caller=submilli:url for capability {capability}; got {caller}"
2443            );
2444        }
2445    }
2446
2447    #[tokio::test]
2448    async fn deny_policy_throws_catchable_permission_denied() {
2449        let source = r#"
2450            import { writeText } from "submilli:fs";
2451            function main(): string {
2452                try {
2453                    writeText("/x.txt", "hi");
2454                } catch (e: PermissionDeniedError) {
2455                    return e.capability + "|" + e.caller + "|" + e.reason;
2456                }
2457                return "not denied";
2458            }
2459        "#;
2460        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2461        data.security_check = Arc::new(DenyAllFs);
2462        let value = run_with(source, data)
2463            .await
2464            .expect("denial is caught in user code — main must not trap");
2465        assert_eq!(
2466            value.as_deref(),
2467            Some("fs.write|main|denied fs.write in test")
2468        );
2469    }
2470
2471    /// Run `source` with the supplied `StoreData`, returning the dispatch result
2472    /// so callers can assert success (asserts held) or a trap.
2473    async fn run_with(source: &str, data: StoreData) -> wasmtime::Result<Option<String>> {
2474        run_with_fuel(source, data, None).await
2475    }
2476
2477    async fn run_with_fuel(
2478        source: &str,
2479        data: StoreData,
2480        fuel: Option<u64>,
2481    ) -> wasmtime::Result<Option<String>> {
2482        let compiled =
2483            compile_script(source, "test.subm", crate::FileId(0), &[], &[]).expect("compile clean");
2484        let cfg = RuntimeConfig::default();
2485        let engine = cfg.engine().expect("engine");
2486        let mut store = cfg.store(&engine, data).expect("store");
2487        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
2488        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
2489        install_runtime_async(&mut linker, &mut store)
2490            .await
2491            .expect("install");
2492        let inst = linker
2493            .instantiate_async(&mut store, &module)
2494            .await
2495            .expect("instantiate");
2496        if let Some(fuel) = fuel {
2497            store.set_fuel(fuel).unwrap();
2498        }
2499        dispatch_main_async(&mut store, &inst).await
2500    }
2501
2502    #[tokio::test]
2503    async fn recursive_remove_finishes_after_fuel_runs_out_following_an_unlink() {
2504        let root = tempfile::tempdir().unwrap();
2505        std::fs::create_dir(root.path().join("tree")).unwrap();
2506        for index in 0..4 {
2507            std::fs::write(root.path().join(format!("tree/file{index}")), "x").unwrap();
2508        }
2509        let vfs = Vfs::external(root.path().to_path_buf()).unwrap();
2510        let result = run_with_fuel(
2511            r#"import { remove, exists } from "submilli:fs";
2512            function main(): void { remove("/tree", true); assert(!exists("/tree")); }"#,
2513            StoreData::with_vfs(vfs.clone()),
2514            Some(9000),
2515        )
2516        .await;
2517        assert!(matches!(
2518            result.unwrap_err().downcast_ref::<wasmtime::Trap>(),
2519            Some(wasmtime::Trap::OutOfFuel)
2520        ));
2521        assert!(
2522            !root.path().join("tree").exists(),
2523            "effects must finish before the next instruction stops"
2524        );
2525        run_with(r#"import { writeText, readText } from "submilli:fs";
2526            function main(): void { writeText("/healthy", "ok"); assert(readText("/healthy") === "ok"); }"#,
2527            StoreData::with_vfs(vfs)).await.unwrap();
2528    }
2529
2530    #[tokio::test]
2531    async fn none_mode_disables_fs_calls() {
2532        let source = r#"
2533            import { writeText } from "submilli:fs";
2534            function main(): void {
2535                writeText("/x.txt", "hi");
2536            }
2537        "#;
2538        let data = StoreData::with_vfs(Vfs::none());
2539        let err = run_with(source, data)
2540            .await
2541            .expect_err("none mode must trap fs calls");
2542        let msg = format!("{err:?}");
2543        assert!(
2544            msg.contains("filesystem is disabled"),
2545            "expected disabled-fs trap; got: {msg}"
2546        );
2547    }
2548
2549    #[tokio::test]
2550    async fn info_reports_mode_and_limits() {
2551        let source = r#"
2552            import { info, Info } from "submilli:fs";
2553            function main(): void {
2554                const fs = info();
2555                assert(fs.mode === "per_session", "mode surfaced");
2556                assert(fs.sizeLimit === 1048576, "size limit surfaced");
2557            }
2558        "#;
2559        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
2560        data.vfs_info = VfsInfo {
2561            mode: VfsMode::PerSession,
2562            size_limit: Some(1024 * 1024),
2563        };
2564        run_with(source, data).await.expect("info asserts hold");
2565    }
2566
2567    fn limited(limit: u64) -> StoreData {
2568        let vfs = Vfs::tempdir().expect("tempdir").with_size_limit(limit);
2569        let mut data = StoreData::with_vfs(vfs);
2570        data.vfs_info.size_limit = Some(limit);
2571        data
2572    }
2573
2574    #[tokio::test]
2575    async fn size_limit_refuses_growth_but_allows_rewrites_within_it() {
2576        let source = r#"
2577            import { writeText, readText, append, info } from "submilli:fs";
2578            function refused(write: () => void): boolean {
2579                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2580            }
2581            function main(): void {
2582                assert(info().sizeLimit === 100, "limit surfaced");
2583                writeText("/a.txt", "x".repeat(60));
2584                assert(refused(() => writeText("/b.txt", "y".repeat(60))), "a second file past the limit");
2585                writeText("/a.txt", "z".repeat(90));
2586                assert(readText("/a.txt") === "z".repeat(90), "a rewrite that ends within the limit");
2587                assert(refused(() => append("/a.txt", new Uint8Array(20))), "an append past the limit");
2588                writeText("/a.txt", "");
2589                writeText("/b.txt", "y".repeat(100));
2590            }
2591        "#;
2592        run_with(source, limited(100))
2593            .await
2594            .expect("limit asserts hold");
2595    }
2596
2597    #[tokio::test]
2598    async fn size_limit_stops_a_writer_and_frees_its_temp_file() {
2599        let source = r#"
2600            import { writer, writeText, exists } from "submilli:fs";
2601            function main(): void {
2602                const w = writer("/log.txt");
2603                w.writeLine("x".repeat(40));
2604                let refused = false;
2605                try { w.writeLine("y".repeat(80)); } catch (e) { refused = e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2606                assert(refused, "the writer stops at the limit");
2607                w.close();
2608                assert(exists("/log.txt"), "what was written before the limit is kept");
2609                writeText("/other.txt", "z".repeat(59));
2610            }
2611        "#;
2612        run_with(source, limited(100))
2613            .await
2614            .expect("writer asserts hold");
2615    }
2616
2617    #[tokio::test]
2618    async fn size_limit_stops_a_copy_at_the_file_that_would_pass_it() {
2619        let source = r#"
2620            import { writeText, copy, exists } from "submilli:fs";
2621            function main(): void {
2622                writeText("/a.txt", "x".repeat(40));
2623                copy("/a.txt", "/b.txt", false);
2624                let refused = false;
2625                try { copy("/a.txt", "/c.txt", false); } catch (e) { refused = e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2626                assert(refused, "a third copy passes the limit");
2627                assert(!exists("/c.txt"), "the refused copy leaves nothing");
2628                copy("/b.txt", "/a.txt", false);
2629            }
2630        "#;
2631        run_with(source, limited(100))
2632            .await
2633            .expect("copy asserts hold");
2634    }
2635
2636    #[tokio::test]
2637    async fn size_limit_counts_what_remove_and_move_free() {
2638        let source = r#"
2639            import { writeText, remove, move } from "submilli:fs";
2640            function refused(write: () => void): boolean {
2641                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2642            }
2643            function main(): void {
2644                writeText("/a.txt", "x".repeat(60));
2645                remove("/a.txt", false);
2646                writeText("/b.txt", "y".repeat(60));
2647                writeText("/c.txt", "z".repeat(30));
2648                move("/c.txt", "/b.txt");
2649                writeText("/d.txt", "w".repeat(70));
2650                move("/d.txt", "/d.txt");
2651                assert(refused(() => writeText("/e.txt", "v".repeat(1))), "a move onto itself frees nothing");
2652            }
2653        "#;
2654        run_with(source, limited(100))
2655            .await
2656            .expect("remove and move free space");
2657    }
2658
2659    #[tokio::test]
2660    async fn size_limit_counts_a_writer_whose_target_shrinks_mid_write() {
2661        let source = r#"
2662            import { writer, writeText } from "submilli:fs";
2663            function refused(write: () => void): boolean {
2664                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2665            }
2666            function main(): void {
2667                writeText("/a.txt", "x".repeat(90));
2668                const w = writer("/a.txt");
2669                writeText("/a.txt", "");
2670                w.writeBytes(new Uint8Array(90));
2671                w.close();
2672                assert(refused(() => writeText("/b.txt", "y".repeat(90))), "the writer's 90 bytes still count");
2673            }
2674        "#;
2675        run_with(source, limited(100))
2676            .await
2677            .expect("stale allowance is not honoured");
2678    }
2679
2680    #[tokio::test]
2681    async fn size_limit_counts_every_writer_on_one_path() {
2682        let source = r#"
2683            import { writer, writeText } from "submilli:fs";
2684            function main(): void {
2685                writeText("/a.txt", "x".repeat(60));
2686                const first = writer("/a.txt");
2687                first.writeBytes(new Uint8Array(30));
2688                const second = writer("/a.txt");
2689                let refused = false;
2690                try { second.writeBytes(new Uint8Array(30)); } catch (e) { refused = e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2691                assert(refused, "two open writers' bytes both count");
2692                first.close();
2693                second.close();
2694            }
2695        "#;
2696        run_with(source, limited(100))
2697            .await
2698            .expect("each writer is counted");
2699    }
2700
2701    #[tokio::test]
2702    async fn a_directory_over_its_limit_can_be_cleaned_up() {
2703        let dir = tempfile::tempdir().expect("tempdir");
2704        std::fs::write(dir.path().join("big.txt"), vec![b'x'; 200]).expect("seed");
2705        let vfs = Vfs::external_with_mode(dir.path().to_path_buf(), VfsMode::PerSession)
2706            .expect("external")
2707            .with_size_limit(100);
2708        let source = r#"
2709            import { writeText, remove } from "submilli:fs";
2710            function main(): void {
2711                writeText("/big.txt", "");
2712                writeText("/big.txt", "x".repeat(10));
2713                remove("/big.txt", false);
2714                writeText("/after.txt", "y".repeat(100));
2715            }
2716        "#;
2717        run_with(source, StoreData::with_vfs(vfs))
2718            .await
2719            .expect("truncating and removing recover the limit");
2720    }
2721
2722    #[tokio::test]
2723    async fn a_writer_temp_file_moved_away_stays_counted() {
2724        let source = r#"
2725            import { writer, list, move, writeText } from "submilli:fs";
2726            function main(): void {
2727                const w = writer("/w.bin");
2728                w.writeBytes(new Uint8Array(60));
2729                let temp = "";
2730                for (const e of list("/", false)) { if (e.name.startsWith("w.bin.")) { temp = e.path; } }
2731                move(temp, "/kept.bin");
2732                let failed = false;
2733                try { w.close(); } catch (e) { failed = true; }
2734                assert(failed, "close notices its temp file was moved");
2735                let refused = false;
2736                try { writeText("/x.txt", "y".repeat(50)); } catch (e) { refused = e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2737                assert(refused, "the moved bytes still count");
2738            }
2739        "#;
2740        run_with(source, limited(100))
2741            .await
2742            .expect("moved temp stays counted");
2743    }
2744
2745    // Relies on a removed file's link count reaching 0 while the writer holds it,
2746    // which Windows may report differently; there the count only over-counts.
2747    #[cfg(unix)]
2748    #[tokio::test]
2749    async fn a_writer_leaves_a_file_the_program_put_at_its_temp_name() {
2750        let source = r#"
2751            import { writer, list, remove, writeText, exists } from "submilli:fs";
2752            function main(): void {
2753                const w = writer("/w.bin");
2754                w.writeBytes(new Uint8Array(40000));
2755                let temp = "";
2756                for (const e of list("/", false)) { if (e.name.startsWith("w.bin.")) { temp = e.path; } }
2757                remove(temp, false);
2758                writeText(temp, "");
2759                let failed = false;
2760                try { w.close(); } catch (e) { failed = true; }
2761                assert(failed, "close notices its temp file was replaced");
2762                assert(exists(temp), "the program's own file is left alone");
2763                writeText("/x.bin", "y".repeat(100000));
2764            }
2765        "#;
2766        run_with(source, limited(100000))
2767            .await
2768            .expect("a replaced temp file is neither committed nor double-freed");
2769    }
2770
2771    #[tokio::test]
2772    async fn an_unmeasured_directory_opens_as_full() {
2773        let vfs = Vfs::tempdir()
2774            .expect("tempdir")
2775            .with_measured_limit(100, None);
2776        let source = r#"
2777            import { writeText, remove } from "submilli:fs";
2778            function main(): string {
2779                writeText("/empty.txt", "");
2780                remove("/empty.txt", false);
2781                try { writeText("/a.txt", "x"); return "allowed"; }
2782                catch (e) { return (e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError) ? "true " : "false ") + (e as Error).message; }
2783            }
2784        "#;
2785        let result = run_with(source, StoreData::with_vfs(vfs))
2786            .await
2787            .expect("runs")
2788            .expect("a result");
2789        assert!(result.starts_with("true "), "{result}");
2790        assert!(result.contains("couldn't be measured"), "{result}");
2791    }
2792
2793    #[tokio::test]
2794    async fn size_limit_counts_what_a_recursive_remove_frees() {
2795        let source = r#"
2796            import { mkdir, writeText, remove } from "submilli:fs";
2797            function main(): void {
2798                mkdir("/d/e", true);
2799                writeText("/d/e/a.txt", "x".repeat(60));
2800                writeText("/d/b.txt", "y".repeat(30));
2801                remove("/d", true);
2802                writeText("/c.txt", "z".repeat(100));
2803            }
2804        "#;
2805        run_with(source, limited(100))
2806            .await
2807            .expect("a recursive remove frees its whole tree");
2808    }
2809
2810    #[tokio::test]
2811    async fn a_removed_file_a_reader_holds_counts_until_the_reader_closes() {
2812        let source = r#"
2813            import { writeText, bytes, remove } from "submilli:fs";
2814            function refused(write: () => void): boolean {
2815                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2816            }
2817            function main(): void {
2818                writeText("/f.bin", "x".repeat(90));
2819                for (const chunk of bytes("/f.bin", 1)) {
2820                    remove("/f.bin", false);
2821                    assert(refused(() => writeText("/g.txt", "y".repeat(20))), "the open file's bytes still count");
2822                    break;
2823                }
2824                writeText("/g.txt", "y".repeat(100));
2825            }
2826        "#;
2827        run_with(source, limited(100))
2828            .await
2829            .expect("a closed reader frees what its removed file held");
2830    }
2831
2832    #[tokio::test]
2833    async fn a_write_over_a_writers_temp_file_is_counted_in_full() {
2834        let source = r#"
2835            import { writer, list, writeText } from "submilli:fs";
2836            function refused(write: () => void): boolean {
2837                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2838            }
2839            function main(): void {
2840                const w = writer("/a.bin");
2841                w.writeBytes(new Uint8Array(40000));
2842                let temp = "";
2843                for (const e of list("/", false)) { if (e.name.startsWith("a.bin.")) { temp = e.path; } }
2844                writeText(temp, "x".repeat(40000));
2845                let failed = false;
2846                try { w.close(); } catch (e) { failed = true; }
2847                assert(failed, "close notices its temp file was replaced");
2848                assert(refused(() => writeText("/b.txt", "y".repeat(60001))), "the 40000 at the temp name count");
2849                writeText("/b.txt", "y".repeat(60000));
2850            }
2851        "#;
2852        run_with(source, limited(100000))
2853            .await
2854            .expect("the replaced temp file's bytes are counted exactly once");
2855    }
2856
2857    #[tokio::test]
2858    async fn rewriting_a_file_a_reader_holds_counts_both_copies() {
2859        let source = r#"
2860            import { writeText, bytes } from "submilli:fs";
2861            function refused(write: () => void): boolean {
2862                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2863            }
2864            function main(): void {
2865                writeText("/f.bin", "x".repeat(40000));
2866                for (const chunk of bytes("/f.bin", 1)) {
2867                    writeText("/f.bin", "z".repeat(40000));
2868                    assert(refused(() => writeText("/g.txt", "y".repeat(20001))), "the old copy is still on disk");
2869                    break;
2870                }
2871                writeText("/g.txt", "y".repeat(60000));
2872            }
2873        "#;
2874        run_with(source, limited(100000))
2875            .await
2876            .expect("the old copy is freed when the reader closes");
2877    }
2878
2879    #[tokio::test]
2880    async fn copying_over_a_file_a_reader_holds_counts_only_the_growth() {
2881        let source = r#"
2882            import { writeText, copy, bytes } from "submilli:fs";
2883            function refused(write: () => void): boolean {
2884                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2885            }
2886            function main(): void {
2887                writeText("/f.bin", "x".repeat(40000));
2888                writeText("/src.bin", "z".repeat(40000));
2889                for (const chunk of bytes("/f.bin", 1)) {
2890                    copy("/src.bin", "/f.bin", false);
2891                    writeText("/g.txt", "y".repeat(20000));
2892                    assert(refused(() => writeText("/h.txt", "w")), "the limit is reached");
2893                    break;
2894                }
2895            }
2896        "#;
2897        run_with(source, limited(100000))
2898            .await
2899            .expect("a copy rewrites the held file in place");
2900    }
2901
2902    #[cfg(unix)]
2903    #[tokio::test]
2904    async fn copying_onto_a_link_frees_the_file_it_rewrites() {
2905        let vfs = Vfs::tempdir().expect("tempdir");
2906        std::fs::write(vfs.root().join("t.bin"), vec![b't'; 50000]).expect("write");
2907        std::os::unix::fs::symlink("t.bin", vfs.root().join("l")).expect("symlink");
2908        let vfs = vfs.with_size_limit(100000);
2909        let mut data = StoreData::with_vfs(vfs);
2910        data.vfs_info.size_limit = Some(100000);
2911        let source = r#"
2912            import { writeText, copy } from "submilli:fs";
2913            function main(): void {
2914                writeText("/s.bin", "0123456789");
2915                copy("/s.bin", "/l", false);
2916                writeText("/g.txt", "y".repeat(99980));
2917            }
2918        "#;
2919        run_with(source, data)
2920            .await
2921            .expect("the link's target shrank to the copied 10 bytes");
2922    }
2923
2924    #[tokio::test]
2925    async fn copying_over_a_writers_temp_file_leaves_it_for_the_writer_to_settle() {
2926        let source = r#"
2927            import { writeText, writer, list, copy, mkdir } from "submilli:fs";
2928            function refused(write: () => void): boolean {
2929                try { write(); return false; } catch (e) { return e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
2930            }
2931            function main(): void {
2932                writeText("/empty", "");
2933                writeText("/keep.bin", "k".repeat(20000));
2934                const w = writer("/a.bin");
2935                w.writeBytes(new Uint8Array(40000));
2936                let temp = "";
2937                for (const e of list("/", false)) {
2938                    if (e.name.startsWith("a.bin.")) { temp = e.path; }
2939                }
2940                copy("/empty", temp, false);
2941                mkdir("/a.bin", false);
2942                writeText("/a.bin/x", "1");
2943                let closed = true;
2944                try { w.close(); } catch (e) { closed = false; }
2945                assert(!closed, "close fails on the directory");
2946                assert(refused(() => writeText("/big.bin", "y".repeat(80000))), "20001 bytes remain on disk");
2947                writeText("/big.bin", "y".repeat(79999));
2948            }
2949        "#;
2950        run_with(source, limited(100000))
2951            .await
2952            .expect("the writer's temp file is freed once, when the writer discards it");
2953    }
2954
2955    fn mount_volume(
2956        vfs: Vfs,
2957        guest: &str,
2958        host: &std::path::Path,
2959        volume: &str,
2960        access: crate::runtime::vfs::Access,
2961        quota: Option<Arc<crate::runtime::DiskQuota>>,
2962    ) -> Vfs {
2963        vfs.with_mount(crate::runtime::vfs::MountSpec {
2964            guest_path: guest.to_string(),
2965            host: host.to_path_buf(),
2966            volume: volume.to_string(),
2967            access,
2968            quota,
2969        })
2970        .expect("mount")
2971    }
2972
2973    #[tokio::test]
2974    async fn mount_routes_its_paths_to_the_volume() {
2975        let volume = tempfile::tempdir().expect("volume");
2976        std::fs::write(volume.path().join("kept.txt"), "kept").expect("seed");
2977        let session = tempfile::tempdir().expect("session");
2978        let vfs = mount_volume(
2979            Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
2980                .expect("root"),
2981            "/data/memory",
2982            volume.path(),
2983            "project-memory",
2984            crate::runtime::vfs::Access::ReadWrite,
2985            Some(Arc::new(crate::runtime::DiskQuota::new(1000, 4))),
2986        );
2987        let root = vfs.root().to_path_buf();
2988        let source = r#"
2989            import { info, readText, writeText, list, stat, exists } from "submilli:fs";
2990            function main(): void {
2991                assert(readText("/data/memory/kept.txt") === "kept", "reads the volume");
2992                writeText("/data/memory/new.txt", "fresh");
2993                assert(readText("/data/memory/../memory/new.txt") === "fresh", ".. resolves before routing");
2994                assert(!exists("/data/memoryx/new.txt"), "a sibling prefix is not the mount");
2995                assert(stat("/data/memory")!.kind === "directory", "the mount point is a directory");
2996                const names: string[] = [];
2997                for (const entry of list("/data", false)) { names.push(entry.name); }
2998                assert(names.length === 1 && names[0] === "memory", "the parent lists the mount point");
2999                const paths: string[] = [];
3000                for (const entry of list("/", true)) { paths.push(entry.path); }
3001                assert(paths.includes("/data/memory/kept.txt"), "a recursive walk descends into the volume");
3002                assert(paths.includes("/data/memory/new.txt"), "and sees new files");
3003                const fs = info();
3004                assert(fs.access === "read_write" && fs.volume === "", "root info");
3005                assert(fs.mounts.length === 1, "one mount");
3006                const mount = fs.mounts[0];
3007                assert(mount.path === "/data/memory" && mount.mode === "named", "mount path and mode");
3008                assert(mount.volume === "project-memory" && mount.access === "read_write", "mount volume and access");
3009                assert(mount.sizeLimit === 1000, "mount size limit");
3010            }
3011        "#;
3012        let data = StoreData::with_vfs(vfs);
3013        let quota = data.vfs.mounts()[0].quota().cloned().expect("quota");
3014        run_with(source, data).await.expect("mount asserts hold");
3015        assert_eq!(
3016            std::fs::read_to_string(volume.path().join("new.txt")).expect("in the volume"),
3017            "fresh"
3018        );
3019        let placeholder = std::fs::read_dir(root.join("data/memory")).expect("placeholder");
3020        assert_eq!(placeholder.count(), 0, "the root's mount point stays empty");
3021        assert_eq!(quota.used(), 9, "the write is charged to the volume");
3022    }
3023
3024    #[tokio::test]
3025    async fn mount_read_only_refuses_every_write() {
3026        let volume = tempfile::tempdir().expect("volume");
3027        std::fs::create_dir(volume.path().join("dir")).expect("seed dir");
3028        std::fs::write(volume.path().join("dir/a.txt"), "a").expect("seed");
3029        let vfs = mount_volume(
3030            Vfs::tempdir().expect("tempdir"),
3031            "/handbook",
3032            volume.path(),
3033            "company-handbook",
3034            crate::runtime::vfs::Access::ReadOnly,
3035            None,
3036        );
3037        let source = r#"
3038            import { info, readText, writeText, append, writer, mkdir, remove, move, copy, list } from "submilli:fs";
3039            function denied(op: () => void): string {
3040                try { op(); } catch (e: PermissionDeniedError) { return e.capability; }
3041                return "allowed";
3042            }
3043            function main(): void {
3044                writeText("/local.txt", "l");
3045                assert(readText("/handbook/dir/a.txt") === "a", "reads work");
3046                let count = 0;
3047                for (const entry of list("/handbook", true)) { count++; }
3048                assert(count === 2, "listing works");
3049                assert(denied(() => writeText("/handbook/b.txt", "b")) === "fs.write", "write");
3050                assert(denied(() => append("/handbook/dir/a.txt", new Uint8Array(1))) === "fs.write", "append");
3051                assert(denied(() => writer("/handbook/c.txt")) === "fs.write", "writer");
3052                assert(denied(() => mkdir("/handbook/new", false)) === "fs.mkdir", "mkdir");
3053                mkdir("/handbook/dir", true);
3054                assert(denied(() => remove("/handbook/dir/a.txt", false)) === "fs.remove", "remove");
3055                assert(denied(() => move("/handbook/dir/a.txt", "/moved.txt")) === "fs.move", "move out");
3056                assert(denied(() => move("/local.txt", "/handbook/l.txt")) === "fs.move", "move in");
3057                assert(denied(() => copy("/local.txt", "/handbook/l.txt", false)) === "fs.copy", "copy in");
3058                copy("/handbook/dir/a.txt", "/copied.txt", false);
3059                assert(readText("/copied.txt") === "a", "copy out works");
3060                assert(info().mounts[0].access === "read_only", "info reports read-only");
3061            }
3062        "#;
3063        run_with(source, StoreData::with_vfs(vfs))
3064            .await
3065            .expect("read-only asserts hold");
3066        assert!(!volume.path().join("b.txt").exists());
3067        assert!(volume.path().join("dir/a.txt").exists());
3068    }
3069
3070    #[tokio::test]
3071    async fn mount_policy_denial_comes_before_the_read_only_refusal() {
3072        let volume = tempfile::tempdir().expect("volume");
3073        let vfs = mount_volume(
3074            Vfs::tempdir().expect("tempdir"),
3075            "/ro",
3076            volume.path(),
3077            "ro",
3078            crate::runtime::vfs::Access::ReadOnly,
3079            None,
3080        );
3081        let source = r#"
3082            import { writeText } from "submilli:fs";
3083            function main(): string {
3084                try { writeText("/ro/x.txt", "hi"); } catch (e: PermissionDeniedError) { return e.reason; }
3085                return "not denied";
3086            }
3087        "#;
3088        let mut data = StoreData::with_vfs(vfs);
3089        data.security_check = Arc::new(DenyAllFs);
3090        let value = run_with(source, data).await.expect("caught");
3091        assert_eq!(value.as_deref(), Some("denied fs.write in test"));
3092    }
3093
3094    #[tokio::test]
3095    async fn mount_read_only_root_refuses_writes_and_needs_existing_mount_points() {
3096        let root = tempfile::tempdir().expect("root");
3097        let volume = tempfile::tempdir().expect("volume");
3098        let read_only = || {
3099            Vfs::external_with_mode(root.path().to_path_buf(), VfsMode::Named)
3100                .expect("root")
3101                .with_access(crate::runtime::vfs::Access::ReadOnly)
3102        };
3103        let missing = read_only().with_mount(crate::runtime::vfs::MountSpec {
3104            guest_path: "/rw".to_string(),
3105            host: volume.path().to_path_buf(),
3106            volume: "rw".to_string(),
3107            access: crate::runtime::vfs::Access::ReadWrite,
3108            quota: None,
3109        });
3110        assert!(
3111            matches!(
3112                missing,
3113                Err(crate::runtime::vfs::MountError::MountPointUnavailable { .. })
3114            ),
3115            "a read-only root is never written, so the mount point must exist"
3116        );
3117        assert!(!root.path().join("rw").exists());
3118        std::fs::create_dir(root.path().join("rw")).expect("mount point");
3119        let vfs = mount_volume(
3120            read_only(),
3121            "/rw",
3122            volume.path(),
3123            "rw",
3124            crate::runtime::vfs::Access::ReadWrite,
3125            None,
3126        );
3127        let source = r#"
3128            import { writeText, info } from "submilli:fs";
3129            function main(): void {
3130                let denied = false;
3131                try { writeText("/x.txt", "x"); } catch (e: PermissionDeniedError) { denied = true; }
3132                assert(denied, "the root is read-only");
3133                writeText("/rw/x.txt", "x");
3134                assert(info().access === "read_only", "root access surfaced");
3135            }
3136        "#;
3137        run_with(source, StoreData::with_vfs(vfs))
3138            .await
3139            .expect("read-only root asserts hold");
3140        assert!(volume.path().join("x.txt").exists());
3141    }
3142
3143    #[tokio::test]
3144    async fn mount_points_cannot_be_removed_moved_or_copied_through() {
3145        let volume = tempfile::tempdir().expect("volume");
3146        std::fs::write(volume.path().join("v.txt"), "v").expect("seed");
3147        let vfs = mount_volume(
3148            Vfs::tempdir().expect("tempdir"),
3149            "/a/m",
3150            volume.path(),
3151            "m",
3152            crate::runtime::vfs::Access::ReadWrite,
3153            None,
3154        );
3155        let source = r#"
3156            import { remove, move, copy, writeText, mkdir, exists } from "submilli:fs";
3157            function fails(op: () => void): boolean {
3158                try { op(); return false; } catch (e) { return true; }
3159            }
3160            function main(): void {
3161                writeText("/a/sibling.txt", "s");
3162                mkdir("/a", true);
3163                assert(fails(() => remove("/a/m", true)), "remove the mount point");
3164                assert(fails(() => remove("/a", true)), "remove an ancestor");
3165                assert(fails(() => move("/a/m", "/b")), "move the mount point");
3166                assert(fails(() => move("/a", "/b")), "move an ancestor");
3167                assert(fails(() => move("/a/sibling.txt", "/a/m")), "replace the mount point");
3168                assert(fails(() => copy("/a", "/b", true)), "copy an ancestor");
3169                assert(fails(() => copy("/", "/a/m/all", true)), "copy the root into a mount");
3170                assert(exists("/a/m/v.txt"), "the volume is intact");
3171                remove("/a/sibling.txt", false);
3172            }
3173        "#;
3174        run_with(source, StoreData::with_vfs(vfs))
3175            .await
3176            .expect("mount-point asserts hold");
3177        assert!(volume.path().join("v.txt").exists());
3178    }
3179
3180    #[tokio::test]
3181    async fn mount_shared_volume_quota_spans_every_vfs_that_mounts_it() {
3182        let volume = tempfile::tempdir().expect("volume");
3183        let quota = Arc::new(crate::runtime::DiskQuota::new(100, 0));
3184        let mount = |vfs: Vfs, guest: &str| {
3185            mount_volume(
3186                vfs,
3187                guest,
3188                volume.path(),
3189                "shared",
3190                crate::runtime::vfs::Access::ReadWrite,
3191                Some(Arc::clone(&quota)),
3192            )
3193        };
3194        let first = mount(Vfs::tempdir().expect("tempdir"), "/one");
3195        let second = mount(Vfs::tempdir().expect("tempdir"), "/two");
3196        let write = r#"
3197            import { writeText } from "submilli:fs";
3198            function main(): void { writeText("/one/a.txt", "x".repeat(60)); }
3199        "#;
3200        run_with(write, StoreData::with_vfs(first))
3201            .await
3202            .expect("first write fits");
3203        let refuse = r#"
3204            import { writeText, readText } from "submilli:fs";
3205            function main(): void {
3206                assert(readText("/two/a.txt")!.length === 60, "the other alias sees it");
3207                let refused = false;
3208                try { writeText("/two/b.txt", "y".repeat(60)); } catch (e) { refused = e instanceof QuotaExceededError && e instanceof Error && !((e as unknown) instanceof RangeError); }
3209                assert(refused, "the shared limit refuses the second write");
3210            }
3211        "#;
3212        run_with(refuse, StoreData::with_vfs(second))
3213            .await
3214            .expect("second alias shares the limit");
3215        assert_eq!(quota.used(), 60);
3216    }
3217
3218    #[tokio::test]
3219    async fn mount_move_between_volumes_copies_then_removes_and_moves_the_charge() {
3220        let volume = tempfile::tempdir().expect("volume");
3221        let mount_quota = Arc::new(crate::runtime::DiskQuota::new(1000, 0));
3222        let vfs = mount_volume(
3223            Vfs::tempdir().expect("tempdir").with_size_limit(1000),
3224            "/memory",
3225            volume.path(),
3226            "memory",
3227            crate::runtime::vfs::Access::ReadWrite,
3228            Some(Arc::clone(&mount_quota)),
3229        );
3230        let root_quota = vfs.quota().cloned().expect("root quota");
3231        let source = r#"
3232            import { writeText, readText, move, mkdir, exists } from "submilli:fs";
3233            function main(): void {
3234                writeText("/a.txt", "x".repeat(10));
3235                mkdir("/tree/sub", true);
3236                writeText("/tree/sub/b.txt", "y".repeat(20));
3237                move("/a.txt", "/memory/a.txt");
3238                move("/tree", "/memory/tree");
3239                assert(!exists("/a.txt") && !exists("/tree"), "sources removed");
3240                assert(readText("/memory/a.txt")!.length === 10, "file moved");
3241                assert(readText("/memory/tree/sub/b.txt")!.length === 20, "tree moved");
3242                move("/memory/a.txt", "/back.txt");
3243                assert(readText("/back.txt")!.length === 10, "moved back");
3244            }
3245        "#;
3246        run_with(source, StoreData::with_vfs(vfs))
3247            .await
3248            .expect("cross-volume move asserts hold");
3249        assert_eq!(root_quota.used(), 10, "the root holds only back.txt");
3250        assert_eq!(mount_quota.used(), 20, "the volume holds the tree");
3251        let leftovers: Vec<_> = std::fs::read_dir(volume.path())
3252            .expect("volume")
3253            .map(|entry| entry.expect("entry").file_name())
3254            .collect();
3255        assert_eq!(
3256            leftovers,
3257            vec![std::ffi::OsString::from("tree")],
3258            "no staging left"
3259        );
3260    }
3261
3262    #[cfg(unix)]
3263    #[tokio::test]
3264    async fn mount_link_to_an_ancestor_cannot_reach_a_mount_point() {
3265        let session = tempfile::tempdir().expect("session");
3266        let volume = tempfile::tempdir().expect("volume");
3267        std::fs::write(volume.path().join("v.txt"), "v").expect("seed");
3268        let vfs = mount_volume(
3269            Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
3270                .expect("root"),
3271            "/a/m",
3272            volume.path(),
3273            "m",
3274            crate::runtime::vfs::Access::ReadWrite,
3275            None,
3276        );
3277        std::os::unix::fs::symlink("a", session.path().join("lnk")).expect("link");
3278        std::fs::create_dir(session.path().join("r")).expect("dir");
3279        std::os::unix::fs::symlink("..", session.path().join("r/up")).expect("link");
3280        std::os::unix::fs::symlink("a/m/x", session.path().join("dangling")).expect("link");
3281        std::os::unix::fs::symlink(".", session.path().join("here")).expect("link");
3282        let source = r#"
3283            import { remove, move, copy, writeText, readText, mkdir, append } from "submilli:fs";
3284            function refused(op: () => void): boolean {
3285                try { op(); return false; } catch (e) { return String(e).includes("mount point"); }
3286            }
3287            function main(): void {
3288                assert(refused(() => remove("/lnk/m", false)), "remove the placeholder through a link");
3289                assert(refused(() => remove("/lnk/m", true)), "recursive remove through a link");
3290                assert(refused(() => writeText("/lnk/m", "x")), "replace the placeholder through a link");
3291                assert(refused(() => writeText("/lnk/m/x.txt", "x")), "write inside the placeholder");
3292                assert(refused(() => move("/r/up/a", "/z")), "move an ancestor through a link");
3293                assert(refused(() => remove("/r/up/a", true)), "remove an ancestor through a link");
3294                assert(refused(() => copy("/r/up/a", "/copy", true)), "copy an ancestor through a link");
3295                copy("/lnk", "/linkcopy", false);
3296                assert(refused(() => mkdir("/lnk/m/new", false)), "mkdir inside the placeholder");
3297                assert(refused(() => append("/dangling", new Uint8Array(1))), "append through a dangling link");
3298                writeText("/plain.txt", "p");
3299                assert(refused(() => copy("/plain.txt", "/dangling", false)), "copy through a dangling link");
3300                writeText("/here", "replaced");
3301                assert(readText("/here") === "replaced", "a write replaces a link to the root rather than refusing");
3302            }
3303        "#;
3304        run_with(source, StoreData::with_vfs(vfs))
3305            .await
3306            .expect("link asserts hold");
3307        assert!(
3308            session.path().join("a/m").is_dir(),
3309            "the placeholder survives"
3310        );
3311        assert_eq!(
3312            std::fs::read_dir(session.path().join("a/m"))
3313                .expect("placeholder")
3314                .count(),
3315            0,
3316            "nothing landed in the placeholder"
3317        );
3318        assert!(volume.path().join("v.txt").exists());
3319        assert!(!session.path().join("copy").exists());
3320        assert!(
3321            std::fs::symlink_metadata(session.path().join("linkcopy"))
3322                .expect("copied")
3323                .file_type()
3324                .is_symlink(),
3325            "copying the link copies the link itself, never the tree behind it"
3326        );
3327    }
3328
3329    #[tokio::test]
3330    async fn mount_move_too_deep_to_remove_is_refused_before_copying() {
3331        let volume = tempfile::tempdir().expect("volume");
3332        let quota = Arc::new(crate::runtime::DiskQuota::new(1 << 20, 0));
3333        let vfs = mount_volume(
3334            Vfs::tempdir().expect("tempdir"),
3335            "/memory",
3336            volume.path(),
3337            "memory",
3338            crate::runtime::vfs::Access::ReadWrite,
3339            Some(Arc::clone(&quota)),
3340        );
3341        let source = r#"
3342            import { mkdir, writeText, move, exists } from "submilli:fs";
3343            function main(): void {
3344                const deep = "/deep" + "/d".repeat(70);
3345                mkdir(deep, true);
3346                writeText(deep + "/f.txt", "x");
3347                let failed = false;
3348                try { move("/deep", "/memory/deep"); } catch (e) { failed = true; }
3349                assert(failed, "the move is refused");
3350                assert(exists(deep + "/f.txt"), "the source is untouched");
3351            }
3352        "#;
3353        run_with(source, StoreData::with_vfs(vfs))
3354            .await
3355            .expect("deep move asserts hold");
3356        assert_eq!(
3357            std::fs::read_dir(volume.path()).expect("volume").count(),
3358            0,
3359            "no staging copy is left behind"
3360        );
3361        assert_eq!(quota.used(), 0, "nothing stays charged");
3362    }
3363
3364    #[cfg(any(target_os = "macos", windows))]
3365    #[test]
3366    fn mount_case_variant_directory_cannot_stand_in_as_a_mount_point() {
3367        use crate::runtime::vfs::{Access, MountError, MountSpec};
3368        let session = tempfile::tempdir().expect("session");
3369        let volume = tempfile::tempdir().expect("volume");
3370        std::fs::create_dir(session.path().join("Memory")).expect("variant");
3371        let result = Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
3372            .expect("root")
3373            .with_mount(MountSpec {
3374                guest_path: "/memory".to_string(),
3375                host: volume.path().to_path_buf(),
3376                volume: "memory".to_string(),
3377                access: Access::ReadWrite,
3378                quota: None,
3379            });
3380        assert!(
3381            matches!(result, Err(MountError::MountPointUnavailable { .. })),
3382            "a case variant is refused"
3383        );
3384    }
3385
3386    #[tokio::test]
3387    async fn mount_move_between_volumes_onto_existing_entries() {
3388        let volume = tempfile::tempdir().expect("volume");
3389        let quota = Arc::new(crate::runtime::DiskQuota::new(1000, 0));
3390        let vfs = mount_volume(
3391            Vfs::tempdir().expect("tempdir"),
3392            "/memory",
3393            volume.path(),
3394            "memory",
3395            crate::runtime::vfs::Access::ReadWrite,
3396            Some(Arc::clone(&quota)),
3397        );
3398        let source = r#"
3399            import { writeText, readText, move, mkdir, exists } from "submilli:fs";
3400            function failure(op: () => void): string {
3401                try { op(); return "ok"; } catch (e) { return String(e); }
3402            }
3403            function main(): void {
3404                writeText("/memory/old.txt", "o".repeat(30));
3405                writeText("/new.txt", "n".repeat(10));
3406                move("/new.txt", "/memory/old.txt");
3407                assert(readText("/memory/old.txt")!.length === 10, "the file is replaced");
3408                mkdir("/dir", false);
3409                writeText("/file.txt", "f");
3410                assert(failure(() => move("/file.txt", "/memory")).includes("mount point"), "onto the mount point");
3411                mkdir("/memory/full", false);
3412                writeText("/memory/full/x.txt", "x");
3413                assert(failure(() => move("/dir", "/memory/full")).includes("not empty"), "onto a non-empty directory");
3414                assert(failure(() => move("/file.txt", "/memory/full")).includes("onto a directory"), "a file onto a directory");
3415                assert(exists("/dir") && exists("/file.txt"), "the sources are untouched");
3416            }
3417        "#;
3418        run_with(source, StoreData::with_vfs(vfs))
3419            .await
3420            .expect("existing-destination asserts hold");
3421        assert_eq!(
3422            quota.used(),
3423            11,
3424            "the replaced file is released; only new files count"
3425        );
3426        let staged: Vec<_> = std::fs::read_dir(volume.path())
3427            .expect("volume")
3428            .map(|entry| entry.expect("entry").file_name())
3429            .filter(|name| name.to_string_lossy().ends_with(".tmp"))
3430            .collect();
3431        assert!(staged.is_empty(), "{staged:?}");
3432    }
3433
3434    #[cfg(target_os = "macos")]
3435    #[tokio::test]
3436    async fn mount_non_ascii_alias_of_a_mount_point_is_refused() {
3437        let session = tempfile::tempdir().expect("session");
3438        let volume = tempfile::tempdir().expect("volume");
3439        let vfs = mount_volume(
3440            Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
3441                .expect("root"),
3442            "/skills",
3443            volume.path(),
3444            "skills",
3445            crate::runtime::vfs::Access::ReadWrite,
3446            None,
3447        );
3448        // U+017F (long s) folds to `s` on a case-insensitive APFS volume.
3449        let source = r#"
3450            import { writeText, remove, move } from "submilli:fs";
3451            function refused(op: () => void): boolean {
3452                try { op(); return false; } catch (e) { return String(e).includes("mount point"); }
3453            }
3454            function main(): void {
3455                assert(refused(() => writeText("/ſkills/x.txt", "x")), "write through the alias");
3456                assert(refused(() => remove("/ſkills", true)), "remove the mount point by its alias");
3457                assert(refused(() => move("/ſkills", "/elsewhere")), "move the mount point by its alias");
3458            }
3459        "#;
3460        run_with(source, StoreData::with_vfs(vfs))
3461            .await
3462            .expect("alias asserts hold");
3463        assert_eq!(
3464            std::fs::read_dir(session.path().join("skills"))
3465                .expect("placeholder")
3466                .count(),
3467            0
3468        );
3469    }
3470
3471    #[cfg(target_os = "macos")]
3472    #[tokio::test]
3473    async fn mount_nested_non_ascii_aliases_of_a_mount_path_are_refused() {
3474        let session = tempfile::tempdir().expect("session");
3475        let volume = tempfile::tempdir().expect("volume");
3476        let vfs = mount_volume(
3477            Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
3478                .expect("root"),
3479            "/sets/skills",
3480            volume.path(),
3481            "skills",
3482            crate::runtime::vfs::Access::ReadWrite,
3483            None,
3484        );
3485        // U+017F (long s) folds to `s` on a case-insensitive APFS volume, in the
3486        // parent as well as in the mount point.
3487        let source = r#"
3488            import { writeText, remove, move } from "submilli:fs";
3489            function refused(op: () => void): boolean {
3490                try { op(); return false; } catch (e) { return String(e).includes("mount point"); }
3491            }
3492            function main(): void {
3493                assert(refused(() => writeText("/\u017Fets/\u017Fkills/x.txt", "x")), "both components aliased");
3494                assert(refused(() => writeText("/Sets/\u017Fkills/x.txt", "x")), "case and Unicode aliases");
3495                assert(refused(() => remove("/\u017Fets", true)), "remove an aliased ancestor");
3496                assert(refused(() => move("/\u017Fets", "/elsewhere")), "move an aliased ancestor");
3497                writeText("/\u017Fets/beside.txt", "beside");
3498            }
3499        "#;
3500        run_with(source, StoreData::with_vfs(vfs))
3501            .await
3502            .expect("nested alias asserts hold");
3503        assert_eq!(
3504            std::fs::read_dir(session.path().join("sets/skills"))
3505                .expect("placeholder")
3506                .count(),
3507            0
3508        );
3509        assert!(
3510            session.path().join("sets/beside.txt").exists(),
3511            "siblings stay writable"
3512        );
3513    }
3514
3515    #[cfg(unix)]
3516    #[tokio::test]
3517    async fn mount_root_link_loops_and_absolute_links_fail_cleanly() {
3518        let session = tempfile::tempdir().expect("session");
3519        let volume = tempfile::tempdir().expect("volume");
3520        let vfs = mount_volume(
3521            Vfs::external_with_mode(session.path().to_path_buf(), VfsMode::PerSession)
3522                .expect("root"),
3523            "/memory",
3524            volume.path(),
3525            "memory",
3526            crate::runtime::vfs::Access::ReadWrite,
3527            None,
3528        );
3529        std::os::unix::fs::symlink("loop", session.path().join("loop")).expect("link");
3530        std::os::unix::fs::symlink("/etc", session.path().join("abs")).expect("link");
3531        let source = r#"
3532            import { writeText, append } from "submilli:fs";
3533            function message(op: () => void): string {
3534                try { op(); return "ok"; } catch (e) { return String(e); }
3535            }
3536            function main(): void {
3537                assert(message(() => append("/loop/x", new Uint8Array(1))) !== "ok", "a loop fails");
3538                assert(message(() => append("/abs/x", new Uint8Array(1))).includes("escapes"), "an absolute link escapes");
3539                writeText("/fine.txt", "fine");
3540            }
3541        "#;
3542        run_with(source, StoreData::with_vfs(vfs))
3543            .await
3544            .expect("loop asserts hold");
3545    }
3546
3547    #[tokio::test]
3548    async fn mount_move_onto_a_mount_ancestor_is_refused_before_copying() {
3549        let volume = tempfile::tempdir().expect("volume");
3550        let other = tempfile::tempdir().expect("other");
3551        let quota = Arc::new(crate::runtime::DiskQuota::new(1 << 20, 0));
3552        let root = tempfile::tempdir().expect("root");
3553        let vfs = mount_volume(
3554            mount_volume(
3555                Vfs::external_with_mode(root.path().to_path_buf(), VfsMode::PerSession)
3556                    .expect("root")
3557                    .with_size_limit(1 << 20),
3558                "/memory",
3559                volume.path(),
3560                "memory",
3561                crate::runtime::vfs::Access::ReadWrite,
3562                None,
3563            ),
3564            "/sub/inner",
3565            other.path(),
3566            "inner",
3567            crate::runtime::vfs::Access::ReadWrite,
3568            Some(Arc::clone(&quota)),
3569        );
3570        let source = r#"
3571            import { writeText, move, exists } from "submilli:fs";
3572            function main(): void {
3573                writeText("/memory/tree.txt", "t");
3574                let failed = false;
3575                try { move("/memory/tree.txt", "/sub"); } catch (e) { failed = String(e).includes("mount point"); }
3576                assert(failed, "the move onto a mount's ancestor is refused");
3577                assert(exists("/memory/tree.txt"), "the source is untouched");
3578            }
3579        "#;
3580        run_with(source, StoreData::with_vfs(vfs))
3581            .await
3582            .expect("ancestor move asserts hold");
3583        assert_eq!(quota.used(), 0);
3584        let staged: Vec<_> = std::fs::read_dir(root.path())
3585            .expect("root")
3586            .map(|entry| entry.expect("entry").file_name())
3587            .filter(|name| name.to_string_lossy().ends_with(".tmp"))
3588            .collect();
3589        assert!(
3590            staged.is_empty(),
3591            "nothing was staged in the root: {staged:?}"
3592        );
3593    }
3594
3595    /// Mounting reads an identity for every directory on the mount path and fails
3596    /// if it can't, so this passing means the host supplies them.
3597    #[test]
3598    fn mount_reads_an_identity_for_each_directory_on_its_path() {
3599        let volume = tempfile::tempdir().expect("volume");
3600        let vfs = mount_volume(
3601            Vfs::tempdir().expect("tempdir"),
3602            "/data/memory",
3603            volume.path(),
3604            "memory",
3605            crate::runtime::vfs::Access::ReadWrite,
3606            None,
3607        );
3608        let names: Vec<_> = vfs.mounts()[0]
3609            .placeholders()
3610            .iter()
3611            .map(|placeholder| placeholder.name.to_string_lossy().into_owned())
3612            .collect();
3613        assert_eq!(
3614            names,
3615            ["data", "memory"],
3616            "every directory on the mount path got a placeholder"
3617        );
3618    }
3619
3620    #[test]
3621    fn mount_paths_are_plain_ascii() {
3622        use crate::runtime::vfs::{Access, MountError, MountSpec};
3623        let volume = tempfile::tempdir().expect("volume");
3624        for bad in ["/mémoire", "/a b", "/a\\b", "/memory.", "/..."] {
3625            let result = Vfs::tempdir().expect("tempdir").with_mount(MountSpec {
3626                guest_path: bad.to_string(),
3627                host: volume.path().to_path_buf(),
3628                volume: "v".to_string(),
3629                access: Access::ReadWrite,
3630                quota: None,
3631            });
3632            assert!(matches!(result, Err(MountError::BadPath(_))), "{bad}");
3633        }
3634    }
3635
3636    #[test]
3637    fn mounts_reject_ambiguous_tables() {
3638        use crate::runtime::vfs::{Access, MountError, MountSpec};
3639        let volume = tempfile::tempdir().expect("volume");
3640        let spec = |guest: &str, name: &str| MountSpec {
3641            guest_path: guest.to_string(),
3642            host: volume.path().to_path_buf(),
3643            volume: name.to_string(),
3644            access: Access::ReadWrite,
3645            quota: None,
3646        };
3647        assert!(matches!(
3648            Vfs::none().with_mount(spec("/a", "a")),
3649            Err(MountError::RootDisabled)
3650        ));
3651        let base = || Vfs::tempdir().expect("tempdir");
3652        for bad in ["a", "/a/", "/a//b", "/a/./b", "/a/../b", ""] {
3653            assert!(
3654                matches!(
3655                    base().with_mount(spec(bad, "a")),
3656                    Err(MountError::BadPath(_))
3657                ),
3658                "{bad:?} is not a normalized absolute path"
3659            );
3660        }
3661        assert!(matches!(
3662            base().with_mount(spec("/", "a")),
3663            Err(MountError::AtRoot)
3664        ));
3665        assert!(matches!(
3666            base().with_mount(spec("/x/.git", "a")),
3667            Err(MountError::ProtectedPath(_))
3668        ));
3669        let one = base().with_mount(spec("/a", "a")).expect("first");
3670        assert!(matches!(
3671            one.clone().with_mount(spec("/a/b", "b")),
3672            Err(MountError::Nested { .. })
3673        ));
3674        assert!(one.clone().with_mount(spec("/c", "a")).is_ok());
3675        let root = one.root().to_path_buf();
3676        std::fs::write(root.join("file"), "f").expect("file");
3677        assert!(matches!(
3678            one.clone().with_mount(spec("/file/x", "c")),
3679            Err(MountError::MountPointUnavailable { .. })
3680        ));
3681        let mut many = base();
3682        for index in 0..crate::runtime::vfs::MAX_MOUNTS {
3683            many = many
3684                .with_mount(spec(&format!("/m{index}"), &format!("v{index}")))
3685                .expect("within the cap");
3686        }
3687        assert!(matches!(
3688            many.with_mount(spec("/extra", "extra")),
3689            Err(MountError::TooMany)
3690        ));
3691    }
3692}