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