diff --git a/crates/engine/src/cmd/place/doc.rs b/crates/engine/src/cmd/place/doc.rs index 8e9ff7c04..fda21d11b 100644 --- a/crates/engine/src/cmd/place/doc.rs +++ b/crates/engine/src/cmd/place/doc.rs @@ -38,9 +38,12 @@ //! changed, once: pictures and objects, fields, fields left out (no result, or nested too deep), the //! other stories, tracked changes and tables. A stream larger than [`MAX_STREAM`] bytes, a main //! text longer than [`text::MAX_TEXT`] characters, more than [`MAX_PIECES`] pieces or -//! [`MAX_PAGES`] property pages, and placed type of more than [`MAX_PARAGRAPHS`] paragraphs or -//! [`MAX_RUNS`] runs are refused, and so are a structure that lies outside its stream, property -//! pages whose runs are listed twice or overlap, and styles based on each other in a loop. +//! [`MAX_PAGES`] property pages, more than [`MAX_RUNS`] runs of character or of paragraph +//! properties, more than [`MAX_PRL_BYTES`] bytes of properties in the styles and property runs, +//! more than [`MAX_PARAGRAPHS`] paragraphs (hidden and deleted ones and those in field +//! instructions included), and placed type of more than [`MAX_RUNS`] runs are refused, and so are +//! a structure that lies outside its stream, property pages whose runs are listed twice or +//! overlap, and styles based on each other in a loop. use std::io::{Cursor, Read}; @@ -78,8 +81,12 @@ const MAX_FONTS: usize = 0x7FF0; const MAX_NAME: usize = 256; /// The most paragraphs placed type takes from one Word document. const MAX_PARAGRAPHS: usize = 100_000; -/// The most runs of one character formatting placed type takes from one Word document. +/// The most runs of one character formatting placed type takes from one Word document, and the +/// most runs of character or of paragraph properties read from its property pages. const MAX_RUNS: usize = 100_000; +/// The most bytes of properties (Prls) read from the style sheet's styles and the property runs; +/// each style's and each run's properties are applied once. +const MAX_PRL_BYTES: usize = 16 << 20; /// The deepest nesting of fields read; deeper ones are left out with their results. const MAX_FIELDS: usize = 64; /// `istdBase` of a style that is based on none. @@ -112,16 +119,17 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<(Story, Vec)> { let table = stream(&mut file, table_name, cmd)?.ok_or_else(|| damaged(cmd, format!("it has no {table_name} stream")))?; let clx = fib.part(&table, CLX, cmd)?.ok_or_else(|| damaged(cmd, "it has no piece table"))?; let text = main_text(&wd, &pieces(clx, cmd)?, fib.ccp_text, cmd)?; + let mut prl_left = MAX_PRL_BYTES; let chpx = match fib.part(&table, PLCF_BTE_CHPX, cmd)? { - Some(plc) => runs(&wd, plc, Fkp::Chpx, cmd)?, + Some(plc) => runs(&wd, plc, Fkp::Chpx, &mut prl_left, cmd)?, None => vec![], }; let papx = match fib.part(&table, PLCF_BTE_PAPX, cmd)? { - Some(plc) => runs(&wd, plc, Fkp::Papx, cmd)?, + Some(plc) => runs(&wd, plc, Fkp::Papx, &mut prl_left, cmd)?, None => vec![], }; let sheet = match fib.part(&table, STSHF, cmd)? { - Some(stsh) => Sheet::read(stsh, cmd)?, + Some(stsh) => Sheet::read(stsh, &mut prl_left, cmd)?, None => Sheet::default(), }; let fonts = match fib.part(&table, STTBF_FFN, cmd)? { @@ -139,6 +147,15 @@ fn damaged(cmd: &str, why: impl std::fmt::Display) -> EngineError { bad(cmd, format!("can't read this Word document: {why}")) } +/// Takes `bytes` of properties from `left`, the bytes of the [`MAX_PRL_BYTES`] not yet read; an +/// error when fewer are left. +fn spend(left: &mut usize, bytes: usize, cmd: &str) -> Result<()> { + *left = left + .checked_sub(bytes) + .ok_or_else(|| bad(cmd, format!("the Word document has more than {} MB of formatting properties, too many to place", MAX_PRL_BYTES >> 20)))?; + Ok(()) +} + /// The stream `name` of the compound file; None when it has no such stream. fn stream(file: &mut cfb::CompoundFile>, name: &str, cmd: &str) -> Result>> { if !file.is_stream(name) { @@ -371,8 +388,9 @@ struct Run<'a> { /// the WordDocument stream `wd`, ordered by file position. The bin table's file positions rise /// ("MUST be sorted in ascending order", and a PlcBteChpx "MUST NOT contain duplicate stream /// offsets"), and so do the runs of its pages one after another: a page listed twice, or pages -/// whose runs overlap, are damaged. -fn runs<'a>(wd: &'a [u8], plc: &[u8], kind: Fkp, cmd: &str) -> Result>> { +/// whose runs overlap, are damaged. More than [`MAX_RUNS`] runs are refused, and each run's +/// properties are taken from `prl_left`. +fn runs<'a>(wd: &'a [u8], plc: &[u8], kind: Fkp, prl_left: &mut usize, cmd: &str) -> Result>> { let broken = || damaged(cmd, "its character or paragraph properties are damaged"); if plc.len() < 4 || !(plc.len() - 4).is_multiple_of(8) { return Err(broken()); @@ -397,6 +415,10 @@ fn runs<'a>(wd: &'a [u8], plc: &[u8], kind: Fkp, cmd: &str) -> Result MAX_RUNS { + return Err(bad(cmd, format!("the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place"))); + } + spend(prl_left, out.get(first..).unwrap_or_default().iter().map(|r| r.grpprl.len()).sum(), cmd)?; } Ok(out) } @@ -500,8 +522,8 @@ struct Sheet { } impl Sheet { - /// The style sheet STSH (§2.9.271). - fn read(stsh: &[u8], cmd: &str) -> Result { + /// The style sheet STSH (§2.9.271); its styles' properties are taken from `prl_left`. + fn read(stsh: &[u8], prl_left: &mut usize, cmd: &str) -> Result { let broken = || damaged(cmd, "its style sheet is damaged"); let size = usize::from(u16_at(stsh, 0).ok_or_else(broken)?); let stshi = bytes_at(stsh, 2, size).ok_or_else(broken)?; @@ -521,6 +543,7 @@ impl Sheet { // LPStd records start on even offsets. at += 2 + usize::from(len) + usize::from(len) % 2; } + spend(prl_left, styles.iter().flatten().map(|s| s.papx.len() + s.chpx.len()).sum(), cmd)?; let root = (Para::default(), Chars { font, ..Chars::default() }); let paragraph = resolve(&styles, 1, root, |(mut para, chars), s| { para.apply(&s.papx); @@ -1024,6 +1047,8 @@ struct Reader<'a> { cell_tab: bool, /// The fields open: true while their instructions are being read. fields: Vec, + /// The fields of `fields` whose instructions are being read. + in_instructions: usize, /// Fields begun past [`MAX_FIELDS`] and not yet ended. field_overflow: usize, /// The text, in runs of one character formatting; paragraphs end with `\n`. @@ -1048,6 +1073,7 @@ impl<'a> Reader<'a> { cell_end: None, cell_tab: false, fields: vec![], + in_instructions: 0, field_overflow: 0, spans: vec![], len: 0, @@ -1063,6 +1089,8 @@ impl<'a> Reader<'a> { // Each property run's formatting, worked out the first time the text reaches it. let mut paragraphs: Vec> = vec![None; papx.len()]; let mut changes: Vec> = vec![None; chpx.len()]; + // The paragraphs read, shown or not: each paragraph's segment and each line break. + let mut paragraphs_read = 0usize; let mut start = 0; while start < text.len() && !self.full { // A paragraph runs to its mark: a paragraph or cell mark, or a page or section break at @@ -1073,6 +1101,11 @@ impl<'a> Reader<'a> { }; let end = text.get(start..).and_then(|rest| rest.iter().position(ends)).map_or(text.len(), |at| start + at + 1); let segment = text.get(start..end).unwrap_or_default(); + paragraphs_read += 1 + segment.iter().filter(|&&(c, _, _)| c == '\u{B}').count(); + if paragraphs_read > MAX_PARAGRAPHS { + self.full = true; + return; + } let mark_fc = segment.last().map(|&(_, fc, _)| fc).unwrap_or_default(); let run = run_at(papx, mark_fc).and_then(|i| Some((papx.get(i)?, paragraphs.get_mut(i)?))); let (para, base) = match run { @@ -1134,6 +1167,7 @@ impl<'a> Reader<'a> { '\u{13}' => { if self.fields.len() < MAX_FIELDS && self.field_overflow == 0 { self.fields.push(true); + self.in_instructions += 1; } else { self.seen.unread_fields = true; self.field_overflow += 1; @@ -1142,7 +1176,9 @@ impl<'a> Reader<'a> { } '\u{14}' if self.field_overflow == 0 => { if let Some(instructions) = self.fields.last_mut() { - *instructions = false; + if std::mem::take(instructions) { + self.in_instructions = self.in_instructions.saturating_sub(1); + } self.seen.fields = true; } return; @@ -1152,13 +1188,14 @@ impl<'a> Reader<'a> { self.field_overflow -= 1; } else if self.fields.pop() == Some(true) { // A field that ends without a separator has no result. + self.in_instructions = self.in_instructions.saturating_sub(1); self.seen.unread_fields = true; } return; } _ => {} } - if self.field_overflow > 0 || self.fields.contains(&true) || chars.hidden { + if self.field_overflow > 0 || self.in_instructions > 0 || chars.hidden { return; } let para = self.para; @@ -1256,7 +1293,7 @@ impl<'a> Reader<'a> { out.push("the Word document's fields were placed as the text of their results".to_string()); } // A field still in its instructions when the text ends has no result either. - if self.seen.unread_fields || self.fields.contains(&true) { + if self.seen.unread_fields || self.in_instructions > 0 { out.push("the Word document's fields without a result that could be read were left out".to_string()); } if self.seen.revisions { diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index f2aaa6b38..0749964ee 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -486,6 +486,8 @@ struct Word97 { pieces: Vec<(&'static str, bool)>, /// Character properties of characters `start..end`. chpx: Vec<(u32, u32, Vec)>, + /// The character properties of character `cp`, instead of `chpx`'s. + chpx_fn: Option Vec>, /// The paragraph style and properties of the paragraph whose mark is character `cp`; a /// character's paragraph is the first one whose mark is at or after it. papx: Vec<(u32, u16, Vec)>, @@ -567,7 +569,10 @@ impl Word97 { let w = if *compressed { 1 } else { 2 }; chars.extend((0..text.encode_utf16().count() as u32).map(|k| (at + k * w, w))); } - let chpx_of = |cp: u32| (0u32, self.chpx.iter().rev().find(|(s, e, _)| (*s..*e).contains(&cp)).map(|r| r.2.clone()).unwrap_or_default()); + let chpx_of = |cp: u32| match self.chpx_fn { + Some(f) => (0u32, f(cp)), + None => (0u32, self.chpx.iter().rev().find(|(s, e, _)| (*s..*e).contains(&cp)).map(|r| r.2.clone()).unwrap_or_default()), + }; let papx_of = |cp: u32| { let p = self.papx.iter().filter(|(mark, _, _)| *mark >= cp).min_by_key(|(mark, _, _)| *mark); p.map_or((u32::MAX, vec![0, 0]), |(mark, istd, g)| (*mark, [istd.to_le_bytes().to_vec(), g.clone()].concat())) @@ -938,6 +943,40 @@ fn doc_styles_are_worked_out_once_however_many_paragraphs_use_them() { assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a\nb", 12.0)]); } +#[test] +fn doc_property_runs_paragraphs_and_properties_read_are_limited() { + let mut s = session(); + let place = |s: &mut Session, doc: &Word97| { + s.execute("file.place", &json!({"name": "a.doc", "dataBase64": vectorcraft_format::base64_encode(&doc.build())})).map_err(|e| e.to_string()) + }; + // 100,001 hidden characters whose properties alternate between two lists that give the same + // formatting: more than 100,000 runs of character properties, though the type placed is one run. + let text: &'static str = format!("{}x\r", "a".repeat(100_001)).leak(); + let alternate = |cp: u32| match cp { + 100_001.. => vec![], + cp if cp % 2 == 0 => prls(&[(0x083C, &[1])]), + _ => prls(&[(0x083C, &[1]), (0x0835, &[0])]), + }; + let e = place(&mut s, &Word97 { pieces: vec![(text, true)], chpx_fn: Some(alternate), ..Word97::default() }).unwrap_err(); + assert!(e.contains("more than 100000 runs of character or paragraph properties"), "{e}"); + // More than 100,000 paragraphs that are hidden, deleted or in a field's instructions. + let marks = "\r".repeat(100_001); + for (text, chpx) in [ + (format!("{marks}x\r"), vec![(0, 100_001, prls(&[(0x083C, &[1])]))]), + (format!("{marks}x\r"), vec![(0, 100_001, prls(&[(0x0800, &[1])]))]), + (format!("\u{13}{marks}\u{14}x\u{15}\r"), vec![]), + ] { + let e = place(&mut s, &Word97 { pieces: vec![(text.leak(), true)], chpx, ..Word97::default() }).unwrap_err(); + assert!(e.contains("at most 100000 paragraphs"), "{e}"); + } + // 270 character styles of 64,800 bytes of properties each: more than 16 MB. + let filler = prls(&[(0x0835, &[0])]).repeat(21_600); + let styles = (0..270).map(|_| Some((2, 0x0FFF, vec![], filler.clone()))).collect(); + let e = place(&mut s, &Word97 { pieces: vec![("words\r", true)], styles, ..Word97::default() }).unwrap_err(); + assert!(e.contains("more than 16 MB of formatting properties"), "{e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), 0, "failed places leave no undo step"); +} + #[test] fn place_reads_what_open_reads_and_text_files() { use cmd::fileio::{DOC_EXTS, DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS}; diff --git a/docs/mcp.md b/docs/mcp.md index c25d6240c..6b4cd1998 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3900,8 +3900,9 @@ are left out, and `warnings` names each kind of content left out or changed once tracked changes, tables, and the other stories the document has); a field with no result is left out with its own warning. Documents saved by Word 6.0 or Word 95, encrypted or password-protected documents and documents last saved with fast save are refused, and so are a stream larger than 64 MiB, a document with more than 4 MiB of text, 100,000 pieces, -16,384 pages of properties or 100,000 paragraphs or runs, one whose 16-bit text holds half of a surrogate pair, one -whose structures lie outside their streams, one whose property pages are out of order, listed twice or overlap, one whose +16,384 pages of properties, 100,000 runs of character or of paragraph properties, 16 MiB of properties in its styles +and property runs, or 100,000 paragraphs (hidden and deleted ones and those in field instructions counted) or runs of +placed type, one whose 16-bit text holds half of a surrogate pair, one whose structures lie outside their streams, one whose property pages are out of order, listed twice or overlap, one whose style sheet counts 4,094 styles or more or holds fewer styles than it counts, and one whose styles are based on each other in a loop. `file.place` answers `format: "doc"`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2294563f6..851b6c50e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1115,6 +1115,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word 97-2003 `.doc` documents as area type with the same formatting, read from the main text's pieces, the style sheet's paragraph and character styles and direct formatting. Table rows are paragraphs with their cells separated by tabs and fields place as their results; fields without a result, hidden and deleted text, pictures, headers, footers, notes, comments and text boxes are left out, and `warnings` names each kind left out or changed. Encrypted documents, documents last saved with fast save and Word 6.0 and Word 95 documents are refused (`file.place`, `crates/engine/src/tests/textimport.rs`). + - Placing a Word 97-2003 `.doc` document works out each style's formatting once and each property run's once, and refuses property pages that are out of order, listed twice or overlap, a style sheet that holds fewer styles than it counts, styles based on each other in a loop, and more than 100,000 runs of character or of paragraph properties, 16 MiB of properties or 100,000 paragraphs, hidden ones included (`file.place`, `crates/engine/src/tests/textimport.rs`). - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284).