1use crate::runtime::host::{abi_arg, abi_result};
16mod cursor;
17pub mod declaration;
18mod finalizers;
19pub(crate) mod value;
20
21use std::sync::Arc;
22
23use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
24
25use crate::runtime::StoreData;
26use crate::runtime::call_log::{Payload, Side, record_payload};
27use crate::runtime::decision::CallTicket;
28use crate::runtime::fuel;
29use crate::runtime::host::register_host_fn_async;
30use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types};
31use crate::runtime::session_kv::{
32 CursorKeys, SessionKvEntry, SessionKvError, SessionKvPage, SessionKvStore,
33};
34use crate::stdlib::abi::{self, backing_struct, f64_field, install_field_getters, string_field};
35use crate::stdlib::shared::check_security_call;
36
37pub const MODULE_NAME: &str = "submilli:session";
38
39pub(super) const MAX_LIST_LIMIT: u32 = 1000;
41
42const MAX_SCAN_PER_PAGE: usize = 512;
46
47const ENTRY_KEY: usize = 1;
49const ENTRY_SIZE_BYTES: usize = 2;
50
51const PAGE_ENTRIES: usize = 1;
53const PAGE_NEXT_CURSOR: usize = 2;
54
55pub use declaration::package_declaration;
56
57pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
58 finalizers::install(linker)?;
59 let engine = linker.engine().clone();
60 let intr = build_intrinsic_types(&engine)?;
61 let string = ValType::Ref(RefType::new(
66 false,
67 HeapType::ConcreteStruct(intr.string.clone()),
68 ));
69 let unknown = ValType::Ref(RefType::new(
71 true,
72 HeapType::ConcreteStruct(intr.object.clone()),
73 ));
74
75 register_host_fn_async(
76 linker,
77 MODULE_NAME,
78 crate::mangle::package_symbol(MODULE_NAME, "get"),
79 FuncType::new(&engine, [string.clone()], [unknown.clone()]),
80 false,
81 |caller, params, results| {
82 Box::pin(async move {
83 let key = read_key(caller, abi_arg(params, 0)?, "get")?;
84 let ticket = gate(caller, "session.read", &key)?;
85 let store = provider(caller, "get")?;
86 let found = store.get(&key).await.map_err(|e| trap(caller, None, &e))?;
87 record_payload(&*caller, ticket, Side::Response, || match &found {
88 Some(payload) => Payload::meta(serde_json::Value::Null)
90 .with_owned_body(String::from_utf16_lossy(payload).into_bytes()),
91 None => Payload::meta(serde_json::json!({ "found": false })),
92 });
93 let Some(payload) = found else {
94 *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
95 return Ok(());
96 };
97 fuel::charge(
100 &mut *caller,
101 fuel::IO,
102 (payload.len() as u64).saturating_mul(2),
103 )?;
104 fuel::charge(&mut *caller, fuel::PARSE, payload.len() as u64)?;
105 *abi_result(results, 0)? = value::deserialize(caller, &payload)?;
106 Ok(())
107 })
108 },
109 )?;
110
111 register_host_fn_async(
112 linker,
113 MODULE_NAME,
114 crate::mangle::package_symbol(MODULE_NAME, "has"),
115 FuncType::new(&engine, [string.clone()], [ValType::I32]),
116 false,
117 |caller, params, results| {
118 Box::pin(async move {
119 let key = read_key(caller, abi_arg(params, 0)?, "has")?;
120 let ticket = gate(caller, "session.read", &key)?;
121 let store = provider(caller, "has")?;
122 let found = store.has(&key).await.map_err(|e| trap(caller, None, &e))?;
123 record_payload(&*caller, ticket, Side::Response, || {
124 Payload::meta(serde_json::json!({ "found": found }))
125 });
126 *abi_result(results, 0)? = Val::I32(i32::from(found));
127 Ok(())
128 })
129 },
130 )?;
131
132 register_host_fn_async(
133 linker,
134 MODULE_NAME,
135 crate::mangle::package_symbol(MODULE_NAME, "set"),
136 FuncType::new(&engine, [string.clone(), unknown.clone()], []),
137 false,
138 |caller, params, _results| {
139 Box::pin(async move {
140 let key = read_key(caller, abi_arg(params, 0)?, "set")?;
141 let ticket = gate(caller, "session.write", &key)?;
142 let payload = value::serialize(caller, abi_arg(params, 1)?).await?;
145 fuel::charge(
147 &mut *caller,
148 fuel::IO,
149 (payload.len() as u64).saturating_mul(2),
150 )?;
151 let store = provider(caller, "set")?;
152 store
153 .set(&key, &payload)
154 .await
155 .map_err(|e| trap(caller, ticket, &e))
156 })
157 },
158 )?;
159
160 register_host_fn_async(
161 linker,
162 MODULE_NAME,
163 crate::mangle::package_symbol(MODULE_NAME, "remove"),
164 FuncType::new(&engine, [string.clone()], [ValType::I32]),
165 false,
166 |caller, params, results| {
167 Box::pin(async move {
168 let key = read_key(caller, abi_arg(params, 0)?, "remove")?;
169 gate(caller, "session.remove", &key)?;
170 let store = provider(caller, "remove")?;
171 *abi_result(results, 0)? = Val::I32(i32::from(
172 store
173 .remove(&key)
174 .await
175 .map_err(|e| trap(caller, None, &e))?,
176 ));
177 Ok(())
178 })
179 },
180 )?;
181
182 register_host_fn_async(
184 linker,
185 MODULE_NAME,
186 crate::mangle::package_symbol(MODULE_NAME, "list"),
187 FuncType::new(
188 &engine,
189 [string.clone(), ValType::F64, unknown.clone()],
190 [unknown.clone()],
191 ),
192 false,
193 |caller, params, results| {
194 Box::pin(async move {
195 *abi_result(results, 0)? = list(
196 caller,
197 abi_arg(params, 0)?,
198 abi_arg(params, 1)?,
199 abi_arg(params, 2)?,
200 )
201 .await?;
202 Ok(())
203 })
204 },
205 )?;
206
207 install_getters(linker, &engine, &intr, string, unknown)?;
208
209 Ok(())
210}
211
212fn install_getters(
216 linker: &mut Linker<StoreData>,
217 engine: &wasmtime::Engine,
218 intr: &IntrinsicTypes,
219 string: ValType,
220 nullable_object: ValType,
221) -> wasmtime::Result<()> {
222 let receiver = ValType::Ref(RefType::new(
225 false,
226 HeapType::ConcreteStruct(intr.object.clone()),
227 ));
228 let array = ValType::Ref(RefType::new(
231 false,
232 HeapType::ConcreteStruct(intr.array.clone()),
233 ));
234 install_field_getters(
235 linker,
236 MODULE_NAME,
237 "Entry",
238 engine,
239 &receiver,
240 &[
241 ("key", ENTRY_KEY, string),
242 ("sizeBytes", ENTRY_SIZE_BYTES, ValType::F64),
243 ],
244 )?;
245 install_field_getters(
246 linker,
247 MODULE_NAME,
248 "Page",
249 engine,
250 &receiver,
251 &[
252 ("entries", PAGE_ENTRIES, array),
253 ("nextCursor", PAGE_NEXT_CURSOR, nullable_object),
254 ],
255 )
256}
257
258fn entry_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
259 let intr = build_intrinsic_types(engine)?;
260 backing_struct(
261 engine,
262 &intr,
263 vec![
264 string_field(&intr), f64_field(), ],
267 )
268}
269
270fn page_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
271 let intr = build_intrinsic_types(engine)?;
272 backing_struct(
273 engine,
274 &intr,
275 vec![
276 abi::array_field(&intr), abi::nullable_object_field(&intr), ],
279 )
280}
281
282async fn list(
285 caller: &mut wasmtime::Caller<'_, StoreData>,
286 prefix_val: &Val,
287 limit_val: &Val,
288 cursor_val: &Val,
289) -> wasmtime::Result<Val> {
290 let prefix = value::read_units(caller, prefix_val, "session.list (prefix)")?;
291 let limit = read_limit(limit_val)?;
292 let ticket = check_security_call(
293 &mut *caller,
294 "session.list",
295 serde_json::json!({ "prefix": String::from_utf16_lossy(&prefix) }),
296 )?;
297 let keys = caller.data().cursor_keys.clone();
298 let resume_after = read_cursor(caller, keys.as_deref(), cursor_val, &prefix)?;
299 let store = provider(caller, "list")?;
300
301 let mut scanned = store
302 .scan(resume_after.as_deref(), &prefix, MAX_SCAN_PER_PAGE)
303 .await
304 .map_err(|e| trap(caller, None, &e))?;
305
306 scanned.entries.truncate(limit);
312 let considered = std::mem::take(&mut scanned.entries);
313 let next_cursor =
314 next_cursor(keys.as_deref(), &prefix, &scanned, &considered, limit).map_err(cursor_trap)?;
315
316 let mut visible = Vec::new();
317 for entry in considered {
318 fuel::charge(&mut *caller, fuel::ELEM, 1)?;
319 if may_read(caller, &entry.key)? {
320 visible.push(entry);
321 }
322 }
323 record_payload(&*caller, ticket, Side::Response, || {
324 let keys: Vec<_> = visible
325 .iter()
326 .map(|entry| String::from_utf16_lossy(&entry.key))
327 .collect();
328 Payload::meta(serde_json::json!({ "keys": keys, "cursor": next_cursor.is_some() }))
329 });
330 build_page(caller, &visible, next_cursor.as_deref())
331}
332
333fn next_cursor(
347 keys: Option<&CursorKeys>,
348 prefix: &[u16],
349 scanned: &SessionKvPage,
350 considered: &[SessionKvEntry],
351 limit: usize,
352) -> Result<Option<String>, cursor::CursorError> {
353 let filled = considered.len() == limit;
354 if !filled && scanned.scanned_all {
355 return Ok(None);
356 }
357 let resume_from = if filled {
358 considered.last().map(|entry| &entry.key)
359 } else {
360 scanned.last_scanned.as_ref()
361 };
362 resume_from
363 .map(|key| cursor::encode(keys, prefix, key))
364 .transpose()
365}
366
367fn read_limit(val: &Val) -> wasmtime::Result<usize> {
368 let Val::F64(bits) = val else {
369 return Err(crate::runtime::host::type_error(
370 "session.list: `limit` must be a number".to_string(),
371 ));
372 };
373 let limit = f64::from_bits(*bits);
374 if !(limit.fract() == 0.0 && (1.0..=f64::from(MAX_LIST_LIMIT)).contains(&limit)) {
375 return Err(crate::runtime::host::range_error(format!(
376 "session.list: `limit` must be a whole number from 1 to {MAX_LIST_LIMIT}, got \
377 {limit} — pass a smaller page size and follow `nextCursor` for the rest"
378 )));
379 }
380 Ok(limit as usize)
381}
382
383fn read_cursor(
384 caller: &mut wasmtime::Caller<'_, StoreData>,
385 keys: Option<&CursorKeys>,
386 val: &Val,
387 prefix: &[u16],
388) -> wasmtime::Result<Option<Vec<u16>>> {
389 if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
390 return Ok(None);
391 }
392 let max_units = cursor::max_cursor_units(provider(caller, "list")?.limits().max_key_units);
393 let units = value::read_units_bounded(caller, val, "session.list (cursor)", max_units)?;
394 cursor::decode(keys, prefix, &units)
395 .map(Some)
396 .map_err(cursor_trap)
397}
398
399fn may_read(caller: &mut wasmtime::Caller<'_, StoreData>, key: &[u16]) -> wasmtime::Result<bool> {
403 let Err(err) = gate(caller, "session.read", key) else {
404 return Ok(true);
405 };
406 match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
410 Some(denial) if denial.is_policy() => {
411 crate::stdlib::shared::mark_filtered(&*caller);
412 Ok(false)
413 }
414 _ => Err(err),
415 }
416}
417
418fn build_page(
419 caller: &mut wasmtime::Caller<'_, StoreData>,
420 entries: &[SessionKvEntry],
421 next_cursor: Option<&str>,
422) -> wasmtime::Result<Val> {
423 let entry_ty = entry_backing_struct(caller.engine())?;
424 let mut built = Vec::with_capacity(entries.len());
425 for entry in entries {
426 let key = crate::runtime::host::write_submilli_string_struct_units(caller, &entry.key)?;
427 built.push(abi::new_backing(
428 caller,
429 entry_ty.clone(),
430 &[
431 Val::AnyRef(Some(key.to_anyref())),
432 Val::F64((entry.size_bytes as f64).to_bits()),
433 ],
434 )?);
435 }
436 let array = value::build_array(caller, built)?;
437 let cursor = match next_cursor {
438 Some(text) => {
439 let units: Vec<u16> = text.encode_utf16().collect();
440 let st = crate::runtime::host::write_submilli_string_struct_units(caller, &units)?;
441 Val::AnyRef(Some(st.to_anyref()))
442 }
443 None => crate::runtime::prelude::undefined::value(caller)?,
444 };
445 let page_ty = page_backing_struct(caller.engine())?;
446 abi::new_backing(caller, page_ty, &[array, cursor])
447}
448
449fn read_key(
450 caller: &mut wasmtime::Caller<'_, StoreData>,
451 val: &Val,
452 op: &str,
453) -> wasmtime::Result<Vec<u16>> {
454 value::read_units(caller, val, &format!("session.{op} (key)"))
455}
456
457fn gate(
461 caller: &mut wasmtime::Caller<'_, StoreData>,
462 capability: &str,
463 key: &[u16],
464) -> wasmtime::Result<Option<CallTicket>> {
465 check_security_call(
466 caller,
467 capability,
468 serde_json::json!({ "key": String::from_utf16_lossy(key) }),
469 )
470}
471
472fn provider(
475 caller: &wasmtime::Caller<'_, StoreData>,
476 op: &str,
477) -> wasmtime::Result<Arc<dyn SessionKvStore>> {
478 caller.data().session_kv.clone().ok_or_else(|| {
479 crate::runtime::host::type_error(format!(
480 "session.{op}: this runtime has no session store configured, so session state \
481 cannot be read or written. The embedder installs one; nothing in the program \
482 can create it."
483 ))
484 })
485}
486
487fn cursor_trap(error: cursor::CursorError) -> wasmtime::Error {
491 match error {
492 cursor::CursorError::Internal(_) => {
493 wasmtime::Error::new(wasmtime::Trap::UnreachableCodeReached).context(error.to_string())
494 }
495 cursor::CursorError::Malformed
496 | cursor::CursorError::UnknownKey
497 | cursor::CursorError::PrefixMismatch
498 | cursor::CursorError::NoEntropy => crate::runtime::host::type_error(error.to_string()),
499 }
500}
501
502fn trap(
511 caller: &wasmtime::Caller<'_, StoreData>,
512 ticket: Option<CallTicket>,
513 error: &SessionKvError,
514) -> wasmtime::Error {
515 match error {
516 SessionKvError::InvalidKey { .. } => crate::runtime::host::type_error(error.to_string()),
517 SessionKvError::LimitExceeded {
518 limit:
519 crate::runtime::session_kv::SessionKvLimitKind::KeyUnits { .. }
520 | crate::runtime::session_kv::SessionKvLimitKind::ValueBytes { .. },
521 ..
522 } => crate::runtime::host::range_error(error.to_string()),
523 SessionKvError::LimitExceeded { .. } => {
524 let who = crate::stdlib::shared::running_package(caller)
525 .or_else(crate::stdlib::shared::PrincipalError::label_or_error);
526 let who = match who {
527 Ok(who) => who,
528 Err(error) => return error,
529 };
530 crate::stdlib::shared::audit_denial_in(
531 caller,
532 ticket,
533 &who,
534 "session.write",
535 &serde_json::json!({}),
536 "quota",
537 "session-state budget exceeded",
538 );
539 crate::runtime::host::quota_exceeded_error(error.to_string())
540 }
541 SessionKvError::SessionClosed { .. } | SessionKvError::Backend { .. } => {
542 crate::runtime::host::fatal_host_error(error)
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use std::sync::Arc;
550
551 use base64::Engine as _;
552
553 use super::SessionKvStore;
554 use crate::runtime::session_kv::{
555 InMemorySessionKv, SessionKvError, SessionKvFuture, SessionKvLimits, SessionKvPage,
556 };
557 use crate::runtime::{
558 CheckOutcome, RuntimeConfig, SecurityCheck, StoreData, Vfs, dispatch_main_async,
559 install_runtime_async,
560 };
561
562 async fn run(source: &str, kv: Option<Arc<dyn SessionKvStore>>) -> wasmtime::Result<()> {
563 run_with_policy(source, kv, None).await
564 }
565
566 async fn run_with_policy(
567 source: &str,
568 kv: Option<Arc<dyn SessionKvStore>>,
569 policy: Option<Arc<dyn SecurityCheck>>,
570 ) -> wasmtime::Result<()> {
571 run_returning(source, kv, policy).await.map(|_| ())
572 }
573
574 async fn run_returning(
577 source: &str,
578 kv: Option<Arc<dyn SessionKvStore>>,
579 policy: Option<Arc<dyn SecurityCheck>>,
580 ) -> wasmtime::Result<String> {
581 let compiled = crate::compile_script(source, "test.ts", crate::FileId(0), &[], &[])
582 .expect("compile clean");
583 let cfg = RuntimeConfig::default();
584 let engine = cfg.engine().expect("engine");
585 let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
586 data.install_type_info(compiled.type_info.clone());
587 data.session_kv = kv;
588 if let Some(policy) = policy {
589 data.security_check = policy;
590 }
591 let mut store = cfg.store_async(&engine, data).expect("store");
592 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
593 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
594 install_runtime_async(&mut linker, &mut store)
595 .await
596 .expect("install");
597 let inst = linker
598 .instantiate_async(&mut store, &module)
599 .await
600 .expect("instantiate");
601 dispatch_main_async(&mut store, &inst)
602 .await
603 .map(Option::unwrap_or_default)
604 }
605
606 #[tokio::test]
607 async fn top_level_undefined_does_not_replace_stored_value() {
608 let kv = Arc::new(InMemorySessionKv::default());
609 let key: Vec<u16> = "kept".encode_utf16().collect();
610 let original: Vec<u16> = r#"{"step":1}"#.encode_utf16().collect();
611 kv.set_now(&key, &original).expect("seed stored value");
612
613 let error = run(
614 r#"
615 import session from "submilli:session";
616 function main(): void {
617 session.set("kept", undefined);
618 }
619 "#,
620 Some(kv.clone()),
621 )
622 .await
623 .expect_err("top-level undefined has no stored JSON representation");
624
625 assert!(format!("{error:#}").contains("top-level undefined"));
626 assert_eq!(kv.get_now(&key).expect("read original"), Some(original));
627 }
628
629 #[tokio::test]
630 async fn nested_undefined_uses_json_object_and_array_rules() {
631 let kv = Arc::new(InMemorySessionKv::default());
632 run(
633 r#"
634 import session from "submilli:session";
635 function main(): void {
636 const items: unknown[] = [1, undefined, null];
637 session.set("nested", {
638 kept: 1,
639 omitted: undefined,
640 inner: { omitted: undefined, kept: null },
641 items,
642 });
643 }
644 "#,
645 Some(kv.clone()),
646 )
647 .await
648 .expect("nested undefined has JSON object and array representations");
649
650 let key: Vec<u16> = "nested".encode_utf16().collect();
651 let payload = kv
652 .get_now(&key)
653 .expect("read stored JSON")
654 .expect("stored value");
655 let text = String::from_utf16(&payload).expect("valid JSON text");
656 let actual: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
657 assert_eq!(
658 actual,
659 serde_json::json!({
660 "kept": 1,
661 "inner": { "kept": null },
662 "items": [1, null, null],
663 })
664 );
665 }
666
667 #[tokio::test]
668 async fn missing_value_is_undefined_and_stored_null_is_preserved() {
669 run(
670 r#"
671 import session from "submilli:session";
672 function main(): void {
673 const missing = session.get<string>("missing");
674 assert(missing === undefined, "missing keys yield undefined");
675 session.set("null", null);
676 const stored = session.get<null>("null");
677 assert(stored === null, "stored null stays null");
678 assert(session.has("null"), "stored null is present");
679 assert(!session.has("missing"), "missing key stays absent");
680 }
681 "#,
682 Some(Arc::new(InMemorySessionKv::default())),
683 )
684 .await
685 .expect("undefined and null remain distinct");
686 }
687
688 #[tokio::test]
689 async fn narrowed_host_carriers() {
690 let source = r#"
691import session from "submilli:session";
692import { Entry, Page } from "submilli:session";
693import { info } from "submilli:fs";
694
695class Parent { value: unknown = null; reset(value: unknown): void { this.value = value; } }
696function rejects(read: () => void): void {
697 let caught = false;
698 try { read(); } catch (e) { caught = e instanceof TypeError; }
699 assert(caught, "unrelated carrier must throw TypeError");
700}
701
702class PageField extends Parent { value: Page | null = null; }
703class EntryField extends Parent { value: Entry | null = null; }
704function main(): void {
705 session.set("key", "value");
706 const p = new PageField(); p.reset(session.list("", 10));
707 assert(p.value!.entries.length === 1, "Page");
708 const e = new EntryField(); e.reset(p.value!.entries[0]);
709 assert(e.value!.key === "key", "Entry");
710 p.reset(e.value); rejects(() => { const v = p.value; });
711 e.reset(info()); rejects(() => { const v = e.value; });
712}
713"#;
714 run(source, Some(Arc::new(InMemorySessionKv::default())))
715 .await
716 .expect("host guards");
717 }
718
719 #[tokio::test]
723 async fn a_runtime_without_a_provider_refuses_every_op() {
724 for op in [
725 r#"session.set("k", 1)"#,
726 r#"const _ = session.get("k")"#,
727 r#"const _ = session.has("k")"#,
728 r#"const _ = session.remove("k")"#,
729 ] {
730 let source = format!(
731 "import session from \"submilli:session\";\n\
732 function main(): void {{ {op}; }}\n"
733 );
734 let err = run(&source, None).await.expect_err("must refuse");
735 let message = format!("{err}");
736 assert!(
737 message.contains("no session store configured"),
738 "{op}: {message}"
739 );
740 assert!(
741 message.contains("The embedder installs one"),
742 "the message must name who can fix it; got: {message}"
743 );
744 }
745 }
746
747 #[tokio::test]
750 async fn the_missing_provider_refusal_is_catchable() {
751 let source = r#"
752 import session from "submilli:session";
753
754 function main(): void {
755 let caught = false;
756 try {
757 session.set("k", 1);
758 } catch (e: Error) {
759 caught = true;
760 }
761 assert(caught, "the refusal is catchable");
762 }
763 "#;
764 run(source, None).await.expect("program completes");
765 }
766
767 #[tokio::test]
768 async fn session_budgets_throw_quota_exceeded_error() {
769 let defaults = SessionKvLimits::default();
770 let stores = [
771 InMemorySessionKv::new(SessionKvLimits {
772 max_entries: 0,
773 ..defaults
774 }),
775 InMemorySessionKv::new(SessionKvLimits {
776 max_session_bytes: 1,
777 ..defaults
778 }),
779 ];
780 for kv in stores {
781 let source = r#"
782 import session from "submilli:session";
783 function main(): void {
784 let caught = false;
785 try { session.set("k", "value"); }
786 catch (e: QuotaExceededError) {
787 caught = e instanceof Error && !((e as unknown) instanceof RangeError);
788 assert(e.name === "QuotaExceededError", "quota name");
789 }
790 assert(caught, "session budget refusal is a quota error");
791 assert(!session.has("k"), "refused write leaves no value");
792 }
793 "#;
794 run(source, Some(Arc::new(kv)))
795 .await
796 .expect("catch quota refusal");
797 }
798 }
799
800 struct FailingStore(SessionKvError);
803
804 impl SessionKvStore for FailingStore {
805 fn get<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, Option<Vec<u16>>> {
806 Box::pin(std::future::ready(Err(self.0.clone())))
807 }
808 fn has<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, bool> {
809 Box::pin(std::future::ready(Err(self.0.clone())))
810 }
811 fn set<'a>(&'a self, _: &'a [u16], _: &'a [u16]) -> SessionKvFuture<'a, ()> {
812 Box::pin(std::future::ready(Err(self.0.clone())))
813 }
814 fn remove<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, bool> {
815 Box::pin(std::future::ready(Err(self.0.clone())))
816 }
817 fn scan<'a>(
818 &'a self,
819 _: Option<&'a [u16]>,
820 _: &'a [u16],
821 _: usize,
822 ) -> SessionKvFuture<'a, SessionKvPage> {
823 Box::pin(std::future::ready(Err(self.0.clone())))
824 }
825 fn limits(&self) -> SessionKvLimits {
826 SessionKvLimits::default()
827 }
828 }
829
830 #[tokio::test]
833 async fn a_closed_session_or_failed_storage_ends_the_run() {
834 let failures = [
835 SessionKvError::SessionClosed { operation: "set" },
836 SessionKvError::Backend {
837 operation: "set",
838 message: "disk I/O error".into(),
839 },
840 ];
841 for op in [
842 r#"session.set("k", 1);"#,
843 r#"session.get<number>("k");"#,
844 r#"session.has("k");"#,
845 r#"session.remove("k");"#,
846 r#"session.list("", 10);"#,
847 ] {
848 for failure in &failures {
849 let source = format!(
850 r#"
851 import session from "submilli:session";
852 function main(): string {{
853 try {{ {op} }} catch (e: Error) {{ return "caught"; }}
854 return "continued";
855 }}
856 "#
857 );
858 let outcome =
859 run_returning(&source, Some(Arc::new(FailingStore(failure.clone()))), None)
860 .await;
861 let error = outcome.expect_err(op);
862 assert!(
863 error.is::<crate::runtime::host::FatalHostError>(),
864 "{op}: {error:?}"
865 );
866 }
867 }
868 }
869
870 #[tokio::test]
871 async fn session_argument_bounds_remain_range_errors() {
872 let defaults = SessionKvLimits::default();
873 for limits in [
874 SessionKvLimits {
875 max_key_units: 1,
876 ..defaults
877 },
878 SessionKvLimits {
879 max_value_bytes: 1,
880 ..defaults
881 },
882 ] {
883 let source = r#"
884 import session from "submilli:session";
885 function main(): void {
886 let caught = false;
887 try { session.set("key", "value"); }
888 catch (e: RangeError) {
889 caught = !((e as unknown) instanceof QuotaExceededError);
890 }
891 assert(caught, "individual key/value caps remain argument errors");
892 }
893 "#;
894 run(source, Some(Arc::new(InMemorySessionKv::new(limits))))
895 .await
896 .expect("catch argument bounds");
897 }
898 }
899
900 #[tokio::test]
903 async fn a_limit_refusal_never_carries_the_stored_value() {
904 let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::new(SessionKvLimits {
905 max_value_bytes: 8,
906 ..SessionKvLimits::default()
907 }));
908 let source = r#"
909 import session from "submilli:session";
910
911 function main(): void {
912 session.set("k", "SUPER_SECRET_PAYLOAD");
913 }
914 "#;
915 let err = run(source, Some(kv)).await.expect_err("over the limit");
916 let message = format!("{err}");
917 assert!(message.contains("value size limit"), "{message}");
918 assert!(
919 message.contains("\"k\""),
920 "the key names the entry: {message}"
921 );
922 assert!(
923 !message.contains("SUPER_SECRET_PAYLOAD"),
924 "the value must never reach a diagnostic: {message}"
925 );
926 }
927
928 struct AllowPrefix(&'static str);
933
934 impl SecurityCheck for AllowPrefix {
935 fn check(
936 &self,
937 _caller: &str,
938 capability: &str,
939 context: &serde_json::Value,
940 ) -> CheckOutcome {
941 if capability != "session.read" {
942 return CheckOutcome::Allow { rule: None };
943 }
944 let key = context.get("key").and_then(|k| k.as_str()).unwrap_or("");
945 if key.starts_with(self.0) {
946 CheckOutcome::Allow { rule: None }
947 } else {
948 CheckOutcome::Deny {
949 rule: None,
950 reason: format!("only {} is readable", self.0),
951 }
952 }
953 }
954 }
955
956 #[tokio::test]
961 async fn a_filtered_listing_reveals_neither_the_hidden_keys_nor_their_count() {
962 let source = r#"
963 import session from "submilli:session";
964
965 function main(): void {
966 session.set("aaa/1", 1);
967 session.set("triage/a", 1);
968 session.set("triage/b", 2);
969 session.set("zzz/9", 9);
970
971 // A page sized to the whole keyspace: every key is a candidate,
972 // so anything the policy hides can only vanish silently.
973 const all = session.list("", 100);
974 assert(all.entries.length === 2, "only the permitted keys are counted");
975 assert(all.entries[0].key === "triage/a", "first permitted key");
976 assert(all.entries[1].key === "triage/b", "second permitted key");
977 assert(all.nextCursor === undefined, "the cursor does not point past hidden keys");
978
979 // Listing a wholly denied prefix is empty and final, not an
980 // error: a throw would itself confirm the keys exist.
981 const denied = session.list("zzz/", 10);
982 assert(denied.entries.length === 0, "a denied prefix lists as empty");
983 assert(denied.nextCursor === undefined, "and reports itself finished");
984 }
985 "#;
986 let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
987 run_with_policy(source, Some(kv), Some(Arc::new(AllowPrefix("triage/"))))
988 .await
989 .expect("program completes");
990 }
991
992 #[tokio::test]
1000 async fn cursors_from_a_filtered_listing_do_not_carry_the_denied_keys() {
1001 let source = r#"
1002 import session from "submilli:session";
1003
1004 function main(): string {
1005 session.set("secret/alpha", 1);
1006 session.set("triage/a", 1);
1007 session.set("zzz-denied", 1);
1008
1009 let cursors = "";
1010 let shown = 0;
1011 let cursor: string | undefined;
1012 let guard = 0;
1013 while (guard < 10) {
1014 guard = guard + 1;
1015 const page = session.list("", 1, cursor);
1016 shown = shown + page.entries.length;
1017 const next = page.nextCursor;
1018 if (next === undefined) { break; }
1019 cursors = cursors + next;
1020 cursor = next;
1021 }
1022 assert(shown === 1, "only the permitted key is listed");
1023 return cursors;
1024 }
1025 "#;
1026 let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
1027 let cursors = run_returning(source, Some(kv), Some(Arc::new(AllowPrefix("triage/"))))
1028 .await
1029 .expect("program completes");
1030
1031 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1033 .decode(cursors.as_bytes())
1034 .unwrap_or_default();
1035 for denied in ["secret/alpha", "zzz-denied"] {
1036 let utf16: Vec<u8> = denied.encode_utf16().flat_map(u16::to_be_bytes).collect();
1037 assert!(
1038 !cursors.contains(denied),
1039 "a denied key is spelled in a cursor: {cursors}"
1040 );
1041 assert!(
1042 !decoded.windows(utf16.len()).any(|w| w == utf16.as_slice()),
1043 "a denied key is recoverable from a cursor: {cursors}"
1044 );
1045 }
1046 }
1047
1048 #[tokio::test]
1052 async fn a_listing_carries_no_value_contents() {
1053 let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
1054 let source = r#"
1055 import session from "submilli:session";
1056
1057 function main(): string {
1058 session.set("k", "SUPER_SECRET_PAYLOAD");
1059 const page = session.list("", 10);
1060 let rendered = "";
1061 for (const entry of page.entries) {
1062 rendered = rendered + entry.key + ":" + entry.sizeBytes.toString();
1063 }
1064 // 20 characters plus the two JSON quotes, two bytes per UTF-16
1065 // code unit.
1066 assert(rendered === "k:44", "the entry carries the key and the size only");
1067 return JSON.stringify(page.entries);
1068 }
1069 "#;
1070 run(source, Some(kv)).await.expect("program completes");
1071 }
1072}