diff --git a/src/formats/detect.rs b/src/formats/detect.rs index 19e803a7..6e7efa79 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 286c918c..dc6a17f8 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 d0f38de2..9856a6f4 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 1cf8095d..31abf04a 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 00000000..dab31113 Binary files /dev/null and b/tests/fixtures/malformed/zeroed-minifat--recovers.doc differ diff --git a/tests/gen_fixtures.py b/tests/gen_fixtures.py index d70df849..b94e2d11 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 00000000..b6c0ef11 --- /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.