1use std::sync::{Arc, OnceLock};
9
10use crate::runtime::host::{
11 abi_arg, permission_denied, permission_denied_invariant, read_string_arg,
12 register_host_fn_async,
13};
14use crate::runtime::{approvals, effects};
15use crate::stdlib::shared::Checked;
16use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
17
18use crate::runtime::StoreData;
19use crate::runtime::decision::{CallSite, EntryPath};
20use crate::runtime::fuel;
21use crate::runtime::intrinsic_types::build_intrinsic_types;
22use crate::runtime::prelude::vtable::dispatch_vtable_slot;
23use crate::{MangledName, PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
24
25pub const MODULE_NAME: &str = "submilli:security";
26
27#[derive(serde::Serialize)]
32struct Check {
33 capability: String,
34 context: serde_json::Value,
35 who: String,
36}
37
38pub fn is_check(mangled: &MangledName) -> bool {
39 *mangled == crate::mangle::package_symbol(MODULE_NAME, "check")
40}
41
42const TO_JSON_SLOT: usize = 1;
44
45pub fn package_declaration() -> PackageDeclaration {
46 let mut defs = PackageDeclaration::with_package(MODULE_NAME);
47 defs.values.insert(
48 "check".to_string(),
49 ValueSymbol {
50 name: "check".to_string(),
51 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "check"),
52 declaration_span: Span::at(crate::FileId::SECURITY),
53 kind: ValueKind::Function {
54 generics: vec!["T".to_string()],
55 params: vec![
56 Param::new("capability", Type::String),
57 Param::new("context", Type::TypeVar("T".to_string())),
58 ],
59 ret: Type::Void,
60 type_predicate: None,
61 doc: crate::doc(
62 crate::FileId::SECURITY,
63 "/**\n * Semantic security check. Invokes the embedder's policy engine; \
64 throws a catchable `PermissionDeniedError` if the policy denies the \
65 call.\n * @param capability Dotted \
66 capability identifier (e.g. `\"test.com/op\"`).\n * @param context \
67 Arbitrary value (typically an object) whose fields the policy matches \
68 against rules. Serialized to JSON at the host boundary.\n */",
69 ),
70 },
71 },
72 );
73 defs
74}
75
76pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
77 let engine = linker.engine().clone();
78 let intr = build_intrinsic_types(&engine)?;
79 let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
80 let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
82 let ty = FuncType::new(&engine, [string, nullable_object], []);
83 register_host_fn_async(
84 linker,
85 MODULE_NAME,
86 crate::mangle::package_symbol(MODULE_NAME, "check"),
87 ty,
88 |caller, params, _results| {
89 Box::pin(async move {
90 let capability = read_string_arg(
91 &mut *caller,
92 abi_arg(params, 0)?,
93 "security.check (capability)",
94 )?;
95 if matches!(*abi_arg(params, 1)?, Val::AnyRef(None)) {
96 wasmtime::bail!("security.check: context must not be null");
97 }
98 let json_val =
99 dispatch_vtable_slot(caller, abi_arg(params, 1)?, TO_JSON_SLOT, &[]).await?;
100 let context_json =
101 read_string_arg(&mut *caller, &json_val, "security.check (context)")?;
102 fuel::charge(&mut *caller, fuel::PARSE, context_json.len() as u64)?;
103 let context: serde_json::Value =
104 serde_json::from_str(&context_json).map_err(|e| {
105 wasmtime::Error::msg(format!("security.check: malformed context JSON: {e}"))
106 })?;
107 let who = consumer_of_running_package(&mut *caller, &capability).inspect_err(
108 |error| {
109 audit_consumer_failure(
110 caller.data().security_check.as_ref(),
111 &context,
112 error,
113 || crate::stdlib::shared::call_origin(&*caller),
114 );
115 },
116 )?;
117
118 let asked_ticket = Arc::new(OnceLock::new());
123 let check_ticket = Arc::clone(&asked_ticket);
124 let asked = effects::execute(
125 caller,
126 "security.check",
127 Check {
128 capability,
129 context,
130 who,
131 },
132 |caller, _, request| {
133 Box::pin(async move {
134 fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
135 let capability = request.capability;
136 let context = request.context;
137 let who = request.who;
138 let policy = caller.data().security_check.clone();
139 let cwd = caller.data().vfs.cwd().to_owned();
140 let ticket = crate::stdlib::shared::begin_recorded_call(
141 &*caller,
142 &who,
143 &capability,
144 );
145 let site = CallSite::new(ticket, EntryPath::PackageCheck);
146 match crate::stdlib::shared::check_or_ask(
147 policy.as_ref(),
148 &who,
149 &capability,
150 &context,
151 &cwd,
152 site,
153 ) {
154 Ok(Checked::Allowed) => Ok(None),
155 Ok(Checked::Ask(request)) => {
156 if let Some(ticket) = ticket {
157 let _ = check_ticket.set(ticket);
158 }
159 Ok(Some(request))
160 }
161 Err(reason) => Err(permission_denied(&who, &capability, reason)),
162 }
163 })
164 },
165 )
166 .await?;
167 if let Some(request) = asked {
168 let site = CallSite::new(asked_ticket.get().copied(), EntryPath::PackageCheck);
169 approvals::ask(caller, request, site).await?;
170 }
171 Ok(())
172 })
173 },
174 )
175}
176
177fn consumer_of_running_package(
205 store: &mut impl wasmtime::AsContextMut<Data = StoreData>,
206 capability: &str,
207) -> wasmtime::Result<String> {
208 let available = store
209 .as_context()
210 .get_fuel()?
211 .saturating_sub(store.as_context().data().host_fuel_pending);
212 let mut work = 0_u64;
213 let mut exhausted = false;
214 let mut running: Option<String> = None;
215 let mut below = None;
216 let mut unknown = None;
217 let walk = wasmtime::WasmBacktrace::visit_modules(&*store, |module| {
218 let step = fuel::ELEM.cost(1);
219 if step > available.saturating_sub(work) {
220 exhausted = true;
221 return std::ops::ControlFlow::Break(());
222 }
223 work += step;
224 let Some(owner) = module.name() else {
225 unknown = Some(permission_denied_invariant(
226 "<unnamed module>",
227 capability,
228 "a frame between the running code and its caller declares no package name, so the caller cannot be identified",
229 ));
230 return std::ops::ControlFlow::Break(());
231 };
232 if let Some(running) = &running {
233 if owner != running {
234 match crate::stdlib::shared::owned_principal(owner) {
235 Ok(owner) => below = Some(owner),
236 Err(error) => unknown = Some(error),
237 }
238 return std::ops::ControlFlow::Break(());
239 }
240 } else {
241 match crate::stdlib::shared::owned_principal(owner) {
242 Ok(owner) => running = Some(owner),
243 Err(error) => {
244 unknown = Some(error);
245 return std::ops::ControlFlow::Break(());
246 }
247 }
248 }
249 std::ops::ControlFlow::Continue(())
250 });
251 fuel::charge_host_fuel(&mut *store, work)?;
252 if exhausted {
253 fuel::charge_host_fuel(&mut *store, fuel::ELEM.cost(1))?;
254 }
255 walk.map_err(crate::runtime::host::fatal_host_error)?;
256 if let Some(error) = unknown {
257 return Err(error);
258 }
259 let running = running.ok_or_else(|| {
260 permission_denied_invariant(
261 "<no wasm frame>",
262 capability,
263 "no wasm frame is executing, so there is no caller to gate",
264 )
265 })?;
266 if running == crate::mangle::USER_PACKAGE && below.is_some() {
267 return Err(permission_denied_invariant(
270 &running,
271 capability,
272 "security.check gates the caller of the package that runs it. This is main's own \
273 code running inside a package, which has no caller to gate — naming the package \
274 here would let main-authored code speak for it",
275 ));
276 }
277 match below {
281 Some(owner) => Ok(owner),
282 None => crate::stdlib::shared::owned_principal(crate::mangle::USER_PACKAGE),
283 }
284}
285
286fn audit_consumer_failure(
289 policy: &dyn crate::runtime::security::SecurityCheck,
290 context: &serde_json::Value,
291 error: &wasmtime::Error,
292 origin: impl FnOnce() -> crate::runtime::decision::CallOrigin,
293) {
294 let Some(denial) = error.downcast_ref::<crate::runtime::host::PermissionDenied>() else {
295 return;
296 };
297 let ticket = policy
298 .recorder()
299 .map(|recorder| recorder.begin_call(&denial.caller, &denial.capability, origin()));
300 crate::stdlib::shared::audit_denial_at(
301 policy,
302 &denial.caller,
303 &denial.capability,
304 context,
305 "invariant",
306 &denial.reason,
307 CallSite::new(ticket, EntryPath::PackageCheck),
308 );
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::runtime::decision::CallOrigin;
315 use crate::runtime::security::CheckOutcome;
316
317 #[test]
318 fn consumer_audit_distinguishes_attribution_denials_from_execution_failures() {
319 use crate::runtime::security::{AuditDecision, SecurityCheck};
320 use std::sync::Mutex;
321 #[derive(Default)]
322 struct AuditSink(Mutex<Vec<(String, String)>>);
323 impl SecurityCheck for AuditSink {
324 fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
325 CheckOutcome::Allow { rule: None }
326 }
327 fn audit(&self, decision: AuditDecision<'_>) {
328 self.0.lock().unwrap().push((
329 decision.caller.to_owned(),
330 decision.reason.unwrap().to_owned(),
331 ));
332 }
333 }
334 let sink = AuditSink::default();
335 let context = serde_json::Value::Null;
336 audit_consumer_failure(
337 &sink,
338 &context,
339 &wasmtime::Error::new(wasmtime::Trap::OutOfFuel),
340 CallOrigin::default,
341 );
342 audit_consumer_failure(
343 &sink,
344 &context,
345 &crate::runtime::host::fatal_host_error("engine failure"),
346 CallOrigin::default,
347 );
348 assert!(sink.0.lock().unwrap().is_empty());
349 for label in ["<no wasm frame>", "<unnamed module>"] {
350 audit_consumer_failure(
351 &sink,
352 &context,
353 &permission_denied_invariant(label, "test.op", "unattributed caller"),
354 CallOrigin::default,
355 );
356 }
357 assert_eq!(
358 *sink.0.lock().unwrap(),
359 vec![
360 (
361 "<no wasm frame>".to_owned(),
362 "unattributed caller".to_owned()
363 ),
364 (
365 "<unnamed module>".to_owned(),
366 "unattributed caller".to_owned()
367 ),
368 ]
369 );
370 }
371
372 #[tokio::test]
373 async fn necessary_caller_walk_is_metered_and_stops_before_short_fuel_work() {
374 use std::sync::{
375 Arc, Mutex,
376 atomic::{AtomicBool, Ordering},
377 };
378 let config = crate::runtime::RuntimeConfig::default();
379 let engine = config.engine().unwrap();
380 let mut store = config
381 .store_async(
382 &engine,
383 StoreData::with_vfs("test-run".into(), crate::runtime::Vfs::none()),
384 )
385 .unwrap();
386 let costs = Arc::new(Mutex::new(Vec::new()));
387 let measured = Arc::clone(&costs);
388 let short = Arc::new(AtomicBool::new(false));
389 let limited = Arc::clone(&short);
390 let mut linker = Linker::new(&engine);
391 linker
392 .func_new(
393 "host",
394 "check",
395 FuncType::new(&engine, [], []),
396 move |mut caller, _, _| {
397 if limited.load(Ordering::Relaxed) {
398 caller.set_fuel(5)?;
399 }
400 assert_eq!(
401 crate::stdlib::shared::running_package(&caller)
402 .map_err(|error| error.into_denial("test.op"))?,
403 "main"
404 );
405 let before = caller.data().host_fuel;
406 assert_eq!(consumer_of_running_package(&mut caller, "test.op")?, "main");
407 measured
408 .lock()
409 .unwrap()
410 .push(caller.data().host_fuel - before);
411 Ok(())
412 },
413 )
414 .unwrap();
415 let source = r#"(module $main
416 (import "host" "check" (func $check))
417 (func $walk (export "walk") (param $depth i32)
418 local.get $depth i32.eqz
419 if call $check else local.get $depth i32.const 1 i32.sub call $walk end))"#;
420 let buffer = wast::parser::ParseBuffer::new(source).unwrap();
421 let mut wat = wast::parser::parse::<wast::Wat>(&buffer).unwrap();
422 let wasm = wat.encode().unwrap();
423 let module = wasmtime::Module::new(&engine, wasm).unwrap();
424 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
425 let walk = instance.get_func(&mut store, "walk").unwrap();
426 for depth in [32, 64] {
427 walk.call_async(&mut store, &[Val::I32(depth)], &mut [])
428 .await
429 .unwrap();
430 }
431 assert_eq!(
432 *costs.lock().unwrap(),
433 vec![fuel::ELEM.cost(33), fuel::ELEM.cost(65)]
434 );
435 short.store(true, Ordering::Relaxed);
436 let error = walk
437 .call_async(&mut store, &[Val::I32(64)], &mut [])
438 .await
439 .unwrap_err();
440 assert_eq!(
441 error.downcast_ref::<wasmtime::Trap>(),
442 Some(&wasmtime::Trap::OutOfFuel)
443 );
444 assert_eq!(store.get_fuel().unwrap(), 0);
445 short.store(false, Ordering::Relaxed);
446 store.set_fuel(1_000_000).unwrap();
447 walk.call_async(&mut store, &[Val::I32(32)], &mut [])
448 .await
449 .unwrap();
450 }
451
452 #[tokio::test]
457 async fn a_package_alone_on_the_stack_answers_main() {
458 let source = "import { check } from \"submilli:security\";\n\n\
459 function main(): void {\n check(\"test.com/op\", { foo: 1 });\n}\n";
460 let compiled = crate::compile::compile_script_owned_by(
461 "test:solo",
462 source,
463 "script.subm",
464 crate::FileId(0),
465 &[],
466 &[],
467 )
468 .expect("compile clean");
469 let cfg = crate::runtime::RuntimeConfig::default();
470 let engine = cfg.engine().expect("engine");
471 let mut data = crate::runtime::StoreData::with_vfs(
472 "test-run".into(),
473 crate::runtime::Vfs::tempdir().expect("tempdir"),
474 );
475 data.install_type_info(compiled.type_info.clone());
476 data.security_check = std::sync::Arc::new(DenyAll);
477 let mut store = cfg.store(&engine, data).expect("store");
478 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
479 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
480 crate::runtime::install_runtime_async(&mut linker, &mut store)
481 .await
482 .expect("install");
483 let inst = linker
484 .instantiate_async(&mut store, &module)
485 .await
486 .expect("instantiate");
487 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
488 .await
489 .expect_err("DenyAll refuses");
490 assert!(
491 format!("{err}").contains("caller=main"),
492 "a lone package's consumer is the script; got: {err}"
493 );
494 }
495
496 #[tokio::test]
502 async fn main_authored_code_inside_a_package_cannot_ask_on_the_packages_behalf() {
503 let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
504 "test:wrap",
505 &[(
506 "lib",
507 r#"
508 /**
509 * Pass-through JSON encoder.
510 * @param value Value to encode.
511 * @returns `value` as JSON, or `undefined` when it has none.
512 */
513 export function passthrough(value: unknown): string | undefined {
514 return JSON.stringify(value);
515 }
516 "#,
517 )],
518 &[],
519 );
520 let cfg = crate::runtime::RuntimeConfig::default();
521 let engine = cfg.engine().expect("engine");
522 let mut data = crate::runtime::StoreData::with_vfs(
523 "test-run".into(),
524 crate::runtime::Vfs::tempdir().expect("tempdir"),
525 );
526 data.install_type_info(lib_type_info);
527 let mut store = cfg.store(&engine, data).expect("store");
528 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
529 crate::runtime::install_runtime_async(&mut linker, &mut store)
530 .await
531 .expect("install");
532 let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
533 let lib_inst = linker
534 .instantiate_async(&mut store, &lib_module)
535 .await
536 .expect("instantiate library");
537 linker
538 .instance(&mut store, "test:wrap", lib_inst)
539 .expect("register library instance");
540 let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
541 let func = lib_inst
542 .get_func(&mut store, public_name.as_str())
543 .expect("library public export");
544 linker
545 .define(&mut store, "test:wrap", "passthrough", func)
546 .expect("plain package import alias");
547
548 let consumer = crate::compile_script(
549 r#"
550 import { passthrough } from "test:wrap";
551 import { check } from "submilli:security";
552
553 function gate(): void {
554 check("test.com/op", { foo: 1 });
555 }
556
557 class Probe {
558 seen: number;
559 constructor() { this.seen = 0; }
560 toJson(): string {
561 gate();
562 this.seen = 1;
563 return "\"ok\"";
564 }
565 }
566
567 function main(): number {
568 const p = new Probe();
569 const _ = passthrough(p);
570 return p.seen;
571 }
572 "#,
573 "consumer.subm",
574 crate::FileId(0),
575 &[&lib_decl],
576 &[],
577 )
578 .expect("consumer compiles");
579 store
580 .data_mut()
581 .install_type_info(consumer.type_info.clone());
582 let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
583 let inst = linker
584 .instantiate_async(&mut store, &consumer_module)
585 .await
586 .expect("instantiate consumer");
587 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
588 .await
589 .expect_err("main-authored code inside a package may not ask");
590 let msg = format!("{err}");
591 assert!(
592 msg.contains("no caller to gate") && !msg.contains("caller=test:wrap"),
593 "must refuse rather than name the package: {msg}"
594 );
595 }
596
597 fn strip_name_section(bytes: &[u8]) -> Vec<u8> {
601 use wasmparser::{Parser, Payload};
602 let mut out = bytes[..8].to_vec();
603 for payload in Parser::new(0).parse_all(bytes) {
604 let payload = payload.expect("payload");
605 if let Payload::CustomSection(reader) = &payload
606 && reader.name() == "name"
607 {
608 continue;
609 }
610 if let Some((id, range)) = payload.as_section() {
611 out.push(id);
612 let mut len = Vec::new();
613 leb128_write(&mut len, range.len() as u64);
614 out.extend_from_slice(&len);
615 out.extend_from_slice(&bytes[range]);
616 }
617 }
618 out
619 }
620
621 fn leb128_write(out: &mut Vec<u8>, mut value: u64) {
622 loop {
623 let mut byte = (value & 0x7f) as u8;
624 value >>= 7;
625 if value != 0 {
626 byte |= 0x80;
627 }
628 out.push(byte);
629 if value == 0 {
630 return;
631 }
632 }
633 }
634
635 #[tokio::test]
642 async fn a_module_without_a_name_is_refused_rather_than_attributed() {
643 let source = r#"
644 import { get } from "submilli:secrets";
645 function main(): void { const _ = get("TOKEN"); }
646 "#;
647 let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
648 .expect("compile clean");
649 let stripped = strip_name_section(&compiled.wasm);
650
651 let cfg = crate::runtime::RuntimeConfig::default();
652 let engine = cfg.engine().expect("engine");
653 let module = wasmtime::Module::new(&engine, &stripped).expect("stripped module validates");
654 assert_eq!(
655 module.name(),
656 None,
657 "the fixture must genuinely lack a module name, or it proves nothing"
658 );
659
660 let mut data = crate::runtime::StoreData::with_vfs(
661 "test-run".into(),
662 crate::runtime::Vfs::tempdir().expect("tempdir"),
663 );
664 data.install_type_info(compiled.type_info.clone());
665 let mut store = cfg.store(&engine, data).expect("store");
666 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
667 crate::runtime::install_runtime_async(&mut linker, &mut store)
668 .await
669 .expect("install");
670 let inst = linker
671 .instantiate_async(&mut store, &module)
672 .await
673 .expect("instantiate");
674 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
675 .await
676 .expect_err("an unnameable principal must be refused");
677 let msg = format!("{err}");
678 assert!(
679 msg.contains("<unnamed module>"),
680 "must name the unresolvable principal rather than defaulting to one: {msg}"
681 );
682 assert!(
683 !msg.contains("caller=main"),
684 "must not fall back to main: {msg}"
685 );
686 }
687
688 #[tokio::test]
696 async fn security_check_from_an_unnamed_module_names_the_right_failure() {
697 let source = r#"
698 import { check } from "submilli:security";
699 function main(): void { check("test.com/op", { foo: 1 }); }
700 "#;
701 let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
702 .expect("compile clean");
703 let stripped = strip_name_section(&compiled.wasm);
704
705 let cfg = crate::runtime::RuntimeConfig::default();
706 let engine = cfg.engine().expect("engine");
707 let module = wasmtime::Module::new(&engine, &stripped).expect("module validates");
708 let mut data = crate::runtime::StoreData::with_vfs(
709 "test-run".into(),
710 crate::runtime::Vfs::tempdir().expect("tempdir"),
711 );
712 data.install_type_info(compiled.type_info.clone());
713 let mut store = cfg.store(&engine, data).expect("store");
714 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
715 crate::runtime::install_runtime_async(&mut linker, &mut store)
716 .await
717 .expect("install");
718 let inst = linker
719 .instantiate_async(&mut store, &module)
720 .await
721 .expect("instantiate");
722 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
723 .await
724 .expect_err("an unnameable principal must be refused");
725 let msg = format!("{err}");
726 assert!(
727 msg.contains("<unnamed module>"),
728 "must name the running module as the unresolvable one, not claim no frame exists: \
729 {msg}"
730 );
731 assert!(
734 !msg.contains("operator's policy"),
735 "an unresolvable principal is an invariant refusal, not a policy denial: {msg}"
736 );
737 }
738
739 #[tokio::test]
750 async fn an_unnamed_frame_between_caller_and_callee_is_refused() {
751 let (inner_bytes, inner_decl, inner_ti) = crate::codegen::tests::compile_package_modules(
752 "test:inner",
753 &[(
754 "lib",
755 r#"
756 import { check } from "submilli:security";
757
758 /**
759 * Gates an operation on behalf of whoever called it.
760 * @param foo Value the capability filter matches.
761 * @capability test.com/op { foo }
762 */
763 export function guarded(foo: number): void {
764 check("test.com/op", { foo });
765 }
766 "#,
767 )],
768 &[],
769 );
770 let (relay_bytes, relay_decl, relay_ti) = crate::codegen::tests::compile_package_modules(
771 "test:relay",
772 &[(
773 "lib",
774 r#"
775 import { guarded } from "test:inner";
776
777 /**
778 * Calls the inner package, standing between it and the script.
779 * @param foo Value passed to the inner package.
780 */
781 export function relay(foo: number): void {
782 guarded(foo);
783 }
784 "#,
785 )],
786 &[&inner_decl],
787 );
788 let relay_bytes = strip_name_section(&relay_bytes);
790
791 let cfg = crate::runtime::RuntimeConfig::default();
792 let engine = cfg.engine().expect("engine");
793 let mut data = crate::runtime::StoreData::with_vfs(
794 "test-run".into(),
795 crate::runtime::Vfs::tempdir().expect("tempdir"),
796 );
797 data.install_type_info(inner_ti);
798 data.install_type_info(relay_ti);
799 let mut store = cfg.store(&engine, data).expect("store");
800 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
801 crate::runtime::install_runtime_async(&mut linker, &mut store)
802 .await
803 .expect("install");
804
805 let inner_module = wasmtime::Module::new(&engine, &inner_bytes).expect("inner module");
806 let inner_inst = linker
807 .instantiate_async(&mut store, &inner_module)
808 .await
809 .expect("instantiate inner");
810 linker
811 .instance(&mut store, "test:inner", inner_inst)
812 .expect("register inner");
813
814 let relay_module = wasmtime::Module::new(&engine, &relay_bytes).expect("relay module");
815 assert_eq!(
816 relay_module.name(),
817 None,
818 "the middle module must genuinely lack a name, or the test proves nothing"
819 );
820 let relay_inst = linker
821 .instantiate_async(&mut store, &relay_module)
822 .await
823 .expect("instantiate relay");
824 linker
825 .instance(&mut store, "test:relay", relay_inst)
826 .expect("register relay");
827 let public_name = crate::mangle::package_symbol("test:relay", "relay");
828 let relay_fn = relay_inst
829 .get_func(&mut store, public_name.as_str())
830 .expect("relay export");
831 linker
832 .define(&mut store, "test:relay", "relay", relay_fn)
833 .expect("plain package import alias");
834
835 let consumer = crate::compile_script(
836 r#"
837 import { relay } from "test:relay";
838 function main(): void { relay(1); }
839 "#,
840 "consumer.subm",
841 crate::FileId(0),
842 &[&relay_decl],
843 &[],
844 )
845 .expect("consumer compiles");
846 store
847 .data_mut()
848 .install_type_info(consumer.type_info.clone());
849 let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
850 let inst = linker
851 .instantiate_async(&mut store, &consumer_module)
852 .await
853 .expect("instantiate consumer");
854 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
855 .await
856 .expect_err("an unnameable frame in the walk must be refused");
857 let msg = format!("{err}");
858 assert!(
859 msg.contains("<unnamed module>"),
860 "must refuse on the unnameable frame: {msg}"
861 );
862 assert!(
863 !msg.contains("caller=main"),
864 "must not skip the unnameable frame and name main: {msg}"
865 );
866 }
867
868 #[test]
869 fn module_name_matches_runtime_constant() {
870 assert_eq!(MODULE_NAME, "submilli:security");
873 }
874
875 struct DenyAll;
876 impl crate::runtime::SecurityCheck for DenyAll {
877 fn check(
878 &self,
879 _caller: &str,
880 _capability: &str,
881 _context: &serde_json::Value,
882 ) -> CheckOutcome {
883 CheckOutcome::Deny {
884 rule: None,
885 reason: "blocked by policy".to_string(),
886 }
887 }
888 }
889
890 #[tokio::test]
891 async fn uncaught_denial_renders_message_and_backtrace() {
892 let source = "import { check } from \"submilli:security\";\n\n\
893 function main(): void {\n check(\"test.com/op\", { foo: 1 });\n}\n";
894 let compiled = crate::compile_script(source, "script.subm", crate::FileId(0), &[], &[])
895 .expect("compile clean");
896 let cfg = crate::runtime::RuntimeConfig::default();
897 let engine = cfg.engine().expect("engine");
898 let mut data = crate::runtime::StoreData::with_vfs(
899 "test-run".into(),
900 crate::runtime::Vfs::tempdir().expect("tempdir"),
901 );
902 data.install_type_info(compiled.type_info.clone());
903 data.security_check = std::sync::Arc::new(DenyAll);
904 let mut store = cfg.store(&engine, data).expect("store");
905 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
906 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
907 crate::runtime::install_runtime_async(&mut linker, &mut store)
908 .await
909 .expect("install");
910 let inst = linker
911 .instantiate_async(&mut store, &module)
912 .await
913 .expect("instantiate");
914 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
915 .await
916 .expect_err("uncaught denial surfaces as a runtime error");
917
918 let msg = format!("{err}");
919 assert!(
920 msg.contains(
921 "PermissionDeniedError: permission denied: \
922 caller=main capability=test.com/op: blocked by policy"
923 ),
924 "expected the class-prefixed denial message; got: {msg}"
925 );
926 assert!(
927 msg.contains("fields:")
928 && msg.contains("capability = \"test.com/op\"")
929 && msg.contains("caller = \"main\""),
930 "denial data fields should render on the fields line; got: {msg}"
931 );
932
933 let (sources, file) = crate::Sources::single("script.subm", source).unwrap();
934 let rendered =
935 crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
936 .expect("an uncaught denial should render a backtrace");
937 assert!(
938 rendered.contains("at main ("),
939 "backtrace should point at main: {rendered}"
940 );
941 assert!(
942 rendered.contains("check(\"test.com/op\""),
943 "source context should show the check() call site: {rendered}"
944 );
945 }
946}