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