diff --git a/src/formats/rtf/lexer.rs b/src/formats/rtf/lexer.rs index 32ec189..e9b5fa5 100644 --- a/src/formats/rtf/lexer.rs +++ b/src/formats/rtf/lexer.rs @@ -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, +} + +/// 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 = 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 }; @@ -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)] @@ -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")); } } diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 5a018ee..a4438cf 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -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}; @@ -23,28 +24,19 @@ pub fn parse(bytes: &[u8]) -> Result { 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, @@ -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 => { @@ -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 diff --git a/src/formats/rtf/tables.rs b/src/formats/rtf/tables.rs index d01b842..59e5184 100644 --- a/src/formats/rtf/tables.rs +++ b/src/formats/rtf/tables.rs @@ -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; @@ -80,20 +80,20 @@ pub struct Prelude { pub lists: HashMap, } -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 = 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 diff --git a/src/package/limits.rs b/src/package/limits.rs index f6dba39..d0603e4 100644 --- a/src/package/limits.rs +++ b/src/package/limits.rs @@ -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.