Skip to main content

oxirs_shacl/custom_components/
js_wasm.rs

1//! JavaScript and WASM Constraint Validators
2//!
3//! This module provides support for custom constraint validators written in JavaScript
4//! or compiled to WebAssembly (WASM). This enables users to write custom validation
5//! logic in familiar languages while maintaining security through sandboxing.
6//!
7//! # Features
8//!
9//! - JavaScript constraint validators with QuickJS runtime
10//! - WebAssembly constraint validators with Wasmer/Wasmtime
11//! - Sandboxed execution with resource limits
12//! - Performance monitoring and statistics
13//! - Error handling and recovery
14//! - Type conversion between RDF terms and JS/WASM values
15//!
16//! # Security
17//!
18//! All external code execution is sandboxed with:
19//! - Memory limits
20//! - Execution time limits
21//! - No network access
22//! - No file system access
23//! - Limited computational resources
24
25use crate::{
26    constraints::ConstraintEvaluationResult,
27    custom_components::{
28        ComponentExecutionResult, ComponentMetadata, CustomConstraint, CustomConstraintComponent,
29        ExecutionMetrics, ResourceQuotas, SandboxingLevel, SecurityPolicy,
30    },
31    ConstraintComponentId, Result, ShaclError,
32};
33use oxirs_core::model::Term;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::time::{Duration, Instant};
37use wasmi::{Config, Engine, Linker, Module, Store, Val};
38
39/// JavaScript/WASM runtime configuration
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RuntimeConfig {
42    /// Maximum memory allowed (bytes)
43    pub max_memory: usize,
44    /// Maximum execution time (milliseconds)
45    pub max_execution_time_ms: u64,
46    /// Maximum stack depth
47    pub max_stack_depth: usize,
48    /// Enable debugging features
49    pub enable_debugging: bool,
50    /// Resource quotas
51    pub quotas: ResourceQuotas,
52}
53
54impl Default for RuntimeConfig {
55    fn default() -> Self {
56        Self {
57            max_memory: 10 * 1024 * 1024, // 10 MB
58            max_execution_time_ms: 5000,  // 5 seconds
59            max_stack_depth: 100,
60            enable_debugging: false,
61            quotas: ResourceQuotas {
62                max_cpu_time: Some(Duration::from_secs(5)),
63                max_sparql_queries: Some(10),
64                max_result_size: Some(10000),
65                max_recursion_depth: Some(100),
66            },
67        }
68    }
69}
70
71/// JavaScript constraint validator
72///
73/// Executes JavaScript code for constraint validation using a sandboxed runtime.
74/// The JavaScript code should define a function `validate(value, parameters)` that
75/// returns a boolean or an object with `valid: boolean` and optional `message: string`.
76#[derive(Debug, Clone)]
77pub struct JavaScriptValidator {
78    /// Component ID
79    component_id: ConstraintComponentId,
80    /// Component metadata
81    metadata: ComponentMetadata,
82    /// JavaScript validation code
83    code: String,
84    /// Runtime configuration
85    config: RuntimeConfig,
86    /// Security policy
87    security_policy: SecurityPolicy,
88}
89
90impl JavaScriptValidator {
91    /// Create a new JavaScript validator
92    pub fn new(
93        component_id: ConstraintComponentId,
94        metadata: ComponentMetadata,
95        code: String,
96    ) -> Self {
97        Self {
98            component_id,
99            metadata,
100            code,
101            config: RuntimeConfig::default(),
102            security_policy: SecurityPolicy {
103                allow_arbitrary_sparql: false,
104                allow_external_access: false,
105                max_execution_time: Some(Duration::from_secs(5)),
106                max_memory_usage: Some(10 * 1024 * 1024),
107                allowed_sparql_operations: std::collections::HashSet::new(),
108                trusted: false,
109                sandboxing_level: SandboxingLevel::Strict,
110                resource_quotas: ResourceQuotas {
111                    max_cpu_time: Some(Duration::from_secs(5)),
112                    max_sparql_queries: Some(0), // No SPARQL in JS validators
113                    max_result_size: Some(1000),
114                    max_recursion_depth: Some(100),
115                },
116            },
117        }
118    }
119
120    /// Create with custom configuration
121    pub fn with_config(mut self, config: RuntimeConfig) -> Self {
122        self.config = config;
123        self
124    }
125
126    /// Create with custom security policy
127    pub fn with_security_policy(mut self, policy: SecurityPolicy) -> Self {
128        self.security_policy = policy;
129        self
130    }
131
132    /// Execute JavaScript validation.
133    ///
134    /// No sandboxed JavaScript runtime is wired into this build, so a configured
135    /// JavaScript validator MUST NOT silently report conformance (which would
136    /// pass every focus node regardless of the actual validation logic).
137    /// Instead this fails loud with [`ShaclError::UnsupportedOperation`], in line
138    /// with the project's fail-loud contract. Wiring a real sandboxed engine
139    /// (e.g. QuickJS via `rquickjs`, or Boa) would replace this method body.
140    pub fn execute(
141        &self,
142        _value: &Term,
143        _parameters: &HashMap<String, Term>,
144    ) -> Result<ComponentExecutionResult> {
145        Err(ShaclError::UnsupportedOperation(format!(
146            "JavaScript constraint validator '{}' cannot be executed: no sandboxed \
147             JavaScript runtime is available in this build. Configure a WASM validator \
148             or a built-in/SPARQL constraint instead.",
149            self.component_id.as_str()
150        )))
151    }
152
153    /// Validate the JavaScript code syntax
154    pub fn validate_code(&self) -> Result<()> {
155        // TODO: Implement syntax validation
156        // For now, just check if code is not empty
157        if self.code.trim().is_empty() {
158            return Err(ShaclError::Configuration(
159                "JavaScript code cannot be empty".to_string(),
160            ));
161        }
162        Ok(())
163    }
164}
165
166impl CustomConstraintComponent for JavaScriptValidator {
167    fn component_id(&self) -> &ConstraintComponentId {
168        &self.component_id
169    }
170
171    fn metadata(&self) -> &ComponentMetadata {
172        &self.metadata
173    }
174
175    fn validate_configuration(&self, _parameters: &HashMap<String, Term>) -> Result<()> {
176        self.validate_code()
177    }
178
179    fn create_constraint(&self, parameters: HashMap<String, Term>) -> Result<CustomConstraint> {
180        Ok(CustomConstraint {
181            component_id: self.component_id.clone(),
182            parameters,
183            sparql_query: None,
184            validation_function: Some("javascript".to_string()),
185            message_template: Some("JavaScript validation failed".to_string()),
186        })
187    }
188}
189
190/// WebAssembly constraint validator
191///
192/// Executes WebAssembly modules for constraint validation using a sandboxed runtime.
193/// The WASM module should export a function `validate(value_ptr, params_ptr) -> i32`
194/// that returns 1 for valid, 0 for invalid.
195#[derive(Debug, Clone)]
196pub struct WasmValidator {
197    /// Component ID
198    component_id: ConstraintComponentId,
199    /// Component metadata
200    metadata: ComponentMetadata,
201    /// WASM module bytes
202    wasm_bytes: Vec<u8>,
203    /// Runtime configuration
204    config: RuntimeConfig,
205    /// Security policy
206    security_policy: SecurityPolicy,
207    /// Function name to call
208    function_name: String,
209}
210
211impl WasmValidator {
212    /// Create a new WASM validator
213    pub fn new(
214        component_id: ConstraintComponentId,
215        metadata: ComponentMetadata,
216        wasm_bytes: Vec<u8>,
217    ) -> Self {
218        Self {
219            component_id,
220            metadata,
221            wasm_bytes,
222            config: RuntimeConfig::default(),
223            security_policy: SecurityPolicy {
224                allow_arbitrary_sparql: false,
225                allow_external_access: false,
226                max_execution_time: Some(Duration::from_secs(5)),
227                max_memory_usage: Some(10 * 1024 * 1024),
228                allowed_sparql_operations: std::collections::HashSet::new(),
229                trusted: false,
230                sandboxing_level: SandboxingLevel::Strict,
231                resource_quotas: ResourceQuotas {
232                    max_cpu_time: Some(Duration::from_secs(5)),
233                    max_sparql_queries: Some(0), // No SPARQL in WASM validators
234                    max_result_size: Some(1000),
235                    max_recursion_depth: Some(100),
236                },
237            },
238            function_name: "validate".to_string(),
239        }
240    }
241
242    /// Create with custom configuration
243    pub fn with_config(mut self, config: RuntimeConfig) -> Self {
244        self.config = config;
245        self
246    }
247
248    /// Create with custom security policy
249    pub fn with_security_policy(mut self, policy: SecurityPolicy) -> Self {
250        self.security_policy = policy;
251        self
252    }
253
254    /// Set the function name to call
255    pub fn with_function_name(mut self, name: String) -> Self {
256        self.function_name = name;
257        self
258    }
259
260    /// Execute WASM validation
261    ///
262    /// Executes the WASM module using wasmi with proper sandboxing and resource limits.
263    /// The WASM module must export a function with the configured name (default: "validate")
264    /// that takes an i32 input and returns an i32 (1 for valid, 0 for invalid).
265    pub fn execute(
266        &self,
267        value: &Term,
268        parameters: &HashMap<String, Term>,
269    ) -> Result<ComponentExecutionResult> {
270        let start = Instant::now();
271
272        // Execute WASM with timeout
273        let result = self.execute_wasm_with_timeout(value, parameters)?;
274
275        let execution_time = start.elapsed();
276
277        // Check execution time limit
278        if execution_time.as_millis() > self.config.max_execution_time_ms as u128 {
279            return Err(ShaclError::Timeout(format!(
280                "WASM execution exceeded time limit: {:?}",
281                execution_time
282            )));
283        }
284
285        Ok(result)
286    }
287
288    /// Execute WASM module with resource limits and timeout
289    fn execute_wasm_with_timeout(
290        &self,
291        value: &Term,
292        _parameters: &HashMap<String, Term>,
293    ) -> Result<ComponentExecutionResult> {
294        let start = Instant::now();
295
296        // Configure wasmi with fuel metering enabled so that a hung or malicious
297        // module (e.g. an infinite loop) is preempted deterministically after a
298        // bounded number of instructions, rather than blocking the calling
299        // thread indefinitely and only being noticed by a post-hoc wall-clock
300        // check. The fuel budget is derived from the configured time limit.
301        let mut config = Config::default();
302        config.consume_fuel(true);
303        let engine = Engine::new(&config);
304
305        // Compile WASM module
306        let module = Module::new(&engine, &self.wasm_bytes[..]).map_err(|e| {
307            ShaclError::Configuration(format!("Failed to compile WASM module: {}", e))
308        })?;
309
310        // Create linker with no host functions (fully sandboxed)
311        let linker = Linker::new(&engine);
312
313        // Create store and load a finite fuel budget. ~5M fuel units per allowed
314        // millisecond bounds runaway execution while leaving ample headroom for
315        // legitimate validators; the minimum ensures very small time limits
316        // still allow non-trivial modules to run.
317        let mut store = Store::new(&engine, ());
318        let fuel_budget = self
319            .config
320            .max_execution_time_ms
321            .saturating_mul(5_000_000)
322            .max(1_000_000);
323        store.set_fuel(fuel_budget).map_err(|e| {
324            ShaclError::Configuration(format!("Failed to set WASM fuel budget: {e}"))
325        })?;
326
327        // Instantiate and start module (wasmi 1.0 API)
328        let instance = linker
329            .instantiate_and_start(&mut store, &module)
330            .map_err(|e| {
331                ShaclError::Configuration(format!(
332                    "Failed to instantiate and start WASM module: {}",
333                    e
334                ))
335            })?;
336
337        // Get the validation function
338        let validate_func = instance
339            .get_func(&store, &self.function_name)
340            .ok_or_else(|| {
341                ShaclError::Configuration(format!(
342                    "WASM module does not export function '{}'",
343                    self.function_name
344                ))
345            })?;
346
347        // Convert term to simple hash for WASM input (simplified)
348        let value_hash = self.term_to_simple_hash(value);
349
350        // Call WASM function with input value
351        let mut results = [Val::I32(0)];
352        validate_func
353            .call(&mut store, &[Val::I32(value_hash)], &mut results)
354            .map_err(|e| {
355                let msg = e.to_string();
356                if msg.to_lowercase().contains("fuel") {
357                    ShaclError::Timeout(format!(
358                        "WASM validator exhausted its instruction budget \
359                         (fuel) and was preempted: {msg}"
360                    ))
361                } else {
362                    ShaclError::Configuration(format!("WASM function execution failed: {msg}"))
363                }
364            })?;
365
366        // Extract result (1 = valid, 0 = invalid)
367        let is_valid = match results[0] {
368            Val::I32(v) => v != 0,
369            _ => {
370                return Err(ShaclError::Configuration(
371                    "WASM function returned unexpected type".to_string(),
372                ))
373            }
374        };
375
376        let execution_time = start.elapsed();
377
378        Ok(ComponentExecutionResult {
379            constraint_result: if is_valid {
380                ConstraintEvaluationResult::Satisfied
381            } else {
382                ConstraintEvaluationResult::Violated {
383                    violating_value: Some(value.clone()),
384                    message: Some("WASM validation failed".to_string()),
385                    details: HashMap::new(),
386                }
387            },
388            metrics: ExecutionMetrics {
389                execution_time,
390                memory_used: 0, // Memory tracking not available in wasmi 0.40
391                sparql_queries: 0,
392                success: true,
393                error: None,
394            },
395            security_violations: Vec::new(),
396        })
397    }
398
399    /// Convert RDF term to simple hash for WASM input
400    /// This is a simplified conversion - production code would use more sophisticated serialization
401    fn term_to_simple_hash(&self, term: &Term) -> i32 {
402        use std::collections::hash_map::DefaultHasher;
403        use std::hash::{Hash, Hasher};
404
405        let mut hasher = DefaultHasher::new();
406
407        match term {
408            Term::NamedNode(node) => {
409                "NamedNode".hash(&mut hasher);
410                node.as_str().hash(&mut hasher);
411            }
412            Term::BlankNode(node) => {
413                "BlankNode".hash(&mut hasher);
414                node.as_str().hash(&mut hasher);
415            }
416            Term::Literal(lit) => {
417                "Literal".hash(&mut hasher);
418                lit.value().hash(&mut hasher);
419            }
420            Term::Variable(var) => {
421                "Variable".hash(&mut hasher);
422                var.as_str().hash(&mut hasher);
423            }
424            Term::QuotedTriple(_) => {
425                "QuotedTriple".hash(&mut hasher);
426            }
427        }
428
429        hasher.finish() as i32
430    }
431
432    /// Validate the WASM module
433    pub fn validate_module(&self) -> Result<()> {
434        // TODO: Implement WASM module validation
435        // For now, just check if bytes are not empty
436        if self.wasm_bytes.is_empty() {
437            return Err(ShaclError::Configuration(
438                "WASM module cannot be empty".to_string(),
439            ));
440        }
441
442        // Check WASM magic number (0x00 0x61 0x73 0x6D)
443        if self.wasm_bytes.len() < 4
444            || self.wasm_bytes[0] != 0x00
445            || self.wasm_bytes[1] != 0x61
446            || self.wasm_bytes[2] != 0x73
447            || self.wasm_bytes[3] != 0x6D
448        {
449            return Err(ShaclError::Configuration(
450                "Invalid WASM module: missing magic number".to_string(),
451            ));
452        }
453
454        Ok(())
455    }
456}
457
458impl CustomConstraintComponent for WasmValidator {
459    fn component_id(&self) -> &ConstraintComponentId {
460        &self.component_id
461    }
462
463    fn metadata(&self) -> &ComponentMetadata {
464        &self.metadata
465    }
466
467    fn validate_configuration(&self, _parameters: &HashMap<String, Term>) -> Result<()> {
468        self.validate_module()
469    }
470
471    fn create_constraint(&self, parameters: HashMap<String, Term>) -> Result<CustomConstraint> {
472        Ok(CustomConstraint {
473            component_id: self.component_id.clone(),
474            parameters,
475            sparql_query: None,
476            validation_function: Some("wasm".to_string()),
477            message_template: Some("WASM validation failed".to_string()),
478        })
479    }
480}
481
482/// Type conversion utilities for JavaScript/WASM interop
483pub mod interop {
484    use super::*;
485    use oxirs_core::model::{BlankNode, Literal, NamedNode};
486
487    /// Convert RDF term to JSON-compatible value for JavaScript
488    pub fn term_to_json(term: &Term) -> serde_json::Value {
489        match term {
490            Term::NamedNode(node) => {
491                serde_json::json!({
492                    "type": "NamedNode",
493                    "value": node.as_str()
494                })
495            }
496            Term::BlankNode(node) => {
497                serde_json::json!({
498                    "type": "BlankNode",
499                    "value": node.as_str()
500                })
501            }
502            Term::Literal(lit) => {
503                serde_json::json!({
504                    "type": "Literal",
505                    "value": lit.value(),
506                    "language": lit.language(),
507                    "datatype": lit.datatype().as_str()
508                })
509            }
510            Term::Variable(var) => {
511                serde_json::json!({
512                    "type": "Variable",
513                    "value": var.as_str()
514                })
515            }
516            Term::QuotedTriple(_triple) => {
517                serde_json::json!({
518                    "type": "QuotedTriple",
519                    "value": "quoted-triple"
520                })
521            }
522        }
523    }
524
525    /// Convert JSON value back to RDF term
526    pub fn json_to_term(value: &serde_json::Value) -> Result<Term> {
527        let obj = value
528            .as_object()
529            .ok_or_else(|| ShaclError::Configuration("Expected JSON object".to_string()))?;
530
531        let term_type = obj
532            .get("type")
533            .and_then(|v| v.as_str())
534            .ok_or_else(|| ShaclError::Configuration("Missing 'type' field".to_string()))?;
535
536        let term_value = obj
537            .get("value")
538            .and_then(|v| v.as_str())
539            .ok_or_else(|| ShaclError::Configuration("Missing 'value' field".to_string()))?;
540
541        match term_type {
542            "NamedNode" => Ok(Term::NamedNode(NamedNode::new(term_value)?)),
543            "BlankNode" => Ok(Term::BlankNode(BlankNode::new(term_value)?)),
544            "Literal" => {
545                // Handle language tag and datatype
546                let language = obj.get("language").and_then(|v| v.as_str());
547                let datatype = obj.get("datatype").and_then(|v| v.as_str());
548
549                let literal = match (language, datatype) {
550                    // Literal with language tag (language takes precedence)
551                    (Some(lang), _) if !lang.is_empty() => {
552                        Literal::new_language_tagged_literal(term_value, lang).map_err(|e| {
553                            ShaclError::Configuration(format!(
554                                "Invalid language tag '{}': {}",
555                                lang, e
556                            ))
557                        })?
558                    }
559                    // Literal with explicit datatype
560                    (_, Some(dt)) if !dt.is_empty() => {
561                        let datatype_node = NamedNode::new(dt).map_err(|e| {
562                            ShaclError::Configuration(format!(
563                                "Invalid datatype IRI '{}': {}",
564                                dt, e
565                            ))
566                        })?;
567                        Literal::new_typed_literal(term_value, datatype_node)
568                    }
569                    // Plain literal (defaults to xsd:string)
570                    _ => Literal::new(term_value),
571                };
572
573                Ok(Term::Literal(literal))
574            }
575            _ => Err(ShaclError::Configuration(format!(
576                "Unknown term type: {}",
577                term_type
578            ))),
579        }
580    }
581}
582
583/// Builder for creating JavaScript/WASM validators
584pub struct ExternalValidatorBuilder {
585    component_id: Option<ConstraintComponentId>,
586    metadata: Option<ComponentMetadata>,
587    runtime_config: RuntimeConfig,
588    security_policy: SecurityPolicy,
589}
590
591impl ExternalValidatorBuilder {
592    /// Create a new builder
593    pub fn new() -> Self {
594        Self {
595            component_id: None,
596            metadata: None,
597            runtime_config: RuntimeConfig::default(),
598            security_policy: SecurityPolicy {
599                allow_arbitrary_sparql: false,
600                allow_external_access: false,
601                max_execution_time: Some(Duration::from_secs(5)),
602                max_memory_usage: Some(10 * 1024 * 1024),
603                allowed_sparql_operations: std::collections::HashSet::new(),
604                trusted: false,
605                sandboxing_level: SandboxingLevel::Strict,
606                resource_quotas: ResourceQuotas {
607                    max_cpu_time: Some(Duration::from_secs(5)),
608                    max_sparql_queries: Some(0),
609                    max_result_size: Some(1000),
610                    max_recursion_depth: Some(100),
611                },
612            },
613        }
614    }
615
616    /// Set component ID
617    pub fn component_id(mut self, id: ConstraintComponentId) -> Self {
618        self.component_id = Some(id);
619        self
620    }
621
622    /// Set metadata
623    pub fn metadata(mut self, metadata: ComponentMetadata) -> Self {
624        self.metadata = Some(metadata);
625        self
626    }
627
628    /// Set runtime configuration
629    pub fn runtime_config(mut self, config: RuntimeConfig) -> Self {
630        self.runtime_config = config;
631        self
632    }
633
634    /// Set security policy
635    pub fn security_policy(mut self, policy: SecurityPolicy) -> Self {
636        self.security_policy = policy;
637        self
638    }
639
640    /// Build a JavaScript validator
641    pub fn build_js(self, code: String) -> Result<JavaScriptValidator> {
642        let component_id = self
643            .component_id
644            .ok_or_else(|| ShaclError::Configuration("Missing component ID".to_string()))?;
645        let metadata = self
646            .metadata
647            .ok_or_else(|| ShaclError::Configuration("Missing metadata".to_string()))?;
648
649        Ok(JavaScriptValidator {
650            component_id,
651            metadata,
652            code,
653            config: self.runtime_config,
654            security_policy: self.security_policy,
655        })
656    }
657
658    /// Build a WASM validator
659    pub fn build_wasm(self, wasm_bytes: Vec<u8>) -> Result<WasmValidator> {
660        let component_id = self
661            .component_id
662            .ok_or_else(|| ShaclError::Configuration("Missing component ID".to_string()))?;
663        let metadata = self
664            .metadata
665            .ok_or_else(|| ShaclError::Configuration("Missing metadata".to_string()))?;
666
667        Ok(WasmValidator {
668            component_id,
669            metadata,
670            wasm_bytes,
671            config: self.runtime_config,
672            security_policy: self.security_policy,
673            function_name: "validate".to_string(),
674        })
675    }
676}
677
678impl Default for ExternalValidatorBuilder {
679    fn default() -> Self {
680        Self::new()
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn test_js_validator_creation() {
690        let metadata = ComponentMetadata {
691            name: "TestJS".to_string(),
692            description: Some("Test JavaScript validator".to_string()),
693            version: Some("1.0.0".to_string()),
694            author: None,
695            parameters: vec![],
696            applicable_to_node_shapes: true,
697            applicable_to_property_shapes: true,
698            example: None,
699        };
700
701        let validator = JavaScriptValidator::new(
702            ConstraintComponentId("test:js".to_string()),
703            metadata,
704            "function validate(value, params) { return true; }".to_string(),
705        );
706
707        assert_eq!(validator.component_id.as_str(), "test:js");
708        assert!(validator.validate_code().is_ok());
709    }
710
711    #[test]
712    fn test_js_validator_empty_code() {
713        let metadata = ComponentMetadata {
714            name: "TestJS".to_string(),
715            description: Some("Test JavaScript validator".to_string()),
716            version: Some("1.0.0".to_string()),
717            author: None,
718            parameters: vec![],
719            applicable_to_node_shapes: true,
720            applicable_to_property_shapes: true,
721            example: None,
722        };
723
724        let validator = JavaScriptValidator::new(
725            ConstraintComponentId("test:js".to_string()),
726            metadata,
727            "".to_string(),
728        );
729
730        assert!(validator.validate_code().is_err());
731    }
732
733    #[test]
734    fn regression_js_validator_execute_is_fail_loud() {
735        use oxirs_core::model::NamedNode;
736        let metadata = ComponentMetadata {
737            name: "TestJS".to_string(),
738            description: None,
739            version: Some("1.0.0".to_string()),
740            author: None,
741            parameters: vec![],
742            applicable_to_node_shapes: true,
743            applicable_to_property_shapes: true,
744            example: None,
745        };
746        let validator = JavaScriptValidator::new(
747            ConstraintComponentId("test:js".to_string()),
748            metadata,
749            "function validate(v, p) { return false; }".to_string(),
750        );
751        let value = Term::NamedNode(NamedNode::new("http://example.org/x").expect("iri"));
752        let res = validator.execute(&value, &HashMap::new());
753        // Must NOT silently report Satisfied; must fail loud.
754        assert!(
755            matches!(res, Err(ShaclError::UnsupportedOperation(_))),
756            "JS validator must fail loud, got {res:?}"
757        );
758    }
759
760    #[test]
761    fn regression_wasm_infinite_loop_is_preempted_by_fuel() {
762        use oxirs_core::model::NamedNode;
763        // Hand-encoded WASM module exporting `validate` (i32)->(i32) whose body
764        // is an infinite loop `(loop (br 0))`. Without fuel metering this would
765        // hang the thread forever; with fuel it must be preempted (Timeout).
766        let wasm_bytes: Vec<u8> = vec![
767            0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, // header
768            0x01, 0x06, 0x01, 0x60, 0x01, 0x7F, 0x01, 0x7F, // type: (i32)->(i32)
769            0x03, 0x02, 0x01, 0x00, // func section: 1 func, type 0
770            0x07, 0x0C, 0x01, 0x08, 0x76, 0x61, 0x6C, 0x69, 0x64, 0x61, 0x74, 0x65, 0x00,
771            0x00, // export "validate" func 0
772            0x0A, 0x0B, 0x01, 0x09, 0x00, 0x03, 0x40, 0x0C, 0x00, 0x0B, 0x41, 0x00,
773            0x0B, // code: loop { br 0 } i32.const 0
774        ];
775        let metadata = ComponentMetadata {
776            name: "LoopWasm".to_string(),
777            description: None,
778            version: Some("1.0.0".to_string()),
779            author: None,
780            parameters: vec![],
781            applicable_to_node_shapes: true,
782            applicable_to_property_shapes: true,
783            example: None,
784        };
785        let mut validator = WasmValidator::new(
786            ConstraintComponentId("test:loop".to_string()),
787            metadata,
788            wasm_bytes,
789        );
790        // Small time budget -> small (but finite) fuel budget.
791        validator.config.max_execution_time_ms = 1;
792        let value = Term::NamedNode(NamedNode::new("http://example.org/x").expect("iri"));
793        let res = validator.execute(&value, &HashMap::new());
794        assert!(
795            matches!(res, Err(ShaclError::Timeout(_))),
796            "infinite-loop WASM must be preempted by fuel (Timeout), got {res:?}"
797        );
798    }
799
800    #[test]
801    fn test_wasm_validator_creation() {
802        let metadata = ComponentMetadata {
803            name: "TestWASM".to_string(),
804            description: Some("Test WASM validator".to_string()),
805            version: Some("1.0.0".to_string()),
806            author: None,
807            parameters: vec![],
808            applicable_to_node_shapes: true,
809            applicable_to_property_shapes: true,
810            example: None,
811        };
812
813        // Valid WASM magic number
814        let wasm_bytes = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
815
816        let validator = WasmValidator::new(
817            ConstraintComponentId("test:wasm".to_string()),
818            metadata,
819            wasm_bytes,
820        );
821
822        assert_eq!(validator.component_id.as_str(), "test:wasm");
823        assert!(validator.validate_module().is_ok());
824    }
825
826    #[test]
827    fn test_wasm_validator_invalid_magic() {
828        let metadata = ComponentMetadata {
829            name: "TestWASM".to_string(),
830            description: Some("Test WASM validator".to_string()),
831            version: Some("1.0.0".to_string()),
832            author: None,
833            parameters: vec![],
834            applicable_to_node_shapes: true,
835            applicable_to_property_shapes: true,
836            example: None,
837        };
838
839        // Invalid WASM magic number
840        let wasm_bytes = vec![0x00, 0x00, 0x00, 0x00];
841
842        let validator = WasmValidator::new(
843            ConstraintComponentId("test:wasm".to_string()),
844            metadata,
845            wasm_bytes,
846        );
847
848        assert!(validator.validate_module().is_err());
849    }
850
851    #[test]
852    fn test_term_to_json_conversion() {
853        use oxirs_core::model::NamedNode;
854
855        let term = Term::NamedNode(NamedNode::new("http://example.org/test").expect("valid IRI"));
856        let json = interop::term_to_json(&term);
857
858        assert_eq!(json["type"], "NamedNode");
859        assert_eq!(json["value"], "http://example.org/test");
860    }
861
862    #[test]
863    fn test_builder_js() {
864        let metadata = ComponentMetadata {
865            name: "TestJS".to_string(),
866            description: Some("Test".to_string()),
867            version: Some("1.0.0".to_string()),
868            author: None,
869            parameters: vec![],
870            applicable_to_node_shapes: true,
871            applicable_to_property_shapes: true,
872            example: None,
873        };
874
875        let validator = ExternalValidatorBuilder::new()
876            .component_id(ConstraintComponentId("test:js".to_string()))
877            .metadata(metadata)
878            .build_js("function validate() { return true; }".to_string())
879            .expect("validation should succeed");
880
881        assert_eq!(validator.component_id.as_str(), "test:js");
882    }
883
884    #[test]
885    fn test_builder_wasm() {
886        let metadata = ComponentMetadata {
887            name: "TestWASM".to_string(),
888            description: Some("Test".to_string()),
889            version: Some("1.0.0".to_string()),
890            author: None,
891            parameters: vec![],
892            applicable_to_node_shapes: true,
893            applicable_to_property_shapes: true,
894            example: None,
895        };
896
897        let wasm_bytes = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
898
899        let validator = ExternalValidatorBuilder::new()
900            .component_id(ConstraintComponentId("test:wasm".to_string()))
901            .metadata(metadata)
902            .build_wasm(wasm_bytes)
903            .expect("operation should succeed");
904
905        assert_eq!(validator.component_id.as_str(), "test:wasm");
906    }
907}