RTF: style-sheet names take Unicode escapes; a malformed \' escape leaks no bytes (#6165)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-10 01:24:23 +01:00
1 parent 43f94a5dca
commit 2922fe566b
3 files changed
+88 -3

No files matched your search

+18 -2
View File
@@ -135,7 +135,13 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
// is not an escape at all.
let h = bytes
.get(i + 2..i + 4)
.and_then(|h| std::str::from_utf8(h).ok())
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|h| u8::from_str_radix(h, 16).ok());
match h {
Some(v) => {
if r.st.dest == Dest::Pict {
@@ -150,7 +156,17 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
i += 4;
}
None => i += 2,
// Not two hex digits: the escape is malformed. Consume the escape and
// any hex digits of a truncated pair, and leave every other byte,
// `}` or a control word's `\`, to be read as the structure it is.
None => {
i += 2;
let mut n = 0;
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
i += 1;
n += 1;
}
}
}
}
b'*' => {
+67
View File
@@ -37,6 +37,73 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn stylesheet_names_take_unicode_escapes() {
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_take_negative_escapes() {
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_honour_the_uc_fallback_count() {
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn a_malformed_hex_escape_leaves_no_stray_text() {
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
let rtf = br"{\rtf1\ansi \'gZok\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:gZok");
// The bytes after a truncated pair must not swallow the group's closing brace or the next
// control word: the group and its destination state end where they should. Paragraph text
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
// formatting is checked too.
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
let survivor_bold = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
let rtf = br"{\rtf1\ansi \'+4z\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:+4z");
let rtf = br"{\rtf1\ansi{\'}}after\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
// A hex digit that starts a valid pair with the byte after it is part of the escape:
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
let rtf = br"{\rtf1\ansi\'4}x\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
+3 -1
View File
@@ -403,7 +403,8 @@ impl Reader {
}
true
}
Dest::StyleSheet => {
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
@@ -420,6 +421,7 @@ impl Reader {
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true