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