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