Skip to main content

xript_runtime/
lib.rs

1mod error;
2pub mod fragment;
3mod manifest;
4mod sandbox;
5
6pub use error::{Result, ValidationIssue, XriptError};
7pub use fragment::{FragmentInstance, FragmentResult, ModInstance};
8pub use manifest::{
9    Binding, Capability, FragmentBinding, FragmentDeclaration, FragmentEvent, FunctionBinding,
10    HookDef, Limits, Manifest, ModManifest, NamespaceBinding, Parameter, Slot,
11};
12pub use sandbox::{
13    ConsoleHandler, ExecutionResult, HostBindings, HostFn, RuntimeOptions, XriptRuntime,
14};
15
16pub fn create_runtime(manifest_json: &str, options: RuntimeOptions) -> Result<XriptRuntime> {
17    let manifest: Manifest = serde_json::from_str(manifest_json)?;
18    XriptRuntime::new(manifest, options)
19}
20
21pub fn create_runtime_from_value(
22    manifest: serde_json::Value,
23    options: RuntimeOptions,
24) -> Result<XriptRuntime> {
25    let manifest: Manifest =
26        serde_json::from_value(manifest).map_err(XriptError::Json)?;
27    XriptRuntime::new(manifest, options)
28}
29
30pub fn create_runtime_from_file(
31    path: &std::path::Path,
32    options: RuntimeOptions,
33) -> Result<XriptRuntime> {
34    let content = std::fs::read_to_string(path)?;
35    create_runtime(&content, options)
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    fn minimal_manifest() -> &'static str {
43        r#"{ "xript": "0.1", "name": "test-app" }"#
44    }
45
46    #[test]
47    fn creates_runtime_from_minimal_manifest() {
48        let rt = create_runtime(
49            minimal_manifest(),
50            RuntimeOptions {
51                host_bindings: HostBindings::new(),
52                capabilities: vec![],
53                console: ConsoleHandler::default(),
54            },
55        );
56        assert!(rt.is_ok());
57    }
58
59    #[test]
60    fn rejects_empty_xript_field() {
61        let result = create_runtime(
62            r#"{ "xript": "", "name": "test" }"#,
63            RuntimeOptions {
64                host_bindings: HostBindings::new(),
65                capabilities: vec![],
66                console: ConsoleHandler::default(),
67            },
68        );
69        assert!(result.is_err());
70        assert!(matches!(
71            result.unwrap_err(),
72            XriptError::ManifestValidation { .. }
73        ));
74    }
75
76    #[test]
77    fn rejects_empty_name_field() {
78        let result = create_runtime(
79            r#"{ "xript": "0.1", "name": "" }"#,
80            RuntimeOptions {
81                host_bindings: HostBindings::new(),
82                capabilities: vec![],
83                console: ConsoleHandler::default(),
84            },
85        );
86        assert!(result.is_err());
87    }
88
89    #[test]
90    fn executes_simple_expressions() {
91        let rt = create_runtime(
92            minimal_manifest(),
93            RuntimeOptions {
94                host_bindings: HostBindings::new(),
95                capabilities: vec![],
96                console: ConsoleHandler::default(),
97            },
98        )
99        .unwrap();
100
101        let result = rt.execute("2 + 2").unwrap();
102        assert_eq!(result.value, serde_json::json!(4));
103        assert!(result.duration_ms >= 0.0);
104    }
105
106    #[test]
107    fn executes_string_expressions() {
108        let rt = create_runtime(
109            minimal_manifest(),
110            RuntimeOptions {
111                host_bindings: HostBindings::new(),
112                capabilities: vec![],
113                console: ConsoleHandler::default(),
114            },
115        )
116        .unwrap();
117
118        let result = rt.execute("'hello' + ' ' + 'world'").unwrap();
119        assert_eq!(result.value, serde_json::json!("hello world"));
120    }
121
122    #[test]
123    fn supports_standard_builtins() {
124        let rt = create_runtime(
125            minimal_manifest(),
126            RuntimeOptions {
127                host_bindings: HostBindings::new(),
128                capabilities: vec![],
129                console: ConsoleHandler::default(),
130            },
131        )
132        .unwrap();
133
134        let result = rt.execute("Math.max(1, 5, 3)").unwrap();
135        assert_eq!(result.value, serde_json::json!(5));
136
137        let result = rt.execute("JSON.stringify({ a: 1 })").unwrap();
138        assert_eq!(result.value, serde_json::json!("{\"a\":1}"));
139    }
140
141    #[test]
142    fn blocks_eval() {
143        let rt = create_runtime(
144            minimal_manifest(),
145            RuntimeOptions {
146                host_bindings: HostBindings::new(),
147                capabilities: vec![],
148                console: ConsoleHandler::default(),
149            },
150        )
151        .unwrap();
152
153        let result = rt.execute("eval('1 + 1')");
154        assert!(result.is_err());
155    }
156
157    #[test]
158    fn blocks_process_and_require() {
159        let rt = create_runtime(
160            minimal_manifest(),
161            RuntimeOptions {
162                host_bindings: HostBindings::new(),
163                capabilities: vec![],
164                console: ConsoleHandler::default(),
165            },
166        )
167        .unwrap();
168
169        let result = rt.execute("typeof process").unwrap();
170        assert_eq!(result.value, serde_json::json!("undefined"));
171
172        let result = rt.execute("typeof require").unwrap();
173        assert_eq!(result.value, serde_json::json!("undefined"));
174    }
175
176    #[test]
177    fn routes_console_output() {
178        use std::sync::{Arc, Mutex};
179
180        let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
181        let logs_clone = logs.clone();
182
183        let rt = create_runtime(
184            minimal_manifest(),
185            RuntimeOptions {
186                host_bindings: HostBindings::new(),
187                capabilities: vec![],
188                console: ConsoleHandler {
189                    log: Box::new(move |msg| logs_clone.lock().unwrap().push(msg.to_string())),
190                    warn: Box::new(|_| {}),
191                    error: Box::new(|_| {}),
192                },
193            },
194        )
195        .unwrap();
196
197        rt.execute("console.log('hello from sandbox')").unwrap();
198
199        let captured = logs.lock().unwrap();
200        assert_eq!(captured.len(), 1);
201        assert_eq!(captured[0], "hello from sandbox");
202    }
203
204    #[test]
205    fn exposes_manifest() {
206        let rt = create_runtime(
207            minimal_manifest(),
208            RuntimeOptions {
209                host_bindings: HostBindings::new(),
210                capabilities: vec![],
211                console: ConsoleHandler::default(),
212            },
213        )
214        .unwrap();
215
216        assert_eq!(rt.manifest().name, "test-app");
217        assert_eq!(rt.manifest().xript, "0.1");
218    }
219
220    #[test]
221    fn rejects_invalid_json() {
222        let result = create_runtime(
223            "not json",
224            RuntimeOptions {
225                host_bindings: HostBindings::new(),
226                capabilities: vec![],
227                console: ConsoleHandler::default(),
228            },
229        );
230        assert!(result.is_err());
231    }
232
233    #[test]
234    fn enforces_timeout() {
235        let rt = create_runtime(
236            r#"{ "xript": "0.1", "name": "test", "limits": { "timeout_ms": 100 } }"#,
237            RuntimeOptions {
238                host_bindings: HostBindings::new(),
239                capabilities: vec![],
240                console: ConsoleHandler::default(),
241            },
242        )
243        .unwrap();
244
245        let result = rt.execute("while(true) {}");
246        assert!(result.is_err());
247        assert!(matches!(
248            result.unwrap_err(),
249            XriptError::ExecutionLimit { .. }
250        ));
251    }
252
253    #[test]
254    fn calls_host_function() {
255        let manifest = r#"{
256            "xript": "0.1",
257            "name": "test",
258            "bindings": {
259                "add": {
260                    "description": "adds two numbers",
261                    "params": [
262                        { "name": "a", "type": "number" },
263                        { "name": "b", "type": "number" }
264                    ]
265                }
266            }
267        }"#;
268
269        let mut bindings = HostBindings::new();
270        bindings.add_function("add", |args: &[serde_json::Value]| {
271            let a = args.get(0).and_then(|v| v.as_f64()).unwrap_or(0.0);
272            let b = args.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
273            Ok(serde_json::json!(a + b))
274        });
275
276        let rt = create_runtime(
277            manifest,
278            RuntimeOptions {
279                host_bindings: bindings,
280                capabilities: vec![],
281                console: ConsoleHandler::default(),
282            },
283        )
284        .unwrap();
285
286        let result = rt.execute("add(3, 4)").unwrap();
287        assert_eq!(result.value, serde_json::json!(7.0));
288    }
289
290    #[test]
291    fn host_function_errors_become_exceptions() {
292        let manifest = r#"{
293            "xript": "0.1",
294            "name": "test",
295            "bindings": {
296                "fail": {
297                    "description": "always fails"
298                }
299            }
300        }"#;
301
302        let mut bindings = HostBindings::new();
303        bindings.add_function("fail", |_: &[serde_json::Value]| {
304            Err("intentional error".into())
305        });
306
307        let rt = create_runtime(
308            manifest,
309            RuntimeOptions {
310                host_bindings: bindings,
311                capabilities: vec![],
312                console: ConsoleHandler::default(),
313            },
314        )
315        .unwrap();
316
317        let result = rt.execute("try { fail(); 'no error' } catch(e) { e.message }");
318        assert!(result.is_ok());
319        assert_eq!(result.unwrap().value, serde_json::json!("intentional error"));
320    }
321
322    #[test]
323    fn denies_ungranated_capabilities() {
324        let manifest = r#"{
325            "xript": "0.1",
326            "name": "test",
327            "bindings": {
328                "dangerousOp": {
329                    "description": "requires permission",
330                    "capability": "dangerous"
331                }
332            },
333            "capabilities": {
334                "dangerous": {
335                    "description": "allows dangerous operations"
336                }
337            }
338        }"#;
339
340        let mut bindings = HostBindings::new();
341        bindings.add_function("dangerousOp", |_: &[serde_json::Value]| {
342            Ok(serde_json::json!("should not reach"))
343        });
344
345        let rt = create_runtime(
346            manifest,
347            RuntimeOptions {
348                host_bindings: bindings,
349                capabilities: vec![],
350                console: ConsoleHandler::default(),
351            },
352        )
353        .unwrap();
354
355        let result = rt.execute("try { dangerousOp(); 'no error' } catch(e) { e.message }");
356        assert!(result.is_ok());
357        let msg = result.unwrap().value.as_str().unwrap().to_string();
358        assert!(msg.contains("capability"));
359    }
360
361    #[test]
362    fn grants_capabilities() {
363        let manifest = r#"{
364            "xript": "0.1",
365            "name": "test",
366            "bindings": {
367                "dangerousOp": {
368                    "description": "requires permission",
369                    "capability": "dangerous"
370                }
371            },
372            "capabilities": {
373                "dangerous": {
374                    "description": "allows dangerous operations"
375                }
376            }
377        }"#;
378
379        let mut bindings = HostBindings::new();
380        bindings.add_function("dangerousOp", |_: &[serde_json::Value]| {
381            Ok(serde_json::json!("access granted"))
382        });
383
384        let rt = create_runtime(
385            manifest,
386            RuntimeOptions {
387                host_bindings: bindings,
388                capabilities: vec!["dangerous".into()],
389                console: ConsoleHandler::default(),
390            },
391        )
392        .unwrap();
393
394        let result = rt.execute("dangerousOp()").unwrap();
395        assert_eq!(result.value, serde_json::json!("access granted"));
396    }
397
398    #[test]
399    fn missing_binding_throws() {
400        let manifest = r#"{
401            "xript": "0.1",
402            "name": "test",
403            "bindings": {
404                "notProvided": {
405                    "description": "host didn't register this"
406                }
407            }
408        }"#;
409
410        let rt = create_runtime(
411            manifest,
412            RuntimeOptions {
413                host_bindings: HostBindings::new(),
414                capabilities: vec![],
415                console: ConsoleHandler::default(),
416            },
417        )
418        .unwrap();
419
420        let result = rt.execute("try { notProvided(); 'no error' } catch(e) { e.message }");
421        assert!(result.is_ok());
422        let msg = result.unwrap().value.as_str().unwrap().to_string();
423        assert!(msg.contains("not provided"));
424    }
425
426    #[test]
427    fn parses_mod_manifest() {
428        let json = r#"{
429            "xript": "0.3",
430            "name": "health-panel",
431            "version": "1.0.0",
432            "title": "Health Panel",
433            "author": "Test Author",
434            "capabilities": ["ui-mount"],
435            "fragments": [
436                {
437                    "id": "health-bar",
438                    "slot": "sidebar.left",
439                    "format": "text/html",
440                    "source": "fragments/panel.html",
441                    "bindings": [
442                        { "name": "health", "path": "player.health" }
443                    ],
444                    "events": [
445                        { "selector": "[data-action='heal']", "on": "click", "handler": "onHeal" }
446                    ],
447                    "priority": 10
448                }
449            ]
450        }"#;
451
452        let mod_manifest: ModManifest = serde_json::from_str(json).unwrap();
453        assert_eq!(mod_manifest.name, "health-panel");
454        assert_eq!(mod_manifest.version, "1.0.0");
455        assert_eq!(mod_manifest.fragments.as_ref().unwrap().len(), 1);
456
457        let frag = &mod_manifest.fragments.as_ref().unwrap()[0];
458        assert_eq!(frag.id, "health-bar");
459        assert_eq!(frag.slot, "sidebar.left");
460        assert_eq!(frag.priority, Some(10));
461        assert_eq!(frag.bindings.as_ref().unwrap().len(), 1);
462        assert_eq!(frag.events.as_ref().unwrap().len(), 1);
463    }
464
465    #[test]
466    fn validates_mod_manifest_required_fields() {
467        let invalid = r#"{
468            "xript": "",
469            "name": "",
470            "version": ""
471        }"#;
472
473        let mod_manifest: ModManifest = serde_json::from_str(invalid).unwrap();
474        let result = manifest::validate_mod_manifest(&mod_manifest);
475        assert!(result.is_err());
476        if let Err(XriptError::ManifestValidation { issues }) = result {
477            assert!(issues.len() >= 3);
478        }
479    }
480
481    #[test]
482    fn sanitizes_script_tags() {
483        let input = r#"<script>alert('xss')</script><p>safe</p>"#;
484        let output = fragment::sanitize_html(input);
485        assert!(!output.contains("<script>"));
486        assert!(output.contains("<p>safe</p>"));
487    }
488
489    #[test]
490    fn sanitizes_event_attributes() {
491        let input = r#"<div onclick="alert('xss')">test</div>"#;
492        let output = fragment::sanitize_html(input);
493        assert!(!output.contains("onclick"));
494        assert!(output.contains("test"));
495    }
496
497    #[test]
498    fn preserves_safe_content() {
499        let input = r#"<div class="panel" data-bind="health" aria-label="hp" role="progressbar"><span>100</span></div>"#;
500        let output = fragment::sanitize_html(input);
501        assert!(output.contains("class=\"panel\""));
502        assert!(output.contains("data-bind=\"health\""));
503        assert!(output.contains("aria-label=\"hp\""));
504        assert!(output.contains("role=\"progressbar\""));
505        assert!(output.contains("<span>100</span>"));
506    }
507
508    #[test]
509    fn resolves_data_bind() {
510        let source = r#"<span data-bind="health">0</span>"#;
511        let mut bindings = std::collections::HashMap::new();
512        bindings.insert("health".to_string(), serde_json::json!(75));
513
514        let result = fragment::process_fragment("test-frag", source, &bindings);
515        assert!(result.html.contains("75"));
516        assert!(!result.html.contains(">0<"));
517    }
518
519    #[test]
520    fn evaluates_data_if() {
521        let source = r#"<div data-if="health < 50" class="warning">low!</div>"#;
522        let mut bindings = std::collections::HashMap::new();
523        bindings.insert("health".to_string(), serde_json::json!(30));
524
525        let result = fragment::process_fragment("test-frag", source, &bindings);
526        assert_eq!(result.visibility.get("health < 50"), Some(&true));
527
528        bindings.insert("health".to_string(), serde_json::json!(80));
529        let result = fragment::process_fragment("test-frag", source, &bindings);
530        assert_eq!(result.visibility.get("health < 50"), Some(&false));
531    }
532
533    #[test]
534    fn cross_validates_slot_exists() {
535        let app_manifest = r#"{
536            "xript": "0.3",
537            "name": "test-app",
538            "slots": [
539                { "id": "sidebar.left", "accepts": ["text/html"] }
540            ]
541        }"#;
542
543        let mod_json = r#"{
544            "xript": "0.3",
545            "name": "test-mod",
546            "version": "1.0.0",
547            "fragments": [
548                { "id": "panel", "slot": "nonexistent", "format": "text/html", "source": "panel.html" }
549            ]
550        }"#;
551
552        let rt = create_runtime(
553            app_manifest,
554            RuntimeOptions {
555                host_bindings: HostBindings::new(),
556                capabilities: vec![],
557                console: ConsoleHandler::default(),
558            },
559        )
560        .unwrap();
561
562        let result = rt.load_mod(
563            mod_json,
564            std::collections::HashMap::new(),
565            &std::collections::HashSet::new(),
566        );
567        assert!(result.is_err());
568    }
569
570    #[test]
571    fn cross_validates_format_accepted() {
572        let app_manifest = r#"{
573            "xript": "0.3",
574            "name": "test-app",
575            "slots": [
576                { "id": "sidebar.left", "accepts": ["text/html"] }
577            ]
578        }"#;
579
580        let mod_json = r#"{
581            "xript": "0.3",
582            "name": "test-mod",
583            "version": "1.0.0",
584            "fragments": [
585                { "id": "panel", "slot": "sidebar.left", "format": "text/plain", "source": "panel.txt" }
586            ]
587        }"#;
588
589        let rt = create_runtime(
590            app_manifest,
591            RuntimeOptions {
592                host_bindings: HostBindings::new(),
593                capabilities: vec![],
594                console: ConsoleHandler::default(),
595            },
596        )
597        .unwrap();
598
599        let result = rt.load_mod(
600            mod_json,
601            std::collections::HashMap::new(),
602            &std::collections::HashSet::new(),
603        );
604        assert!(result.is_err());
605    }
606
607    #[test]
608    fn cross_validates_capability_gating() {
609        let app_manifest = r#"{
610            "xript": "0.3",
611            "name": "test-app",
612            "slots": [
613                { "id": "main.overlay", "accepts": ["text/html"], "capability": "ui-mount" }
614            ]
615        }"#;
616
617        let mod_json = r#"{
618            "xript": "0.3",
619            "name": "test-mod",
620            "version": "1.0.0",
621            "fragments": [
622                { "id": "overlay", "slot": "main.overlay", "format": "text/html", "source": "<p>hi</p>", "inline": true }
623            ]
624        }"#;
625
626        let rt = create_runtime(
627            app_manifest,
628            RuntimeOptions {
629                host_bindings: HostBindings::new(),
630                capabilities: vec![],
631                console: ConsoleHandler::default(),
632            },
633        )
634        .unwrap();
635
636        let no_caps = std::collections::HashSet::new();
637        let result = rt.load_mod(mod_json, std::collections::HashMap::new(), &no_caps);
638        assert!(result.is_err());
639
640        let mut with_caps = std::collections::HashSet::new();
641        with_caps.insert("ui-mount".to_string());
642        let result = rt.load_mod(mod_json, std::collections::HashMap::new(), &with_caps);
643        assert!(result.is_ok());
644    }
645
646    #[test]
647    fn load_mod_integration() {
648        let app_manifest = r#"{
649            "xript": "0.3",
650            "name": "test-app",
651            "slots": [
652                { "id": "sidebar.left", "accepts": ["text/html"], "multiple": true }
653            ]
654        }"#;
655
656        let mod_json = r#"{
657            "xript": "0.3",
658            "name": "health-panel",
659            "version": "1.0.0",
660            "fragments": [
661                {
662                    "id": "health-bar",
663                    "slot": "sidebar.left",
664                    "format": "text/html",
665                    "source": "<div data-bind=\"health\"><span>0</span></div>",
666                    "inline": true,
667                    "bindings": [
668                        { "name": "health", "path": "player.health" }
669                    ]
670                }
671            ]
672        }"#;
673
674        let rt = create_runtime(
675            app_manifest,
676            RuntimeOptions {
677                host_bindings: HostBindings::new(),
678                capabilities: vec![],
679                console: ConsoleHandler::default(),
680            },
681        )
682        .unwrap();
683
684        let mod_instance = rt.load_mod(
685            mod_json,
686            std::collections::HashMap::new(),
687            &std::collections::HashSet::new(),
688        )
689        .unwrap();
690
691        assert_eq!(mod_instance.name, "health-panel");
692        assert_eq!(mod_instance.fragments.len(), 1);
693        assert_eq!(mod_instance.fragments[0].id, "health-bar");
694
695        let data = serde_json::json!({ "player": { "health": 75 } });
696        let results = mod_instance.update_bindings(&data);
697        assert_eq!(results.len(), 1);
698        assert!(results[0].html.contains("75"));
699    }
700
701    #[test]
702    fn fragment_hook_registration() {
703        let rt = create_runtime(
704            minimal_manifest(),
705            RuntimeOptions {
706                host_bindings: HostBindings::new(),
707                capabilities: vec![],
708                console: ConsoleHandler::default(),
709            },
710        )
711        .unwrap();
712
713        let result = rt.execute("typeof hooks.fragment.mount").unwrap();
714        assert_eq!(result.value, serde_json::json!("function"));
715
716        let result = rt.execute("typeof hooks.fragment.unmount").unwrap();
717        assert_eq!(result.value, serde_json::json!("function"));
718
719        let result = rt.execute("typeof hooks.fragment.update").unwrap();
720        assert_eq!(result.value, serde_json::json!("function"));
721
722        let result = rt.execute("typeof hooks.fragment.suspend").unwrap();
723        assert_eq!(result.value, serde_json::json!("function"));
724
725        let result = rt.execute("typeof hooks.fragment.resume").unwrap();
726        assert_eq!(result.value, serde_json::json!("function"));
727    }
728
729    #[test]
730    fn strips_javascript_uris() {
731        let input = r#"<a href="javascript:alert('xss')">click</a>"#;
732        let output = fragment::sanitize_html(input);
733        assert!(!output.contains("javascript:"));
734        assert!(output.contains("click"));
735    }
736
737    #[test]
738    fn strips_iframe_elements() {
739        let input = r#"<iframe src="evil.com"></iframe><p>ok</p>"#;
740        let output = fragment::sanitize_html(input);
741        assert!(!output.contains("<iframe"));
742        assert!(output.contains("<p>ok</p>"));
743    }
744}