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}