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