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