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