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