dwara_core/dataplane/
translation_soap.rs

1//! SOAP/XML protocol translation (DW-100).
2//!
3//! The [`SoapTranslator`] implements [`super::translation::ProtocolTranslator`]
4//! for routes that bridge a REST/JSON client and a SOAP/XML upstream (or
5//! vice versa). Two directions:
6//!
7//! - **SOAP-to-REST** (`kind: soap_to_rest`): the client sends a SOAP
8//!   XML envelope; the translator parses the envelope, extracts the
9//!   `Body`'s first child element (the operation), and converts its XML
10//!   children to a JSON body the REST upstream expects.
11//! - **REST-to-SOAP** (`kind: rest_to_soap`): the upstream returns a
12//!   REST JSON body; the translator wraps it in a SOAP envelope
13//!   (`Envelope > Body > {operation}`) with the configured operation
14//!   name and namespace, converting the JSON to XML.
15//!
16//! ## Minimal XML parser
17//!
18//! A full XML library would be a new dependency (and a deny.toml
19//! review). SOAP envelopes are a narrow XML subset: elements with
20//! optional attributes, text content, and nested elements -- no DTDs,
21//! no entity expansion, no processing instructions. A minimal hand-rolled
22//! parser handles that subset and rejects everything else (fail-closed
23//! against anything it cannot prove well-formed). The parser is bounded:
24//! a depth cap prevents element-nesting DoS, and a size cap rejects
25//! oversized bodies before parsing.
26//!
27//! ## XML <-> JSON convention
28//!
29//! The conversion follows a simple, deterministic convention:
30//!
31//! - An element with only text content -> a JSON string (or number/bool
32//!   when the text parses as one).
33//! - An element with child elements -> a JSON object mapping each child
34//!   tag to its converted value. Repeated child tags become a JSON array.
35//! - Attributes -> a `@attr` key in the object.
36//!
37//! This is sufficient for SOAP body payloads (which are flat-ish data
38//! records); complex XSD types are out of scope for the hand-rolled
39//! converter.
40//!
41//! ## Feature gating
42//!
43//! Compiled under the `soap` cargo feature (which implies
44//! `protocol_translation`).
45
46use bytes::Bytes;
47use hyper::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
48use hyper::{Method, Request, Response};
49use serde_json::Value;
50
51use super::translation::{
52    ProtocolTranslator, TranslatedRequest, TranslatedResponse, TranslationBody, TranslationError,
53};
54use crate::config::SoapTranslation;
55
56/// The SOAP/XML media type (the SOAP side sends and expects
57/// `text/xml`; SOAP 1.2 uses `application/soap+xml` but `text/xml` is
58/// the universally accepted default).
59const SOAP_CONTENT_TYPE: &str = "text/xml";
60
61/// The REST/JSON media type.
62const JSON_CONTENT_TYPE: &str = "application/json";
63
64/// The SOAP envelope namespace (SOAP 1.1).
65const SOAP_ENVELOPE_NS: &str = "http://schemas.xmlsoap.org/soap/envelope/";
66
67/// Maximum element-nesting depth the parser will track before aborting
68/// (parser-DoS cap). A SOAP envelope this deep is malformed.
69const PARSE_DEPTH_CAP: usize = 256;
70
71/// The SOAP-to-REST / REST-to-SOAP translator.
72#[derive(Debug, Clone)]
73pub struct SoapTranslator {
74    /// The direction this translator was built for.
75    direction: SoapDirection,
76    /// The SOAP operation name (the Body's first child element name).
77    operation: String,
78    /// The XML namespace for the operation element.
79    namespace: String,
80}
81
82/// The translation direction.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum SoapDirection {
85    /// A SOAP client -> REST upstream.
86    SoapToRest,
87    /// A REST client -> SOAP upstream.
88    RestToSoap,
89}
90
91impl SoapTranslator {
92    /// Build a SOAP-to-REST translator. The operation name is used to
93    /// locate the Body's payload element (the first child whose local
94    /// name matches, ignoring namespace prefixes).
95    pub fn soap_to_rest(cfg: &SoapTranslation) -> Result<Self, TranslationError> {
96        if cfg.operation.trim().is_empty() {
97            return Err(TranslationError::SchemaNotFound(
98                "soap translation requires an operation name".to_string(),
99            ));
100        }
101        Ok(SoapTranslator {
102            direction: SoapDirection::SoapToRest,
103            operation: cfg.operation.clone(),
104            namespace: cfg.namespace.clone(),
105        })
106    }
107
108    /// Build a REST-to-SOAP translator. Both the operation name and
109    /// namespace are required (the envelope's payload element carries
110    /// the namespace).
111    pub fn rest_to_soap(cfg: &SoapTranslation) -> Result<Self, TranslationError> {
112        if cfg.operation.trim().is_empty() {
113            return Err(TranslationError::SchemaNotFound(
114                "soap translation requires an operation name".to_string(),
115            ));
116        }
117        if cfg.namespace.trim().is_empty() {
118            return Err(TranslationError::SchemaNotFound(
119                "rest_to_soap translation requires a namespace".to_string(),
120            ));
121        }
122        Ok(SoapTranslator {
123            direction: SoapDirection::RestToSoap,
124            operation: cfg.operation.clone(),
125            namespace: cfg.namespace.clone(),
126        })
127    }
128
129    /// The configured direction.
130    pub fn direction(&self) -> SoapDirection {
131        self.direction
132    }
133}
134
135impl ProtocolTranslator for SoapTranslator {
136    fn translate_request(
137        &self,
138        req: &Request<TranslationBody>,
139    ) -> Result<TranslatedRequest, TranslationError> {
140        match self.direction {
141            SoapDirection::SoapToRest => {
142                let body = req.body().as_bytes();
143                let envelope = parse_envelope(body)?;
144                let payload = extract_body_payload(&envelope, &self.operation)?;
145                let json = xml_element_to_json(&payload);
146                let body = serde_json::to_vec(&json).map_err(|e| {
147                    TranslationError::TranslationFailed(format!(
148                        "failed to serialize soap body to json: {e}"
149                    ))
150                })?;
151                let mut headers = HeaderMap::new();
152                headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
153                Ok(TranslatedRequest {
154                    method: req.method().clone(),
155                    path: req.uri().path().to_string(),
156                    headers,
157                    body: Bytes::from(body),
158                })
159            }
160            SoapDirection::RestToSoap => {
161                let body = req.body().as_bytes();
162                let json: Value = serde_json::from_slice(body).map_err(|e| {
163                    TranslationError::InvalidBody(format!("expected a JSON body: {e}"))
164                })?;
165                let envelope = build_envelope(&self.operation, &self.namespace, &json);
166                let mut headers = HeaderMap::new();
167                headers.insert(CONTENT_TYPE, HeaderValue::from_static(SOAP_CONTENT_TYPE));
168                Ok(TranslatedRequest {
169                    method: Method::POST,
170                    path: req.uri().path().to_string(),
171                    headers,
172                    body: Bytes::from(envelope.into_bytes()),
173                })
174            }
175        }
176    }
177
178    fn translate_response(
179        &self,
180        resp: &Response<TranslationBody>,
181    ) -> Result<TranslatedResponse, TranslationError> {
182        match self.direction {
183            SoapDirection::SoapToRest => {
184                // The REST upstream returned a plain JSON body; wrap it
185                // in a SOAP envelope the SOAP client expects.
186                let body = resp.body().as_bytes();
187                let json: Value = if body.is_empty() {
188                    Value::Null
189                } else {
190                    serde_json::from_slice(body).map_err(|e| {
191                        TranslationError::InvalidBody(format!(
192                            "expected a JSON response from the rest upstream: {e}"
193                        ))
194                    })?
195                };
196                let envelope = build_envelope(&self.operation, &self.namespace, &json);
197                let mut headers = HeaderMap::new();
198                headers.insert(CONTENT_TYPE, HeaderValue::from_static(SOAP_CONTENT_TYPE));
199                Ok(TranslatedResponse {
200                    status: resp.status(),
201                    headers,
202                    body: Bytes::from(envelope.into_bytes()),
203                })
204            }
205            SoapDirection::RestToSoap => {
206                // The SOAP upstream returned a SOAP envelope; unwrap the
207                // Body's payload into a REST JSON body.
208                let body = resp.body().as_bytes();
209                let envelope = parse_envelope(body)?;
210                let payload = extract_body_payload(&envelope, &self.operation)?;
211                let json = xml_element_to_json(&payload);
212                let body = serde_json::to_vec(&json).map_err(|e| {
213                    TranslationError::TranslationFailed(format!(
214                        "failed to serialize soap body to json: {e}"
215                    ))
216                })?;
217                let mut headers = HeaderMap::new();
218                headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
219                Ok(TranslatedResponse {
220                    status: resp.status(),
221                    headers,
222                    body: Bytes::from(body),
223                })
224            }
225        }
226    }
227
228    fn content_type_in(&self) -> &str {
229        match self.direction {
230            SoapDirection::SoapToRest => SOAP_CONTENT_TYPE,
231            SoapDirection::RestToSoap => JSON_CONTENT_TYPE,
232        }
233    }
234
235    fn content_type_out(&self) -> &str {
236        match self.direction {
237            SoapDirection::SoapToRest => JSON_CONTENT_TYPE,
238            SoapDirection::RestToSoap => SOAP_CONTENT_TYPE,
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// SOAP envelope helpers
245// ---------------------------------------------------------------------------
246
247/// Build a SOAP 1.1 envelope wrapping a JSON value as the Body's
248/// operation element.
249fn build_envelope(operation: &str, namespace: &str, payload: &Value) -> String {
250    let mut out = String::with_capacity(256);
251    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
252    out.push_str("<soap:Envelope xmlns:soap=\"");
253    out.push_str(SOAP_ENVELOPE_NS);
254    out.push_str("\">");
255    out.push_str("<soap:Body>");
256    // The operation element carries the configured namespace.
257    out.push('<');
258    out.push_str(operation);
259    out.push_str(" xmlns=\"");
260    out.push_str(namespace);
261    out.push_str("\">");
262    json_to_xml_element(payload, operation, &mut out);
263    out.push_str("</");
264    out.push_str(operation);
265    out.push('>');
266    out.push_str("</soap:Body>");
267    out.push_str("</soap:Envelope>");
268    out
269}
270
271/// Render a JSON value as XML child elements of the enclosing operation
272/// element. A JSON object maps each key to a child element; a JSON array
273/// maps each item to a repeated child element with the enclosing key; a
274/// scalar becomes the text content of the enclosing element (the caller
275/// has already opened the element and supplies the tag name).
276fn json_to_xml_element(value: &Value, tag: &str, out: &mut String) {
277    match value {
278        Value::Null => {}
279        Value::Bool(b) => {
280            out.push_str(&escape_xml_text(&b.to_string()));
281        }
282        Value::Number(n) => {
283            out.push_str(&escape_xml_text(&n.to_string()));
284        }
285        Value::String(s) => {
286            out.push_str(&escape_xml_text(s));
287        }
288        Value::Array(arr) => {
289            for item in arr {
290                out.push('<');
291                out.push_str(tag);
292                out.push('>');
293                json_to_xml_element(item, tag, out);
294                out.push_str("</");
295                out.push_str(tag);
296                out.push('>');
297            }
298        }
299        Value::Object(obj) => {
300            for (k, v) in obj {
301                out.push('<');
302                out.push_str(&escape_xml_name(k));
303                out.push('>');
304                json_to_xml_element(v, k, out);
305                out.push_str("</");
306                out.push_str(&escape_xml_name(k));
307                out.push('>');
308            }
309        }
310    }
311}
312
313/// Extract the SOAP Body's first child element whose local name matches
314/// the configured operation (namespace prefixes ignored). Returns the
315/// payload element (the operation element with its children to convert).
316fn extract_body_payload(
317    envelope: &XmlElement,
318    operation: &str,
319) -> Result<XmlElement, TranslationError> {
320    let body = find_child_by_local_name(envelope, "Body").ok_or_else(|| {
321        TranslationError::InvalidBody("soap envelope has no Body element".to_string())
322    })?;
323    // The Body's first child element is the operation payload. Match by
324    // local name (strip any namespace prefix) so the caller does not
325    // need to know the prefix the client used.
326    for child in &body.children {
327        if let XmlChild::Element(el) = child {
328            if local_name(&el.name) == operation {
329                return Ok(el.clone());
330            }
331        }
332    }
333    Err(TranslationError::InvalidBody(format!(
334        "soap Body has no child element matching operation '{operation}'"
335    )))
336}
337
338/// Find the first child element whose local name (prefix stripped)
339/// matches `name`.
340fn find_child_by_local_name<'a>(el: &'a XmlElement, name: &str) -> Option<&'a XmlElement> {
341    for child in &el.children {
342        if let XmlChild::Element(c) = child {
343            if local_name(&c.name) == name {
344                return Some(c);
345            }
346        }
347    }
348    None
349}
350
351/// Strip the namespace prefix from a qualified name (`soap:Body` ->
352/// `Body`).
353fn local_name(qname: &str) -> &str {
354    qname
355        .rsplit_once(':')
356        .map(|(_, local)| local)
357        .unwrap_or(qname)
358}
359
360/// Convert an XML element to a JSON value following the module's
361/// convention (see the module docs).
362fn xml_element_to_json(el: &XmlElement) -> Value {
363    // Collect child elements by tag (repeated tags -> array).
364    let mut child_map: std::collections::BTreeMap<String, Vec<Value>> =
365        std::collections::BTreeMap::new();
366    let mut text = String::new();
367    let mut has_elements = false;
368    for child in &el.children {
369        match child {
370            XmlChild::Element(c) => {
371                has_elements = true;
372                child_map
373                    .entry(local_name(&c.name).to_string())
374                    .or_default()
375                    .push(xml_element_to_json(c));
376            }
377            XmlChild::Text(t) => {
378                text.push_str(t);
379            }
380        }
381    }
382
383    if !has_elements {
384        // Leaf element: text content -> scalar JSON value.
385        let trimmed = text.trim();
386        if trimmed.is_empty() {
387            return Value::Null;
388        }
389        return parse_scalar(trimmed);
390    }
391
392    // Object element: attributes as @-prefixed keys, children by tag.
393    let mut obj = serde_json::Map::new();
394    for (attr, val) in &el.attributes {
395        obj.insert(format!("@{attr}"), parse_scalar(val));
396    }
397    for (tag, values) in child_map {
398        if values.len() == 1 {
399            obj.insert(tag, values.into_iter().next().unwrap());
400        } else {
401            obj.insert(tag, Value::Array(values));
402        }
403    }
404    Value::Object(obj)
405}
406
407/// Parse a text scalar into a JSON value: integers, floats, booleans,
408/// and null are recognized; everything else is a string.
409fn parse_scalar(s: &str) -> Value {
410    if s.eq_ignore_ascii_case("true") {
411        return Value::Bool(true);
412    }
413    if s.eq_ignore_ascii_case("false") {
414        return Value::Bool(false);
415    }
416    if s.eq_ignore_ascii_case("null") {
417        return Value::Null;
418    }
419    if let Ok(n) = s.parse::<i64>() {
420        return Value::Number(n.into());
421    }
422    if let Ok(n) = s.parse::<u64>() {
423        return Value::Number(n.into());
424    }
425    if let Ok(f) = s.parse::<f64>() {
426        if let Some(n) = serde_json::Number::from_f64(f) {
427            return Value::Number(n);
428        }
429    }
430    Value::String(s.to_string())
431}
432
433/// Escape text for XML text content.
434fn escape_xml_text(s: &str) -> String {
435    let mut out = String::with_capacity(s.len());
436    for c in s.chars() {
437        match c {
438            '<' => out.push_str("&lt;"),
439            '>' => out.push_str("&gt;"),
440            '&' => out.push_str("&amp;"),
441            c => out.push(c),
442        }
443    }
444    out
445}
446
447/// Escape a string for use as an XML element name. Names must start
448/// with a letter or underscore; invalid start characters are prefixed
449/// with `_`. This is a best-effort sanitization for JSON keys coming
450/// from a REST body.
451fn escape_xml_name(name: &str) -> String {
452    let mut out = String::with_capacity(name.len());
453    for (i, c) in name.chars().enumerate() {
454        let valid = if i == 0 {
455            c.is_ascii_alphabetic() || c == '_'
456        } else {
457            c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
458        };
459        if valid {
460            out.push(c);
461        } else {
462            out.push('_');
463        }
464    }
465    if out.is_empty() {
466        out.push('_');
467    }
468    out
469}
470
471// ---------------------------------------------------------------------------
472// Minimal XML parser
473// ---------------------------------------------------------------------------
474
475/// A parsed XML element: name (qualified, prefix retained), attributes,
476/// and children (elements and text).
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct XmlElement {
479    pub name: String,
480    pub attributes: Vec<(String, String)>,
481    pub children: Vec<XmlChild>,
482}
483
484/// One child of an XML element: a nested element or a text run.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum XmlChild {
487    Element(XmlElement),
488    Text(String),
489}
490
491/// Parse a SOAP XML envelope into its root element. Rejects anything
492/// that is not a well-formed (enough) single-rooted XML document the
493/// parser can prove: DTDs, entity declarations, processing instructions
494/// other than the optional XML declaration, and unbalanced tags all
495/// fail closed.
496fn parse_envelope(input: &[u8]) -> Result<XmlElement, TranslationError> {
497    let text = std::str::from_utf8(input)
498        .map_err(|e| TranslationError::InvalidBody(format!("soap body is not valid UTF-8: {e}")))?;
499    let mut parser = XmlParser::new(text);
500    parser.skip_prolog()?;
501    let root = parser.parse_element(0)?;
502    // Trailing non-whitespace is malformed.
503    parser.skip_whitespace();
504    if !parser.remaining().is_empty() {
505        return Err(TranslationError::InvalidBody(
506            "trailing content after the root element".to_string(),
507        ));
508    }
509    Ok(root)
510}
511
512/// A minimal, hand-rolled XML parser. Tracks a byte cursor; produces
513/// [`XmlElement`]s. Bounded by [`PARSE_DEPTH_CAP`].
514struct XmlParser<'a> {
515    input: &'a str,
516    pos: usize,
517}
518
519impl<'a> XmlParser<'a> {
520    fn new(input: &'a str) -> Self {
521        XmlParser { input, pos: 0 }
522    }
523
524    fn remaining(&self) -> &'a str {
525        &self.input[self.pos..]
526    }
527
528    fn peek(&self) -> Option<char> {
529        self.remaining().chars().next()
530    }
531
532    fn advance(&mut self, n: usize) {
533        self.pos += n;
534    }
535
536    fn skip_whitespace(&mut self) {
537        while let Some(c) = self.peek() {
538            if c.is_whitespace() {
539                self.advance(c.len_utf8());
540            } else {
541                break;
542            }
543        }
544    }
545
546    /// Skip the optional XML prolog (`<?xml ... ?>`) and any
547    /// comments/processing instructions before the root element.
548    fn skip_prolog(&mut self) -> Result<(), TranslationError> {
549        loop {
550            self.skip_whitespace();
551            if self.remaining().starts_with("<?") {
552                // Processing instruction: skip to `?>`.
553                let end = self.remaining().find("?>").ok_or_else(|| {
554                    TranslationError::InvalidBody("unterminated processing instruction".to_string())
555                })?;
556                self.advance(end + 2);
557                continue;
558            }
559            if self.remaining().starts_with("<!--") {
560                // Comment: skip to `-->`.
561                let end = self.remaining().find("-->").ok_or_else(|| {
562                    TranslationError::InvalidBody("unterminated comment".to_string())
563                })?;
564                self.advance(end + 3);
565                continue;
566            }
567            if self.remaining().starts_with("<!") {
568                // DTDs and entity declarations are rejected (the parser
569                // does not expand entities -- fail closed).
570                return Err(TranslationError::InvalidBody(
571                    "DTDs and entity declarations are not supported".to_string(),
572                ));
573            }
574            break;
575        }
576        Ok(())
577    }
578
579    /// Parse a single element starting at `<`. Returns the element.
580    fn parse_element(&mut self, depth: usize) -> Result<XmlElement, TranslationError> {
581        if depth > PARSE_DEPTH_CAP {
582            return Err(TranslationError::InvalidBody(format!(
583                "xml element nesting exceeds the parser depth cap ({PARSE_DEPTH_CAP})"
584            )));
585        }
586        self.skip_whitespace();
587        if self.peek() != Some('<') {
588            return Err(TranslationError::InvalidBody(
589                "expected '<' to start an element".to_string(),
590            ));
591        }
592        self.advance(1); // consume '<'
593        let name = self.parse_name()?;
594        let mut attributes = Vec::new();
595        loop {
596            self.skip_whitespace();
597            match self.peek() {
598                Some('/') => {
599                    // Empty element: `<name .../>`.
600                    self.advance(1);
601                    if self.peek() != Some('>') {
602                        return Err(TranslationError::InvalidBody(
603                            "expected '>' to close an empty element".to_string(),
604                        ));
605                    }
606                    self.advance(1);
607                    return Ok(XmlElement {
608                        name,
609                        attributes,
610                        children: Vec::new(),
611                    });
612                }
613                Some('>') => {
614                    self.advance(1);
615                    break;
616                }
617                Some(_) => {
618                    let attr = self.parse_attribute()?;
619                    attributes.push(attr);
620                }
621                None => {
622                    return Err(TranslationError::InvalidBody(
623                        "unterminated element start tag".to_string(),
624                    ));
625                }
626            }
627        }
628
629        // Parse children until the closing `</name>`.
630        let mut children = Vec::new();
631        loop {
632            let rest = self.remaining();
633            if rest.starts_with("</") {
634                self.advance(2);
635                let close_name = self.parse_name()?;
636                if local_name(&close_name) != local_name(&name) {
637                    return Err(TranslationError::InvalidBody(format!(
638                        "mismatched closing tag: expected </{name}>, got </{close_name}>"
639                    )));
640                }
641                self.skip_whitespace();
642                if self.peek() != Some('>') {
643                    return Err(TranslationError::InvalidBody(
644                        "expected '>' to close the end tag".to_string(),
645                    ));
646                }
647                self.advance(1);
648                return Ok(XmlElement {
649                    name,
650                    attributes,
651                    children,
652                });
653            }
654            if rest.starts_with("<!--") {
655                let end = rest.find("-->").ok_or_else(|| {
656                    TranslationError::InvalidBody("unterminated comment".to_string())
657                })?;
658                self.advance(end + 3);
659                continue;
660            }
661            if rest.starts_with("<![CDATA[") {
662                let end = rest.find("]]>").ok_or_else(|| {
663                    TranslationError::InvalidBody("unterminated CDATA section".to_string())
664                })?;
665                let cdata = &rest[9..end];
666                children.push(XmlChild::Text(cdata.to_string()));
667                self.advance(end + 3);
668                continue;
669            }
670            if rest.starts_with('<') {
671                let el = self.parse_element(depth + 1)?;
672                children.push(XmlChild::Element(el));
673                continue;
674            }
675            // Text content up to the next `<`.
676            let end = rest.find('<').unwrap_or(rest.len());
677            let text = &rest[..end];
678            if !text.is_empty() {
679                children.push(XmlChild::Text(decode_entities(text)));
680                self.advance(end);
681            }
682        }
683    }
684
685    /// Parse an XML name (letters, digits, `_`, `-`, `.`, `:`).
686    fn parse_name(&mut self) -> Result<String, TranslationError> {
687        let start = self.pos;
688        while let Some(c) = self.peek() {
689            if c.is_ascii_alphanumeric() || c == '_' || c == ':' || c == '-' || c == '.' {
690                self.advance(c.len_utf8());
691            } else {
692                break;
693            }
694        }
695        if self.pos == start {
696            return Err(TranslationError::InvalidBody(
697                "expected an XML name".to_string(),
698            ));
699        }
700        Ok(self.input[start..self.pos].to_string())
701    }
702
703    /// Parse a `name="value"` attribute.
704    fn parse_attribute(&mut self) -> Result<(String, String), TranslationError> {
705        let name = self.parse_name()?;
706        self.skip_whitespace();
707        if self.peek() != Some('=') {
708            return Err(TranslationError::InvalidBody(
709                "expected '=' after an attribute name".to_string(),
710            ));
711        }
712        self.advance(1);
713        self.skip_whitespace();
714        let quote = self.peek().ok_or_else(|| {
715            TranslationError::InvalidBody("expected a quoted attribute value".to_string())
716        })?;
717        if quote != '"' && quote != '\'' {
718            return Err(TranslationError::InvalidBody(
719                "attribute value must be quoted".to_string(),
720            ));
721        }
722        self.advance(1);
723        let value_start = self.pos;
724        while let Some(c) = self.peek() {
725            if c == quote {
726                let value = decode_entities(&self.input[value_start..self.pos]);
727                self.advance(1);
728                return Ok((name, value));
729            }
730            self.advance(c.len_utf8());
731        }
732        Err(TranslationError::InvalidBody(
733            "unterminated attribute value".to_string(),
734        ))
735    }
736}
737
738/// Decode the minimal set of XML entities the parser handles (`&lt;`,
739/// `&gt;`, `&amp;`, `&quot;`, `&apos;`). Numeric character references
740/// (`&#nn;` / `&#xHH;`) are also decoded. Unknown entities fail closed
741/// (returned verbatim -- the parser does not expand custom entities).
742fn decode_entities(s: &str) -> String {
743    if !s.contains('&') {
744        return s.to_string();
745    }
746    let mut out = String::with_capacity(s.len());
747    let bytes = s.as_bytes();
748    let mut i = 0;
749    while i < bytes.len() {
750        if bytes[i] == b'&' {
751            if let Some(end) = s[i + 1..].find(';') {
752                let entity = &s[i + 1..i + 1 + end];
753                if let Some(decoded) = decode_one_entity(entity) {
754                    out.push_str(&decoded);
755                    i += 1 + end + 1;
756                    continue;
757                }
758            }
759            // Unknown/unterminated: keep the '&' verbatim.
760            out.push('&');
761            i += 1;
762        } else {
763            out.push(bytes[i] as char);
764            i += 1;
765        }
766    }
767    out
768}
769
770/// Decode a single entity body (the text between `&` and `;`).
771fn decode_one_entity(entity: &str) -> Option<String> {
772    match entity {
773        "lt" => Some("<".to_string()),
774        "gt" => Some(">".to_string()),
775        "amp" => Some("&".to_string()),
776        "quot" => Some("\"".to_string()),
777        "apos" => Some("'".to_string()),
778        _ => {
779            if let Some(hex) = entity.strip_prefix("#x") {
780                let code = u32::from_str_radix(hex, 16).ok()?;
781                char::from_u32(code).map(|c| c.to_string())
782            } else if let Some(dec) = entity.strip_prefix('#') {
783                let code = dec.parse::<u32>().ok()?;
784                char::from_u32(code).map(|c| c.to_string())
785            } else {
786                None
787            }
788        }
789    }
790}