Skip to main content

submilli_engine/stdlib/session/
mod.rs

1//! `submilli:session` — a key-value store scoped to one session.
2//!
3//! Rust host functions registered directly under the package name. Each op runs
4//! `check_security` before touching the store; the gated capabilities are
5//! cataloged in [`crate::stdlib::capabilities`] — keep it in sync when adding
6//! or removing a gate (see AGENTS.md).
7//!
8//! Storage is the embedder's: [`StoreData::session_kv`] holds the provider. A
9//! runtime with none configured refuses every op rather than inventing state,
10//! so a program never silently writes into a store nobody can read back.
11//!
12//! Keys and payloads stay UTF-16 code units from the guest `$string` to the
13//! trait and back — see [`value`] for why.
14
15use 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
36/// Entries one `list` page may hold. Matches the catalog's documented range.
37pub(super) const MAX_LIST_LIMIT: u32 = 1000;
38
39/// Keys one `list` call walks before it stops and hands back a cursor, however
40/// few of them matched the prefix. A work bound, not a result bound: a prefix
41/// matching nothing still costs at most this much, and the caller pages on.
42const MAX_SCAN_PER_PAGE: usize = 512;
43
44// `$EntryBacking` field indices (0 is the vtable).
45const ENTRY_KEY: usize = 1;
46const ENTRY_SIZE_BYTES: usize = 2;
47
48// `$PageBacking` field indices.
49const 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    // `key: string` takes the whole `$string` struct rather than the raw payload
58    // array: the raw-string unwrap/rewrap in `emit_direct_call` is applied only
59    // on the plain `Call` path, so a generic or indirect call site would hand us
60    // the struct regardless. Same convention as `security.check`.
61    let string = ValType::Ref(RefType::new(
62        false,
63        HeapType::ConcreteStruct(intr.string.clone()),
64    ));
65    // `unknown` — a value and a `get` result alike — lowers to `(ref null $Object)`.
66    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        /* deterministic = */ 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                    // The size is of the copy and digest's encoding: UTF-8 bytes.
85                    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                // Read from the store (code units, two bytes each), then
94                // parsed back into values.
95                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        /* deterministic = */ 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        /* deterministic = */ 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                // Serialization runs before the provider is consulted: a value
139                // with no JSON form must leave the previous entry intact.
140                let payload = value::serialize(caller, abi_arg(params, 1)?).await?;
141                // Written to the store: code units, two bytes each.
142                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        /* deterministic = */ 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    // `list` re-enters no guest code, so it registers synchronously.
175    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        /* deterministic = */ 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
201/// `Entry` and `Page` are `Dispatch::Direct`, so each property is a host getter
202/// under `submilli:session#<Iface>#<prop>` whose result must be exactly what
203/// codegen lowers the declared type to — there is no coercion in between.
204fn 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    // A Direct receiver is the non-null `(ref $Object)`; the guest holds these
212    // backings as the nullable `unknown` lowering and codegen bridges the two.
213    let receiver = ValType::Ref(RefType::new(
214        false,
215        HeapType::ConcreteStruct(intr.object.clone()),
216    ));
217    // `entries: Entry[]` lowers to a non-null `$Array`; `nextCursor: string |
218    // undefined` is a mixed union, so it lowers to the universal `(ref null $Object)`.
219    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), // key
254            f64_field(),         // sizeBytes
255        ],
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),           // entries
266            abi::nullable_object_field(&intr), // nextCursor
267        ],
268    )
269}
270
271/// One `list` call: gate the operation, then walk the keyspace gating each
272/// candidate key, until the page fills or the scan bound stops the walk.
273fn 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    // The `limit` applies to candidates, not to survivors of the filter: taking
294    // `limit` candidates and only then filtering keeps the cursor independent of
295    // what the policy hid. Refilling the page from further keys would make the
296    // number of keys consumed a function of how many were denied, which is the
297    // disclosure the filter exists to prevent.
298    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
319/// Where the next page resumes, or `None` once nothing matching can remain.
320///
321/// Two things leave keys behind and both must mint a cursor: the `limit` cut the
322/// matches short, or the scan bound stopped the walk with the keyspace
323/// unfinished. The second is the subtle one — it can leave a page empty while
324/// matches sit just past `last_scanned` — so an empty page is never on its own
325/// evidence that a listing is done.
326///
327/// The cursor is derived from the keys *considered*, never from the ones that
328/// survived the filter, so two callers under different policies get the same
329/// page boundary and neither learns what the stricter one hid. The key it
330/// resumes after is therefore routinely one the caller was denied — see
331/// [`cursor`] for why that means the cursor must be sealed.
332fn 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
383/// The per-key half of the double gate. A denial omits the key rather than
384/// failing the call: a listing that threw on the first forbidden key would
385/// itself disclose that the key exists.
386fn 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    // Only the policy's own answer filters. An invariant denial means the check
391    // could not be made at all, and swallowing it would turn a runtime refusal
392    // into a silently short listing.
393    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
441/// The policy sees the key as text. A lone surrogate is not expressible in a
442/// JSON string, so the context renders it lossily — matching on such a key is
443/// not something a rule can express, and the store keeps the exact units.
444fn 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
456/// Clone the provider out of the store before any `await` or guest re-entry:
457/// the borrow on `caller.data()` cannot be held across either.
458fn 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
471/// Both cursor paths — minting one and reading one back — map through here, so
472/// a caller cannot tell the two apart by the shape of the failure. No variant
473/// names a key, so the message passes through whole.
474fn 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
485/// Every store failure reaches the guest as a catchable error. The `Display`
486/// impl already excludes the stored value, so the message can be passed
487/// through whole.
488///
489/// A write-quota refusal continues the `session.write` call `ticket` names; any other
490/// operation passes `None`, so the refusal is recorded as a call of its own.
491fn 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    /// `main`'s output, for the tests that assert on what the program observed
552    /// rather than only that it completed.
553    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    /// An unconfigured runtime must refuse rather than fabricate a store: a
697    /// program that silently wrote into per-run state would look like it
698    /// persisted and lose everything.
699    #[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    /// The refusal is catchable, not an uncatchable trap — a program can fall
725    /// back to running without session state.
726    #[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    /// A quota refusal names the limit and the key, and never the value — the
811    /// property `SessionKvError` maintains must survive the mapping to a trap.
812    #[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    /// The per-key filter the catalog's `key glob "triage/*"` example describes:
839    /// `session.read` allowed for some keys and denied for others. The fixture
840    /// harness denies a whole capability, so only a context-reading policy can
841    /// exercise the selective case.
842    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    /// Denied keys must be invisible in every channel a listing has: the
867    /// entries, the count, and the cursor. A cursor that pointed past a hidden
868    /// key would let a caller detect that something sat between two keys it can
869    /// see.
870    #[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    /// The exfiltration a decodable cursor allowed, run the way a guest would.
903    ///
904    /// Paging at `limit: 1` makes every cursor resume after exactly one key, and
905    /// the keys considered include the ones the filter hid — so if a cursor can
906    /// be read, the denied key names come back in order. The listing itself is
907    /// correct here and always was: it shows only `triage/*`. What this asserts
908    /// is that the cursors it hands out carry nothing further.
909    #[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        // The cursors are base64url, which a guest decodes as easily as we do.
942        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    /// `list` discloses metadata only. The value is reachable through `get`,
959    /// which is gated per key on its own — a listing must not be a way around
960    /// that.
961    #[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}