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