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