diff --git a/src/formats/csv.rs b/src/formats/csv.rs index 5d4618b8..07591068 100644 --- a/src/formats/csv.rs +++ b/src/formats/csv.rs @@ -10,12 +10,21 @@ use crate::error::ConvertError; use crate::model::{Block, Cell, Document, Inline, Table, TableKind}; +use crate::package::materialization::MaterializationBudget; use crate::shared::header::resolve_header_rows; use crate::shared::text::clean_text; use csv::ReaderBuilder; use std::borrow::Cow; pub fn parse(bytes: &[u8]) -> Result { + parse_with_budget(bytes, MaterializationBudget::default()) +} + +fn parse_with_budget( + bytes: &[u8], + mut budget: MaterializationBudget, +) -> Result { + budget.check_input(bytes.len())?; let text = decode(bytes); let delimiter = sniff_delimiter(&text); @@ -34,8 +43,13 @@ pub fn parse(bytes: &[u8]) -> Result { continue; } }; - let cells: Vec = - record.iter().map(|f| Cell::from_inlines(vec![Inline::plain(clean_text(f))])).collect(); + let mut cells = Vec::new(); + for field in &record { + budget.charge_cell()?; + budget.charge_text(field.len())?; + budget.charge_text_run()?; + cells.push(Cell::from_inlines(vec![Inline::plain(clean_text(field))])); + } rows.push(cells); } @@ -150,4 +164,25 @@ mod tests { let doc = parse(&bytes).unwrap(); assert!(!doc.blocks.is_empty()); } + + fn resource_limit_name(error: ConvertError) -> &'static str { + match error { + ConvertError::ResourceLimit { limit, .. } => limit, + other => panic!("expected resource limit, got {other}"), + } + } + + #[test] + fn cell_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 1024, 3, 10); + let error = parse_with_budget(b"a,b\nc,d\n", budget).unwrap_err(); + assert_eq!(resource_limit_name(error), "max_materialized_cells"); + } + + #[test] + fn copied_text_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 5, 10, 10); + let error = parse_with_budget(b"abcdef\n", budget).unwrap_err(); + assert_eq!(resource_limit_name(error), "max_materialized_text_bytes"); + } } diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 3094e67d..7875c734 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -9,6 +9,7 @@ mod tables; use crate::error::ConvertError; use crate::model::{Block, Document, Inline, Note, NoteKind, Style, inlines_are_empty}; +use crate::package::materialization::MaterializationBudget; use crate::package::xml::{Element, Node, ns}; use crate::shared::blockstyle::{BlockStyle, StyledRun}; use crate::shared::delta::rebase_emphasis; @@ -23,6 +24,14 @@ use table::TableState; use tables::{LIST_LEVELS, Prelude, codepage_encoding, parse_prelude}; pub fn parse(bytes: &[u8]) -> Result { + parse_with_budget(bytes, MaterializationBudget::default()) +} + +fn parse_with_budget( + bytes: &[u8], + budget: MaterializationBudget, +) -> Result { + budget.check_input(bytes.len())?; if !bytes.starts_with(b"{\\rtf") { return Err(ConvertError::malformed("not an RTF file")); } @@ -30,7 +39,7 @@ pub fn parse(bytes: &[u8]) -> Result { // header for \ansicpg first. let default_encoding = scan_codepage(bytes); let prelude = parse_prelude(bytes, default_encoding); - let mut parser = Parser::new(bytes, prelude, default_encoding); + let mut parser = Parser::new(bytes, prelude, default_encoding, budget); parser.run()?; parser.finish() } @@ -612,6 +621,8 @@ struct Parser<'a> { prelude: Prelude, decoder: TextDecoder, recovered: bool, + budget: MaterializationBudget, + materialization_error: Option, inlines: Vec, blocks: Vec, @@ -628,6 +639,7 @@ impl<'a> Parser<'a> { bytes: &'a [u8], prelude: Prelude, default_encoding: &'static encoding_rs::Encoding, + budget: MaterializationBudget, ) -> Self { Parser { lexer: Lexer::new(bytes), @@ -636,6 +648,8 @@ impl<'a> Parser<'a> { prelude, decoder: TextDecoder::new(default_encoding), recovered: false, + budget, + materialization_error: None, inlines: Vec::new(), blocks: Vec::new(), list_run: Vec::new(), @@ -707,6 +721,9 @@ impl<'a> Parser<'a> { } } } + if let Some(error) = self.materialization_error.take() { + return Err(error); + } } if !self.stack.is_empty() { self.recovered = true; @@ -715,6 +732,9 @@ impl<'a> Parser<'a> { log::warn!("recovered unbalanced rtf groups"); } self.flush_pending(); + if let Some(error) = self.materialization_error.take() { + return Err(error); + } self.finish_math(); self.end_paragraph() } @@ -1049,6 +1069,14 @@ impl<'a> Parser<'a> { }; let (lines, display) = math.finish(); for (i, tex) in lines.into_iter().enumerate() { + if let Err(error) = + self.budget.charge_text(tex.len()).and_then(|_| self.budget.charge_text_run()) + { + if self.materialization_error.is_none() { + self.materialization_error = Some(error); + } + return; + } if i > 0 { self.inlines.push(Inline::LineBreak); } @@ -1127,6 +1155,12 @@ impl<'a> Parser<'a> { } fn push_text(&mut self, text: String) { + if let Err(error) = self.budget.charge_text(text.len()) { + if self.materialization_error.is_none() { + self.materialization_error = Some(error); + } + return; + } let text = clean_text(&text); if text.is_empty() { return; @@ -1152,6 +1186,12 @@ impl<'a> Parser<'a> { } Capture::None => { if !self.state.suppress { + if let Err(error) = self.budget.charge_text_run() { + if self.materialization_error.is_none() { + self.materialization_error = Some(error); + } + return; + } self.inlines.push(Inline::Text { text, style: self.state.style }); } } @@ -1168,6 +1208,9 @@ impl<'a> Parser<'a> { } fn end_paragraph(&mut self) -> Result<(), ConvertError> { + if let Some(error) = self.materialization_error.take() { + return Err(error); + } let inlines = std::mem::take(&mut self.inlines); let listtext = self.dest.listtext.take(); let math_display = std::mem::take(&mut self.dest.math_display); @@ -1203,12 +1246,20 @@ impl<'a> Parser<'a> { { let text = format!("{} ", label.clone().unwrap_or_else(|| key.marker.label(*number))); + self.budget.charge_text(text.len())?; + self.budget.charge_text_run()?; content.insert(0, Inline::Text { text, style: Style::PLAIN }); } self.blocks.push(Block::Heading { level, anchor: None, content }); return Ok(()); } if let Some((key, level, number, label)) = entry { + if key.marker.ordered() + && let Some(label) = label.as_ref() + { + self.budget.charge_text(label.len())?; + self.budget.charge_text_run()?; + } self.list_run.push(ListEntry { level, key, @@ -1292,6 +1343,10 @@ impl<'a> Parser<'a> { } fn end_cell(&mut self, depth: usize) -> Result<(), ConvertError> { + if let Some(error) = self.materialization_error.take() { + return Err(error); + } + self.budget.charge_cell()?; let inlines = std::mem::take(&mut self.inlines); self.dest.listtext = None; self.table.end_cell(depth, self.state.block, inlines) @@ -1414,4 +1469,59 @@ mod tests { assert_eq!(doc.assets.len(), 1, "only the preferred picture: {:?}", doc.assets); assert_eq!(doc.assets[0].media_type, "image/png"); } + + fn rtf_resource_limit_name(error: ConvertError) -> &'static str { + match error { + ConvertError::ResourceLimit { limit, .. } => limit, + other => panic!("expected resource limit, got {other}"), + } + } + + #[test] + fn rtf_text_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 5, 10, 10); + let error = parse_with_budget(br"{\rtf1 abcdef}", budget).unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_text_bytes"); + } + + #[test] + fn rtf_text_run_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 1024, 10, 2); + let error = parse_with_budget(br"{\rtf1 a\b b\b0 c\par}", budget).unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_text_runs"); + } + + #[test] + fn rtf_math_run_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 1024, 10, 0); + let error = parse_with_budget(br"{\rtf1{\mmath{\*\moMath{\mr x}}}}", budget).unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_text_runs"); + } + + #[test] + fn rtf_cell_materialization_is_bounded() { + let budget = MaterializationBudget::with_limits(1024, 1024, 1, 10); + let error = + parse_with_budget(br"{\rtf1\trowd\cellx1000\cellx2000 a\cell b\cell\row}", budget) + .unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_cells"); + } + + #[test] + fn rtf_generated_list_labels_are_bounded() { + let budget = MaterializationBudget::with_limits(4096, 4, 10, 10); + let error = parse_with_budget( + br"{\rtf1{\*\listtable{\list{\listlevel\levelnfc0\levelstartat1{\leveltext \'03\'00.\'00;}{\levelnumbers \'01\'03;}}\listid1}}{\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}}\pard\ls1\ilvl0 item\par}", + budget, + ) + .unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_text_bytes"); + } + + #[test] + fn pending_text_limit_precedes_cell_limit() { + let budget = MaterializationBudget::with_limits(1024, 1, 0, 10); + let error = parse_with_budget(br"{\rtf1\trowd\cellx1000 ab\cell\row}", budget).unwrap_err(); + assert_eq!(rtf_resource_limit_name(error), "max_materialized_text_bytes"); + } } diff --git a/src/package/limits.rs b/src/package/limits.rs index 1921ebca..dcb42077 100644 --- a/src/package/limits.rs +++ b/src/package/limits.rs @@ -9,6 +9,22 @@ /// Maximum decompressed size of a single archive entry: 128 MiB. pub const MAX_ENTRY_BYTES: u64 = 128 * 1024 * 1024; +/// Maximum size of a standalone, non-archive input: 128 MiB. +/// +/// CSV and RTF do not pass through the archive layer, so this prevents +/// them from duplicating an already-unbounded input before their model +/// materialization budgets can take effect. +pub const MAX_STANDALONE_INPUT_BYTES: usize = 128 * 1024 * 1024; + +/// Maximum text bytes copied into the document model: 64 MiB. +pub const MAX_MATERIALIZED_TEXT_BYTES: usize = 64 * 1024 * 1024; + +/// Maximum table cells materialized by a standalone frontend. +pub const MAX_MATERIALIZED_CELLS: usize = 4_000_000; + +/// Maximum separately allocated text runs in the document model. +pub const MAX_MATERIALIZED_TEXT_RUNS: usize = 2_000_000; + /// Maximum total decompressed bytes read from one archive: 512 MiB. pub const MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; diff --git a/src/package/materialization.rs b/src/package/materialization.rs new file mode 100644 index 00000000..e9679896 --- /dev/null +++ b/src/package/materialization.rs @@ -0,0 +1,134 @@ +//! Shared budgets for non-archive frontends that build the document model. + +use crate::error::ConvertError; +use crate::package::limits; + +/// Tracks allocations whose count can be amplified independently of +/// the input byte length, such as CSV cells and RTF text runs. +#[derive(Debug, Clone)] +pub(crate) struct MaterializationBudget { + text_bytes: usize, + cells: usize, + text_runs: usize, + max_input_bytes: usize, + max_text_bytes: usize, + max_cells: usize, + max_text_runs: usize, +} + +impl Default for MaterializationBudget { + fn default() -> Self { + Self { + text_bytes: 0, + cells: 0, + text_runs: 0, + max_input_bytes: limits::MAX_STANDALONE_INPUT_BYTES, + max_text_bytes: limits::MAX_MATERIALIZED_TEXT_BYTES, + max_cells: limits::MAX_MATERIALIZED_CELLS, + max_text_runs: limits::MAX_MATERIALIZED_TEXT_RUNS, + } + } +} + +impl MaterializationBudget { + /// Reject a standalone input before a decoder or lexer duplicates it. + pub(crate) fn check_input(&self, bytes: usize) -> Result<(), ConvertError> { + if bytes > self.max_input_bytes { + return Err(ConvertError::ResourceLimit { + limit: "max_standalone_input_bytes", + detail: format!( + "standalone input is {bytes} bytes (limit {})", + self.max_input_bytes + ), + }); + } + Ok(()) + } + + /// Charge text bytes copied into retained strings in the model. + pub(crate) fn charge_text(&mut self, bytes: usize) -> Result<(), ConvertError> { + self.text_bytes = + self.text_bytes.checked_add(bytes).ok_or_else(|| ConvertError::ResourceLimit { + limit: "max_materialized_text_bytes", + detail: "materialized text byte counter overflowed".into(), + })?; + if self.text_bytes > self.max_text_bytes { + return Err(ConvertError::ResourceLimit { + limit: "max_materialized_text_bytes", + detail: format!( + "materialized text reached {} bytes (limit {})", + self.text_bytes, self.max_text_bytes + ), + }); + } + Ok(()) + } + + /// Charge one content-bearing table cell. + pub(crate) fn charge_cell(&mut self) -> Result<(), ConvertError> { + self.cells = self.cells.saturating_add(1); + if self.cells > self.max_cells { + return Err(ConvertError::ResourceLimit { + limit: "max_materialized_cells", + detail: format!( + "materialized cell count reached {} (limit {})", + self.cells, self.max_cells + ), + }); + } + Ok(()) + } + + /// Charge one separately allocated text run. + pub(crate) fn charge_text_run(&mut self) -> Result<(), ConvertError> { + self.text_runs = self.text_runs.saturating_add(1); + if self.text_runs > self.max_text_runs { + return Err(ConvertError::ResourceLimit { + limit: "max_materialized_text_runs", + detail: format!( + "materialized text run count reached {} (limit {})", + self.text_runs, self.max_text_runs + ), + }); + } + Ok(()) + } + + /// Construct a small deterministic budget for unit tests. + #[cfg(test)] + pub(crate) fn with_limits( + max_input_bytes: usize, + max_text_bytes: usize, + max_cells: usize, + max_text_runs: usize, + ) -> Self { + Self { max_input_bytes, max_text_bytes, max_cells, max_text_runs, ..Self::default() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limit_name(error: ConvertError) -> &'static str { + match error { + ConvertError::ResourceLimit { limit, .. } => limit, + other => panic!("expected resource limit, got {other}"), + } + } + + #[test] + fn every_counter_is_hard_bounded() { + let budget = MaterializationBudget::with_limits(3, 3, 1, 1); + assert_eq!(limit_name(budget.check_input(4).unwrap_err()), "max_standalone_input_bytes"); + + let mut budget = MaterializationBudget::with_limits(10, 3, 1, 1); + budget.check_input(3).unwrap(); + budget.charge_text(3).unwrap(); + assert_eq!(limit_name(budget.charge_text(1).unwrap_err()), "max_materialized_text_bytes"); + budget.charge_cell().unwrap(); + assert_eq!(limit_name(budget.charge_cell().unwrap_err()), "max_materialized_cells"); + budget.charge_text_run().unwrap(); + assert_eq!(limit_name(budget.charge_text_run().unwrap_err()), "max_materialized_text_runs"); + } +} diff --git a/src/package/mod.rs b/src/package/mod.rs index a3c974f7..5d664600 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -4,6 +4,7 @@ pub mod archive; pub mod limits; +pub(crate) mod materialization; pub mod path; pub mod relationships; pub mod xml;