Implement bounded XML LLSD codec

This commit is contained in:
2026-08-09 01:08:14 +00:00
parent 2308e55671
commit 84bc31d031
14 changed files with 853 additions and 34 deletions

View File

@@ -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"<llsd><undef /></llsd>".to_vec()),
Ok(OSD::Undefined)
);
let cases: &[(&[u8], &str)] = &[
(
b"<? llsd/protobuf ?>",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDProtobuf(System.Byte[])",
),
(
b"<llsd><undef /></llsd>",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])",
),
(
b"{}",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeJson(System.String)",

View File

@@ -329,33 +329,25 @@ impl OSDParser {
pub fn deserialize_llsd_xml_with_bytes(
xml_data: Vec<u8>,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
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<dyn libremetaverse_types::compat::ReadWrite + Send>,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
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_structured_data::OSD, crate::Error> {
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_structured_data::OSD, crate::Error> {
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<String, crate::Error> {
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<Vec<u8>, 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<String, crate::Error> {
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(

View File

@@ -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<Mutex<String>>);
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::*;

View File

@@ -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();
}

View File

@@ -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<u8>) -> Result<OSD, Error> {
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<OSD, Error> {
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<dyn ReadWrite + Send>) -> Result<OSD, Error> {
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<OSD, Error> {
deserialize_string(reader.0)
}
fn deserialize_text(text: &str) -> Result<OSD, Error> {
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<Vec<u8>, Error> {
Ok(serialize_string(value)?.into_bytes())
}
pub(crate) fn serialize_string(value: OSD) -> Result<String, Error> {
let inner = serialize_inner(value)?;
bounded_join("<llsd>", &inner, "</llsd>")
}
pub(crate) fn serialize_inner(value: OSD) -> Result<String, Error> {
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<String, String>,
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<OSD, Error> {
let start = self.read_start()?;
self.parse_started(start, depth)
}
fn parse_started(&mut self, start: StartTag, depth: usize) -> Result<OSD, Error> {
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<OSD, Error> {
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::<OSD>(),
"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<OSD, Error> {
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<String, Error> {
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<String, Error> {
let mut output = String::new();
loop {
if self.at_end(name) {
break;
}
if self.rest().starts_with("<!--") {
self.skip_comment()?;
continue;
}
if self.rest().starts_with("<?") {
self.skip_pi()?;
continue;
}
if self.rest().starts_with("<![CDATA[") {
self.position += 9;
let end = self
.rest()
.find("]]>")
.ok_or_else(|| self.error("unterminated XML CDATA"))?;
output.push_str(&self.rest()[..end]);
self.position += end + 3;
continue;
}
if self.rest().starts_with('<') {
return Err(self.error("nested element in XML LLSD scalar"));
}
let end = self.rest().find('<').unwrap_or(self.rest().len());
output.push_str(&decode_entities(
&self.rest()[..end],
self.absolute_position(),
)?);
self.position += end;
if output.len() > MAX_BYTES {
return Err(self.error("XML LLSD text allocation limit exceeded"));
}
}
Ok(output)
}
fn read_start(&mut self) -> Result<StartTag, Error> {
self.skip_misc()?;
let position = self.absolute_position();
self.expect("<", "expected XML start element")?;
if self.rest().starts_with('/')
|| self.rest().starts_with('!')
|| self.rest().starts_with('?')
{
return Err(self.error_at(position, "expected XML start element"));
}
let name = self.read_name()?;
let mut attributes = HashMap::new();
loop {
self.skip_ascii_whitespace();
if self.rest().starts_with("/>") {
self.position += 2;
return Ok(StartTag {
name,
attributes,
empty: true,
position,
});
}
if self.rest().starts_with('>') {
self.position += 1;
return Ok(StartTag {
name,
attributes,
empty: false,
position,
});
}
let attribute = self.read_name()?;
self.skip_ascii_whitespace();
self.expect("=", "expected XML attribute equals")?;
self.skip_ascii_whitespace();
let quote = self
.rest()
.as_bytes()
.first()
.copied()
.filter(|byte| matches!(byte, b'\'' | b'"'))
.ok_or_else(|| self.error("expected quoted XML attribute"))?;
self.position += 1;
let end = self
.rest()
.find(char::from(quote))
.ok_or_else(|| self.error("unterminated XML attribute"))?;
let value = decode_entities(&self.rest()[..end], self.absolute_position())?;
self.position += end + 1;
attributes.insert(local_name(&attribute).to_owned(), value);
}
}
fn read_end(&mut self, expected: &str) -> Result<(), Error> {
self.expect("</", "expected XML end element")?;
let name = self.read_name()?;
self.skip_ascii_whitespace();
self.expect(">", "unterminated XML end element")?;
if local_name(&name) != local_name(expected) {
return Err(self.error("mismatched XML LLSD end element"));
}
Ok(())
}
fn at_end(&self, name: &str) -> bool {
self.rest()
.strip_prefix("</")
.and_then(|rest| {
rest.split(|c: char| c == '>' || c.is_ascii_whitespace())
.next()
})
.is_some_and(|found| local_name(found) == local_name(name))
}
fn skip_misc(&mut self) -> Result<(), Error> {
loop {
self.skip_ascii_whitespace();
if self.rest().starts_with("<?") {
self.skip_pi()?;
} else if self.rest().starts_with("<!--") {
self.skip_comment()?;
} else if self
.rest()
.get(..9)
.is_some_and(|value| value.eq_ignore_ascii_case("<!DOCTYPE"))
{
return Err(self.error("XML DTD and entity declarations are disabled"));
} else {
break;
}
}
Ok(())
}
fn skip_pi(&mut self) -> Result<(), Error> {
let end = self
.rest()
.find("?>")
.ok_or_else(|| self.error("unterminated XML processing instruction"))?;
self.position += end + 2;
Ok(())
}
fn skip_comment(&mut self) -> Result<(), Error> {
let end = self
.rest()
.find("-->")
.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<String, Error> {
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("<undef />"),
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("<array>")?;
for value in values {
self.write_value(value, depth + 1)?;
}
self.push("</array>")
}
OSD::Map(values) => {
self.push("<map>")?;
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("</map>")
}
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 {
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
_ => 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<String, Error> {
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<String, Error> {
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("<llsd><string>&#x10137; &amp;</string></llsd>".into()).unwrap(),
OSD::String("𐄷 &".into())
);
}
#[test]
fn empty_binary_ignores_encoding_like_the_reference_reader() {
assert_eq!(
deserialize_string("<llsd><binary encoding='legacy' /></llsd>".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 = "<map><key>a&lt;&amp;</key><binary encoding=\"base64\">AP8=</binary><key>z</key><undef /></map>";
assert_eq!(serialize_inner(value.clone()).unwrap(), inner);
assert_eq!(
serialize_string(value.clone()).unwrap(),
format!("<llsd>{inner}</llsd>")
);
assert_eq!(
serialize_bytes(value.clone()).unwrap(),
format!("<llsd>{inner}</llsd>").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 = "<?xml version='1.0'?><llsd><!-- ignored --><string><![CDATA[<raw>]]>&amp;</string></llsd>";
assert_eq!(
deserialize_reader(crate::xml::Reader(input.into())).unwrap(),
OSD::String("<raw>&".into())
);
let stream: Box<dyn ReadWrite + Send> =
Box::new(std::io::Cursor::new(input.as_bytes().to_vec()));
assert_eq!(
deserialize_stream(stream).unwrap(),
OSD::String("<raw>&".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"),
"<llsd><array><integer>1</array></llsd>",
"<llsd><binary encoding='base16'>00</binary></llsd>",
] {
assert!(matches!(
deserialize_string(input.into()),
Err(Error::Parse { .. })
));
}
match deserialize_string("<llsd><integer>1</real></llsd>".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 = "<llsd>".to_owned();
too_deep.push_str(&"<array>".repeat(OSD::DEFAULT_MAX_DEPTH + 2));
too_deep.push_str("<undef />");
too_deep.push_str(&"</array>".repeat(OSD::DEFAULT_MAX_DEPTH + 2));
too_deep.push_str("</llsd>");
assert!(matches!(
deserialize_string(too_deep),
Err(Error::Parse {
context: "XML LLSD nesting depth exceeded",
..
})
));
}
}