From fff0296ef42795efd7bdc5a87ff813225bf874e6 Mon Sep 17 00:00:00 2001 From: mathurshubham Date: Thu, 6 Aug 2026 19:50:30 +0530 Subject: [PATCH] fix(ole): repair the zero-filled MiniFAT Apple's Cocoa exporter emits .doc files written by TextEdit ("Save as Word 97") and `textutil -convert doc` zero-fill the MiniFAT instead of terminating chains with FREESECT/ENDOFCHAIN, so every entry reads as "next is mini sector 0" and cfb rejects the file ("Malformed MiniFAT: mini sector 0 pointed to twice") before any content is seen. An all-zero MiniFAT cannot occur in a valid file - entry 0 would point at itself - so that exact shape is safe to treat as the writer defect: rebuild the chains from the directory entries (each mini stream below the cutoff owns contiguous sectors from its start, sized from the entry) and retry the open. The repair is gated on the full signature - CFB magic, no external DIFAT, every MiniFAT entry zero, no overlapping rebuilt chains - and any other failure keeps its original error. doc, ppt, and content detection all open through the new helper, so a TextEdit .doc is both detected from bytes and converted. The streams the parsers need live in regular sectors; the repair only has to make the container open. Fixes firecrawl/anydoc#37 --- src/formats/detect.rs | 4 +- src/formats/doc/mod.rs | 7 +- src/formats/ppt/mod.rs | 7 +- src/shared/binary.rs | 245 ++++++++++++++++++ .../malformed/zeroed-minifat--recovers.doc | Bin 0 -> 20480 bytes tests/gen_fixtures.py | 29 +++ ...lformed__zeroed-minifat--recovers.doc.snap | 79 ++++++ 7 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/malformed/zeroed-minifat--recovers.doc create mode 100644 tests/snapshots/snapshots__malformed__zeroed-minifat--recovers.doc.snap diff --git a/src/formats/detect.rs b/src/formats/detect.rs index 19e803a..6e7efa7 100644 --- a/src/formats/detect.rs +++ b/src/formats/detect.rs @@ -24,7 +24,6 @@ use crate::Format; use crate::package::Package; use crate::package::relationships::{read_rels, rel_type}; use crate::package::xml::{Element, ns}; -use std::io::Cursor; const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; const CT_NS: &str = "http://schemas.openxmlformats.org/package/2006/content-types"; @@ -51,7 +50,7 @@ pub fn from_bytes(bytes: &[u8]) -> Option { /// OOXML packages (`EncryptedPackage`) stay `None`: the inner format is /// unknowable, and the frontend reports `Encrypted` precisely. fn detect_ole(bytes: &[u8]) -> Option { - let ole = cfb::CompoundFile::open(Cursor::new(bytes)).ok()?; + let ole = crate::shared::binary::open_ole(bytes).ok()?; // Stream-name comparison is case-insensitive, matching CFB's own // uppercase name comparisons; producers vary (`WORKBOOK`, `BOOK`). let mut found = None; @@ -209,6 +208,7 @@ fn opc_format_by_path(part: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::io::Cursor; use std::io::Write; fn zip_of(parts: &[(&str, &[u8])]) -> Vec { diff --git a/src/formats/doc/mod.rs b/src/formats/doc/mod.rs index 286c918..dc6a17f 100644 --- a/src/formats/doc/mod.rs +++ b/src/formats/doc/mod.rs @@ -15,7 +15,7 @@ use crate::model::{ }; use crate::package::limits; use crate::shared::assets::AssetSink; -use crate::shared::binary::{get_u16, get_u32, read_ole_stream}; +use crate::shared::binary::{get_u16, get_u32, open_ole, read_ole_stream}; use crate::shared::delta::rebase_emphasis; use crate::shared::fields::{FieldFrame, field_result}; use crate::shared::grid::{CellProp, GridRow, build_edge_table}; @@ -24,13 +24,10 @@ use crate::shared::list::{ListEntry, ListKey, flush_list}; use lists::{LEVELS, ListDef, Lists}; use sprm::{PapDelta, Tap, apply_chpx, apply_pap_sprms, chpx_istd}; use std::collections::HashMap; -use std::io::Cursor; use stsh::Stylesheet; pub fn parse(bytes: &[u8]) -> Result { - let cursor = Cursor::new(bytes); - let mut ole = cfb::CompoundFile::open(cursor) - .map_err(|e| ConvertError::malformed(format!("not an OLE2 compound file: {e}")))?; + let mut ole = open_ole(bytes)?; let word_doc = read_ole_stream(&mut ole, "WordDocument")?; if get_u16(&word_doc, 0) != Some(0xA5EC) { diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs index d0f38de..9856a6f 100644 --- a/src/formats/ppt/mod.rs +++ b/src/formats/ppt/mod.rs @@ -11,13 +11,12 @@ mod styletext; use crate::error::ConvertError; use crate::model::{Block, Document, Inline, Style, inlines_are_empty}; use crate::package::limits; -use crate::shared::binary::{get_u32, read_ole_stream}; +use crate::shared::binary::{get_u32, open_ole, read_ole_stream}; use crate::shared::delta::{StyleDelta, rebase_emphasis}; use crate::shared::list::{ListEntry, ListKey, MarkerKind, flush_list}; use crate::shared::officeart::record_at; use crate::shared::text::clean_text; use std::collections::HashMap; -use std::io::Cursor; use styletext::{CharProps, MasterLevel, StyleRuns}; /// One master's per-text-type level defaults, keyed by TxMasterStyleAtom @@ -25,9 +24,7 @@ use styletext::{CharProps, MasterLevel, StyleRuns}; type MasterStyles = HashMap>; pub fn parse(bytes: &[u8]) -> Result { - let cursor = Cursor::new(bytes); - let mut ole = cfb::CompoundFile::open(cursor) - .map_err(|e| ConvertError::malformed(format!("not an OLE2 compound file: {e}")))?; + let mut ole = open_ole(bytes)?; let data = read_ole_stream(&mut ole, "PowerPoint Document")?; let current_user = read_ole_stream(&mut ole, "Current User").unwrap_or_default(); if get_u32(¤t_user, 12) == Some(0xF3D1_C4DF) { diff --git a/src/shared/binary.rs b/src/shared/binary.rs index 1cf8095..31abf04 100644 --- a/src/shared/binary.rs +++ b/src/shared/binary.rs @@ -40,3 +40,248 @@ pub fn read_ole_stream( } Ok(bytes) } + +/// Open an OLE2 compound file, repairing one known writer defect first. +/// +/// Apple's Cocoa exporter (TextEdit "Save as Word 97", `textutil -convert +/// doc`) zero-fills the MiniFAT instead of terminating chains with +/// `FREESECT`/`ENDOFCHAIN`, so every entry reads as "next is mini sector 0" +/// and `cfb` rejects the file ("mini sector 0 pointed to twice"). An +/// all-zero MiniFAT cannot occur in a valid file — entry 0 would point at +/// itself — so when that exact shape is detected the chains are rebuilt +/// from the directory entries (each mini stream's sectors are contiguous +/// from its start, which is how sequential writers allocate them) and the +/// open is retried. Anything else fails exactly as before. +pub fn open_ole(bytes: &[u8]) -> Result>>, ConvertError> { + match cfb::CompoundFile::open(std::io::Cursor::new(bytes.to_vec())) { + Ok(ole) => Ok(ole), + Err(original) => match repair_zeroed_minifat(bytes) { + Some(repaired) => cfb::CompoundFile::open(std::io::Cursor::new(repaired)) + .map_err(|_| ole_open_error(&original)), + None => Err(ole_open_error(&original)), + }, + } +} + +fn ole_open_error(e: &std::io::Error) -> ConvertError { + ConvertError::malformed(format!("not an OLE2 compound file: {e}")) +} + +/// Rebuild an all-zero MiniFAT from the directory entries; `None` when the +/// file does not have that exact defect or the rebuild would be ambiguous. +fn repair_zeroed_minifat(bytes: &[u8]) -> Option> { + const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + const ENDOFCHAIN: u32 = 0xFFFF_FFFE; + const FREESECT: u32 = 0xFFFF_FFFF; + const MAX_SECTORS: u32 = 0xFFFF_FFFA; + + if !bytes.starts_with(&OLE_MAGIC) { + return None; + } + let sector_shift = get_u16(bytes, 30)?; + if !(7..=15).contains(§or_shift) { + return None; + } + let sector_size = 1usize << sector_shift; + let mini_shift = get_u16(bytes, 32)?; + let mini_size = 1u64 << mini_shift.min(15); + let mini_cutoff = get_u32(bytes, 56)? as u64; + let dir_start = get_u32(bytes, 48)?; + let minifat_start = get_u32(bytes, 60)?; + let num_minifat = get_u32(bytes, 64)?; + if num_minifat == 0 || minifat_start >= MAX_SECTORS { + return None; + } + + // Sector N starts at (N + 1) << sector_shift: the 512-byte header pads + // to a full sector in version-4 (4096-byte-sector) files. + let sector_off = |sector: u32| -> Option { + let off = (sector as usize).checked_add(1)?.checked_mul(sector_size)?; + (off + sector_size <= bytes.len()).then_some(off) + }; + + // FAT sectors from the header DIFAT (first 109 entries). Documents small + // enough to come from the writers with this defect never need an + // external DIFAT chain; bail rather than guess if one is present. + if get_u32(bytes, 72)? != 0 { + return None; + } + let mut fat: Vec = Vec::new(); + for i in 0..109 { + let s = get_u32(bytes, 76 + i * 4)?; + if s >= MAX_SECTORS { + break; + } + let off = sector_off(s)?; + for j in 0..sector_size / 4 { + fat.push(get_u32(bytes, off + j * 4)?); + } + } + let next_in_fat = |sector: u32| -> Option { fat.get(sector as usize).copied() }; + + // Walk the MiniFAT's own sector chain and require every entry to be + // zero — the defect's signature, impossible in a valid file. + let mut minifat_sectors: Vec = Vec::new(); + let mut cursor = minifat_start; + while cursor < MAX_SECTORS && minifat_sectors.len() < num_minifat as usize { + if minifat_sectors.contains(&cursor) { + return None; + } + minifat_sectors.push(cursor); + cursor = next_in_fat(cursor)?; + } + let entries_per_sector = sector_size / 4; + for &s in &minifat_sectors { + let off = sector_off(s)?; + for j in 0..entries_per_sector { + if get_u32(bytes, off + j * 4)? != 0 { + return None; + } + } + } + + // Mini-stream chains from the directory: every stream entry below the + // mini cutoff owns `ceil(size / mini_size)` contiguous mini sectors. + let total_entries = minifat_sectors.len() * entries_per_sector; + let mut minifat: Vec = vec![FREESECT; total_entries]; + let mut dir_sectors: Vec = Vec::new(); + let mut cursor = dir_start; + while cursor < MAX_SECTORS { + if dir_sectors.contains(&cursor) || dir_sectors.len() > 4096 { + return None; + } + dir_sectors.push(cursor); + cursor = next_in_fat(cursor)?; + } + for &s in &dir_sectors { + let off = sector_off(s)?; + for e in 0..sector_size / 128 { + let entry = off + e * 128; + let object_type = *bytes.get(entry + 66)?; + if object_type != 2 { + continue; // streams only; the root's size is the container + } + let size = get_u32(bytes, entry + 120)? as u64; + if size == 0 || size >= mini_cutoff { + continue; + } + let start = get_u32(bytes, entry + 116)?; + let count = size.div_ceil(mini_size) as usize; + let (first, end) = (start as usize, start as usize + count); + if end > total_entries { + return None; + } + for (i, slot) in minifat.iter_mut().enumerate().take(end).skip(first) { + if *slot != FREESECT { + return None; // overlapping chains: not confidently repairable + } + *slot = if i + 1 == end { ENDOFCHAIN } else { (i + 1) as u32 }; + } + } + } + + // Write the rebuilt table over the zeroed sectors. + let mut repaired = bytes.to_vec(); + for (n, &s) in minifat_sectors.iter().enumerate() { + let off = sector_off(s)?; + for j in 0..entries_per_sector { + let value = minifat.get(n * entries_per_sector + j).copied().unwrap_or(FREESECT); + repaired[off + j * 4..off + j * 4 + 4].copy_from_slice(&value.to_le_bytes()); + } + } + Some(repaired) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Read, Write}; + + /// A compound file with small streams (mini-stream users) and one large + /// stream, built by the `cfb` crate itself so its geometry is valid. + fn valid_ole() -> Vec { + let mut ole = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); + ole.create_stream("Small").unwrap().write_all(b"tiny mini stream payload").unwrap(); + ole.create_stream("Other").unwrap().write_all(&[0x42u8; 200]).unwrap(); + ole.create_stream("Big").unwrap().write_all(&vec![0x7Au8; 5000]).unwrap(); + ole.into_inner().into_inner() + } + + fn sector_range(bytes: &[u8], sector: u32) -> std::ops::Range { + let size = 1usize << get_u16(bytes, 30).unwrap(); + (sector as usize + 1) * size..(sector as usize + 2) * size + } + + /// Overwrite every MiniFAT sector with the given fill byte. Zero + /// replicates the Apple Cocoa writer defect; other fills make chains + /// that are corrupt without matching the repair's signature. + fn fill_minifat(bytes: &mut [u8], fill: u8) { + let size = 1usize << get_u16(bytes, 30).unwrap(); + let minifat_start = get_u32(bytes, 60).unwrap(); + let num_minifat = get_u32(bytes, 64).unwrap(); + let mut fat: Vec = Vec::new(); + for i in 0..109 { + let s = get_u32(bytes, 76 + i * 4).unwrap(); + if s >= 0xFFFF_FFFA { + break; + } + let range = sector_range(bytes, s); + for j in 0..size / 4 { + fat.push(get_u32(bytes, range.start + j * 4).unwrap()); + } + } + let mut cursor = minifat_start; + for _ in 0..num_minifat { + let range = sector_range(bytes, cursor); + bytes[range].fill(fill); + cursor = fat[cursor as usize]; + } + } + + fn read_stream(bytes: &[u8], name: &str) -> Vec { + let mut ole = open_ole(bytes).unwrap(); + let mut out = Vec::new(); + ole.open_stream(name).unwrap().read_to_end(&mut out).unwrap(); + out + } + + #[test] + fn zeroed_minifat_is_repaired() { + // The Apple Cocoa writer defect: a zero-filled MiniFAT. The rebuilt + // chains must make every stream readable with its original bytes. + let mut bytes = valid_ole(); + fill_minifat(&mut bytes, 0); + assert!( + cfb::CompoundFile::open(Cursor::new(bytes.clone())).is_err(), + "harness sanity: cfb must reject the zeroed MiniFAT on its own" + ); + assert_eq!(read_stream(&bytes, "Small"), b"tiny mini stream payload"); + assert_eq!(read_stream(&bytes, "Other"), [0x42u8; 200]); + assert_eq!(read_stream(&bytes, "Big"), vec![0x7Au8; 5000]); + } + + #[test] + fn valid_files_bypass_the_repair() { + // A well-formed file opens directly, and the repair recognizes that + // it carries no defect. + let bytes = valid_ole(); + assert_eq!(read_stream(&bytes, "Small"), b"tiny mini stream payload"); + assert!(repair_zeroed_minifat(&bytes).is_none(), "no defect, no repair"); + } + + #[test] + fn other_minifat_corruption_still_fails() { + // A MiniFAT that is corrupt but NOT all-zero is outside the repair's + // signature: the original error must surface unchanged. + let mut bytes = valid_ole(); + fill_minifat(&mut bytes, 0x01); // every entry: "next is mini sector 0x01010101" + assert!(repair_zeroed_minifat(&bytes).is_none(), "signature must not match"); + assert!(open_ole(&bytes).is_err()); + } + + #[test] + fn non_ole_bytes_fail_cleanly() { + assert!(open_ole(b"not a compound file at all").is_err()); + assert!(repair_zeroed_minifat(b"%PDF-1.4 whatever").is_none()); + } +} diff --git a/tests/fixtures/malformed/zeroed-minifat--recovers.doc b/tests/fixtures/malformed/zeroed-minifat--recovers.doc new file mode 100644 index 0000000000000000000000000000000000000000..dab31113d4bd6f43322d5d4c8a8c4e80c2eac5c6 GIT binary patch literal 20480 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 ay3N#A2x5UaVc#b*{sOxg6YX^F|NjBD`mn+P literal 0 HcmV?d00001 diff --git a/tests/gen_fixtures.py b/tests/gen_fixtures.py index d70df84..b94e2d1 100644 --- a/tests/gen_fixtures.py +++ b/tests/gen_fixtures.py @@ -1530,6 +1530,33 @@ def zip_replace(src, dst, name, data): write_zip(dst, entries) + +def zero_minifat(raw): + """Zero every MiniFAT sector, replicating the Apple Cocoa .doc writer + defect (TextEdit "Save as Word 97", `textutil -convert doc`): chains are + left as zeros instead of FREESECT/ENDOFCHAIN markers, which strict CFB + readers reject as a self-referencing mini-sector chain.""" + d = bytearray(raw) + shift = int.from_bytes(d[30:32], "little") + size = 1 << shift + minifat_start = int.from_bytes(d[60:64], "little") + num_minifat = int.from_bytes(d[64:68], "little") + fat = [] + for i in range(109): + s = int.from_bytes(d[76 + i * 4:80 + i * 4], "little") + if s >= 0xFFFFFFFA: + break + off = (s + 1) * size + fat.extend(int.from_bytes(d[off + j * 4:off + j * 4 + 4], "little") + for j in range(size // 4)) + cur = minifat_start + for _ in range(num_minifat): + off = (cur + 1) * size + d[off:off + size] = b"\0" * size + cur = fat[cur] + return bytes(d) + + def malformed(): m = OUT / "malformed" m.mkdir(parents=True, exist_ok=True) @@ -1560,6 +1587,8 @@ def malformed(): doc = OUT / "doc" / "text.doc" if doc.exists(): (m / "truncated--errors.doc").write_bytes(doc.read_bytes()[:4096]) + (m / "zeroed-minifat--recovers.doc").write_bytes( + zero_minifat(doc.read_bytes())) ppt = OUT / "ppt" / "pres.ppt" if ppt.exists(): # Corrupt every UserEditAtom record type so the persist directory is diff --git a/tests/snapshots/snapshots__malformed__zeroed-minifat--recovers.doc.snap b/tests/snapshots/snapshots__malformed__zeroed-minifat--recovers.doc.snap new file mode 100644 index 0000000..b6c0ef1 --- /dev/null +++ b/tests/snapshots/snapshots__malformed__zeroed-minifat--recovers.doc.snap @@ -0,0 +1,79 @@ +--- +source: tests/snapshots.rs +expression: output +--- +# Fixture Document + +Plain paragraph with **bold**, *italic*, and ~~struck~~ runs. + +**Style-bold paragraph with a** NotBold-styled span **inside.** + +## Lists + +1. First numbered + +2. Second numbered + + - a) Alpha sub one + + - b) Alpha sub two + + - i. Roman sub sub + +3. Third numbered + +Interrupting paragraph between lists. + +4. Fourth, continuing the count + +- IV. Roman starting at four + +- I. Roman five + +- Bullet one + +- Bullet two + + - Nested bullet + +## Table + +| | | | +| --- | --- | --- | +| Wide head | | End | +| Tall | B2 | C2 | +| | B3 | C3 | + +## Notes and special text + +Music clef 𝄞 appears before this footnote[^1] reference. + +An endnote follows here[^2]. + +Persian with ZWNJ: می‌خواهم. Family emoji: 👨‍👩‍👧. + +Markdown specials: \*stars* \_under_ \[bracket] \`tick` #hash 1. dotted | pipe. + +## Links and anchors + +External link to [example](https://example.com/page). + +Relative link to [a sibling file](../../fixture-src/sibling.odt). + +This plain paragraph carries a bookmark. + +Jump to the bookmarked paragraph. + +## Objects + +Inline image: done. + +Text box: after the box. + +## Quote and code + +Value below one millionth: 0.0000004 should survive. + +[^1]: Footnote after an astral character. + +[^2]: Endnote body text.