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;
18mod finalizers;
19pub(crate) mod value;
20
21use std::sync::Arc;
22
23use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
24
25use crate::runtime::StoreData;
26use crate::runtime::call_log::{Payload, Side, record_payload};
27use crate::runtime::decision::CallTicket;
28use crate::runtime::fuel;
29use crate::runtime::host::register_host_fn_async;
30use crate::runtime::intrinsic_types::{IntrinsicTypes, build_intrinsic_types};
31use crate::runtime::session_kv::{
32    CursorKeys, SessionKvEntry, SessionKvError, SessionKvPage, SessionKvStore,
33};
34use crate::stdlib::abi::{self, backing_struct, f64_field, install_field_getters, string_field};
35use crate::stdlib::shared::check_security_call;
36
37pub const MODULE_NAME: &str = "submilli:session";
38
39/// Entries one `list` page may hold. Matches the catalog's documented range.
40pub(super) const MAX_LIST_LIMIT: u32 = 1000;
41
42/// Keys one `list` call walks before it stops and hands back a cursor, however
43/// few of them matched the prefix. A work bound, not a result bound: a prefix
44/// matching nothing still costs at most this much, and the caller pages on.
45const MAX_SCAN_PER_PAGE: usize = 512;
46
47// `$EntryBacking` field indices (0 is the vtable).
48const ENTRY_KEY: usize = 1;
49const ENTRY_SIZE_BYTES: usize = 2;
50
51// `$PageBacking` field indices.
52const PAGE_ENTRIES: usize = 1;
53const PAGE_NEXT_CURSOR: usize = 2;
54
55pub use declaration::package_declaration;
56
57pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
58    finalizers::install(linker)?;
59    let engine = linker.engine().clone();
60    let intr = build_intrinsic_types(&engine)?;
61    // `key: string` takes the whole `$string` struct rather than the raw payload
62    // array: the raw-string unwrap/rewrap in `emit_direct_call` is applied only
63    // on the plain `Call` path, so a generic or indirect call site would hand us
64    // the struct regardless. Same convention as `security.check`.
65    let string = ValType::Ref(RefType::new(
66        false,
67        HeapType::ConcreteStruct(intr.string.clone()),
68    ));
69    // `unknown` — a value and a `get` result alike — lowers to `(ref null $Object)`.
70    let unknown = ValType::Ref(RefType::new(
71        true,
72        HeapType::ConcreteStruct(intr.object.clone()),
73    ));
74
75    register_host_fn_async(
76        linker,
77        MODULE_NAME,
78        crate::mangle::package_symbol(MODULE_NAME, "get"),
79        FuncType::new(&engine, [string.clone()], [unknown.clone()]),
80        /* deterministic = */ false,
81        |caller, params, results| {
82            Box::pin(async move {
83                let key = read_key(caller, abi_arg(params, 0)?, "get")?;
84                let ticket = gate(caller, "session.read", &key)?;
85                let store = provider(caller, "get")?;
86                let found = store.get(&key).await.map_err(|e| trap(caller, None, &e))?;
87                record_payload(&*caller, ticket, Side::Response, || match &found {
88                    // The size is of the copy and digest's encoding: UTF-8 bytes.
89                    Some(payload) => Payload::meta(serde_json::Value::Null)
90                        .with_owned_body(String::from_utf16_lossy(payload).into_bytes()),
91                    None => Payload::meta(serde_json::json!({ "found": false })),
92                });
93                let Some(payload) = found else {
94                    *abi_result(results, 0)? = crate::runtime::prelude::undefined::value(caller)?;
95                    return Ok(());
96                };
97                // Read from the store (code units, two bytes each), then
98                // parsed back into values.
99                fuel::charge(
100                    &mut *caller,
101                    fuel::IO,
102                    (payload.len() as u64).saturating_mul(2),
103                )?;
104                fuel::charge(&mut *caller, fuel::PARSE, payload.len() as u64)?;
105                *abi_result(results, 0)? = value::deserialize(caller, &payload)?;
106                Ok(())
107            })
108        },
109    )?;
110
111    register_host_fn_async(
112        linker,
113        MODULE_NAME,
114        crate::mangle::package_symbol(MODULE_NAME, "has"),
115        FuncType::new(&engine, [string.clone()], [ValType::I32]),
116        /* deterministic = */ false,
117        |caller, params, results| {
118            Box::pin(async move {
119                let key = read_key(caller, abi_arg(params, 0)?, "has")?;
120                let ticket = gate(caller, "session.read", &key)?;
121                let store = provider(caller, "has")?;
122                let found = store.has(&key).await.map_err(|e| trap(caller, None, &e))?;
123                record_payload(&*caller, ticket, Side::Response, || {
124                    Payload::meta(serde_json::json!({ "found": found }))
125                });
126                *abi_result(results, 0)? = Val::I32(i32::from(found));
127                Ok(())
128            })
129        },
130    )?;
131
132    register_host_fn_async(
133        linker,
134        MODULE_NAME,
135        crate::mangle::package_symbol(MODULE_NAME, "set"),
136        FuncType::new(&engine, [string.clone(), unknown.clone()], []),
137        /* deterministic = */ false,
138        |caller, params, _results| {
139            Box::pin(async move {
140                let key = read_key(caller, abi_arg(params, 0)?, "set")?;
141                let ticket = gate(caller, "session.write", &key)?;
142                // Serialization runs before the provider is consulted: a value
143                // with no JSON form must leave the previous entry intact.
144                let payload = value::serialize(caller, abi_arg(params, 1)?).await?;
145                // Written to the store: code units, two bytes each.
146                fuel::charge(
147                    &mut *caller,
148                    fuel::IO,
149                    (payload.len() as u64).saturating_mul(2),
150                )?;
151                let store = provider(caller, "set")?;
152                store
153                    .set(&key, &payload)
154                    .await
155                    .map_err(|e| trap(caller, ticket, &e))
156            })
157        },
158    )?;
159
160    register_host_fn_async(
161        linker,
162        MODULE_NAME,
163        crate::mangle::package_symbol(MODULE_NAME, "remove"),
164        FuncType::new(&engine, [string.clone()], [ValType::I32]),
165        /* deterministic = */ false,
166        |caller, params, results| {
167            Box::pin(async move {
168                let key = read_key(caller, abi_arg(params, 0)?, "remove")?;
169                gate(caller, "session.remove", &key)?;
170                let store = provider(caller, "remove")?;
171                *abi_result(results, 0)? = Val::I32(i32::from(
172                    store
173                        .remove(&key)
174                        .await
175                        .map_err(|e| trap(caller, None, &e))?,
176                ));
177                Ok(())
178            })
179        },
180    )?;
181
182    // `list` re-enters no guest code, but its store may reach durable storage.
183    register_host_fn_async(
184        linker,
185        MODULE_NAME,
186        crate::mangle::package_symbol(MODULE_NAME, "list"),
187        FuncType::new(
188            &engine,
189            [string.clone(), ValType::F64, unknown.clone()],
190            [unknown.clone()],
191        ),
192        /* deterministic = */ false,
193        |caller, params, results| {
194            Box::pin(async move {
195                *abi_result(results, 0)? = list(
196                    caller,
197                    abi_arg(params, 0)?,
198                    abi_arg(params, 1)?,
199                    abi_arg(params, 2)?,
200                )
201                .await?;
202                Ok(())
203            })
204        },
205    )?;
206
207    install_getters(linker, &engine, &intr, string, unknown)?;
208
209    Ok(())
210}
211
212/// `Entry` and `Page` are `Dispatch::Direct`, so each property is a host getter
213/// under `submilli:session#<Iface>#<prop>` whose result must be exactly what
214/// codegen lowers the declared type to — there is no coercion in between.
215fn install_getters(
216    linker: &mut Linker<StoreData>,
217    engine: &wasmtime::Engine,
218    intr: &IntrinsicTypes,
219    string: ValType,
220    nullable_object: ValType,
221) -> wasmtime::Result<()> {
222    // A Direct receiver is the non-null `(ref $Object)`; the guest holds these
223    // backings as the nullable `unknown` lowering and codegen bridges the two.
224    let receiver = ValType::Ref(RefType::new(
225        false,
226        HeapType::ConcreteStruct(intr.object.clone()),
227    ));
228    // `entries: Entry[]` lowers to a non-null `$Array`; `nextCursor: string |
229    // undefined` is a mixed union, so it lowers to the universal `(ref null $Object)`.
230    let array = ValType::Ref(RefType::new(
231        false,
232        HeapType::ConcreteStruct(intr.array.clone()),
233    ));
234    install_field_getters(
235        linker,
236        MODULE_NAME,
237        "Entry",
238        engine,
239        &receiver,
240        &[
241            ("key", ENTRY_KEY, string),
242            ("sizeBytes", ENTRY_SIZE_BYTES, ValType::F64),
243        ],
244    )?;
245    install_field_getters(
246        linker,
247        MODULE_NAME,
248        "Page",
249        engine,
250        &receiver,
251        &[
252            ("entries", PAGE_ENTRIES, array),
253            ("nextCursor", PAGE_NEXT_CURSOR, nullable_object),
254        ],
255    )
256}
257
258fn entry_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
259    let intr = build_intrinsic_types(engine)?;
260    backing_struct(
261        engine,
262        &intr,
263        vec![
264            string_field(&intr), // key
265            f64_field(),         // sizeBytes
266        ],
267    )
268}
269
270fn page_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
271    let intr = build_intrinsic_types(engine)?;
272    backing_struct(
273        engine,
274        &intr,
275        vec![
276            abi::array_field(&intr),           // entries
277            abi::nullable_object_field(&intr), // nextCursor
278        ],
279    )
280}
281
282/// One `list` call: gate the operation, then walk the keyspace gating each
283/// candidate key, until the page fills or the scan bound stops the walk.
284async fn list(
285    caller: &mut wasmtime::Caller<'_, StoreData>,
286    prefix_val: &Val,
287    limit_val: &Val,
288    cursor_val: &Val,
289) -> wasmtime::Result<Val> {
290    let prefix = value::read_units(caller, prefix_val, "session.list (prefix)")?;
291    let limit = read_limit(limit_val)?;
292    let ticket = check_security_call(
293        &mut *caller,
294        "session.list",
295        serde_json::json!({ "prefix": String::from_utf16_lossy(&prefix) }),
296    )?;
297    let keys = caller.data().cursor_keys.clone();
298    let resume_after = read_cursor(caller, keys.as_deref(), cursor_val, &prefix)?;
299    let store = provider(caller, "list")?;
300
301    let mut scanned = store
302        .scan(resume_after.as_deref(), &prefix, MAX_SCAN_PER_PAGE)
303        .await
304        .map_err(|e| trap(caller, None, &e))?;
305
306    // The `limit` applies to candidates, not to survivors of the filter: taking
307    // `limit` candidates and only then filtering keeps the cursor independent of
308    // what the policy hid. Refilling the page from further keys would make the
309    // number of keys consumed a function of how many were denied, which is the
310    // disclosure the filter exists to prevent.
311    scanned.entries.truncate(limit);
312    let considered = std::mem::take(&mut scanned.entries);
313    let next_cursor =
314        next_cursor(keys.as_deref(), &prefix, &scanned, &considered, limit).map_err(cursor_trap)?;
315
316    let mut visible = Vec::new();
317    for entry in considered {
318        fuel::charge(&mut *caller, fuel::ELEM, 1)?;
319        if may_read(caller, &entry.key)? {
320            visible.push(entry);
321        }
322    }
323    record_payload(&*caller, ticket, Side::Response, || {
324        let keys: Vec<_> = visible
325            .iter()
326            .map(|entry| String::from_utf16_lossy(&entry.key))
327            .collect();
328        Payload::meta(serde_json::json!({ "keys": keys, "cursor": next_cursor.is_some() }))
329    });
330    build_page(caller, &visible, next_cursor.as_deref())
331}
332
333/// Where the next page resumes, or `None` once nothing matching can remain.
334///
335/// Two things leave keys behind and both must mint a cursor: the `limit` cut the
336/// matches short, or the scan bound stopped the walk with the keyspace
337/// unfinished. The second is the subtle one — it can leave a page empty while
338/// matches sit just past `last_scanned` — so an empty page is never on its own
339/// evidence that a listing is done.
340///
341/// The cursor is derived from the keys *considered*, never from the ones that
342/// survived the filter, so two callers under different policies get the same
343/// page boundary and neither learns what the stricter one hid. The key it
344/// resumes after is therefore routinely one the caller was denied — see
345/// [`cursor`] for why that means the cursor must be sealed.
346fn next_cursor(
347    keys: Option<&CursorKeys>,
348    prefix: &[u16],
349    scanned: &SessionKvPage,
350    considered: &[SessionKvEntry],
351    limit: usize,
352) -> Result<Option<String>, cursor::CursorError> {
353    let filled = considered.len() == limit;
354    if !filled && scanned.scanned_all {
355        return Ok(None);
356    }
357    let resume_from = if filled {
358        considered.last().map(|entry| &entry.key)
359    } else {
360        scanned.last_scanned.as_ref()
361    };
362    resume_from
363        .map(|key| cursor::encode(keys, prefix, key))
364        .transpose()
365}
366
367fn read_limit(val: &Val) -> wasmtime::Result<usize> {
368    let Val::F64(bits) = val else {
369        return Err(crate::runtime::host::type_error(
370            "session.list: `limit` must be a number".to_string(),
371        ));
372    };
373    let limit = f64::from_bits(*bits);
374    if !(limit.fract() == 0.0 && (1.0..=f64::from(MAX_LIST_LIMIT)).contains(&limit)) {
375        return Err(crate::runtime::host::range_error(format!(
376            "session.list: `limit` must be a whole number from 1 to {MAX_LIST_LIMIT}, got \
377             {limit} — pass a smaller page size and follow `nextCursor` for the rest"
378        )));
379    }
380    Ok(limit as usize)
381}
382
383fn read_cursor(
384    caller: &mut wasmtime::Caller<'_, StoreData>,
385    keys: Option<&CursorKeys>,
386    val: &Val,
387    prefix: &[u16],
388) -> wasmtime::Result<Option<Vec<u16>>> {
389    if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
390        return Ok(None);
391    }
392    let max_units = cursor::max_cursor_units(provider(caller, "list")?.limits().max_key_units);
393    let units = value::read_units_bounded(caller, val, "session.list (cursor)", max_units)?;
394    cursor::decode(keys, prefix, &units)
395        .map(Some)
396        .map_err(cursor_trap)
397}
398
399/// The per-key half of the double gate. A denial omits the key rather than
400/// failing the call: a listing that threw on the first forbidden key would
401/// itself disclose that the key exists.
402fn may_read(caller: &mut wasmtime::Caller<'_, StoreData>, key: &[u16]) -> wasmtime::Result<bool> {
403    let Err(err) = gate(caller, "session.read", key) else {
404        return Ok(true);
405    };
406    // Only the policy's own answer filters. An invariant denial means the check
407    // could not be made at all, and swallowing it would turn a runtime refusal
408    // into a silently short listing.
409    match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
410        Some(denial) if denial.is_policy() => {
411            crate::stdlib::shared::mark_filtered(&*caller);
412            Ok(false)
413        }
414        _ => Err(err),
415    }
416}
417
418fn build_page(
419    caller: &mut wasmtime::Caller<'_, StoreData>,
420    entries: &[SessionKvEntry],
421    next_cursor: Option<&str>,
422) -> wasmtime::Result<Val> {
423    let entry_ty = entry_backing_struct(caller.engine())?;
424    let mut built = Vec::with_capacity(entries.len());
425    for entry in entries {
426        let key = crate::runtime::host::write_submilli_string_struct_units(caller, &entry.key)?;
427        built.push(abi::new_backing(
428            caller,
429            entry_ty.clone(),
430            &[
431                Val::AnyRef(Some(key.to_anyref())),
432                Val::F64((entry.size_bytes as f64).to_bits()),
433            ],
434        )?);
435    }
436    let array = value::build_array(caller, built)?;
437    let cursor = match next_cursor {
438        Some(text) => {
439            let units: Vec<u16> = text.encode_utf16().collect();
440            let st = crate::runtime::host::write_submilli_string_struct_units(caller, &units)?;
441            Val::AnyRef(Some(st.to_anyref()))
442        }
443        None => crate::runtime::prelude::undefined::value(caller)?,
444    };
445    let page_ty = page_backing_struct(caller.engine())?;
446    abi::new_backing(caller, page_ty, &[array, cursor])
447}
448
449fn read_key(
450    caller: &mut wasmtime::Caller<'_, StoreData>,
451    val: &Val,
452    op: &str,
453) -> wasmtime::Result<Vec<u16>> {
454    value::read_units(caller, val, &format!("session.{op} (key)"))
455}
456
457/// The policy sees the key as text. A lone surrogate is not expressible in a
458/// JSON string, so the context renders it lossily — matching on such a key is
459/// not something a rule can express, and the store keeps the exact units.
460fn gate(
461    caller: &mut wasmtime::Caller<'_, StoreData>,
462    capability: &str,
463    key: &[u16],
464) -> wasmtime::Result<Option<CallTicket>> {
465    check_security_call(
466        caller,
467        capability,
468        serde_json::json!({ "key": String::from_utf16_lossy(key) }),
469    )
470}
471
472/// Clone the provider out of the store before any `await` or guest re-entry:
473/// the borrow on `caller.data()` cannot be held across either.
474fn provider(
475    caller: &wasmtime::Caller<'_, StoreData>,
476    op: &str,
477) -> wasmtime::Result<Arc<dyn SessionKvStore>> {
478    caller.data().session_kv.clone().ok_or_else(|| {
479        crate::runtime::host::type_error(format!(
480            "session.{op}: this runtime has no session store configured, so session state \
481             cannot be read or written. The embedder installs one; nothing in the program \
482             can create it."
483        ))
484    })
485}
486
487/// Both cursor paths — minting one and reading one back — map through here, so
488/// a caller cannot tell the two apart by the shape of the failure. No variant
489/// names a key, so the message passes through whole.
490fn cursor_trap(error: cursor::CursorError) -> wasmtime::Error {
491    match error {
492        cursor::CursorError::Internal(_) => {
493            wasmtime::Error::new(wasmtime::Trap::UnreachableCodeReached).context(error.to_string())
494        }
495        cursor::CursorError::Malformed
496        | cursor::CursorError::UnknownKey
497        | cursor::CursorError::PrefixMismatch
498        | cursor::CursorError::NoEntropy => crate::runtime::host::type_error(error.to_string()),
499    }
500}
501
502/// A refusal reaches the guest as a catchable error. A closed session or a
503/// storage failure ends the run instead: the operation may or may not have
504/// happened, and a program that caught it would carry on as though it had not.
505/// The `Display` impl already excludes the stored value, so the message can be
506/// passed through whole.
507///
508/// A write-quota refusal continues the `session.write` call `ticket` names; any other
509/// operation passes `None`, so the refusal is recorded as a call of its own.
510fn trap(
511    caller: &wasmtime::Caller<'_, StoreData>,
512    ticket: Option<CallTicket>,
513    error: &SessionKvError,
514) -> wasmtime::Error {
515    match error {
516        SessionKvError::InvalidKey { .. } => crate::runtime::host::type_error(error.to_string()),
517        SessionKvError::LimitExceeded {
518            limit:
519                crate::runtime::session_kv::SessionKvLimitKind::KeyUnits { .. }
520                | crate::runtime::session_kv::SessionKvLimitKind::ValueBytes { .. },
521            ..
522        } => crate::runtime::host::range_error(error.to_string()),
523        SessionKvError::LimitExceeded { .. } => {
524            let who = crate::stdlib::shared::running_package(caller)
525                .or_else(crate::stdlib::shared::PrincipalError::label_or_error);
526            let who = match who {
527                Ok(who) => who,
528                Err(error) => return error,
529            };
530            crate::stdlib::shared::audit_denial_in(
531                caller,
532                ticket,
533                &who,
534                "session.write",
535                &serde_json::json!({}),
536                "quota",
537                "session-state budget exceeded",
538            );
539            crate::runtime::host::quota_exceeded_error(error.to_string())
540        }
541        SessionKvError::SessionClosed { .. } | SessionKvError::Backend { .. } => {
542            crate::runtime::host::fatal_host_error(error)
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use std::sync::Arc;
550
551    use base64::Engine as _;
552
553    use super::SessionKvStore;
554    use crate::runtime::session_kv::{
555        InMemorySessionKv, SessionKvError, SessionKvFuture, SessionKvLimits, SessionKvPage,
556    };
557    use crate::runtime::{
558        CheckOutcome, RuntimeConfig, SecurityCheck, StoreData, Vfs, dispatch_main_async,
559        install_runtime_async,
560    };
561
562    async fn run(source: &str, kv: Option<Arc<dyn SessionKvStore>>) -> wasmtime::Result<()> {
563        run_with_policy(source, kv, None).await
564    }
565
566    async fn run_with_policy(
567        source: &str,
568        kv: Option<Arc<dyn SessionKvStore>>,
569        policy: Option<Arc<dyn SecurityCheck>>,
570    ) -> wasmtime::Result<()> {
571        run_returning(source, kv, policy).await.map(|_| ())
572    }
573
574    /// `main`'s output, for the tests that assert on what the program observed
575    /// rather than only that it completed.
576    async fn run_returning(
577        source: &str,
578        kv: Option<Arc<dyn SessionKvStore>>,
579        policy: Option<Arc<dyn SecurityCheck>>,
580    ) -> wasmtime::Result<String> {
581        let compiled = crate::compile_script(source, "test.ts", crate::FileId(0), &[], &[])
582            .expect("compile clean");
583        let cfg = RuntimeConfig::default();
584        let engine = cfg.engine().expect("engine");
585        let mut data = StoreData::with_vfs(Vfs::tempdir().expect("tempdir"));
586        data.install_type_info(compiled.type_info.clone());
587        data.session_kv = kv;
588        if let Some(policy) = policy {
589            data.security_check = policy;
590        }
591        let mut store = cfg.store_async(&engine, data).expect("store");
592        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
593        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
594        install_runtime_async(&mut linker, &mut store)
595            .await
596            .expect("install");
597        let inst = linker
598            .instantiate_async(&mut store, &module)
599            .await
600            .expect("instantiate");
601        dispatch_main_async(&mut store, &inst)
602            .await
603            .map(Option::unwrap_or_default)
604    }
605
606    #[tokio::test]
607    async fn top_level_undefined_does_not_replace_stored_value() {
608        let kv = Arc::new(InMemorySessionKv::default());
609        let key: Vec<u16> = "kept".encode_utf16().collect();
610        let original: Vec<u16> = r#"{"step":1}"#.encode_utf16().collect();
611        kv.set_now(&key, &original).expect("seed stored value");
612
613        let error = run(
614            r#"
615            import session from "submilli:session";
616            function main(): void {
617                session.set("kept", undefined);
618            }
619            "#,
620            Some(kv.clone()),
621        )
622        .await
623        .expect_err("top-level undefined has no stored JSON representation");
624
625        assert!(format!("{error:#}").contains("top-level undefined"));
626        assert_eq!(kv.get_now(&key).expect("read original"), Some(original));
627    }
628
629    #[tokio::test]
630    async fn nested_undefined_uses_json_object_and_array_rules() {
631        let kv = Arc::new(InMemorySessionKv::default());
632        run(
633            r#"
634            import session from "submilli:session";
635            function main(): void {
636                const items: unknown[] = [1, undefined, null];
637                session.set("nested", {
638                    kept: 1,
639                    omitted: undefined,
640                    inner: { omitted: undefined, kept: null },
641                    items,
642                });
643            }
644            "#,
645            Some(kv.clone()),
646        )
647        .await
648        .expect("nested undefined has JSON object and array representations");
649
650        let key: Vec<u16> = "nested".encode_utf16().collect();
651        let payload = kv
652            .get_now(&key)
653            .expect("read stored JSON")
654            .expect("stored value");
655        let text = String::from_utf16(&payload).expect("valid JSON text");
656        let actual: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
657        assert_eq!(
658            actual,
659            serde_json::json!({
660                "kept": 1,
661                "inner": { "kept": null },
662                "items": [1, null, null],
663            })
664        );
665    }
666
667    #[tokio::test]
668    async fn missing_value_is_undefined_and_stored_null_is_preserved() {
669        run(
670            r#"
671            import session from "submilli:session";
672            function main(): void {
673                const missing = session.get<string>("missing");
674                assert(missing === undefined, "missing keys yield undefined");
675                session.set("null", null);
676                const stored = session.get<null>("null");
677                assert(stored === null, "stored null stays null");
678                assert(session.has("null"), "stored null is present");
679                assert(!session.has("missing"), "missing key stays absent");
680            }
681            "#,
682            Some(Arc::new(InMemorySessionKv::default())),
683        )
684        .await
685        .expect("undefined and null remain distinct");
686    }
687
688    #[tokio::test]
689    async fn narrowed_host_carriers() {
690        let source = r#"
691import session from "submilli:session";
692import { Entry, Page } from "submilli:session";
693import { info } from "submilli:fs";
694
695class Parent { value: unknown = null; reset(value: unknown): void { this.value = value; } }
696function rejects(read: () => void): void {
697 let caught = false;
698 try { read(); } catch (e) { caught = e instanceof TypeError; }
699 assert(caught, "unrelated carrier must throw TypeError");
700}
701
702class PageField extends Parent { value: Page | null = null; }
703class EntryField extends Parent { value: Entry | null = null; }
704function main(): void {
705 session.set("key", "value");
706 const p = new PageField(); p.reset(session.list("", 10));
707 assert(p.value!.entries.length === 1, "Page");
708 const e = new EntryField(); e.reset(p.value!.entries[0]);
709 assert(e.value!.key === "key", "Entry");
710 p.reset(e.value); rejects(() => { const v = p.value; });
711 e.reset(info()); rejects(() => { const v = e.value; });
712}
713"#;
714        run(source, Some(Arc::new(InMemorySessionKv::default())))
715            .await
716            .expect("host guards");
717    }
718
719    /// An unconfigured runtime must refuse rather than fabricate a store: a
720    /// program that silently wrote into per-run state would look like it
721    /// persisted and lose everything.
722    #[tokio::test]
723    async fn a_runtime_without_a_provider_refuses_every_op() {
724        for op in [
725            r#"session.set("k", 1)"#,
726            r#"const _ = session.get("k")"#,
727            r#"const _ = session.has("k")"#,
728            r#"const _ = session.remove("k")"#,
729        ] {
730            let source = format!(
731                "import session from \"submilli:session\";\n\
732                 function main(): void {{ {op}; }}\n"
733            );
734            let err = run(&source, None).await.expect_err("must refuse");
735            let message = format!("{err}");
736            assert!(
737                message.contains("no session store configured"),
738                "{op}: {message}"
739            );
740            assert!(
741                message.contains("The embedder installs one"),
742                "the message must name who can fix it; got: {message}"
743            );
744        }
745    }
746
747    /// The refusal is catchable, not an uncatchable trap — a program can fall
748    /// back to running without session state.
749    #[tokio::test]
750    async fn the_missing_provider_refusal_is_catchable() {
751        let source = r#"
752            import session from "submilli:session";
753
754            function main(): void {
755                let caught = false;
756                try {
757                    session.set("k", 1);
758                } catch (e: Error) {
759                    caught = true;
760                }
761                assert(caught, "the refusal is catchable");
762            }
763        "#;
764        run(source, None).await.expect("program completes");
765    }
766
767    #[tokio::test]
768    async fn session_budgets_throw_quota_exceeded_error() {
769        let defaults = SessionKvLimits::default();
770        let stores = [
771            InMemorySessionKv::new(SessionKvLimits {
772                max_entries: 0,
773                ..defaults
774            }),
775            InMemorySessionKv::new(SessionKvLimits {
776                max_session_bytes: 1,
777                ..defaults
778            }),
779        ];
780        for kv in stores {
781            let source = r#"
782                import session from "submilli:session";
783                function main(): void {
784                    let caught = false;
785                    try { session.set("k", "value"); }
786                    catch (e: QuotaExceededError) {
787                        caught = e instanceof Error && !((e as unknown) instanceof RangeError);
788                        assert(e.name === "QuotaExceededError", "quota name");
789                    }
790                    assert(caught, "session budget refusal is a quota error");
791                    assert(!session.has("k"), "refused write leaves no value");
792                }
793            "#;
794            run(source, Some(Arc::new(kv)))
795                .await
796                .expect("catch quota refusal");
797        }
798    }
799
800    /// A store that fails every operation the way a durable one can: the
801    /// session closed under the run, or storage itself failed.
802    struct FailingStore(SessionKvError);
803
804    impl SessionKvStore for FailingStore {
805        fn get<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, Option<Vec<u16>>> {
806            Box::pin(std::future::ready(Err(self.0.clone())))
807        }
808        fn has<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, bool> {
809            Box::pin(std::future::ready(Err(self.0.clone())))
810        }
811        fn set<'a>(&'a self, _: &'a [u16], _: &'a [u16]) -> SessionKvFuture<'a, ()> {
812            Box::pin(std::future::ready(Err(self.0.clone())))
813        }
814        fn remove<'a>(&'a self, _: &'a [u16]) -> SessionKvFuture<'a, bool> {
815            Box::pin(std::future::ready(Err(self.0.clone())))
816        }
817        fn scan<'a>(
818            &'a self,
819            _: Option<&'a [u16]>,
820            _: &'a [u16],
821            _: usize,
822        ) -> SessionKvFuture<'a, SessionKvPage> {
823            Box::pin(std::future::ready(Err(self.0.clone())))
824        }
825        fn limits(&self) -> SessionKvLimits {
826            SessionKvLimits::default()
827        }
828    }
829
830    /// An operation that may or may not have happened must not be something a
831    /// program can catch and carry on past, as if it knew which.
832    #[tokio::test]
833    async fn a_closed_session_or_failed_storage_ends_the_run() {
834        let failures = [
835            SessionKvError::SessionClosed { operation: "set" },
836            SessionKvError::Backend {
837                operation: "set",
838                message: "disk I/O error".into(),
839            },
840        ];
841        for op in [
842            r#"session.set("k", 1);"#,
843            r#"session.get<number>("k");"#,
844            r#"session.has("k");"#,
845            r#"session.remove("k");"#,
846            r#"session.list("", 10);"#,
847        ] {
848            for failure in &failures {
849                let source = format!(
850                    r#"
851                    import session from "submilli:session";
852                    function main(): string {{
853                        try {{ {op} }} catch (e: Error) {{ return "caught"; }}
854                        return "continued";
855                    }}
856                    "#
857                );
858                let outcome =
859                    run_returning(&source, Some(Arc::new(FailingStore(failure.clone()))), None)
860                        .await;
861                let error = outcome.expect_err(op);
862                assert!(
863                    error.is::<crate::runtime::host::FatalHostError>(),
864                    "{op}: {error:?}"
865                );
866            }
867        }
868    }
869
870    #[tokio::test]
871    async fn session_argument_bounds_remain_range_errors() {
872        let defaults = SessionKvLimits::default();
873        for limits in [
874            SessionKvLimits {
875                max_key_units: 1,
876                ..defaults
877            },
878            SessionKvLimits {
879                max_value_bytes: 1,
880                ..defaults
881            },
882        ] {
883            let source = r#"
884                import session from "submilli:session";
885                function main(): void {
886                    let caught = false;
887                    try { session.set("key", "value"); }
888                    catch (e: RangeError) {
889                        caught = !((e as unknown) instanceof QuotaExceededError);
890                    }
891                    assert(caught, "individual key/value caps remain argument errors");
892                }
893            "#;
894            run(source, Some(Arc::new(InMemorySessionKv::new(limits))))
895                .await
896                .expect("catch argument bounds");
897        }
898    }
899
900    /// A quota refusal names the limit and the key, and never the value — the
901    /// property `SessionKvError` maintains must survive the mapping to a trap.
902    #[tokio::test]
903    async fn a_limit_refusal_never_carries_the_stored_value() {
904        let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::new(SessionKvLimits {
905            max_value_bytes: 8,
906            ..SessionKvLimits::default()
907        }));
908        let source = r#"
909            import session from "submilli:session";
910
911            function main(): void {
912                session.set("k", "SUPER_SECRET_PAYLOAD");
913            }
914        "#;
915        let err = run(source, Some(kv)).await.expect_err("over the limit");
916        let message = format!("{err}");
917        assert!(message.contains("value size limit"), "{message}");
918        assert!(
919            message.contains("\"k\""),
920            "the key names the entry: {message}"
921        );
922        assert!(
923            !message.contains("SUPER_SECRET_PAYLOAD"),
924            "the value must never reach a diagnostic: {message}"
925        );
926    }
927
928    /// The per-key filter the catalog's `key glob "triage/*"` example describes:
929    /// `session.read` allowed for some keys and denied for others. The fixture
930    /// harness denies a whole capability, so only a context-reading policy can
931    /// exercise the selective case.
932    struct AllowPrefix(&'static str);
933
934    impl SecurityCheck for AllowPrefix {
935        fn check(
936            &self,
937            _caller: &str,
938            capability: &str,
939            context: &serde_json::Value,
940        ) -> CheckOutcome {
941            if capability != "session.read" {
942                return CheckOutcome::Allow { rule: None };
943            }
944            let key = context.get("key").and_then(|k| k.as_str()).unwrap_or("");
945            if key.starts_with(self.0) {
946                CheckOutcome::Allow { rule: None }
947            } else {
948                CheckOutcome::Deny {
949                    rule: None,
950                    reason: format!("only {} is readable", self.0),
951                }
952            }
953        }
954    }
955
956    /// Denied keys must be invisible in every channel a listing has: the
957    /// entries, the count, and the cursor. A cursor that pointed past a hidden
958    /// key would let a caller detect that something sat between two keys it can
959    /// see.
960    #[tokio::test]
961    async fn a_filtered_listing_reveals_neither_the_hidden_keys_nor_their_count() {
962        let source = r#"
963            import session from "submilli:session";
964
965            function main(): void {
966                session.set("aaa/1", 1);
967                session.set("triage/a", 1);
968                session.set("triage/b", 2);
969                session.set("zzz/9", 9);
970
971                // A page sized to the whole keyspace: every key is a candidate,
972                // so anything the policy hides can only vanish silently.
973                const all = session.list("", 100);
974                assert(all.entries.length === 2, "only the permitted keys are counted");
975                assert(all.entries[0].key === "triage/a", "first permitted key");
976                assert(all.entries[1].key === "triage/b", "second permitted key");
977                assert(all.nextCursor === undefined, "the cursor does not point past hidden keys");
978
979                // Listing a wholly denied prefix is empty and final, not an
980                // error: a throw would itself confirm the keys exist.
981                const denied = session.list("zzz/", 10);
982                assert(denied.entries.length === 0, "a denied prefix lists as empty");
983                assert(denied.nextCursor === undefined, "and reports itself finished");
984            }
985        "#;
986        let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
987        run_with_policy(source, Some(kv), Some(Arc::new(AllowPrefix("triage/"))))
988            .await
989            .expect("program completes");
990    }
991
992    /// The exfiltration a decodable cursor allowed, run the way a guest would.
993    ///
994    /// Paging at `limit: 1` makes every cursor resume after exactly one key, and
995    /// the keys considered include the ones the filter hid — so if a cursor can
996    /// be read, the denied key names come back in order. The listing itself is
997    /// correct here and always was: it shows only `triage/*`. What this asserts
998    /// is that the cursors it hands out carry nothing further.
999    #[tokio::test]
1000    async fn cursors_from_a_filtered_listing_do_not_carry_the_denied_keys() {
1001        let source = r#"
1002            import session from "submilli:session";
1003
1004            function main(): string {
1005                session.set("secret/alpha", 1);
1006                session.set("triage/a", 1);
1007                session.set("zzz-denied", 1);
1008
1009                let cursors = "";
1010                let shown = 0;
1011                let cursor: string | undefined;
1012                let guard = 0;
1013                while (guard < 10) {
1014                    guard = guard + 1;
1015                    const page = session.list("", 1, cursor);
1016                    shown = shown + page.entries.length;
1017                    const next = page.nextCursor;
1018                    if (next === undefined) { break; }
1019                    cursors = cursors + next;
1020                    cursor = next;
1021                }
1022                assert(shown === 1, "only the permitted key is listed");
1023                return cursors;
1024            }
1025        "#;
1026        let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
1027        let cursors = run_returning(source, Some(kv), Some(Arc::new(AllowPrefix("triage/"))))
1028            .await
1029            .expect("program completes");
1030
1031        // The cursors are base64url, which a guest decodes as easily as we do.
1032        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1033            .decode(cursors.as_bytes())
1034            .unwrap_or_default();
1035        for denied in ["secret/alpha", "zzz-denied"] {
1036            let utf16: Vec<u8> = denied.encode_utf16().flat_map(u16::to_be_bytes).collect();
1037            assert!(
1038                !cursors.contains(denied),
1039                "a denied key is spelled in a cursor: {cursors}"
1040            );
1041            assert!(
1042                !decoded.windows(utf16.len()).any(|w| w == utf16.as_slice()),
1043                "a denied key is recoverable from a cursor: {cursors}"
1044            );
1045        }
1046    }
1047
1048    /// `list` discloses metadata only. The value is reachable through `get`,
1049    /// which is gated per key on its own — a listing must not be a way around
1050    /// that.
1051    #[tokio::test]
1052    async fn a_listing_carries_no_value_contents() {
1053        let kv: Arc<dyn SessionKvStore> = Arc::new(InMemorySessionKv::default());
1054        let source = r#"
1055            import session from "submilli:session";
1056
1057            function main(): string {
1058                session.set("k", "SUPER_SECRET_PAYLOAD");
1059                const page = session.list("", 10);
1060                let rendered = "";
1061                for (const entry of page.entries) {
1062                    rendered = rendered + entry.key + ":" + entry.sizeBytes.toString();
1063                }
1064                // 20 characters plus the two JSON quotes, two bytes per UTF-16
1065                // code unit.
1066                assert(rendered === "k:44", "the entry carries the key and the size only");
1067                return JSON.stringify(page.entries);
1068            }
1069        "#;
1070        run(source, Some(kv)).await.expect("program completes");
1071    }
1072}