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