1use 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 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 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 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 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
104fn 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}