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