Skip to main content

sim_lib_lang_javascript/
modules.rs

1//! ECMAScript source modules and dynamic source under explicit authority.
2
3use std::sync::Arc;
4
5use sim_kernel::{
6    CapabilityName, CapabilitySet, Cx, Dir, ReadPolicy, Result, Shape, Symbol, Value,
7};
8use sim_lib_core::{ReadEvalBroker, ReadEvalRequest, ReadEvalSource, RequestOrigin};
9use sim_lib_namespace::{
10    ModuleIdentity, ModuleInstance, ModuleLoader, ModuleRequest, ModuleResolutionReceipt,
11};
12use sim_shape::AnyShape;
13
14/// JavaScript policy over the shared parse/link/evaluate module lifecycle.
15///
16/// The shared loader canonicalizes supplied-root identities, detects cycles,
17/// caches failures, and exposes its default export through a live binding cell.
18pub struct JavascriptModulePolicy {
19    loader: ModuleLoader,
20    codec: Symbol,
21}
22
23impl Default for JavascriptModulePolicy {
24    fn default() -> Self {
25        Self::with_codec(Symbol::qualified("codec", "javascript"))
26    }
27}
28
29impl JavascriptModulePolicy {
30    /// Builds module policy for an installed compatible source codec.
31    pub fn with_codec(codec: Symbol) -> Self {
32        Self {
33            loader: ModuleLoader::new(),
34            codec,
35        }
36    }
37
38    /// Parses, links, and evaluates an ESM source from the supplied root.
39    pub fn load(
40        &self,
41        cx: &mut Cx,
42        specifier: impl Into<String>,
43        admission: JavascriptModuleAdmission,
44    ) -> Result<ModuleInstance> {
45        self.load_from(cx, None, specifier, admission)
46    }
47
48    /// Resolves a static import relative to an already linked module.
49    pub fn load_from(
50        &self,
51        cx: &mut Cx,
52        importer: Option<ModuleIdentity>,
53        specifier: impl Into<String>,
54        admission: JavascriptModuleAdmission,
55    ) -> Result<ModuleInstance> {
56        self.loader.load(
57            cx,
58            ModuleRequest {
59                root_id: admission.root_id,
60                root: admission.root,
61                importer,
62                specifier: specifier.into(),
63                codec: self.codec.clone(),
64                read_policy: admission.read_policy,
65                requires: admission.requires,
66                allow: admission.allow,
67            },
68        )
69    }
70
71    /// Dynamic import uses the same lifecycle and supplied-root envelope.
72    pub fn dynamic_import(
73        &self,
74        cx: &mut Cx,
75        importer: Option<ModuleIdentity>,
76        specifier: impl Into<String>,
77        admission: JavascriptModuleAdmission,
78    ) -> Result<ModuleInstance> {
79        self.load_from(cx, importer, specifier, admission)
80    }
81
82    /// Ordered link, cache-hit, cycle, and failure evidence.
83    pub fn receipts(&self) -> Result<Vec<ModuleResolutionReceipt>> {
84        self.loader.receipts()
85    }
86}
87
88/// Host-authored root and authority supplied for one ESM resolution.
89pub struct JavascriptModuleAdmission {
90    /// Stable identity for the supplied module root.
91    pub root_id: Symbol,
92    /// The only directory visible to module resolution.
93    pub root: Arc<dyn Dir>,
94    /// Trusted policy used by diminished read-eval.
95    pub read_policy: ReadPolicy,
96    /// Powers the importing caller must already hold.
97    pub requires: Vec<CapabilityName>,
98    /// Diminished powers visible during source evaluation.
99    pub allow: CapabilitySet,
100}
101
102/// Capability-gated JavaScript `eval`/`Function` source entry.
103pub struct DynamicJavascript {
104    broker: ReadEvalBroker,
105    codec: Symbol,
106}
107
108impl Default for DynamicJavascript {
109    fn default() -> Self {
110        Self::with_codec(Symbol::qualified("codec", "javascript"))
111    }
112}
113
114impl DynamicJavascript {
115    /// Builds dynamic source policy for an installed compatible codec.
116    pub fn with_codec(codec: Symbol) -> Self {
117        Self {
118            broker: ReadEvalBroker::new(),
119            codec,
120        }
121    }
122
123    /// Evaluates dynamic text only through diminished read-eval.
124    pub fn evaluate(
125        &self,
126        cx: &mut Cx,
127        source: impl Into<String>,
128        admission: JavascriptDynamicAdmission,
129    ) -> Result<Value> {
130        self.broker.admit(
131            cx,
132            ReadEvalRequest {
133                origin: RequestOrigin::new(Symbol::qualified("javascript", "dynamic-source")),
134                codec: self.codec.clone(),
135                source: ReadEvalSource::Text(source.into()),
136                read_policy: admission.read_policy,
137                requires: admission.requires,
138                allow: admission.allow,
139                expected_shape: admission.expected_shape,
140            },
141        )
142    }
143}
144
145/// Host-authored authority envelope for dynamic JavaScript source.
146pub struct JavascriptDynamicAdmission {
147    /// Trusted read policy; source cannot construct it.
148    pub read_policy: ReadPolicy,
149    /// Powers the caller must already hold.
150    pub requires: Vec<CapabilityName>,
151    /// Diminished powers visible during evaluation.
152    pub allow: CapabilitySet,
153    /// Required result shape.
154    pub expected_shape: Arc<dyn Shape>,
155}
156
157impl JavascriptDynamicAdmission {
158    /// Builds an envelope with no ambient capabilities and an unconstrained result.
159    pub fn new(read_policy: ReadPolicy) -> Self {
160        Self {
161            read_policy,
162            requires: Vec::new(),
163            allow: CapabilitySet::new(),
164            expected_shape: Arc::new(AnyShape),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::{collections::BTreeMap, sync::RwLock};
172
173    use sim_codec_lisp::LispCodecLib;
174    use sim_kernel::{
175        ClassId, ClassRef, CodecId, DefaultFactory, EagerPolicy, Error, Object, ObjectCompat,
176        Table, TrustLevel, read_eval_capability,
177    };
178    use sim_lib_namespace::{ModuleResolutionOutcome, module_load_capability};
179
180    use super::*;
181
182    fn context() -> (Cx, sim_kernel::GrantSeat) {
183        let (mut cx, seat) = Cx::new_seated(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
184        cx.load_lib(&LispCodecLib::new(CodecId(71)).unwrap())
185            .unwrap();
186        (cx, seat)
187    }
188
189    fn trusted() -> ReadPolicy {
190        ReadPolicy {
191            trust: TrustLevel::TrustedSource,
192            capabilities: CapabilitySet::new().grant(read_eval_capability()),
193        }
194    }
195
196    #[derive(Default)]
197    struct MemoryDir(RwLock<BTreeMap<Symbol, Value>>);
198
199    impl MemoryDir {
200        fn source(&self, cx: &mut Cx, name: &str, source: &str) {
201            self.0.write().unwrap().insert(
202                Symbol::new(name),
203                cx.factory().string(source.to_owned()).unwrap(),
204            );
205        }
206    }
207
208    impl Object for MemoryDir {
209        fn display(&self, _cx: &mut Cx) -> Result<String> {
210            Ok("javascript-memory-root".to_owned())
211        }
212        fn as_any(&self) -> &dyn std::any::Any {
213            self
214        }
215    }
216    impl ObjectCompat for MemoryDir {
217        fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
218            cx.factory()
219                .class_stub(ClassId(0), Symbol::qualified("test", "JavascriptRoot"))
220        }
221        fn as_table_impl(&self) -> Option<&dyn Table> {
222            Some(self)
223        }
224        fn as_dir(&self) -> Option<&dyn Dir> {
225            Some(self)
226        }
227    }
228    impl Table for MemoryDir {
229        fn backend_symbol(&self) -> Symbol {
230            Symbol::qualified("test", "javascript-root")
231        }
232        fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
233            self.0
234                .read()
235                .unwrap()
236                .get(&key)
237                .cloned()
238                .map_or_else(|| cx.factory().nil(), Ok)
239        }
240        fn set(&self, _cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
241            self.0.write().unwrap().insert(key, value);
242            Ok(())
243        }
244        fn has(&self, _cx: &mut Cx, key: Symbol) -> Result<bool> {
245            Ok(self.0.read().unwrap().contains_key(&key))
246        }
247        fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
248            self.0
249                .write()
250                .unwrap()
251                .remove(&key)
252                .map_or_else(|| cx.factory().nil(), Ok)
253        }
254        fn keys(&self, _cx: &mut Cx) -> Result<Vec<Symbol>> {
255            Ok(self.0.read().unwrap().keys().cloned().collect())
256        }
257        fn entries(&self, _cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
258            Ok(self
259                .0
260                .read()
261                .unwrap()
262                .iter()
263                .map(|(k, v)| (k.clone(), v.clone()))
264                .collect())
265        }
266        fn len(&self, _cx: &mut Cx) -> Result<usize> {
267            Ok(self.0.read().unwrap().len())
268        }
269        fn clear(&self, _cx: &mut Cx) -> Result<()> {
270            self.0.write().unwrap().clear();
271            Ok(())
272        }
273    }
274    impl Dir for MemoryDir {
275        fn mkdir(&self, _cx: &mut Cx, _name: Symbol) -> Result<Value> {
276            Err(Error::Eval("nested dirs unsupported".into()))
277        }
278        fn opendir(&self, _cx: &mut Cx, _name: Symbol) -> Result<Option<Value>> {
279            Ok(None)
280        }
281        fn rmdir(&self, cx: &mut Cx, _name: Symbol) -> Result<Value> {
282            cx.factory().nil()
283        }
284        fn is_dir(&self, _cx: &mut Cx, _name: Symbol) -> Result<bool> {
285            Ok(false)
286        }
287    }
288
289    fn admission(root: Arc<MemoryDir>) -> JavascriptModuleAdmission {
290        JavascriptModuleAdmission {
291            root_id: Symbol::new("modules"),
292            root,
293            read_policy: trusted(),
294            requires: vec![module_load_capability()],
295            allow: CapabilitySet::new(),
296        }
297    }
298
299    #[test]
300    fn esm_uses_shared_live_lifecycle_supplied_roots_and_cached_failures() {
301        let (mut cx, seat) = context();
302        seat.grant(&mut cx, read_eval_capability()).unwrap();
303        seat.grant(&mut cx, module_load_capability()).unwrap();
304        let root = Arc::new(MemoryDir::default());
305        root.source(&mut cx, "answer.mjs", "42");
306        root.source(&mut cx, "broken.mjs", "(");
307        let modules = JavascriptModulePolicy::with_codec(Symbol::qualified("codec", "lisp"));
308        let first = modules
309            .load(&mut cx, "answer.mjs", admission(root.clone()))
310            .unwrap();
311        let imported = modules
312            .dynamic_import(&mut cx, None, "answer.mjs", admission(root.clone()))
313            .unwrap();
314        assert_eq!(first.identity(), imported.identity());
315        assert_eq!(
316            first
317                .default_export()
318                .get()
319                .unwrap()
320                .object()
321                .display(&mut cx)
322                .unwrap(),
323            "42"
324        );
325        assert!(
326            modules
327                .load(&mut cx, "broken.mjs", admission(root.clone()))
328                .is_err()
329        );
330        root.source(&mut cx, "broken.mjs", "41");
331        assert!(
332            modules
333                .load(&mut cx, "broken.mjs", admission(root))
334                .is_err()
335        );
336        assert_eq!(
337            modules
338                .receipts()
339                .unwrap()
340                .iter()
341                .map(|r| r.outcome)
342                .collect::<Vec<_>>(),
343            vec![
344                ModuleResolutionOutcome::Linked,
345                ModuleResolutionOutcome::CacheHit,
346                ModuleResolutionOutcome::Failed,
347                ModuleResolutionOutcome::Failed,
348            ]
349        );
350    }
351
352    #[test]
353    fn dynamic_source_rejects_ambient_authority() {
354        let (mut cx, _seat) = context();
355        let dynamic = DynamicJavascript::with_codec(Symbol::qualified("codec", "lisp"));
356        let denied = dynamic
357            .evaluate(
358                &mut cx,
359                "42",
360                JavascriptDynamicAdmission::new(ReadPolicy {
361                    trust: TrustLevel::Untrusted,
362                    capabilities: CapabilitySet::new(),
363                }),
364            )
365            .unwrap_err();
366        assert!(matches!(
367            denied,
368            Error::TrustDenied { .. } | Error::CapabilityDenied { .. }
369        ));
370    }
371}