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"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"", "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("").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("")?; + let mut entries: Vec<_> = values.iter().collect(); + entries.sort_unstable_by_key(|(key, _)| *key); + for (key, value) in entries { + self.scalar("key", key)?; + self.write_value(value, depth + 1)?; + } + 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!("")) + } + 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!("")) + } + 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 = "a<&AP8=z"; + 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)}")