Skip to main content

submilli_engine/stdlib/
variables.rs

1//! `submilli:variables` — policy-gated access to Blueprint-declared variables.
2//!
3//! One Rust host function registered directly under the package name;
4//! the linker resolves the user import (`submilli:variables#get`) with no Wasm
5//! module.
6
7use crate::runtime::effects;
8use crate::runtime::host::{
9    abi_arg, fatal_host_error, read_string_arg_units, register_host_value_async,
10    write_submilli_string_struct,
11};
12use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
13
14use crate::runtime::intrinsic_types::build_intrinsic_types;
15use crate::runtime::{StoreData, fuel};
16use crate::stdlib::shared::check_security;
17use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
18
19pub const MODULE_NAME: &str = "submilli:variables";
20
21pub fn package_declaration() -> PackageDeclaration {
22    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
23    defs.values.insert(
24        "get".to_string(),
25        ValueSymbol {
26            name: "get".to_string(),
27            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "get"),
28            declaration_span: Span::at(crate::FileId::VARIABLES),
29            kind: ValueKind::Function {
30                generics: Vec::new(),
31                params: vec![Param::new("name", Type::String)],
32                ret: Type::union(vec![Type::String, Type::Undefined]),
33                type_predicate: None,
34                doc: crate::doc(
35                    crate::FileId::VARIABLES,
36                    "/**\n * Read immutable Blueprint/session configuration. Packages only; main-module reads are always denied. Authorized missing names return undefined.\n * @param name Variable name declared in the Blueprint variables block.\n * @capability variables.get { name: $name }\n */",
37                ),
38            },
39        },
40    );
41    defs
42}
43
44pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
45    let engine = linker.engine().clone();
46    let intr = build_intrinsic_types(&engine)?;
47    let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(intr.string)));
48    // `string | undefined` lowers to the erased `(ref null $Object)` in codegen.
49    let nullable_object = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(intr.object)));
50    let ty = FuncType::new(&engine, [string], [nullable_object]);
51    register_host_value_async(
52        linker,
53        MODULE_NAME,
54        crate::mangle::package_symbol(MODULE_NAME, "get"),
55        ty,
56        |caller, params| {
57            Box::pin(async move {
58                let units = read_string_arg_units(caller, abi_arg(params, 0)?, "variables.get")?;
59                fuel::charge(&mut *caller, fuel::SCAN, units.len() as u64)?;
60                // Configuration names are Unicode scalar strings. A guest name with
61                // an unpaired surrogate cannot name one; never replace it with U+FFFD
62                // and accidentally read a different configured variable.
63                let name = variable_name(&units)?;
64                let mut context = serde_json::Map::new();
65                context.insert(
66                    "name".into(),
67                    name.map_or(serde_json::Value::Null, serde_json::Value::String),
68                );
69                let context = serde_json::Value::Object(context);
70                let name = context["name"].as_str();
71                // The gate owns its context; retain the validated key in a separate
72                // fallibly allocated string for lookup after authorization.
73                let mut lookup = String::new();
74                if let Some(name) = name {
75                    fuel::charge(&mut *caller, fuel::COPY, name.len() as u64)?;
76                    lookup
77                        .try_reserve_exact(name.len())
78                        .map_err(fatal_host_error)?;
79                    lookup.push_str(name);
80                }
81                let valid_name = name.is_some();
82                check_security(&mut *caller, "variables.get", context)?;
83                let name = valid_name.then_some(lookup);
84                let observation =
85                    effects::execute(caller, "variables.get", name, |caller, _, name| {
86                        Box::pin(async move {
87                            let variables = caller.data().variables.clone();
88                            if let Some(name) = &name {
89                                let depth = variables.len().max(1).ilog2().saturating_add(1);
90                                fuel::charge(
91                                    &mut *caller,
92                                    fuel::SCAN,
93                                    (name.len() as u64).saturating_mul(u64::from(depth)),
94                                )?;
95                            }
96                            Ok(name.as_ref().and_then(|name| variables.get(name)).cloned())
97                        })
98                    })
99                    .await?;
100                let Some(value) = &observation else {
101                    return crate::runtime::prelude::undefined::value(caller);
102                };
103                let value = write_submilli_string_struct(caller, value)?;
104                Ok(Val::AnyRef(Some(value.to_anyref())))
105            })
106        },
107    )
108}
109
110/// Three UTF-8 bytes per UTF-16 unit bounds the output, including pairs. Reserve
111/// fallibly before pushing so a guest-controlled name cannot trigger growth.
112fn variable_name(units: &[u16]) -> wasmtime::Result<Option<String>> {
113    let capacity = units
114        .len()
115        .checked_mul(3)
116        .ok_or_else(|| fatal_host_error("variable name capacity overflow"))?;
117    let mut name = String::new();
118    name.try_reserve_exact(capacity).map_err(fatal_host_error)?;
119    for character in char::decode_utf16(units.iter().copied()) {
120        let Ok(character) = character else {
121            return Ok(None);
122        };
123        name.push(character);
124    }
125    Ok(Some(name))
126}
127
128#[cfg(test)]
129mod tests {
130    use std::collections::BTreeMap;
131    use std::sync::{Arc, Mutex};
132
133    use crate::runtime::security::{CheckOutcome, SecurityCheck};
134    use crate::runtime::{
135        RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
136    };
137
138    #[derive(Default)]
139    struct NamePolicy {
140        seen: Mutex<Vec<serde_json::Value>>,
141    }
142
143    impl SecurityCheck for NamePolicy {
144        fn check(
145            &self,
146            caller: &str,
147            capability: &str,
148            context: &serde_json::Value,
149        ) -> CheckOutcome {
150            assert_eq!(caller, "@pied-piper/browser");
151            assert_eq!(capability, "variables.get");
152            self.seen.lock().unwrap().push(context.clone());
153            if context["name"] == "DENIED" || context["name"] == "USER_ID" {
154                CheckOutcome::Deny {
155                    rule: None,
156                    reason: "name is not granted".into(),
157                }
158            } else {
159                CheckOutcome::Allow { rule: None }
160            }
161        }
162    }
163
164    #[tokio::test]
165    async fn packages_read_strings_and_missing_names_with_dynamic_name_checks() {
166        let policy = Arc::new(NamePolicy::default());
167        run_as(
168            Some("@pied-piper/browser"),
169            r#"
170            import * as variables from "submilli:variables";
171            function read(name: string): string | undefined { return variables.get(name); }
172            function main(): void {
173                assert(read("BROWSER_STORE_NAME") === "user-1");
174                assert(read("ABSENT") === undefined);
175                assert(read("EMPTY") === "");
176                assert(read("UNICODE") === "😀");
177                assert(read("\ud800") === undefined);
178                for (const name of ["DENIED", "USER_ID"]) {
179                    let denied = false;
180                    try { read(name); } catch (error: unknown) {
181                        denied = error instanceof PermissionDeniedError;
182                    }
183                    assert(denied, "name filter must deny");
184                }
185            }
186        "#,
187            Some(policy.clone()),
188        )
189        .await
190        .unwrap();
191        let seen = policy.seen.lock().unwrap();
192        assert_eq!(seen.len(), 7);
193        assert_eq!(seen[0]["name"], "BROWSER_STORE_NAME");
194        assert!(seen[4]["name"].is_null());
195    }
196
197    #[tokio::test]
198    async fn main_cannot_read_present_or_missing_variables_even_with_allow_all() {
199        run_as(
200            None,
201            r#"
202            import { get } from "submilli:variables";
203            function main(): void {
204                for (const name of ["BROWSER_STORE_NAME", "ABSENT"]) {
205                    let denied = false;
206                    try { get(name); } catch (error: unknown) {
207                        denied = error instanceof PermissionDeniedError;
208                    }
209                    assert(denied, "main must never read configuration");
210                }
211            }
212        "#,
213            None,
214        )
215        .await
216        .unwrap();
217    }
218
219    async fn run_as(
220        package: Option<&str>,
221        source: &str,
222        policy: Option<Arc<dyn SecurityCheck>>,
223    ) -> wasmtime::Result<Option<String>> {
224        let compiled = match package {
225            Some(package) => crate::compile::compile_script_owned_by(
226                package,
227                source,
228                "variables.ts",
229                crate::FileId(0),
230                &[],
231                &[],
232            ),
233            None => crate::compile_script(source, "variables.ts", crate::FileId(0), &[], &[]),
234        }
235        .expect("program compiles");
236        let config = RuntimeConfig::default();
237        let engine = config.engine()?;
238        let mut data = StoreData::with_vfs("test-run".into(), Vfs::none());
239        data.variables = Arc::new(BTreeMap::from([
240            ("BROWSER_STORE_NAME".into(), "user-1".into()),
241            ("USER_ID".into(), "user-1".into()),
242            ("DENIED".into(), "hidden".into()),
243            ("EMPTY".into(), "".into()),
244            ("UNICODE".into(), "😀".into()),
245            ("�".into(), "must not match a lone surrogate".into()),
246        ]));
247        if let Some(policy) = policy {
248            data.security_check = policy;
249        }
250        let mut store = config.store_async(&engine, data)?;
251        let module = wasmtime::Module::new(&engine, &compiled.wasm)?;
252        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
253        install_runtime_async(&mut linker, &mut store).await?;
254        let instance = linker.instantiate_async(&mut store, &module).await?;
255        dispatch_main_async(&mut store, &instance).await
256    }
257}