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