Skip to content

Commit ce488bc

Browse files
authored
Merge pull request #964 from williamareynolds/fix/de-doctype-in-text-unreachable
Fix unreachable!() panic when DOCTYPE appears between text runs in element content
2 parents 2778564 + e00ae5c commit ce488bc

3 files changed

Lines changed: 113 additions & 4 deletions

File tree

Changelog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818

1919
### Bug Fixes
2020

21+
- Fix `unreachable!()` panic in the serde deserializer when a DOCTYPE
22+
declaration appears between two text runs inside an element (e.g.
23+
`<a>x<!DOCTYPE y>z</a>`). The DOCTYPE used to break `drain_text`'s
24+
consecutive-text merge, so two `DeEvent::Text` events reached
25+
`read_text` and tripped its "Cannot be two consequent Text events"
26+
invariant. DOCTYPE is now treated as transparent during text drain —
27+
it still goes through the entity resolver, but the surrounding text
28+
is merged into one run. Discovered via libFuzzer on a real-world
29+
SAML deserializer harness.
30+
2131
### Misc Changes
2232

2333

src/de/mod.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,17 +2372,31 @@ impl<'i, R: XmlRead<'i>, E: EntityResolver> XmlReader<'i, R, E> {
23722372
/// Returns `true` when next event is not a text event in any form.
23732373
#[inline(always)]
23742374
const fn current_event_is_last_text(&self) -> bool {
2375-
// If next event is a text or CDATA, we should not trim trailing spaces
2375+
// If next event is a text-like event or a DocType (which is
2376+
// metadata and invisible to the data model), we should not
2377+
// trim trailing spaces — there is more content to drain, and
2378+
// any DocType between us and the next text run needs to be
2379+
// absorbed so `read_text` does not later see two consecutive
2380+
// `DeEvent::Text`. Without DocType here, an input like
2381+
// `<a>x<!DOCTYPE y>z</a>` produces two text events for `x`
2382+
// and `z`, tripping `unreachable!()` in `read_text`.
23762383
!matches!(
23772384
self.lookahead,
2378-
Ok(PayloadEvent::Text(_)) | Ok(PayloadEvent::CData(_) | PayloadEvent::GeneralRef(_))
2385+
Ok(PayloadEvent::Text(_)
2386+
| PayloadEvent::CData(_)
2387+
| PayloadEvent::GeneralRef(_)
2388+
| PayloadEvent::DocType(_))
23792389
)
23802390
}
23812391

23822392
/// Read all consequent [`Text`] and [`CData`] events until non-text event
23832393
/// occurs. Content of all events would be appended to `result` and returned
23842394
/// as [`DeEvent::Text`].
23852395
///
2396+
/// DocType events that fall between text events are absorbed by the
2397+
/// entity resolver and do not break the run — see
2398+
/// [`Self::current_event_is_last_text`] for the rationale.
2399+
///
23862400
/// [`Text`]: PayloadEvent::Text
23872401
/// [`CData`]: PayloadEvent::CData
23882402
fn drain_text(&mut self, mut result: Cow<'i, str>) -> Result<DeEvent<'i>, DeError> {
@@ -2399,9 +2413,16 @@ impl<'i, R: XmlRead<'i>, E: EntityResolver> XmlReader<'i, R, E> {
23992413
.to_mut()
24002414
.push_str(&e.xml_content(self.reader.xml_version())?),
24012415
PayloadEvent::GeneralRef(e) => self.resolve_reference(result.to_mut(), e)?,
2416+
PayloadEvent::DocType(e) => {
2417+
self.entity_resolver
2418+
.capture(e)
2419+
.map_err(|err| DeError::Custom(format!("cannot parse DTD: {}", err)))?;
2420+
}
24022421

2403-
// SAFETY: current_event_is_last_text checks that event is Text, CData or GeneralRef
2404-
_ => unreachable!("Only `Text`, `CData` or `GeneralRef` events can come here"),
2422+
// SAFETY: current_event_is_last_text checks that event is Text, CData, GeneralRef, or DocType
2423+
_ => unreachable!(
2424+
"Only `Text`, `CData`, `GeneralRef` or `DocType` events can come here"
2425+
),
24052426
}
24062427
}
24072428
Ok(DeEvent::Text(Text::new(result)))

tests/serde-de.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,84 @@ mod resolve {
18961896
}
18971897
}
18981898

1899+
/// A DOCTYPE declaration inside an element's text content used to split
1900+
/// the surrounding character data into two consecutive `DeEvent::Text`
1901+
/// events, tripping an `unreachable!()` in `read_text` ("Cannot be two
1902+
/// consequent Text events"). The reader emits Text/DocType/Text for an
1903+
/// input like `<a>x<!DOCTYPE y>z</a>`, and `drain_text` did not drain
1904+
/// across DocType events, so the two text runs reached the
1905+
/// deserializer un-merged. Discovered via libFuzzer on a real-world
1906+
/// SAML response harness.
1907+
///
1908+
/// The expected behavior is: DOCTYPE inside an element is captured by
1909+
/// the entity resolver (it has the same role as a prolog-level DOCTYPE
1910+
/// for entity definitions) and the surrounding text is treated as one
1911+
/// continuous run.
1912+
mod doctype_in_element_text {
1913+
use super::*;
1914+
use pretty_assertions::assert_eq;
1915+
1916+
#[derive(Debug, Deserialize, PartialEq)]
1917+
struct Wrapper {
1918+
#[serde(rename = "$text")]
1919+
text: String,
1920+
}
1921+
1922+
/// Minimal regression repro: a single DOCTYPE event between two
1923+
/// adjacent text runs inside an element should not panic.
1924+
#[test]
1925+
fn single_doctype_between_text() {
1926+
let xml = r#"<a>x<!DOCTYPE y>z</a>"#;
1927+
let got: Wrapper = from_str(xml).unwrap();
1928+
assert_eq!(got, Wrapper { text: "xz".into() });
1929+
}
1930+
1931+
/// Multiple DOCTYPE events stacked between text runs (matching the
1932+
/// shape of the fuzzer-discovered input that originally surfaced
1933+
/// this panic on real SAML traffic).
1934+
#[test]
1935+
fn multiple_doctypes_between_text() {
1936+
let xml = r#"<a>x<!DOCTYPE y><!DOCTYPE z>w</a>"#;
1937+
let got: Wrapper = from_str(xml).unwrap();
1938+
assert_eq!(got, Wrapper { text: "xw".into() });
1939+
}
1940+
1941+
/// DOCTYPE followed by text only (no leading text). Should produce
1942+
/// the trailing text without panic.
1943+
#[test]
1944+
fn leading_doctype_then_text() {
1945+
let xml = r#"<a><!DOCTYPE y>x</a>"#;
1946+
let got: Wrapper = from_str(xml).unwrap();
1947+
assert_eq!(got, Wrapper { text: "x".into() });
1948+
}
1949+
1950+
/// Whitespace adjacent to the DOCTYPE on both sides should be
1951+
/// preserved verbatim when the two text runs are merged — the
1952+
/// DOCTYPE is treated as transparent, not as a delimiter.
1953+
#[test]
1954+
fn whitespace_around_doctype() {
1955+
let xml = "<a>x <!DOCTYPE y>\tz</a>";
1956+
let got: Wrapper = from_str(xml).unwrap();
1957+
assert_eq!(
1958+
got,
1959+
Wrapper {
1960+
text: "x \tz".into()
1961+
}
1962+
);
1963+
1964+
// Whitespace at the element boundaries (before the leading
1965+
// text and after the trailing text) is also preserved.
1966+
let xml = "<a> x<!DOCTYPE y>z </a>";
1967+
let got: Wrapper = from_str(xml).unwrap();
1968+
assert_eq!(
1969+
got,
1970+
Wrapper {
1971+
text: " xz ".into()
1972+
}
1973+
);
1974+
}
1975+
}
1976+
18991977
/// Tests for https://github.com/tafia/quick-xml/pull/603.
19001978
///
19011979
/// According to <https://www.w3.org/TR/xml11/#NT-prolog> comments,

0 commit comments

Comments
 (0)