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