Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 72 additions & 20 deletions src/formats/rtf/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,39 @@ impl<'a> Lexer<'a> {
}
}

/// Extract the balanced content of destination groups named `name`
/// (`{\name ...}` or `{\*\name ...}`), bin-aware. Returns the byte ranges of
/// the group bodies including the destination word.
pub fn destination_groups<'a>(bytes: &'a [u8], name: &str) -> Vec<&'a [u8]> {
let mut out = Vec::new();
/// The prelude tables and codepage, extracted in one lexer pass.
pub struct PreludeScan<'a> {
pub fonttbl: Vec<&'a [u8]>,
pub stylesheet: Vec<&'a [u8]>,
pub listtable: Vec<&'a [u8]>,
pub listoverridetable: Vec<&'a [u8]>,
pub codepage: Option<i32>,
}

/// Which prelude destination an open group captures into.
#[derive(Clone, Copy)]
enum Dest {
Fonts,
Styles,
Lists,
Overrides,
}

/// Capture the prelude destination groups and the header `\ansicpg` value.
pub fn scan_prelude(bytes: &[u8]) -> PreludeScan<'_> {
let mut scan = PreludeScan {
fonttbl: Vec::new(),
stylesheet: Vec::new(),
listtable: Vec::new(),
listoverridetable: Vec::new(),
codepage: None,
};
let mut lexer = Lexer::new(bytes);
// Track group starts; when a group's first control word (skipping `\*`)
// matches, remember its start depth and capture until it closes.
// A group whose first control word (skipping `\*`) names a prelude
// table is captured from after that word until its group closes.
let mut depth = 0usize;
let mut expecting_word_at: Option<usize> = None;
let mut capture: Option<(usize, usize)> = None; // (depth, start_pos)
let mut capture: Option<(usize, usize, Dest)> = None; // (depth, start, dest)
loop {
let before = lexer.pos;
let Some(token) = lexer.next_token() else { break };
Expand All @@ -128,26 +150,41 @@ pub fn destination_groups<'a>(bytes: &'a [u8], name: &str) -> Vec<&'a [u8]> {
expecting_word_at = Some(depth);
}
Token::Close => {
if let Some((d, start)) = capture
if let Some((d, start, dest)) = capture
&& depth == d
{
out.push(&bytes[start..before]);
let range = &bytes[start..before];
match dest {
Dest::Fonts => scan.fonttbl.push(range),
Dest::Styles => scan.stylesheet.push(range),
Dest::Lists => scan.listtable.push(range),
Dest::Overrides => scan.listoverridetable.push(range),
}
capture = None;
}
depth = depth.saturating_sub(1);
expecting_word_at = None;
}
Token::Symbol(b'*') if expecting_word_at == Some(depth) => {}
Token::Word { name: w, .. } => {
if expecting_word_at == Some(depth) && w == name && capture.is_none() {
capture = Some((depth, lexer.pos));
Token::Word { name, param } => {
if name == "ansicpg" && scan.codepage.is_none() {
scan.codepage = param;
}
if expecting_word_at == Some(depth) && capture.is_none() {
match name {
"fonttbl" => capture = Some((depth, lexer.pos, Dest::Fonts)),
"stylesheet" => capture = Some((depth, lexer.pos, Dest::Styles)),
"listtable" => capture = Some((depth, lexer.pos, Dest::Lists)),
"listoverridetable" => capture = Some((depth, lexer.pos, Dest::Overrides)),
_ => {}
}
}
expecting_word_at = None;
}
_ => expecting_word_at = None,
}
}
out
scan
}

#[cfg(test)]
Expand Down Expand Up @@ -192,12 +229,27 @@ mod tests {
}

#[test]
fn destination_extraction() {
let src = br"{\rtf1{\*\listtable{\list\listid5}}{\fonttbl{\f0 Arial;}} body}";
let lists = destination_groups(src, "listtable");
assert_eq!(lists.len(), 1);
assert!(lists[0].starts_with(br"{\list"));
let fonts = destination_groups(src, "fonttbl");
fn prelude_scan_finds_destinations_and_codepage() {
let src = br"{\rtf1\ansicpg1252{\*\listtable{\list\listid5}}{\fonttbl{\f0 Arial;}} body}";
let scan = scan_prelude(src);
assert_eq!(scan.codepage, Some(1252));
assert_eq!(scan.listtable.len(), 1);
assert!(scan.listtable[0].starts_with(br"{\list"));
let fonts = scan.fonttbl;
assert_eq!(fonts.len(), 1);
assert!(fonts[0].starts_with(br"{\f0"));
}

#[test]
fn prelude_scan_finds_styles_and_overrides() {
let src =
br"{\rtf1{\stylesheet{\s0 Normal;}}{\*\listoverridetable{\listoverride\listid1\ls1}}}";
let scan = scan_prelude(src);
let styles = scan.stylesheet;
assert_eq!(styles.len(), 1);
assert!(styles[0].starts_with(br"{\s0"));
let overrides = scan.listoverridetable;
assert_eq!(overrides.len(), 1);
assert!(overrides[0].starts_with(br"{\listoverride"));
}
}
44 changes: 26 additions & 18 deletions src/formats/rtf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ mod tables;

use crate::error::ConvertError;
use crate::model::{Block, Document, Inline, Note, NoteKind, Style, inlines_are_empty};
use crate::package::limits;
use crate::shared::blockstyle::{BlockStyle, StyledRun};
use crate::shared::delta::rebase_emphasis;
use crate::shared::fields::field_result;
use crate::shared::list::{ListEntry, ListKey, MarkerKind, flush_list};
use crate::shared::text::clean_text;
use lexer::{Lexer, Token};
use lexer::{Lexer, Token, scan_prelude};
use std::collections::HashMap;
use table::TableState;
use tables::{LIST_LEVELS, Prelude, codepage_encoding, parse_prelude};
Expand All @@ -23,28 +24,19 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
if !bytes.starts_with(b"{\\rtf") {
return Err(ConvertError::malformed("not an RTF file"));
}
// The code page must be known before the font table decodes; scan the
// header for \ansicpg first.
let default_encoding = scan_codepage(bytes);
let prelude = parse_prelude(bytes, default_encoding);
// One header scan finds the codepage and prelude tables; the main
// parse pass below is the only other full lex.
let scan = scan_prelude(bytes);
let default_encoding = scan
.codepage
.map(|cp| codepage_encoding(cp.max(0) as u32))
.unwrap_or(encoding_rs::WINDOWS_1252);
let prelude = parse_prelude(&scan, default_encoding);
let mut parser = Parser::new(bytes, prelude, default_encoding);
parser.run()?;
parser.finish()
}

fn scan_codepage(bytes: &[u8]) -> &'static encoding_rs::Encoding {
// \ansicpg sits in the header, but generator comments and extra header
// words can push it past any fixed prefix; the lexer scan is linear and
// stops at the first match.
let mut lexer = Lexer::new(bytes);
while let Some(token) = lexer.next_token() {
if let Token::Word { name: "ansicpg", param: Some(cp) } = token {
return codepage_encoding(cp.max(0) as u32);
}
}
encoding_rs::WINDOWS_1252
}

#[derive(Clone, Copy, PartialEq)]
enum Capture {
None,
Expand Down Expand Up @@ -436,6 +428,12 @@ impl<'a> Parser<'a> {
match token {
Token::Open => {
self.flush_pending();
if self.stack.len() >= limits::MAX_RTF_DEPTH {
return Err(ConvertError::ResourceLimit {
limit: "max_rtf_depth",
detail: format!("group nesting exceeds {}", limits::MAX_RTF_DEPTH),
});
}
self.stack.push(self.state);
}
Token::Close => {
Expand Down Expand Up @@ -1036,6 +1034,16 @@ impl<'a> Parser<'a> {
mod tests {
use super::*;

#[test]
fn group_nesting_depth_is_capped() {
let mut src = br"{\rtf1".to_vec();
for _ in 0..(limits::MAX_RTF_DEPTH + 2) {
src.push(b'{');
}
let err = parse(&src).unwrap_err();
assert!(matches!(err, ConvertError::ResourceLimit { limit: "max_rtf_depth", .. }));
}

#[test]
fn missing_list_table_keeps_listtext_marker() {
// A \ls with no list-table definition degrades to a bullet, but the
Expand Down
12 changes: 6 additions & 6 deletions src/formats/rtf/tables.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! RTF prelude tables parsed into typed definitions: the font table (with
//! per-font charsets), the style sheet, and the list/list-override tables.

use crate::formats::rtf::lexer::{Lexer, Token, destination_groups};
use crate::formats::rtf::lexer::{Lexer, PreludeScan, Token};
use crate::shared::blockstyle::{self, BlockStyle};
use crate::shared::delta::StyleDelta;
use crate::shared::list::MarkerKind;
Expand Down Expand Up @@ -80,20 +80,20 @@ pub struct Prelude {
pub lists: HashMap<i32, ListDef>,
}

pub fn parse_prelude(bytes: &[u8], default_encoding: &'static encoding_rs::Encoding) -> Prelude {
pub fn parse_prelude(scan: &PreludeScan<'_>, default_encoding: &'static encoding_rs::Encoding) -> Prelude {
let mut prelude = Prelude::default();

for group in destination_groups(bytes, "fonttbl") {
for group in &scan.fonttbl {
parse_fonttbl(group, &mut prelude.fonts, default_encoding);
}
for group in destination_groups(bytes, "stylesheet") {
for group in &scan.stylesheet {
parse_stylesheet(group, &mut prelude.styles, default_encoding);
}
let mut by_list_id: HashMap<i32, ListDef> = HashMap::new();
for group in destination_groups(bytes, "listtable") {
for group in &scan.listtable {
parse_listtable(group, &mut by_list_id, default_encoding);
}
for group in destination_groups(bytes, "listoverridetable") {
for group in &scan.listoverridetable {
parse_overrides(group, &by_list_id, &mut prelude.lists, default_encoding);
}
prelude
Expand Down
3 changes: 3 additions & 0 deletions src/package/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub const MAX_ENTRY_COUNT: usize = 100_000;
/// Maximum XML element nesting depth.
pub const MAX_XML_DEPTH: usize = 256;

/// Maximum RTF group nesting depth.
pub const MAX_RTF_DEPTH: usize = 256;

/// Maximum number of XML nodes (elements + text runs) in one part. Sized
/// from the measured worst-case DOM cost (~400 bytes/node, see the node-cap
/// memory test) so a saturating part stays around the archive budget.
Expand Down