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