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