diff --git a/README.md b/README.md
index f50f593..ee0b783 100644
--- a/README.md
+++ b/README.md
@@ -90,5 +90,12 @@ reference escape rules and accepted scalar spellings, base64, base16 and
length-prefixed binary forms, and both compact and formatted output. Malformed
notation reports UTF-16 offsets with parser context, while input, output,
nesting, node, and aggregate allocation limits apply to untrusted text.
+The native XML LLSD codec covers the wrapper and inner-element APIs, all scalar
+and container elements, namespace-local names, XML declarations, comments,
+CDATA, entity decoding, and the reference's nonstandard Linden processing
+instruction handling. Serialization is compact and deterministic for maps.
+DTD declarations and named entity expansion are disabled; input, output,
+nesting, node, and aggregate allocation limits are enforced with byte-positioned
+parse errors.
The controlled audit aggregates every expected failure by standardized C#
member ID and rejects unrelated fixture, assertion, compile, or symbol errors.
diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md
index 5006821..c77e7ce 100644
--- a/api/SHIM-COVERAGE.md
+++ b/api/SHIM-COVERAGE.md
@@ -12,7 +12,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand.
| `LibreMetaverse.RLV` | 28 | 499 | callable failure-only shim |
| `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | callable failure-only shim |
| `LibreMetaverse.Rendering.Simple` | 1 | 6 | callable failure-only shim |
-| `LibreMetaverse.StructuredData` | 16 | 295 | native implementation: 15 types / 281 members; remaining surface is callable failure-only shims |
+| `LibreMetaverse.StructuredData` | 16 | 295 | native implementation: 15 types / 289 members; remaining surface is callable failure-only shims |
| `LibreMetaverse.Types` | 45 | 942 | native implementation: 45 types / 942 members; no generated shims remain |
| `LibreMetaverse.Utilities` | 3 | 13 | callable failure-only shim |
| `LibreMetaverse.Voice.Vivox` | 64 | 531 | callable failure-only shim |
diff --git a/crates/libremetaverse-structured-data/src/dispatch.rs b/crates/libremetaverse-structured-data/src/dispatch.rs
index 33f046b..b6fcec7 100644
--- a/crates/libremetaverse-structured-data/src/dispatch.rs
+++ b/crates/libremetaverse-structured-data/src/dispatch.rs
@@ -160,15 +160,15 @@ mod tests {
deserialize_bytes(b" llsd/binary ?>i\0\0\0\0".to_vec()),
Ok(OSD::Integer(0))
);
+ assert_eq!(
+ deserialize_bytes(b"".to_vec()),
+ Ok(OSD::Undefined)
+ );
let cases: &[(&[u8], &str)] = &[
(
b" llsd/protobuf ?>",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDProtobuf(System.Byte[])",
),
- (
- b"",
- "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])",
- ),
(
b"{}",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeJson(System.String)",
diff --git a/crates/libremetaverse-structured-data/src/generated.rs b/crates/libremetaverse-structured-data/src/generated.rs
index d2dfa22..8321972 100644
--- a/crates/libremetaverse-structured-data/src/generated.rs
+++ b/crates/libremetaverse-structured-data/src/generated.rs
@@ -329,33 +329,25 @@ impl OSDParser {
pub fn deserialize_llsd_xml_with_bytes(
xml_data: Vec,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])",
- )
+ crate::xml_codec::deserialize_bytes(xml_data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)`.
pub fn deserialize_llsd_xml_with_stream(
xml_stream: Box,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)",
- )
+ crate::xml_codec::deserialize_stream(xml_stream)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)`.
pub fn deserialize_llsd_xml_with_string(
xml_data: String,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)",
- )
+ crate::xml_codec::deserialize_string(xml_data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)`.
pub fn deserialize_llsd_xml_with_xml_reader(
xml_data: libremetaverse_structured_data::xml::Reader,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)",
- )
+ crate::xml_codec::deserialize_reader(xml_data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.EscapeCharacter(System.String,System.Char)`.
pub fn escape_character(
@@ -454,9 +446,7 @@ impl OSDParser {
pub fn serialize_llsd_inner_xml_string(
data: libremetaverse_structured_data::OSD,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDInnerXmlString(LibreMetaverse.StructuredData.OSD)",
- )
+ crate::xml_codec::serialize_inner(data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotation(LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_notation(
@@ -495,26 +485,20 @@ impl OSDParser {
pub fn serialize_llsd_xml_bytes(
data: libremetaverse_structured_data::OSD,
) -> Result, crate::Error> {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlBytes(LibreMetaverse.StructuredData.OSD)",
- )
+ crate::xml_codec::serialize_bytes(data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_xml_element(
writer: libremetaverse_structured_data::xml::Writer,
data: libremetaverse_structured_data::OSD,
) -> Result<(), crate::Error> {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)",
- )
+ crate::xml_codec::serialize_element(writer, data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_xml_string(
data: libremetaverse_structured_data::OSD,
) -> Result {
- libremetaverse_types::not_implemented(
- "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)",
- )
+ crate::xml_codec::serialize_string(data)
}
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SkipWhiteSpace(System.IO.Stream)`.
pub fn skip_white_space(
diff --git a/crates/libremetaverse-structured-data/src/lib.rs b/crates/libremetaverse-structured-data/src/lib.rs
index 542ddaf..a655798 100644
--- a/crates/libremetaverse-structured-data/src/lib.rs
+++ b/crates/libremetaverse-structured-data/src/lib.rs
@@ -7,16 +7,44 @@ mod dispatch;
mod generated;
mod model;
mod notation;
+mod xml_codec;
pub mod xml {
+ use std::sync::{Arc, Mutex};
+
/// Project-owned XML element boundary; external XML APIs are not copied.
pub struct Element;
/// Native XML reader boundary; external XML APIs are not copied.
- pub struct Reader;
+ #[derive(Clone, Debug, Default, Eq, PartialEq)]
+ pub struct Reader(pub String);
/// Native XML writer boundary; external XML APIs are not copied.
- pub struct Writer;
+ #[derive(Clone, Debug, Default)]
+ pub struct Writer(pub Arc>);
+
+ impl Writer {
+ #[must_use]
+ pub fn contents(&self) -> String {
+ self.0
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clone()
+ }
+
+ pub(crate) fn append(&self, value: &str) -> Result<(), crate::Error> {
+ let mut output = self.0.lock().map_err(|_| crate::Error::InvalidOperation)?;
+ let length = output
+ .len()
+ .checked_add(value.len())
+ .ok_or(crate::Error::Argument)?;
+ if length > crate::OSD::DEFAULT_MAX_BINARY_BYTES {
+ return Err(crate::Error::Argument);
+ }
+ output.push_str(value);
+ Ok(())
+ }
+ }
}
pub use generated::*;
diff --git a/crates/libremetaverse-structured-data/src/model.rs b/crates/libremetaverse-structured-data/src/model.rs
index 6f3f1d7..a31f22d 100644
--- a/crates/libremetaverse-structured-data/src/model.rs
+++ b/crates/libremetaverse-structured-data/src/model.rs
@@ -1556,7 +1556,7 @@ fn valid_uri_reference(value: &str) -> bool {
!value.chars().any(char::is_whitespace) && !value.chars().any(char::is_control)
}
-fn format_uri_for_codec(value: &str) -> String {
+pub(crate) fn format_uri_for_codec(value: &str) -> String {
if !value.contains("://") {
return value.to_owned();
}
diff --git a/crates/libremetaverse-structured-data/src/xml_codec.rs b/crates/libremetaverse-structured-data/src/xml_codec.rs
new file mode 100644
index 0000000..8010e45
--- /dev/null
+++ b/crates/libremetaverse-structured-data/src/xml_codec.rs
@@ -0,0 +1,759 @@
+//! Bounded LLSD XML parsing and serialization without entity expansion.
+
+#![allow(clippy::missing_errors_doc)]
+#![allow(clippy::needless_pass_by_value)]
+#![allow(clippy::unused_self)]
+
+use crate::{Error, OSD};
+use base64::Engine as _;
+use libremetaverse_types::UUID;
+use libremetaverse_types::compat::{ReadWrite, Uri};
+use std::collections::HashMap;
+use std::io::Read as _;
+use std::time::UNIX_EPOCH;
+
+const MAX_BYTES: usize = OSD::DEFAULT_MAX_BINARY_BYTES;
+
+pub(crate) fn deserialize_bytes(data: Vec) -> Result {
+ if data.len() > MAX_BYTES {
+ return Err(parse_error(0, "XML LLSD input exceeds allocation limit"));
+ }
+ let text = std::str::from_utf8(data.strip_prefix(b"\xef\xbb\xbf").unwrap_or(&data))
+ .map_err(|_| parse_error(0, "XML LLSD input is not valid UTF-8"))?;
+ deserialize_text(text)
+}
+
+pub(crate) fn deserialize_string(data: String) -> Result {
+ if data.len() > MAX_BYTES {
+ return Err(parse_error(0, "XML LLSD input exceeds allocation limit"));
+ }
+ deserialize_text(data.strip_prefix('\u{feff}').unwrap_or(&data))
+}
+
+pub(crate) fn deserialize_stream(mut stream: Box) -> Result {
+ let mut data = Vec::new();
+ (&mut *stream)
+ .take((MAX_BYTES + 1) as u64)
+ .read_to_end(&mut data)
+ .map_err(|_| Error::InvalidOperation)?;
+ deserialize_bytes(data)
+}
+
+pub(crate) fn deserialize_reader(reader: crate::xml::Reader) -> Result {
+ deserialize_string(reader.0)
+}
+
+fn deserialize_text(text: &str) -> Result {
+ let mut trimmed = text.trim_start();
+ if trimmed.starts_with("") {
+ let end = trimmed.find("?>").ok_or_else(|| {
+ parse_error(
+ text.len() - trimmed.len(),
+ "unterminated XML processing instruction",
+ )
+ })?;
+ trimmed = trimmed[end + 2..].trim_start();
+ }
+ let first = trimmed
+ .find('<')
+ .ok_or_else(|| parse_error(0, "missing XML LLSD element"))?;
+ let origin = text.len() - trimmed.len() + first;
+ let mut parser = Parser::new(&trimmed[first..], origin);
+ parser.skip_misc()?;
+ let start = parser.read_start()?;
+ let value = if local_name(&start.name) == "llsd" {
+ if start.empty {
+ OSD::Undefined
+ } else {
+ parser.skip_misc()?;
+ if parser.at_end("llsd") {
+ parser.read_end("llsd")?;
+ OSD::Undefined
+ } else {
+ let value = parser.parse_value(0)?;
+ parser.skip_misc()?;
+ parser.read_end("llsd")?;
+ value
+ }
+ }
+ } else {
+ parser.parse_started(start, 0)?
+ };
+ value.validate_limits(
+ OSD::DEFAULT_MAX_DEPTH,
+ OSD::DEFAULT_MAX_NODES,
+ OSD::DEFAULT_MAX_BINARY_BYTES,
+ )?;
+ Ok(value)
+}
+
+pub(crate) fn serialize_bytes(value: OSD) -> Result, Error> {
+ Ok(serialize_string(value)?.into_bytes())
+}
+
+pub(crate) fn serialize_string(value: OSD) -> Result {
+ let inner = serialize_inner(value)?;
+ bounded_join("", &inner, "")
+}
+
+pub(crate) fn serialize_inner(value: OSD) -> Result {
+ value.validate_limits(
+ OSD::DEFAULT_MAX_DEPTH,
+ OSD::DEFAULT_MAX_NODES,
+ OSD::DEFAULT_MAX_BINARY_BYTES,
+ )?;
+ let mut encoder = Encoder::new();
+ encoder.write_value(&value, 0)?;
+ Ok(encoder.output)
+}
+
+pub(crate) fn serialize_element(writer: crate::xml::Writer, value: OSD) -> Result<(), Error> {
+ writer.append(&serialize_inner(value)?)
+}
+
+struct StartTag {
+ name: String,
+ attributes: HashMap,
+ empty: bool,
+ position: usize,
+}
+
+struct Parser<'a> {
+ input: &'a str,
+ position: usize,
+ origin: usize,
+ nodes: usize,
+ allocated: usize,
+}
+
+impl<'a> Parser<'a> {
+ const fn new(input: &'a str, origin: usize) -> Self {
+ Self {
+ input,
+ position: 0,
+ origin,
+ nodes: 0,
+ allocated: 0,
+ }
+ }
+
+ fn parse_value(&mut self, depth: usize) -> Result {
+ let start = self.read_start()?;
+ self.parse_started(start, depth)
+ }
+
+ fn parse_started(&mut self, start: StartTag, depth: usize) -> Result {
+ if depth > OSD::DEFAULT_MAX_DEPTH {
+ return Err(self.error("XML LLSD nesting depth exceeded"));
+ }
+ self.nodes = self
+ .nodes
+ .checked_add(1)
+ .ok_or_else(|| self.error("XML LLSD node overflow"))?;
+ if self.nodes > OSD::DEFAULT_MAX_NODES {
+ return Err(self.error("XML LLSD node limit exceeded"));
+ }
+ let name = local_name(&start.name);
+ match name {
+ "map" => self.parse_map(start, depth),
+ "array" => self.parse_array(start, depth),
+ "undef" => {
+ self.finish_empty_or_text(&start, false)?;
+ Ok(OSD::Undefined)
+ }
+ "boolean" => {
+ let text = self.finish_empty_or_text(&start, true)?;
+ Ok(OSD::Boolean(matches!(text.trim(), "1" | "true")))
+ }
+ "integer" => {
+ let text = self.finish_empty_or_text(&start, true)?;
+ Ok(OSD::Integer(text.trim().parse().unwrap_or(0)))
+ }
+ "real" => {
+ let text = self.finish_empty_or_text(&start, true)?;
+ let value = if text.trim().eq_ignore_ascii_case("nan") {
+ f64::NAN
+ } else {
+ text.trim().parse().unwrap_or(0.0)
+ };
+ Ok(OSD::Real(value))
+ }
+ "string" => Ok(OSD::String(self.finish_empty_or_text(&start, true)?)),
+ "uuid" => {
+ let text = self.finish_empty_or_text(&start, true)?;
+ Ok(OSD::UUID(
+ UUID::new_with_string(text.trim().into()).unwrap_or_else(|_| UUID::zero()),
+ ))
+ }
+ "date" => {
+ let text = self.finish_empty_or_text(&start, true)?;
+ Ok(OSD::Date(
+ crate::model::parse_system_time_for_codec(text.trim()).unwrap_or(UNIX_EPOCH),
+ ))
+ }
+ "uri" => Ok(OSD::Uri(Uri(self.finish_empty_or_text(&start, true)?))),
+ "binary" => {
+ if !start.empty
+ && start
+ .attributes
+ .get("encoding")
+ .is_some_and(|value| value != "base64")
+ {
+ return Err(
+ self.error_at(start.position, "unsupported XML LLSD binary encoding")
+ );
+ }
+ let text = self.finish_empty_or_text(&start, true)?;
+ let compact: Vec<_> = text
+ .bytes()
+ .filter(|byte| !byte.is_ascii_whitespace())
+ .collect();
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(compact)
+ .map_err(|_| self.error_at(start.position, "invalid XML LLSD base64 value"))?;
+ self.add_allocation(bytes.len(), "XML LLSD binary allocation limit exceeded")?;
+ Ok(OSD::Binary(bytes))
+ }
+ _ => Err(self.error_at(start.position, "unknown XML LLSD element")),
+ }
+ }
+
+ fn parse_array(&mut self, start: StartTag, depth: usize) -> Result {
+ if start.empty {
+ return Ok(OSD::Array(Vec::new()));
+ }
+ let mut values = Vec::new();
+ loop {
+ self.skip_misc()?;
+ if self.at_end("array") {
+ self.read_end("array")?;
+ break;
+ }
+ values.push(self.parse_value(depth + 1)?);
+ self.add_allocation(
+ std::mem::size_of::(),
+ "XML LLSD array allocation limit exceeded",
+ )?;
+ if values.len() > OSD::DEFAULT_MAX_NODES {
+ return Err(self.error("XML LLSD array node limit exceeded"));
+ }
+ }
+ Ok(OSD::Array(values))
+ }
+
+ fn parse_map(&mut self, start: StartTag, depth: usize) -> Result {
+ if start.empty {
+ return Ok(OSD::Map(HashMap::new()));
+ }
+ let mut values = HashMap::new();
+ loop {
+ self.skip_misc()?;
+ if self.at_end("map") {
+ self.read_end("map")?;
+ break;
+ }
+ let key = self.read_start()?;
+ if local_name(&key.name) != "key" {
+ return Err(self.error_at(key.position, "expected XML LLSD map key"));
+ }
+ let key = self.finish_empty_or_text(&key, true)?;
+ self.skip_misc()?;
+ let value = self.parse_value(depth + 1)?;
+ self.add_allocation(
+ std::mem::size_of::<(String, OSD)>(),
+ "XML LLSD map allocation limit exceeded",
+ )?;
+ values.insert(key, value);
+ if values.len() > OSD::DEFAULT_MAX_NODES {
+ return Err(self.error("XML LLSD map node limit exceeded"));
+ }
+ }
+ Ok(OSD::Map(values))
+ }
+
+ fn finish_empty_or_text(
+ &mut self,
+ start: &StartTag,
+ allow_text: bool,
+ ) -> Result {
+ if start.empty {
+ return Ok(String::new());
+ }
+ let text = if allow_text {
+ self.read_text(&start.name)?
+ } else {
+ self.skip_misc()?;
+ String::new()
+ };
+ self.read_end(&start.name)?;
+ self.add_allocation(text.len(), "XML LLSD text allocation limit exceeded")?;
+ Ok(text)
+ }
+
+ fn read_text(&mut self, name: &str) -> Result {
+ let mut output = String::new();
+ loop {
+ if self.at_end(name) {
+ break;
+ }
+ if self.rest().starts_with("")
+ .ok_or_else(|| self.error("unterminated XML comment"))?;
+ self.position += end + 3;
+ Ok(())
+ }
+ fn skip_ascii_whitespace(&mut self) {
+ while self
+ .rest()
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_whitespace)
+ {
+ self.position += 1;
+ }
+ }
+ fn read_name(&mut self) -> Result {
+ let length = self
+ .rest()
+ .bytes()
+ .take_while(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b':' | b'.')
+ })
+ .count();
+ if length == 0 {
+ return Err(self.error("invalid XML name"));
+ }
+ let name = self.rest()[..length].to_owned();
+ self.position += length;
+ Ok(name)
+ }
+ fn expect(&mut self, text: &str, context: &'static str) -> Result<(), Error> {
+ if self.rest().starts_with(text) {
+ self.position += text.len();
+ Ok(())
+ } else {
+ Err(self.error(context))
+ }
+ }
+ fn add_allocation(&mut self, amount: usize, context: &'static str) -> Result<(), Error> {
+ self.allocated = self
+ .allocated
+ .checked_add(amount)
+ .ok_or_else(|| self.error(context))?;
+ if self.allocated > MAX_BYTES {
+ Err(self.error(context))
+ } else {
+ Ok(())
+ }
+ }
+ fn rest(&self) -> &'a str {
+ &self.input[self.position..]
+ }
+ const fn absolute_position(&self) -> usize {
+ self.origin + self.position
+ }
+ const fn error(&self, context: &'static str) -> Error {
+ self.error_at(self.absolute_position(), context)
+ }
+ const fn error_at(&self, position: usize, context: &'static str) -> Error {
+ parse_error(position, context)
+ }
+}
+
+struct Encoder {
+ output: String,
+}
+impl Encoder {
+ fn new() -> Self {
+ Self {
+ output: String::with_capacity(128),
+ }
+ }
+ fn write_value(&mut self, value: &OSD, depth: usize) -> Result<(), Error> {
+ if depth > OSD::DEFAULT_MAX_DEPTH {
+ return Err(parse_error(
+ self.output.len(),
+ "XML LLSD nesting depth exceeded",
+ ));
+ }
+ match value {
+ OSD::Undefined => self.push(""),
+ OSD::Boolean(value) => self.scalar("boolean", if *value { "1" } else { "0" }),
+ OSD::Integer(value) => self.scalar("integer", &value.to_string()),
+ OSD::Real(value) => self.scalar("real", &crate::model::format_real_for_codec(*value)),
+ OSD::String(value) => self.scalar("string", value),
+ OSD::UUID(value) => self.scalar("uuid", &value.to_string()),
+ OSD::Date(value) => {
+ self.scalar("date", &crate::model::format_system_time_for_codec(*value))
+ }
+ OSD::Uri(Uri(value)) => self.scalar("uri", &crate::model::format_uri_for_codec(value)),
+ OSD::Binary(value) => self.scalar_with_attribute(
+ "binary",
+ "encoding",
+ "base64",
+ &base64::engine::general_purpose::STANDARD.encode(value),
+ ),
+ OSD::Array(values) => {
+ self.push("")?;
+ for value in values {
+ self.write_value(value, depth + 1)?;
+ }
+ self.push("")
+ }
+ OSD::Map(values) => {
+ self.push("")
+ }
+ OSD::LlsdXml(value) => self.push(value),
+ }
+ }
+ fn scalar(&mut self, name: &str, value: &str) -> Result<(), Error> {
+ self.push(&format!("<{name}>"))?;
+ self.push_escaped(value)?;
+ self.push(&format!("{name}>"))
+ }
+ fn scalar_with_attribute(
+ &mut self,
+ name: &str,
+ attribute: &str,
+ attribute_value: &str,
+ value: &str,
+ ) -> Result<(), Error> {
+ self.push(&format!("<{name} {attribute}=\"{attribute_value}\">"))?;
+ self.push(value)?;
+ self.push(&format!("{name}>"))
+ }
+ fn push(&mut self, value: &str) -> Result<(), Error> {
+ let length = self
+ .output
+ .len()
+ .checked_add(value.len())
+ .ok_or(Error::Argument)?;
+ if length > MAX_BYTES {
+ return Err(Error::Argument);
+ }
+ self.output.push_str(value);
+ Ok(())
+ }
+
+ fn push_escaped(&mut self, value: &str) -> Result<(), Error> {
+ let mut start = 0;
+ for (index, character) in value.char_indices() {
+ let replacement = match character {
+ '&' => "&",
+ '<' => "<",
+ '>' => ">",
+ _ => continue,
+ };
+ self.push(&value[start..index])?;
+ self.push(replacement)?;
+ start = index + character.len_utf8();
+ }
+ self.push(&value[start..])
+ }
+}
+
+fn decode_entities(value: &str, origin: usize) -> Result {
+ let mut output = String::with_capacity(value.len());
+ let mut remaining = value;
+ while let Some(index) = remaining.find('&') {
+ output.push_str(&remaining[..index]);
+ let entity_start = origin + value.len() - remaining.len() + index;
+ remaining = &remaining[index + 1..];
+ let end = remaining
+ .find(';')
+ .ok_or_else(|| parse_error(entity_start, "unterminated XML entity"))?;
+ let entity = &remaining[..end];
+ let character = match entity {
+ "amp" => '&',
+ "lt" => '<',
+ "gt" => '>',
+ "apos" => '\'',
+ "quot" => '"',
+ entity if entity.starts_with("#x") => char::from_u32(
+ u32::from_str_radix(&entity[2..], 16)
+ .map_err(|_| parse_error(entity_start, "invalid XML character reference"))?,
+ )
+ .ok_or_else(|| parse_error(entity_start, "invalid XML character reference"))?,
+ entity if entity.starts_with('#') => char::from_u32(
+ entity[1..]
+ .parse()
+ .map_err(|_| parse_error(entity_start, "invalid XML character reference"))?,
+ )
+ .ok_or_else(|| parse_error(entity_start, "invalid XML character reference"))?,
+ _ => {
+ return Err(parse_error(
+ entity_start,
+ "XML entity expansion is disabled",
+ ));
+ }
+ };
+ output.push(character);
+ remaining = &remaining[end + 1..];
+ }
+ output.push_str(remaining);
+ Ok(output)
+}
+fn local_name(name: &str) -> &str {
+ name.rsplit(':').next().unwrap_or(name)
+}
+fn bounded_join(prefix: &str, value: &str, suffix: &str) -> Result {
+ let length = prefix
+ .len()
+ .checked_add(value.len())
+ .and_then(|length| length.checked_add(suffix.len()))
+ .ok_or(Error::Argument)?;
+ if length > MAX_BYTES {
+ return Err(Error::Argument);
+ }
+ Ok([prefix, value, suffix].concat())
+}
+const fn parse_error(position: usize, context: &'static str) -> Error {
+ Error::Parse { position, context }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn every_variant_round_trips_and_entities_are_exact() {
+ let value = OSD::Map(HashMap::from([
+ ("text<&".into(), OSD::String("< > & ' \"".into())),
+ (
+ "nested".into(),
+ OSD::Array(vec![OSD::Boolean(true), OSD::Binary(vec![0, 255])]),
+ ),
+ ]));
+ let xml = serialize_string(value.clone()).unwrap();
+ assert_eq!(deserialize_string(xml).unwrap(), value);
+ assert_eq!(
+ deserialize_string("𐄷 &".into()).unwrap(),
+ OSD::String("𐄷 &".into())
+ );
+ }
+ #[test]
+ fn empty_binary_ignores_encoding_like_the_reference_reader() {
+ assert_eq!(
+ deserialize_string("".into()).unwrap(),
+ OSD::Binary(Vec::new())
+ );
+ }
+
+ #[test]
+ fn every_serialization_entry_point_is_compact_and_exact() {
+ let value = OSD::Map(HashMap::from([
+ ("z".into(), OSD::Undefined),
+ ("a<&".into(), OSD::Binary(vec![0, 255])),
+ ]));
+ let inner = "";
+ assert_eq!(serialize_inner(value.clone()).unwrap(), inner);
+ assert_eq!(
+ serialize_string(value.clone()).unwrap(),
+ format!("{inner}")
+ );
+ assert_eq!(
+ serialize_bytes(value.clone()).unwrap(),
+ format!("{inner}").into_bytes()
+ );
+ let writer = crate::xml::Writer::default();
+ serialize_element(writer.clone(), value).unwrap();
+ assert_eq!(writer.contents(), inner);
+ }
+
+ #[test]
+ fn reader_stream_comments_and_cdata_match_xml_behavior() {
+ let input = "]]>&";
+ assert_eq!(
+ deserialize_reader(crate::xml::Reader(input.into())).unwrap(),
+ OSD::String("&".into())
+ );
+ let stream: Box =
+ Box::new(std::io::Cursor::new(input.as_bytes().to_vec()));
+ assert_eq!(
+ deserialize_stream(stream).unwrap(),
+ OSD::String("&".into())
+ );
+ }
+
+ #[test]
+ fn hostile_entities_and_malformed_nesting_are_rejected() {
+ for input in [
+ include_str!("../../../fuzz/corpus/xml_llsd/doctype_entity.xml"),
+ include_str!("../../../fuzz/corpus/xml_llsd/external_entity.xml"),
+ include_str!("../../../fuzz/corpus/xml_llsd/unknown_entity.xml"),
+ "1",
+ "00",
+ ] {
+ assert!(matches!(
+ deserialize_string(input.into()),
+ Err(Error::Parse { .. })
+ ));
+ }
+
+ match deserialize_string("1".into()) {
+ Err(Error::Parse { position, context }) => {
+ assert!(position > 6);
+ assert_eq!(context, "nested element in XML LLSD scalar");
+ }
+ result => panic!("expected positioned XML parse error, got {result:?}"),
+ }
+
+ let mut too_deep = "".to_owned();
+ too_deep.push_str(&"".repeat(OSD::DEFAULT_MAX_DEPTH + 2));
+ too_deep.push_str("");
+ too_deep.push_str(&"".repeat(OSD::DEFAULT_MAX_DEPTH + 2));
+ too_deep.push_str("");
+ assert!(matches!(
+ deserialize_string(too_deep),
+ Err(Error::Parse {
+ context: "XML LLSD nesting depth exceeded",
+ ..
+ })
+ ));
+ }
+}
diff --git a/fuzz/corpus/xml_llsd/README.md b/fuzz/corpus/xml_llsd/README.md
new file mode 100644
index 0000000..38d6428
--- /dev/null
+++ b/fuzz/corpus/xml_llsd/README.md
@@ -0,0 +1,6 @@
+# XML LLSD security regression corpus
+
+These inputs exercise the XML LLSD parser's entity boundary. DTD declarations,
+external entities, and undeclared named entities must be rejected without file
+or network access and without entity expansion. The structured-data unit tests
+load every fixture directly.
diff --git a/fuzz/corpus/xml_llsd/doctype_entity.xml b/fuzz/corpus/xml_llsd/doctype_entity.xml
new file mode 100644
index 0000000..6599579
--- /dev/null
+++ b/fuzz/corpus/xml_llsd/doctype_entity.xml
@@ -0,0 +1 @@
+]>&x;
diff --git a/fuzz/corpus/xml_llsd/external_entity.xml b/fuzz/corpus/xml_llsd/external_entity.xml
new file mode 100644
index 0000000..107555f
--- /dev/null
+++ b/fuzz/corpus/xml_llsd/external_entity.xml
@@ -0,0 +1 @@
+]>&x;
diff --git a/fuzz/corpus/xml_llsd/unknown_entity.xml b/fuzz/corpus/xml_llsd/unknown_entity.xml
new file mode 100644
index 0000000..9b5ee9c
--- /dev/null
+++ b/fuzz/corpus/xml_llsd/unknown_entity.xml
@@ -0,0 +1 @@
+&unknown;
diff --git a/tests/red-suite-baseline.json b/tests/red-suite-baseline.json
index 76cc748..a27680d 100644
--- a/tests/red-suite-baseline.json
+++ b/tests/red-suite-baseline.json
@@ -69,7 +69,22 @@
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.UUIDs::test",
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.Vector3ApproxEquals::test",
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.VectorCasting::test",
- "LibreMetaverse.Tests/UtilsConversionsTests.cs::UtilsConversionsTests.StringToBytes::test"
+ "LibreMetaverse.Tests/UtilsConversionsTests.cs::UtilsConversionsTests.StringToBytes::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeBinary::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeBoolean::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeDates::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeIntegers::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeLLSDSample::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeNestedContainers::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeNoDTD::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeReals::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeSillyPI::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeSillyPI_LowercasePI::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeSillyPI_NoWhitespaceAfterPI::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeStrings::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeURI::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUUID::test",
+ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUndef::test"
],
- "support_passes": 79
+ "support_passes": 84
}
diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py
index 9816f3d..c580a66 100644
--- a/tools/generate_api_shims.py
+++ b/tools/generate_api_shims.py
@@ -91,6 +91,14 @@ NATIVE_MEMBER_BODIES = {
"crate::notation::deserialize_reader(reader)",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDNotation(System.String)":
"crate::notation::deserialize_string(notation_data)",
+ "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])":
+ "crate::xml_codec::deserialize_bytes(xml_data)",
+ "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)":
+ "crate::xml_codec::deserialize_stream(xml_stream)",
+ "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)":
+ "crate::xml_codec::deserialize_string(xml_data)",
+ "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)":
+ "crate::xml_codec::deserialize_reader(xml_data)",
"M:LibreMetaverse.StructuredData.OSDParser.EscapeCharacter(System.String,System.Char)":
"crate::notation::escape_character(s, c)",
"M:LibreMetaverse.StructuredData.OSDParser.FindByte(System.IO.Stream,System.Byte)":
@@ -119,6 +127,8 @@ NATIVE_MEMBER_BODIES = {
"crate::binary::serialize_stream(data)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDBinaryStream(LibreMetaverse.StructuredData.OSD,System.Boolean)":
"crate::binary::serialize_stream_with_header(data, prepend_header)",
+ "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDInnerXmlString(LibreMetaverse.StructuredData.OSD)":
+ "crate::xml_codec::serialize_inner(data)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotation(LibreMetaverse.StructuredData.OSD)":
"crate::notation::serialize(osd)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationFormatted(LibreMetaverse.StructuredData.OSD)":
@@ -127,6 +137,12 @@ NATIVE_MEMBER_BODIES = {
"crate::notation::serialize_stream(osd)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationStreamFormatted(LibreMetaverse.StructuredData.OSD)":
"crate::notation::serialize_stream_formatted(osd)",
+ "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlBytes(LibreMetaverse.StructuredData.OSD)":
+ "crate::xml_codec::serialize_bytes(data)",
+ "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)":
+ "crate::xml_codec::serialize_element(writer, data)",
+ "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)":
+ "crate::xml_codec::serialize_string(data)",
"M:LibreMetaverse.StructuredData.OSDParser.SkipWhiteSpace(System.IO.Stream)":
"crate::binary::skip_whitespace(stream)",
"M:LibreMetaverse.StructuredData.OSDParser.UnescapeCharacter(System.String,System.Char)":
diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py
index a068d69..4fb2398 100644
--- a/tools/generate_rust_mapping.py
+++ b/tools/generate_rust_mapping.py
@@ -983,6 +983,7 @@ def validate_generated_shims() -> None:
"crate::binary::",
"crate::dispatch::",
"crate::notation::",
+ "crate::xml_codec::",
)
):
raise ValueError(f"generated shim function does not use the standardized failure: {path.relative_to(ROOT)}")