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