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