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