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::call_origin(&*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 origin: impl FnOnce() -> crate::runtime::decision::CallOrigin,
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, origin()));
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::decision::CallOrigin;
265 use crate::runtime::security::CheckOutcome;
266
267 #[test]
268 fn consumer_audit_distinguishes_attribution_denials_from_execution_failures() {
269 use crate::runtime::security::{AuditDecision, SecurityCheck};
270 use std::sync::Mutex;
271 #[derive(Default)]
272 struct AuditSink(Mutex<Vec<(String, String)>>);
273 impl SecurityCheck for AuditSink {
274 fn check(&self, _: &str, _: &str, _: &serde_json::Value) -> CheckOutcome {
275 CheckOutcome::Allow { rule: None }
276 }
277 fn audit(&self, decision: AuditDecision<'_>) {
278 self.0.lock().unwrap().push((
279 decision.caller.to_owned(),
280 decision.reason.unwrap().to_owned(),
281 ));
282 }
283 }
284 let sink = AuditSink::default();
285 let context = serde_json::Value::Null;
286 audit_consumer_failure(
287 &sink,
288 &context,
289 &wasmtime::Error::new(wasmtime::Trap::OutOfFuel),
290 CallOrigin::default,
291 );
292 audit_consumer_failure(
293 &sink,
294 &context,
295 &crate::runtime::host::fatal_host_error("engine failure"),
296 CallOrigin::default,
297 );
298 assert!(sink.0.lock().unwrap().is_empty());
299 for label in ["<no wasm frame>", "<unnamed module>"] {
300 audit_consumer_failure(
301 &sink,
302 &context,
303 &permission_denied_invariant(label, "test.op", "unattributed caller"),
304 CallOrigin::default,
305 );
306 }
307 assert_eq!(
308 *sink.0.lock().unwrap(),
309 vec![
310 (
311 "<no wasm frame>".to_owned(),
312 "unattributed caller".to_owned()
313 ),
314 (
315 "<unnamed module>".to_owned(),
316 "unattributed caller".to_owned()
317 ),
318 ]
319 );
320 }
321
322 #[tokio::test]
323 async fn necessary_caller_walk_is_metered_and_stops_before_short_fuel_work() {
324 use std::sync::{
325 Arc, Mutex,
326 atomic::{AtomicBool, Ordering},
327 };
328 let config = crate::runtime::RuntimeConfig::default();
329 let engine = config.engine().unwrap();
330 let mut store = config
331 .store_async(&engine, StoreData::with_vfs(crate::runtime::Vfs::none()))
332 .unwrap();
333 let costs = Arc::new(Mutex::new(Vec::new()));
334 let measured = Arc::clone(&costs);
335 let short = Arc::new(AtomicBool::new(false));
336 let limited = Arc::clone(&short);
337 let mut linker = Linker::new(&engine);
338 linker
339 .func_new(
340 "host",
341 "check",
342 FuncType::new(&engine, [], []),
343 move |mut caller, _, _| {
344 if limited.load(Ordering::Relaxed) {
345 caller.set_fuel(5)?;
346 }
347 assert_eq!(
348 crate::stdlib::shared::running_package(&caller)
349 .map_err(|error| error.into_denial("test.op"))?,
350 "main"
351 );
352 let before = caller.data().host_fuel;
353 assert_eq!(consumer_of_running_package(&mut caller, "test.op")?, "main");
354 measured
355 .lock()
356 .unwrap()
357 .push(caller.data().host_fuel - before);
358 Ok(())
359 },
360 )
361 .unwrap();
362 let source = r#"(module $main
363 (import "host" "check" (func $check))
364 (func $walk (export "walk") (param $depth i32)
365 local.get $depth i32.eqz
366 if call $check else local.get $depth i32.const 1 i32.sub call $walk end))"#;
367 let buffer = wast::parser::ParseBuffer::new(source).unwrap();
368 let mut wat = wast::parser::parse::<wast::Wat>(&buffer).unwrap();
369 let wasm = wat.encode().unwrap();
370 let module = wasmtime::Module::new(&engine, wasm).unwrap();
371 let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
372 let walk = instance.get_func(&mut store, "walk").unwrap();
373 for depth in [32, 64] {
374 walk.call_async(&mut store, &[Val::I32(depth)], &mut [])
375 .await
376 .unwrap();
377 }
378 assert_eq!(
379 *costs.lock().unwrap(),
380 vec![fuel::ELEM.cost(33), fuel::ELEM.cost(65)]
381 );
382 short.store(true, Ordering::Relaxed);
383 let error = walk
384 .call_async(&mut store, &[Val::I32(64)], &mut [])
385 .await
386 .unwrap_err();
387 assert_eq!(
388 error.downcast_ref::<wasmtime::Trap>(),
389 Some(&wasmtime::Trap::OutOfFuel)
390 );
391 assert_eq!(store.get_fuel().unwrap(), 0);
392 short.store(false, Ordering::Relaxed);
393 store.set_fuel(1_000_000).unwrap();
394 walk.call_async(&mut store, &[Val::I32(32)], &mut [])
395 .await
396 .unwrap();
397 }
398
399 #[tokio::test]
404 async fn a_package_alone_on_the_stack_answers_main() {
405 let source = "import { check } from \"submilli:security\";\n\n\
406 function main(): void {\n check(\"test.com/op\", { foo: 1 });\n}\n";
407 let compiled = crate::compile::compile_script_owned_by(
408 "test:solo",
409 source,
410 "script.subm",
411 crate::FileId(0),
412 &[],
413 &[],
414 )
415 .expect("compile clean");
416 let cfg = crate::runtime::RuntimeConfig::default();
417 let engine = cfg.engine().expect("engine");
418 let mut data =
419 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
420 data.install_type_info(compiled.type_info.clone());
421 data.security_check = std::sync::Arc::new(DenyAll);
422 let mut store = cfg.store(&engine, data).expect("store");
423 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
424 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
425 crate::runtime::install_runtime_async(&mut linker, &mut store)
426 .await
427 .expect("install");
428 let inst = linker
429 .instantiate_async(&mut store, &module)
430 .await
431 .expect("instantiate");
432 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
433 .await
434 .expect_err("DenyAll refuses");
435 assert!(
436 format!("{err}").contains("caller=main"),
437 "a lone package's consumer is the script; got: {err}"
438 );
439 }
440
441 #[tokio::test]
447 async fn main_authored_code_inside_a_package_cannot_ask_on_the_packages_behalf() {
448 let (lib_bytes, lib_decl, lib_type_info) = crate::codegen::tests::compile_package_modules(
449 "test:wrap",
450 &[(
451 "lib",
452 r#"
453 /**
454 * Pass-through JSON encoder.
455 * @param value Value to encode.
456 * @returns `value` as JSON, or `undefined` when it has none.
457 */
458 export function passthrough(value: unknown): string | undefined {
459 return JSON.stringify(value);
460 }
461 "#,
462 )],
463 &[],
464 );
465 let cfg = crate::runtime::RuntimeConfig::default();
466 let engine = cfg.engine().expect("engine");
467 let mut data =
468 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
469 data.install_type_info(lib_type_info);
470 let mut store = cfg.store(&engine, data).expect("store");
471 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
472 crate::runtime::install_runtime_async(&mut linker, &mut store)
473 .await
474 .expect("install");
475 let lib_module = wasmtime::Module::new(&engine, &lib_bytes).expect("library module");
476 let lib_inst = linker
477 .instantiate_async(&mut store, &lib_module)
478 .await
479 .expect("instantiate library");
480 linker
481 .instance(&mut store, "test:wrap", lib_inst)
482 .expect("register library instance");
483 let public_name = crate::mangle::package_symbol("test:wrap", "passthrough");
484 let func = lib_inst
485 .get_func(&mut store, public_name.as_str())
486 .expect("library public export");
487 linker
488 .define(&mut store, "test:wrap", "passthrough", func)
489 .expect("plain package import alias");
490
491 let consumer = crate::compile_script(
492 r#"
493 import { passthrough } from "test:wrap";
494 import { check } from "submilli:security";
495
496 function gate(): void {
497 check("test.com/op", { foo: 1 });
498 }
499
500 class Probe {
501 seen: number;
502 constructor() { this.seen = 0; }
503 toJson(): string {
504 gate();
505 this.seen = 1;
506 return "\"ok\"";
507 }
508 }
509
510 function main(): number {
511 const p = new Probe();
512 const _ = passthrough(p);
513 return p.seen;
514 }
515 "#,
516 "consumer.subm",
517 crate::FileId(0),
518 &[&lib_decl],
519 &[],
520 )
521 .expect("consumer compiles");
522 store
523 .data_mut()
524 .install_type_info(consumer.type_info.clone());
525 let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
526 let inst = linker
527 .instantiate_async(&mut store, &consumer_module)
528 .await
529 .expect("instantiate consumer");
530 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
531 .await
532 .expect_err("main-authored code inside a package may not ask");
533 let msg = format!("{err}");
534 assert!(
535 msg.contains("no caller to gate") && !msg.contains("caller=test:wrap"),
536 "must refuse rather than name the package: {msg}"
537 );
538 }
539
540 fn strip_name_section(bytes: &[u8]) -> Vec<u8> {
544 use wasmparser::{Parser, Payload};
545 let mut out = bytes[..8].to_vec();
546 for payload in Parser::new(0).parse_all(bytes) {
547 let payload = payload.expect("payload");
548 if let Payload::CustomSection(reader) = &payload
549 && reader.name() == "name"
550 {
551 continue;
552 }
553 if let Some((id, range)) = payload.as_section() {
554 out.push(id);
555 let mut len = Vec::new();
556 leb128_write(&mut len, range.len() as u64);
557 out.extend_from_slice(&len);
558 out.extend_from_slice(&bytes[range]);
559 }
560 }
561 out
562 }
563
564 fn leb128_write(out: &mut Vec<u8>, mut value: u64) {
565 loop {
566 let mut byte = (value & 0x7f) as u8;
567 value >>= 7;
568 if value != 0 {
569 byte |= 0x80;
570 }
571 out.push(byte);
572 if value == 0 {
573 return;
574 }
575 }
576 }
577
578 #[tokio::test]
585 async fn a_module_without_a_name_is_refused_rather_than_attributed() {
586 let source = r#"
587 import { get } from "submilli:secrets";
588 function main(): void { const _ = get("TOKEN"); }
589 "#;
590 let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
591 .expect("compile clean");
592 let stripped = strip_name_section(&compiled.wasm);
593
594 let cfg = crate::runtime::RuntimeConfig::default();
595 let engine = cfg.engine().expect("engine");
596 let module = wasmtime::Module::new(&engine, &stripped).expect("stripped module validates");
597 assert_eq!(
598 module.name(),
599 None,
600 "the fixture must genuinely lack a module name, or it proves nothing"
601 );
602
603 let mut data =
604 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
605 data.install_type_info(compiled.type_info.clone());
606 let mut store = cfg.store(&engine, data).expect("store");
607 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
608 crate::runtime::install_runtime_async(&mut linker, &mut store)
609 .await
610 .expect("install");
611 let inst = linker
612 .instantiate_async(&mut store, &module)
613 .await
614 .expect("instantiate");
615 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
616 .await
617 .expect_err("an unnameable principal must be refused");
618 let msg = format!("{err}");
619 assert!(
620 msg.contains("<unnamed module>"),
621 "must name the unresolvable principal rather than defaulting to one: {msg}"
622 );
623 assert!(
624 !msg.contains("caller=main"),
625 "must not fall back to main: {msg}"
626 );
627 }
628
629 #[tokio::test]
637 async fn security_check_from_an_unnamed_module_names_the_right_failure() {
638 let source = r#"
639 import { check } from "submilli:security";
640 function main(): void { check("test.com/op", { foo: 1 }); }
641 "#;
642 let compiled = crate::compile_script(source, "anon.subm", crate::FileId(0), &[], &[])
643 .expect("compile clean");
644 let stripped = strip_name_section(&compiled.wasm);
645
646 let cfg = crate::runtime::RuntimeConfig::default();
647 let engine = cfg.engine().expect("engine");
648 let module = wasmtime::Module::new(&engine, &stripped).expect("module validates");
649 let mut data =
650 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
651 data.install_type_info(compiled.type_info.clone());
652 let mut store = cfg.store(&engine, data).expect("store");
653 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
654 crate::runtime::install_runtime_async(&mut linker, &mut store)
655 .await
656 .expect("install");
657 let inst = linker
658 .instantiate_async(&mut store, &module)
659 .await
660 .expect("instantiate");
661 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
662 .await
663 .expect_err("an unnameable principal must be refused");
664 let msg = format!("{err}");
665 assert!(
666 msg.contains("<unnamed module>"),
667 "must name the running module as the unresolvable one, not claim no frame exists: \
668 {msg}"
669 );
670 assert!(
673 !msg.contains("operator's policy"),
674 "an unresolvable principal is an invariant refusal, not a policy denial: {msg}"
675 );
676 }
677
678 #[tokio::test]
689 async fn an_unnamed_frame_between_caller_and_callee_is_refused() {
690 let (inner_bytes, inner_decl, inner_ti) = crate::codegen::tests::compile_package_modules(
691 "test:inner",
692 &[(
693 "lib",
694 r#"
695 import { check } from "submilli:security";
696
697 /**
698 * Gates an operation on behalf of whoever called it.
699 * @param foo Value the capability filter matches.
700 * @capability test.com/op { foo }
701 */
702 export function guarded(foo: number): void {
703 check("test.com/op", { foo });
704 }
705 "#,
706 )],
707 &[],
708 );
709 let (relay_bytes, relay_decl, relay_ti) = crate::codegen::tests::compile_package_modules(
710 "test:relay",
711 &[(
712 "lib",
713 r#"
714 import { guarded } from "test:inner";
715
716 /**
717 * Calls the inner package, standing between it and the script.
718 * @param foo Value passed to the inner package.
719 */
720 export function relay(foo: number): void {
721 guarded(foo);
722 }
723 "#,
724 )],
725 &[&inner_decl],
726 );
727 let relay_bytes = strip_name_section(&relay_bytes);
729
730 let cfg = crate::runtime::RuntimeConfig::default();
731 let engine = cfg.engine().expect("engine");
732 let mut data =
733 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
734 data.install_type_info(inner_ti);
735 data.install_type_info(relay_ti);
736 let mut store = cfg.store(&engine, data).expect("store");
737 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
738 crate::runtime::install_runtime_async(&mut linker, &mut store)
739 .await
740 .expect("install");
741
742 let inner_module = wasmtime::Module::new(&engine, &inner_bytes).expect("inner module");
743 let inner_inst = linker
744 .instantiate_async(&mut store, &inner_module)
745 .await
746 .expect("instantiate inner");
747 linker
748 .instance(&mut store, "test:inner", inner_inst)
749 .expect("register inner");
750
751 let relay_module = wasmtime::Module::new(&engine, &relay_bytes).expect("relay module");
752 assert_eq!(
753 relay_module.name(),
754 None,
755 "the middle module must genuinely lack a name, or the test proves nothing"
756 );
757 let relay_inst = linker
758 .instantiate_async(&mut store, &relay_module)
759 .await
760 .expect("instantiate relay");
761 linker
762 .instance(&mut store, "test:relay", relay_inst)
763 .expect("register relay");
764 let public_name = crate::mangle::package_symbol("test:relay", "relay");
765 let relay_fn = relay_inst
766 .get_func(&mut store, public_name.as_str())
767 .expect("relay export");
768 linker
769 .define(&mut store, "test:relay", "relay", relay_fn)
770 .expect("plain package import alias");
771
772 let consumer = crate::compile_script(
773 r#"
774 import { relay } from "test:relay";
775 function main(): void { relay(1); }
776 "#,
777 "consumer.subm",
778 crate::FileId(0),
779 &[&relay_decl],
780 &[],
781 )
782 .expect("consumer compiles");
783 store
784 .data_mut()
785 .install_type_info(consumer.type_info.clone());
786 let consumer_module = wasmtime::Module::new(&engine, &consumer.wasm).expect("module");
787 let inst = linker
788 .instantiate_async(&mut store, &consumer_module)
789 .await
790 .expect("instantiate consumer");
791 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
792 .await
793 .expect_err("an unnameable frame in the walk must be refused");
794 let msg = format!("{err}");
795 assert!(
796 msg.contains("<unnamed module>"),
797 "must refuse on the unnameable frame: {msg}"
798 );
799 assert!(
800 !msg.contains("caller=main"),
801 "must not skip the unnameable frame and name main: {msg}"
802 );
803 }
804
805 #[test]
806 fn module_name_matches_runtime_constant() {
807 assert_eq!(MODULE_NAME, "submilli:security");
810 }
811
812 struct DenyAll;
813 impl crate::runtime::SecurityCheck for DenyAll {
814 fn check(
815 &self,
816 _caller: &str,
817 _capability: &str,
818 _context: &serde_json::Value,
819 ) -> CheckOutcome {
820 CheckOutcome::Deny {
821 rule: None,
822 reason: "blocked by policy".to_string(),
823 }
824 }
825 }
826
827 #[tokio::test]
828 async fn uncaught_denial_renders_message_and_backtrace() {
829 let source = "import { check } from \"submilli:security\";\n\n\
830 function main(): void {\n check(\"test.com/op\", { foo: 1 });\n}\n";
831 let compiled = crate::compile_script(source, "script.subm", crate::FileId(0), &[], &[])
832 .expect("compile clean");
833 let cfg = crate::runtime::RuntimeConfig::default();
834 let engine = cfg.engine().expect("engine");
835 let mut data =
836 crate::runtime::StoreData::with_vfs(crate::runtime::Vfs::tempdir().expect("tempdir"));
837 data.install_type_info(compiled.type_info.clone());
838 data.security_check = std::sync::Arc::new(DenyAll);
839 let mut store = cfg.store(&engine, data).expect("store");
840 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
841 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
842 crate::runtime::install_runtime_async(&mut linker, &mut store)
843 .await
844 .expect("install");
845 let inst = linker
846 .instantiate_async(&mut store, &module)
847 .await
848 .expect("instantiate");
849 let err = crate::runtime::dispatch_main_async(&mut store, &inst)
850 .await
851 .expect_err("uncaught denial surfaces as a runtime error");
852
853 let msg = format!("{err}");
854 assert!(
855 msg.contains(
856 "PermissionDeniedError: permission denied: \
857 caller=main capability=test.com/op: blocked by policy"
858 ),
859 "expected the class-prefixed denial message; got: {msg}"
860 );
861 assert!(
862 msg.contains("fields:")
863 && msg.contains("capability = \"test.com/op\"")
864 && msg.contains("caller = \"main\""),
865 "denial data fields should render on the fields line; got: {msg}"
866 );
867
868 let (sources, file) = crate::Sources::single("script.subm", source).unwrap();
869 let rendered =
870 crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
871 .expect("an uncaught denial should render a backtrace");
872 assert!(
873 rendered.contains("at main ("),
874 "backtrace should point at main: {rendered}"
875 );
876 assert!(
877 rendered.contains("check(\"test.com/op\""),
878 "source context should show the check() call site: {rendered}"
879 );
880 }
881}