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