Skip to main content

camel_language_xpath/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use async_trait::async_trait;
4use camel_language_api::{Body, Exchange, Value};
5use camel_language_api::{Expression, Language, LanguageError, Predicate};
6use serde_json::Value as JsonValue;
7use sxd_document::parser;
8use sxd_xpath::{Context, Factory, Value as SxdValue};
9use tracing::{debug, warn};
10
11// TODO(XPH-002): sxd-xpath is unmaintained; replacement planned.
12// Input size is bounded to prevent resource exhaustion.
13
14/// Configuration for XPath evaluation.
15///
16/// # Security note (XPH-002)
17/// The underlying `sxd-xpath` crate is unmaintained. This config provides
18/// a `max_input_bytes` guard to limit resource consumption.
19///
20/// # TODO(XPH-001): Namespace support
21/// The XPath evaluation context does not yet support namespace declarations.
22/// To evaluate expressions with XML namespaces (e.g. `/soap:Envelope/soap:Body`),
23/// a namespace map (`HashMap<String, String>` mapping prefix → URI) must be
24/// added to this config and registered with the `sxd_xpath::Context` before
25/// evaluation.
26#[derive(Debug, Clone)]
27pub struct XPathConfig {
28    /// Maximum allowed XML input size in bytes. Default: 1 MiB.
29    pub max_input_bytes: Option<usize>,
30}
31
32impl Default for XPathConfig {
33    fn default() -> Self {
34        Self {
35            max_input_bytes: Some(1_048_576), // 1 MiB
36        }
37    }
38}
39
40pub struct XPathLanguage {
41    config: XPathConfig,
42}
43
44struct XPathExpression {
45    xpath: sxd_xpath::XPath,
46    config: XPathConfig,
47}
48
49struct XPathPredicate {
50    xpath: sxd_xpath::XPath,
51    config: XPathConfig,
52}
53
54// SAFETY: Audited against sxd-xpath 0.4.2. `sxd_xpath::XPath` is
55// `Box<dyn sxd_xpath::expression::Expression + 'static>`. The inner
56// `Expression` trait is `&self`-only (see sxd-xpath `expression.rs`),
57// and concrete types in production code contain no `Cell`/`RefCell`/etc.
58// (the only `Rc<RefCell<…>>` in the crate lives behind `#[cfg(test)]`).
59// So `&XPathExpression` / `&XPathPredicate` can be shared across threads
60// without data races. `Send` is implied by the same reasoning plus the
61// absence of self-references in the value.
62// sxd-xpath is pinned to =0.4.2 in Cargo.toml; a version bump requires
63// re-auditing expression.rs for interior mutability.
64unsafe impl Send for XPathExpression {}
65unsafe impl Sync for XPathExpression {}
66unsafe impl Send for XPathPredicate {}
67unsafe impl Sync for XPathPredicate {}
68
69fn extract_xml(exchange: &Exchange) -> Result<String, LanguageError> {
70    match &exchange.input.body {
71        Body::Xml(s) => Ok(s.clone()),
72        other => other
73            .clone()
74            .try_into_xml()
75            .map_err(|e| {
76                LanguageError::EvalError(format!("body is not XML and cannot be coerced: {e}"))
77            })
78            .and_then(|b| match b {
79                Body::Xml(s) => Ok(s),
80                _ => Err(LanguageError::EvalError(
81                    "body coercion did not produce XML".into(),
82                )),
83            }),
84    }
85}
86
87#[cfg(test)]
88thread_local! {
89    static COMPILE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
90}
91
92fn compile_xpath(query: &str) -> Result<sxd_xpath::XPath, LanguageError> {
93    #[cfg(test)]
94    {
95        COMPILE_COUNT.with(|c| c.set(c.get() + 1));
96    }
97    let factory = Factory::new();
98    factory
99        .build(query)
100        .map_err(|e| {
101            warn!(error = %e, "xpath expression compile failed");
102            LanguageError::ParseError {
103                expr: query.to_string(),
104                reason: e.to_string(),
105            }
106        })
107        .and_then(|opt| {
108            opt.ok_or_else(|| {
109                warn!("xpath expression compile failed");
110                LanguageError::ParseError {
111                    expr: query.to_string(),
112                    reason: "empty XPath expression".into(),
113                }
114            })
115        })
116}
117
118fn run_query(
119    xpath: &sxd_xpath::XPath,
120    xml: &str,
121    config: &XPathConfig,
122) -> Result<JsonValue, LanguageError> {
123    if let Some(max) = config.max_input_bytes
124        && xml.len() > max
125    {
126        return Err(LanguageError::EvalError(
127            "input exceeds maximum allowed size".into(),
128        ));
129    }
130    let package = parser::parse(xml).map_err(|_| {
131        // sxd parse errors can embed document-derived content (e.g. MismatchedTag
132        // includes tag names from the exchange body which may be sensitive).
133        // Return a generic message; do NOT include the raw error in logs or errors.
134        warn!("xpath: body XML could not be parsed");
135        LanguageError::EvalError("xml parse error: body is not valid XML".to_string())
136    })?;
137    let doc = package.as_document();
138    // TODO(XPH-001): Namespace declarations are not yet supported. The context
139    // should be populated with namespace prefix → URI mappings from XPathConfig
140    // before calling xpath.evaluate().
141    let context = Context::new();
142    let result = xpath.evaluate(&context, doc.root()).map_err(|_| {
143        // sxd_xpath eval errors describe query structure issues (unknown variable/function,
144        // type mismatch). No document-derived values are embedded, but we follow the
145        // same conservative pattern: generic message, no raw external error strings.
146        warn!("xpath: expression evaluation failed");
147        LanguageError::EvalError(
148            "xpath query failed: expression could not be evaluated".to_string(),
149        )
150    })?;
151
152    Ok(match result {
153        SxdValue::Nodeset(ns) => {
154            let nodes: Vec<_> = ns.document_order();
155            match nodes.len() {
156                0 => JsonValue::Null,
157                1 => JsonValue::String(nodes[0].string_value()),
158                _ => JsonValue::Array(
159                    nodes
160                        .into_iter()
161                        .map(|n| JsonValue::String(n.string_value()))
162                        .collect(),
163                ),
164            }
165        }
166        SxdValue::Boolean(b) => JsonValue::Bool(b),
167        SxdValue::Number(n) => serde_json::Number::from_f64(n)
168            .map(JsonValue::Number)
169            .unwrap_or(JsonValue::Null),
170        SxdValue::String(s) => JsonValue::String(s),
171    })
172}
173
174#[async_trait]
175impl Expression for XPathExpression {
176    async fn evaluate(&self, exchange: &Exchange) -> Result<Value, LanguageError> {
177        let xml = extract_xml(exchange)?;
178        run_query(&self.xpath, &xml, &self.config)
179    }
180}
181
182#[async_trait]
183impl Predicate for XPathPredicate {
184    async fn matches(&self, exchange: &Exchange) -> Result<bool, LanguageError> {
185        let xml = extract_xml(exchange)?;
186        let result = run_query(&self.xpath, &xml, &self.config)?;
187        Ok(match &result {
188            JsonValue::Null => false,
189            JsonValue::Bool(b) => *b,
190            JsonValue::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
191            JsonValue::String(s) => !s.is_empty(),
192            JsonValue::Array(arr) => !arr.is_empty(),
193            _ => true,
194        })
195    }
196}
197
198impl Default for XPathLanguage {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl XPathLanguage {
205    /// Create an XPathLanguage::new() with the default configuration (1 MiB input limit).
206    pub fn new() -> Self {
207        Self::with_config(XPathConfig::default())
208    }
209
210    /// Create an XPathLanguage::new() with a custom configuration.
211    pub fn with_config(config: XPathConfig) -> Self {
212        Self { config }
213    }
214}
215
216impl Language for XPathLanguage {
217    fn name(&self) -> &'static str {
218        "xpath"
219    }
220
221    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
222        let xpath = compile_xpath(script)?;
223        debug!("xpath expression compiled");
224        Ok(Box::new(XPathExpression {
225            xpath,
226            config: self.config.clone(),
227        }))
228    }
229
230    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
231        let xpath = compile_xpath(script)?;
232        debug!("xpath expression compiled");
233        Ok(Box::new(XPathPredicate {
234            xpath,
235            config: self.config.clone(),
236        }))
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use camel_language_api::Message;
244    use std::cell::Cell;
245
246    /// Reset this thread's compile counter, returning the previous value.
247    fn reset_compile_count() -> usize {
248        COMPILE_COUNT.with(|c| c.replace(0))
249    }
250
251    /// Read this thread's compile counter without resetting.
252    fn read_compile_count() -> usize {
253        COMPILE_COUNT.with(Cell::get)
254    }
255
256    async fn exchange_with_xml(xml: &str) -> Exchange {
257        Exchange::new(Message::new(Body::Xml(xml.to_string())))
258    }
259
260    async fn exchange_with_text_body(text: &str) -> Exchange {
261        Exchange::new(Message::new(Body::Text(text.to_string())))
262    }
263
264    async fn empty_exchange() -> Exchange {
265        Exchange::new(Message::default())
266    }
267
268    #[tokio::test]
269    async fn expression_simple_path() {
270        let lang = XPathLanguage::new();
271        let expr = lang.create_expression("/root/name").unwrap();
272        let ex = exchange_with_xml("<root><name>books</name></root>").await;
273        let result = expr.evaluate(&ex).await.unwrap();
274        assert_eq!(result, JsonValue::String("books".to_string()));
275    }
276
277    #[tokio::test]
278    async fn expression_nested_path() {
279        let lang = XPathLanguage::new();
280        let expr = lang.create_expression("/root/inner/value").unwrap();
281        let ex = exchange_with_xml("<root><inner><value>42</value></inner></root>").await;
282        let result = expr.evaluate(&ex).await.unwrap();
283        assert_eq!(result, JsonValue::String("42".to_string()));
284    }
285
286    #[tokio::test]
287    async fn expression_attribute_access() {
288        let lang = XPathLanguage::new();
289        let expr = lang.create_expression("/root/item/@id").unwrap();
290        let ex = exchange_with_xml("<root><item id=\"123\"/></root>").await;
291        let result = expr.evaluate(&ex).await.unwrap();
292        assert_eq!(result, JsonValue::String("123".to_string()));
293    }
294
295    #[tokio::test]
296    async fn expression_text_function() {
297        let lang = XPathLanguage::new();
298        let expr = lang.create_expression("/root/name/text()").unwrap();
299        let ex = exchange_with_xml("<root><name>hello</name></root>").await;
300        let result = expr.evaluate(&ex).await.unwrap();
301        assert_eq!(result, JsonValue::String("hello".to_string()));
302    }
303
304    #[tokio::test]
305    async fn expression_wildcard() {
306        let lang = XPathLanguage::new();
307        let expr = lang.create_expression("/root/item").unwrap();
308        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
309        let result = expr.evaluate(&ex).await.unwrap();
310        assert_eq!(
311            result,
312            JsonValue::Array(vec![
313                JsonValue::String("a".to_string()),
314                JsonValue::String("b".to_string()),
315            ])
316        );
317    }
318
319    #[tokio::test]
320    async fn expression_predicate_position() {
321        let lang = XPathLanguage::new();
322        let expr = lang.create_expression("/root/item[2]").unwrap();
323        let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>").await;
324        let result = expr.evaluate(&ex).await.unwrap();
325        assert_eq!(result, JsonValue::String("b".to_string()));
326    }
327
328    #[tokio::test]
329    async fn expression_count_function() {
330        let lang = XPathLanguage::new();
331        let expr = lang.create_expression("count(/root/item)").unwrap();
332        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
333        let result = expr.evaluate(&ex).await.unwrap();
334        assert_eq!(
335            result,
336            JsonValue::Number(serde_json::Number::from_f64(2.0).unwrap())
337        );
338    }
339
340    #[tokio::test]
341    async fn expression_text_body_with_valid_xml() {
342        let lang = XPathLanguage::new();
343        let expr = lang.create_expression("/root/value").unwrap();
344        let ex = exchange_with_text_body("<root><value>test</value></root>").await;
345        let result = expr.evaluate(&ex).await.unwrap();
346        assert_eq!(result, JsonValue::String("test".to_string()));
347    }
348
349    #[tokio::test]
350    async fn expression_text_body_with_invalid_xml() {
351        let lang = XPathLanguage::new();
352        let expr = lang.create_expression("/root").unwrap();
353        let ex = exchange_with_text_body("not xml at all").await;
354        let result = expr.evaluate(&ex).await;
355        assert!(result.is_err());
356    }
357
358    #[tokio::test]
359    async fn expression_empty_body_is_error() {
360        let lang = XPathLanguage::new();
361        let expr = lang.create_expression("/root").unwrap();
362        let ex = empty_exchange().await;
363        let result = expr.evaluate(&ex).await;
364        assert!(result.is_err());
365    }
366
367    #[tokio::test]
368    async fn expression_empty_result_is_null() {
369        let lang = XPathLanguage::new();
370        let expr = lang.create_expression("/root/missing").unwrap();
371        let ex = exchange_with_xml("<root><name>test</name></root>").await;
372        let result = expr.evaluate(&ex).await.unwrap();
373        assert_eq!(result, JsonValue::Null);
374    }
375
376    #[tokio::test]
377    async fn expression_invalid_xpath_syntax() {
378        let lang = XPathLanguage::new();
379        let result = lang.create_expression("//[invalid");
380        let err = match result {
381            Err(e) => e,
382            Ok(_) => panic!("expected ParseError"),
383        };
384        match err {
385            LanguageError::ParseError { expr, reason } => {
386                assert!(!expr.is_empty());
387                assert!(!reason.is_empty());
388            }
389            other => panic!("expected ParseError, got {other:?}"),
390        }
391    }
392
393    #[tokio::test]
394    async fn predicate_non_empty_nodeset_is_true() {
395        let lang = XPathLanguage::new();
396        let pred = lang.create_predicate("/root/item").unwrap();
397        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
398        assert!(pred.matches(&ex).await.unwrap());
399    }
400
401    #[tokio::test]
402    async fn predicate_empty_result_is_false() {
403        let lang = XPathLanguage::new();
404        let pred = lang.create_predicate("/root/missing").unwrap();
405        let ex = exchange_with_xml("<root><name>test</name></root>").await;
406        assert!(!pred.matches(&ex).await.unwrap());
407    }
408
409    #[tokio::test]
410    async fn predicate_boolean_expression() {
411        let lang = XPathLanguage::new();
412        let pred = lang.create_predicate("count(/root/item) > 2").unwrap();
413        let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>").await;
414        assert!(pred.matches(&ex).await.unwrap());
415    }
416
417    #[tokio::test]
418    async fn predicate_numeric_comparison_false() {
419        let lang = XPathLanguage::new();
420        let pred = lang.create_predicate("count(/root/item) > 5").unwrap();
421        let ex = exchange_with_xml("<root><item>a</item></root>").await;
422        assert!(!pred.matches(&ex).await.unwrap());
423    }
424
425    #[tokio::test]
426    async fn expression_rejects_oversized_input() {
427        let lang = XPathLanguage::with_config(XPathConfig {
428            max_input_bytes: Some(100),
429        });
430        let expr = lang.create_expression("/root").unwrap();
431        let big_xml = format!("<root>{}</root>", "x".repeat(200));
432        let ex = exchange_with_xml(&big_xml).await;
433        let result = expr.evaluate(&ex).await;
434        assert!(result.is_err());
435        match result.unwrap_err() {
436            LanguageError::EvalError(msg) => {
437                assert!(msg.contains("input exceeds maximum allowed size"));
438            }
439            other => panic!("expected EvalError, got {other:?}"),
440        }
441    }
442
443    #[tokio::test]
444    async fn predicate_rejects_oversized_input() {
445        let lang = XPathLanguage::with_config(XPathConfig {
446            max_input_bytes: Some(100),
447        });
448        let pred = lang.create_predicate("/root").unwrap();
449        let big_xml = format!("<root>{}</root>", "x".repeat(200));
450        let ex = exchange_with_xml(&big_xml).await;
451        let result = pred.matches(&ex).await;
452        assert!(result.is_err());
453    }
454
455    /// Regression test for FC-LANG-RECOMPILE: proves the XPath is compiled
456    /// exactly once at `create_expression` time, not on every evaluation.
457    /// Before the fix, each `evaluate` call re-invoked `compile_xpath`,
458    /// so the counter would increment 1 (create) + N (evaluates).
459    /// Uses a thread-local counter so parallel tokio tests do not interfere.
460    #[tokio::test]
461    async fn expression_compiles_only_once() {
462        let lang = XPathLanguage::new();
463        let _ = reset_compile_count();
464        let expr = lang.create_expression("/root/item").unwrap();
465        assert_eq!(
466            read_compile_count(),
467            1,
468            "create_expression should compile exactly once (got {})",
469            read_compile_count()
470        );
471
472        for i in 0..5 {
473            let ex = exchange_with_xml("<root><item>a</item></root>").await;
474            let _ = expr.evaluate(&ex).await.unwrap();
475            assert_eq!(
476                read_compile_count(),
477                1,
478                "evaluate call #{i} must not trigger recompilation (got {})",
479                read_compile_count()
480            );
481        }
482    }
483
484    /// Regression test for FC-LANG-RECOMPILE on the predicate path.
485    #[tokio::test]
486    async fn predicate_compiles_only_once() {
487        let lang = XPathLanguage::new();
488        let _ = reset_compile_count();
489        let pred = lang.create_predicate("/root/item").unwrap();
490        assert_eq!(
491            read_compile_count(),
492            1,
493            "create_predicate should compile exactly once (got {})",
494            read_compile_count()
495        );
496
497        for i in 0..5 {
498            let ex = exchange_with_xml("<root><item>a</item></root>").await;
499            let _ = pred.matches(&ex).await.unwrap();
500            assert_eq!(
501                read_compile_count(),
502                1,
503                "matches call #{i} must not trigger recompilation (got {})",
504                read_compile_count()
505            );
506        }
507    }
508}