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

@@ -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 length-prefixed binary forms, and both compact and formatted output. Malformed
notation reports UTF-16 offsets with parser context, while input, output, notation reports UTF-16 offsets with parser context, while input, output,
nesting, node, and aggregate allocation limits apply to untrusted text. 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# The controlled audit aggregates every expected failure by standardized C#
member ID and rejects unrelated fixture, assertion, compile, or symbol errors. member ID and rejects unrelated fixture, assertion, compile, or symbol errors.

View File

@@ -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.RLV` | 28 | 499 | callable failure-only shim |
| `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | callable failure-only shim | | `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | callable failure-only shim |
| `LibreMetaverse.Rendering.Simple` | 1 | 6 | 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.Types` | 45 | 942 | native implementation: 45 types / 942 members; no generated shims remain |
| `LibreMetaverse.Utilities` | 3 | 13 | callable failure-only shim | | `LibreMetaverse.Utilities` | 3 | 13 | callable failure-only shim |
| `LibreMetaverse.Voice.Vivox` | 64 | 531 | callable failure-only shim | | `LibreMetaverse.Voice.Vivox` | 64 | 531 | callable failure-only shim |

View File

@@ -160,15 +160,15 @@ mod tests {
deserialize_bytes(b"<? llsd/binary ?>i\0\0\0\0".to_vec()), deserialize_bytes(b"<? llsd/binary ?>i\0\0\0\0".to_vec()),
Ok(OSD::Integer(0)) Ok(OSD::Integer(0))
); );
assert_eq!(
deserialize_bytes(b"<llsd><undef /></llsd>".to_vec()),
Ok(OSD::Undefined)
);
let cases: &[(&[u8], &str)] = &[ let cases: &[(&[u8], &str)] = &[
( (
b"<? llsd/protobuf ?>", b"<? llsd/protobuf ?>",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDProtobuf(System.Byte[])", "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDProtobuf(System.Byte[])",
), ),
(
b"<llsd><undef /></llsd>",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])",
),
( (
b"{}", b"{}",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeJson(System.String)", "M:LibreMetaverse.StructuredData.OSDParser.DeserializeJson(System.String)",

View File

@@ -329,33 +329,25 @@ impl OSDParser {
pub fn deserialize_llsd_xml_with_bytes( pub fn deserialize_llsd_xml_with_bytes(
xml_data: Vec<u8>, xml_data: Vec<u8>,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> { ) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::deserialize_bytes(xml_data)
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Byte[])",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)`.
pub fn deserialize_llsd_xml_with_stream( pub fn deserialize_llsd_xml_with_stream(
xml_stream: Box<dyn libremetaverse_types::compat::ReadWrite + Send>, xml_stream: Box<dyn libremetaverse_types::compat::ReadWrite + Send>,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> { ) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::deserialize_stream(xml_stream)
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.IO.Stream)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)`.
pub fn deserialize_llsd_xml_with_string( pub fn deserialize_llsd_xml_with_string(
xml_data: String, xml_data: String,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> { ) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::deserialize_string(xml_data)
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.String)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)`.
pub fn deserialize_llsd_xml_with_xml_reader( pub fn deserialize_llsd_xml_with_xml_reader(
xml_data: libremetaverse_structured_data::xml::Reader, xml_data: libremetaverse_structured_data::xml::Reader,
) -> Result<libremetaverse_structured_data::OSD, crate::Error> { ) -> Result<libremetaverse_structured_data::OSD, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::deserialize_reader(xml_data)
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDXml(System.Xml.XmlReader)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.EscapeCharacter(System.String,System.Char)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.EscapeCharacter(System.String,System.Char)`.
pub fn escape_character( pub fn escape_character(
@@ -454,9 +446,7 @@ impl OSDParser {
pub fn serialize_llsd_inner_xml_string( pub fn serialize_llsd_inner_xml_string(
data: libremetaverse_structured_data::OSD, data: libremetaverse_structured_data::OSD,
) -> Result<String, crate::Error> { ) -> Result<String, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::serialize_inner(data)
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDInnerXmlString(LibreMetaverse.StructuredData.OSD)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotation(LibreMetaverse.StructuredData.OSD)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotation(LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_notation( pub fn serialize_llsd_notation(
@@ -495,26 +485,20 @@ impl OSDParser {
pub fn serialize_llsd_xml_bytes( pub fn serialize_llsd_xml_bytes(
data: libremetaverse_structured_data::OSD, data: libremetaverse_structured_data::OSD,
) -> Result<Vec<u8>, crate::Error> { ) -> Result<Vec<u8>, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::serialize_bytes(data)
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlBytes(LibreMetaverse.StructuredData.OSD)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_xml_element( pub fn serialize_llsd_xml_element(
writer: libremetaverse_structured_data::xml::Writer, writer: libremetaverse_structured_data::xml::Writer,
data: libremetaverse_structured_data::OSD, data: libremetaverse_structured_data::OSD,
) -> Result<(), crate::Error> { ) -> Result<(), crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::serialize_element(writer, data)
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlElement(System.Xml.XmlWriter,LibreMetaverse.StructuredData.OSD)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)`.
pub fn serialize_llsd_xml_string( pub fn serialize_llsd_xml_string(
data: libremetaverse_structured_data::OSD, data: libremetaverse_structured_data::OSD,
) -> Result<String, crate::Error> { ) -> Result<String, crate::Error> {
libremetaverse_types::not_implemented( crate::xml_codec::serialize_string(data)
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDXmlString(LibreMetaverse.StructuredData.OSD)",
)
} }
/// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SkipWhiteSpace(System.IO.Stream)`. /// C# member: `M:LibreMetaverse.StructuredData.OSDParser.SkipWhiteSpace(System.IO.Stream)`.
pub fn skip_white_space( pub fn skip_white_space(

View File

@@ -7,16 +7,44 @@ mod dispatch;
mod generated; mod generated;
mod model; mod model;
mod notation; mod notation;
mod xml_codec;
pub mod xml { pub mod xml {
use std::sync::{Arc, Mutex};
/// Project-owned XML element boundary; external XML APIs are not copied. /// Project-owned XML element boundary; external XML APIs are not copied.
pub struct Element; pub struct Element;
/// Native XML reader boundary; external XML APIs are not copied. /// 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. /// 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::*; 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) !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("://") { if !value.contains("://") {
return value.to_owned(); 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",
..
})
));
}
}

View File

@@ -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.

View File

@@ -0,0 +1 @@
<!DOCTYPE llsd [<!ENTITY x "expanded">]><llsd><string>&x;</string></llsd>

View File

@@ -0,0 +1 @@
<!DOCTYPE llsd [<!ENTITY x SYSTEM "file:///etc/passwd">]><llsd><string>&x;</string></llsd>

View File

@@ -0,0 +1 @@
<llsd><string>&unknown;</string></llsd>

View File

@@ -69,7 +69,22 @@
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.UUIDs::test", "LibreMetaverse.Tests/TypeTests.cs::TypeTests.UUIDs::test",
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.Vector3ApproxEquals::test", "LibreMetaverse.Tests/TypeTests.cs::TypeTests.Vector3ApproxEquals::test",
"LibreMetaverse.Tests/TypeTests.cs::TypeTests.VectorCasting::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
} }

View File

@@ -91,6 +91,14 @@ NATIVE_MEMBER_BODIES = {
"crate::notation::deserialize_reader(reader)", "crate::notation::deserialize_reader(reader)",
"M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDNotation(System.String)": "M:LibreMetaverse.StructuredData.OSDParser.DeserializeLLSDNotation(System.String)":
"crate::notation::deserialize_string(notation_data)", "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)": "M:LibreMetaverse.StructuredData.OSDParser.EscapeCharacter(System.String,System.Char)":
"crate::notation::escape_character(s, c)", "crate::notation::escape_character(s, c)",
"M:LibreMetaverse.StructuredData.OSDParser.FindByte(System.IO.Stream,System.Byte)": "M:LibreMetaverse.StructuredData.OSDParser.FindByte(System.IO.Stream,System.Byte)":
@@ -119,6 +127,8 @@ NATIVE_MEMBER_BODIES = {
"crate::binary::serialize_stream(data)", "crate::binary::serialize_stream(data)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDBinaryStream(LibreMetaverse.StructuredData.OSD,System.Boolean)": "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDBinaryStream(LibreMetaverse.StructuredData.OSD,System.Boolean)":
"crate::binary::serialize_stream_with_header(data, prepend_header)", "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)": "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotation(LibreMetaverse.StructuredData.OSD)":
"crate::notation::serialize(osd)", "crate::notation::serialize(osd)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationFormatted(LibreMetaverse.StructuredData.OSD)": "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationFormatted(LibreMetaverse.StructuredData.OSD)":
@@ -127,6 +137,12 @@ NATIVE_MEMBER_BODIES = {
"crate::notation::serialize_stream(osd)", "crate::notation::serialize_stream(osd)",
"M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationStreamFormatted(LibreMetaverse.StructuredData.OSD)": "M:LibreMetaverse.StructuredData.OSDParser.SerializeLLSDNotationStreamFormatted(LibreMetaverse.StructuredData.OSD)":
"crate::notation::serialize_stream_formatted(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)": "M:LibreMetaverse.StructuredData.OSDParser.SkipWhiteSpace(System.IO.Stream)":
"crate::binary::skip_whitespace(stream)", "crate::binary::skip_whitespace(stream)",
"M:LibreMetaverse.StructuredData.OSDParser.UnescapeCharacter(System.String,System.Char)": "M:LibreMetaverse.StructuredData.OSDParser.UnescapeCharacter(System.String,System.Char)":

View File

@@ -983,6 +983,7 @@ def validate_generated_shims() -> None:
"crate::binary::", "crate::binary::",
"crate::dispatch::", "crate::dispatch::",
"crate::notation::", "crate::notation::",
"crate::xml_codec::",
) )
): ):
raise ValueError(f"generated shim function does not use the standardized failure: {path.relative_to(ROOT)}") raise ValueError(f"generated shim function does not use the standardized failure: {path.relative_to(ROOT)}")