//! Tolerant XML scraping helpers — promoted from two near-duplicate //! hand-rolls in `paramount.rs` (attribute extraction) and `criterion.rs` //! (tag-text extraction). //! //! These are NOT a full XML parser. They handle the subset of XML the //! BD-J authoring tools we've seen actually emit: ASCII tag/attr //! names, no entity references inside label strings, optional XML //! namespaces. Hardening goals over the prior `find("")` / //! `find(r#"name=""#)` matchers: //! //! 1. **Case-insensitive** tag and attribute names — vendors casing //! is inconsistent across authoring-tool revisions. //! 2. **Namespace-aware** — strip an optional `ns:` prefix so //! `` and `` both match. //! 3. **Whitespace-tolerant** — multiple/tab/newline characters //! around `=` between attribute name and value; whitespace inside //! the opening tag. //! 4. **Quote-style tolerant** — both `"value"` and `'value'`. //! 5. **Self-closing tag handling** — `` and `` both //! work; [`text`] returns `Some("")` for empty content. //! //! Out of scope (intentionally simple): XML entity decoding //! (`&`, `<`, etc.), CDATA sections, comments, processing //! instructions, DTD declarations. None of the BD-J authored disc //! data we've observed exercises any of those — labels are plain //! ASCII/Latin-1 in attribute values. /// Extract the value of attribute `name` from one XML element /// fragment (e.g. ``). /// /// Returns the raw attribute text (no entity decoding) or `None` if /// the attribute isn't present. Empty string for `name=""` is /// represented as `Some("")`. pub fn attr(element: &str, name: &str) -> Option { let bytes = element.as_bytes(); let name_lower = name.to_ascii_lowercase(); let name_bytes = name_lower.as_bytes(); let mut i = 0; while i + name_bytes.len() < bytes.len() { // Skip over a quoted attribute value entirely so a name token // embedded inside another attribute's value (e.g. // `y="name='inner'"`) is never matched as a real attribute. if bytes[i] == b'"' || bytes[i] == b'\'' { let q = bytes[i]; i += 1; while i < bytes.len() && bytes[i] != q { i += 1; } // Step past the closing quote (or to EOF). i += 1; continue; } // Find the next position where `name=` could start. We need // a word boundary before the name (whitespace or `<` or `:`). if i > 0 && is_name_char(bytes[i - 1]) { i += 1; continue; } if !slice_eq_ignore_case(&bytes[i..i + name_bytes.len()], name_bytes) { i += 1; continue; } let after_name = i + name_bytes.len(); // The character immediately after the name must not be a // name-continuation (otherwise we matched a prefix like // `lang_id` when looking for `lang`). if after_name < bytes.len() && is_name_char(bytes[after_name]) { i = after_name; continue; } // Walk past whitespace, then `=`, then more whitespace, then // the opening quote. let mut j = after_name; while j < bytes.len() && is_ws(bytes[j]) { j += 1; } if j >= bytes.len() || bytes[j] != b'=' { i = j.max(i + 1); continue; } j += 1; // past '=' while j < bytes.len() && is_ws(bytes[j]) { j += 1; } if j >= bytes.len() { return None; } let quote = bytes[j]; if quote != b'"' && quote != b'\'' { // Unquoted attribute values aren't part of well-formed // XML (HTML5 allows them, XML doesn't). Skip. i = j; continue; } let value_start = j + 1; let close = bytes[value_start..].iter().position(|&b| b == quote)?; let value = &element[value_start..value_start + close]; return Some(value.to_string()); } None } /// Extract the trimmed text content of the first occurrence of /// `...` in `xml`. Returns `None` if the tag isn't found /// or its opening tag is malformed. /// /// Whitespace around the inner text is stripped. Self-closing /// `` yields `Some("")`. Nested same-name tags are NOT /// handled — the first close encountered wins (this matches the /// prior behavior in criterion.rs). pub fn text(xml: &str, tag: &str) -> Option { let (_open_end, body_start) = find_open_tag(xml, tag, 0)?; // For self-closing tags, body_start is past `/>` and there is no // content. Detect with a *byte* comparison: slicing `&xml[..]` two // bytes back can land inside a multi-byte UTF-8 char and panic // (untrusted on-disc XML), but indexing the byte slice never does. let b = xml.as_bytes(); if body_start >= 2 && b[body_start - 2] == b'/' && b[body_start - 1] == b'>' { return Some(String::new()); } // Find the matching close tag. Case-insensitive + namespace-aware. let close_start = find_close_tag(xml, tag, body_start)?; Some(xml[body_start..close_start].trim().to_string()) } /// Locate the next `` opening AND its closing `` in /// `xml`, starting at byte offset `from`. Returns `(element_start, /// element_end)` — `element_start` is the `<` of the opening tag, /// `element_end` is one past the `>` of the closing tag. Useful for /// iterating over repeated elements like `` blocks in /// `paramount`. /// /// For self-closing elements, `element_end` points just past `/>` and /// there is no separate body range (`element_end - element_start` /// spans only the `` text). pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> { let bytes = xml.as_bytes(); let mut i = from; while i < bytes.len() { if bytes[i] != b'<' { i += 1; continue; } // Try matching tag name at i+1 (after `<`). let after_lt = i + 1; if !matches_tag_name_at(bytes, after_lt, tag) { i += 1; continue; } // Found an open tag at offset i. Walk to find the closing `>` // of the open tag itself. let mut j = after_lt; // Skip past the tag name (and optional namespace prefix). while j < bytes.len() && (is_name_char(bytes[j]) || bytes[j] == b':') { j += 1; } // Walk attributes — track quoting state. let mut self_closing = false; while j < bytes.len() { match bytes[j] { b'>' => { j += 1; break; } b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => { self_closing = true; j += 2; break; } b'"' | b'\'' => { let q = bytes[j]; j += 1; while j < bytes.len() && bytes[j] != q { j += 1; } if j < bytes.len() { j += 1; } } _ => j += 1, } } if self_closing { return Some((i, j)); } // Find matching close. Doesn't handle nested same-name; OK // for our authoring-tool subset. let close_start = find_close_tag(xml, tag, j)?; let close_end = find_byte(bytes, b'>', close_start)? + 1; return Some((i, close_end)); } None } // ── Internal helpers ─────────────────────────────────────────────────────── /// True if `bytes[start..]` opens a tag named `tag`, allowing an /// optional `ns:` namespace prefix. Comparison is case-insensitive. /// The character after the tag name must not be a name-continuation /// (so `` doesn't match ``). fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool { // Compare case-insensitively without allocating a lowercased copy // of `tag` on every call (hot path: once per `<`/` bytes.len() { return false; } if !bytes[name_start..name_start + tag_bytes.len()].eq_ignore_ascii_case(tag_bytes) { return false; } // Boundary: char after the tag name must be `>`, `/`, whitespace. let after = name_start + tag_bytes.len(); if after >= bytes.len() { return false; } matches!(bytes[after], b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') } /// Find the offset of the next `` (or ``) in `xml` /// starting at `from`. Case-insensitive; returns the offset of the /// `<`. None if not found. fn find_close_tag(xml: &str, tag: &str, from: usize) -> Option { let bytes = xml.as_bytes(); let mut i = from; while i + 2 < bytes.len() { if bytes[i] == b'<' && bytes[i + 1] == b'/' { // Check tag name (with optional namespace). if matches_tag_name_at(bytes, i + 2, tag) { return Some(i); } } i += 1; } None } /// Find the open tag of `` in `xml` starting at `from`. Returns /// `(after_open_lt, after_open_gt)` — the offsets are: just past /// the `<` of the open tag, and just past the `>` of the open tag. fn find_open_tag(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> { let bytes = xml.as_bytes(); let (elem_start, _) = find_element(xml, tag, from)?; let after_lt = elem_start + 1; // Find the `>` that ends the open tag (handling quoted attrs). let mut j = after_lt; while j < bytes.len() { match bytes[j] { b'>' => return Some((after_lt, j + 1)), b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => { return Some((after_lt, j + 2)); } b'"' | b'\'' => { let q = bytes[j]; j += 1; while j < bytes.len() && bytes[j] != q { j += 1; } if j < bytes.len() { j += 1; } } _ => j += 1, } } None } fn find_byte(bytes: &[u8], target: u8, from: usize) -> Option { bytes[from..] .iter() .position(|&b| b == target) .map(|p| p + from) } /// True if `c` can be part of an XML name token (rough). We accept /// alphanumerics, `_`, `-`, `.`. fn is_name_char(c: u8) -> bool { c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.' } fn is_ws(c: u8) -> bool { matches!(c, b' ' | b'\t' | b'\n' | b'\r') } fn slice_eq_ignore_case(a: &[u8], b_lower: &[u8]) -> bool { if a.len() != b_lower.len() { return false; } a.iter() .zip(b_lower.iter()) .all(|(&x, &y)| x.to_ascii_lowercase() == y) } #[cfg(test)] mod tests { use super::*; #[test] fn attr_basic() { assert_eq!( attr(r#""#, "name"), Some("Feature".into()) ); assert_eq!( attr(r#""#, "id"), Some("00222".into()) ); } #[test] fn attr_case_insensitive_name() { assert_eq!( attr(r#""#, "name"), Some("Feature".into()) ); assert_eq!( attr(r#""#, "Name"), Some("Feature".into()) ); } #[test] fn attr_accepts_single_quotes() { assert_eq!( attr(r#""#, "name"), Some("Feature".into()) ); } #[test] fn attr_whitespace_around_equals() { assert_eq!( attr(r#""#, "name"), Some("Feature".into()) ); assert_eq!( attr("", "name"), Some("Feature".into()) ); } #[test] fn attr_missing_returns_none() { assert_eq!(attr(r#""#, "id"), None); assert_eq!(attr("", "name"), None); } #[test] fn attr_no_substring_false_positive() { // Looking for "lang" should NOT match "lang_id" or // "language" because of the name-char boundary check. assert_eq!(attr(r#""#, "lang"), None); } #[test] fn attr_empty_value() { assert_eq!(attr(r#""#, "name"), Some("".into())); } #[test] fn text_basic() { assert_eq!(text("hello", "x"), Some("hello".into())); assert_eq!( text(" hello world ", "x"), Some("hello world".into()) ); } #[test] fn text_case_insensitive_tag() { assert_eq!(text("foo", "x"), Some("foo".into())); assert_eq!(text("bar", "foo"), Some("bar".into())); } #[test] fn text_namespace_prefix() { assert_eq!(text("value", "tag"), Some("value".into())); assert_eq!(text("v", "bar"), Some("v".into())); } #[test] fn text_self_closing() { assert_eq!(text("", "x"), Some("".into())); assert_eq!(text("", "x"), Some("".into())); assert_eq!(text("", "x"), Some("".into())); } #[test] fn text_with_attrs() { assert_eq!( text(r#"hello"#, "x"), Some("hello".into()) ); } #[test] fn text_missing_close_returns_none() { assert_eq!(text("hello", "x"), None); } #[test] fn text_skips_inner_tags_naively() { // Limitation noted: nested same-name tags aren't handled. // Different-name nesting works (we just return everything // between the open and close). assert_eq!( text("nested", "x"), Some("nested".into()) ); } #[test] fn find_element_basic() { let xml = r#" body"#; let (s, e) = find_element(xml, "y", 0).unwrap(); assert_eq!(&xml[s..e], r#"body"#); } #[test] fn find_element_self_closing() { let xml = r#""#; let (s, e) = find_element(xml, "x", 0).unwrap(); assert_eq!(&xml[s..e], ""); } #[test] fn find_element_handles_quoted_gt_in_attr() { // A `>` inside a quoted attribute value should not terminate // the open tag prematurely. let xml = r#"body"#; let (s, e) = find_element(xml, "x", 0).unwrap(); assert_eq!(&xml[s..e], r#"body"#); } #[test] fn find_element_iteration() { let xml = "

a

b

c

"; let mut positions = Vec::new(); let mut from = 0; while let Some((s, e)) = find_element(xml, "p", from) { positions.push(&xml[s..e]); from = e; } assert_eq!(positions, vec!["

a

", "

b

", "

c

"]); } #[test] fn find_element_with_namespace() { let xml = r#""#; let (s, e) = find_element(xml, "item", 0).unwrap(); assert_eq!(&xml[s..e], r#""#); } #[test] fn text_multibyte_before_self_close_does_not_panic() { // A multi-byte UTF-8 char ending right before the `/>` used to // panic on a non-char-boundary str slice in `text()`. The // byte-level self-closing check must handle it cleanly. // 'é' (0xC3 0xA9) directly precedes the `/>`. assert_eq!(text("é", "x"), Some("é".into())); // Self-closing form with a multi-byte char in an attr value. assert_eq!(text(r#""#, "x"), Some("".into())); assert_eq!(text("日本語", "x"), Some("日本語".into())); } #[test] fn attr_not_matched_inside_quoted_value() { // `name` appears only inside another attribute's quoted value; // it must NOT be returned as a real attribute. assert_eq!(attr(r#""#, "name"), None); // A real `name` attribute after a decoy value still resolves. assert_eq!( attr(r#""#, "name"), Some("real".into()) ); } // ── Additional hardening tests ───────────────────────────────────────── /// Spec: BD-J XML attr names are case-insensitive. /// Mutation: remove `.to_ascii_lowercase()` on attr name → uppercase fails. #[test] fn attr_fully_mixed_case_roundtrip() { assert_eq!(attr(r#""#, "lang"), Some("fra".into())); assert_eq!(attr(r#""#, "LANG"), Some("fra".into())); } /// Spec: hyphenated attribute names include `-` as a name char. /// Mutation: remove `-` from `is_name_char` → `lang-id` boundary broken. #[test] fn attr_hyphenated_name_exact_match() { // Searching for `lang-id` must match exactly, not confuse with `lang`. assert_eq!( attr(r#""#, "lang-id"), Some("eng".into()) ); assert_eq!( attr(r#""#, "lang"), Some("fra".into()) ); } /// Spec: underscore-extended attr names must not match the base name. /// Paramount format: `aud_com1_idx` must not match `aud`. /// Mutation: remove the `is_name_char(bytes[after_name])` guard → prefix matched. #[test] fn attr_no_prefix_match_with_underscore_extension() { assert_eq!( attr(r#""#, "aud"), Some("eng".into()) ); } /// Spec: `xml::text` must return `Some("")` for `` (self-closing). /// Mutation: return None for self-closing → callers break. #[test] fn text_self_closing_no_whitespace() { assert_eq!(text("", "x"), Some("".into())); } /// Spec: self-closing with Unicode attr must not panic. /// Mutation: use byte-offset self-close check → panic on multi-byte boundary. #[test] fn text_self_closing_with_unicode_attr_does_not_panic() { assert_eq!(text(r#" "#, "x"), Some("".into())); } /// Spec: namespace prefix in BOTH open and close tags must be stripped. /// Mutation: only strip prefix from opening tag, not closing → None. #[test] fn text_namespace_prefix_on_both_open_and_close() { assert_eq!(text("value", "tag"), Some("value".into())); } /// The first occurrence wins, not the last. /// Mutation: use rfind instead of find → second value returned. #[test] fn text_returns_first_occurrence() { let xml = "firstsecond"; assert_eq!(text(xml, "x"), Some("first".into())); } /// `find_element` must advance correctly past each matched element. /// Mutation: advance from by 1 instead of end → elements double-counted. #[test] fn find_element_correctly_advances_past_each_element() { let xml = "123"; let mut vals = Vec::new(); let mut from = 0; while let Some((s, e)) = find_element(xml, "a", from) { vals.push(text(&xml[s..e], "a").unwrap()); from = e; } assert_eq!(vals, vec!["1", "2", "3"]); } /// `>` inside a quoted attribute value must not end the open tag. /// Mutation: don't skip quoted regions → `>` in attr value ends tag early. #[test] fn find_element_gt_in_attr_does_not_end_tag_prematurely() { let xml = r#"body"#; let (s, e) = find_element(xml, "a", 0).unwrap(); assert_eq!(&xml[s..e], r#"body"#); } /// Missing close tag must return None, not a truncated content. /// Mutation: return text after the open tag unconditionally → wrong value. #[test] fn text_missing_close_is_none_never_truncated() { assert_eq!(text("incomplete", "x"), None); } /// `attr` with `name=""` (empty string value) returns Some(""), not None. /// Mutation: filter out empty returns → empty attr becomes None. #[test] fn attr_returns_some_empty_string_for_empty_value() { assert_eq!( attr(r#""#, "forced_sub"), Some("".into()) ); } /// Single-char attr name must not falsely match inside a word boundary. /// Mutation: remove boundary check → `id` matches `pid`. #[test] fn attr_single_char_name_boundary() { assert_eq!( attr(r#""#, "id"), Some("3".into()) ); } /// `find_element` from a non-zero offset must start the search at that offset. /// Mutation: always start from 0 → finds elements before `from`. #[test] fn find_element_respects_from_offset() { let xml = "

a

b

"; let (s, e) = find_element(xml, "p", 8).unwrap(); assert_eq!(&xml[s..e], "

b

"); } /// `text` trims surrounding whitespace from element content. /// Mutation: remove `.trim()` call → whitespace included. #[test] fn text_trims_internal_whitespace() { assert_eq!(text(" hello ", "x"), Some("hello".into())); assert_eq!( text("\n Aurora Drift\n", "x"), Some("Aurora Drift".into()) ); } /// `attr` with single-quote value must match, same as double-quote. /// Mutation: accept only double-quote → single-quote attrs fail. #[test] fn attr_single_quote_value() { assert_eq!(attr(r#""#, "a"), Some("hello".into())); } /// tag name with leading numeric char after namespace prefix is still matched /// as long as the local name matches exactly (BD tools sometimes use namespace-prefixed tags). #[test] fn find_element_handles_namespace_with_numeric_prefix_class() { let xml = r#"Title"#; let (s, e) = find_element(xml, "name", 0).unwrap(); assert_eq!(&xml[s..e], "Title"); } }