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