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