1104 lines
43 KiB
Rust
1104 lines
43 KiB
Rust
//! Legacy binary Office formats: `.doc`, `.xls`, `.ppt`.
|
||
//!
|
||
//! These are OLE2 compound files — a FAT-like container of named streams —
|
||
//! rather than the zip-of-XML their `x`-suffixed successors use, so nothing in
|
||
//! [`super::office`] can read them. What the three have in common is only the
|
||
//! container; inside, each stores its text a completely different way, so this
|
||
//! module is three parsers sharing a reader.
|
||
//!
|
||
//! # What "supported" means here
|
||
//!
|
||
//! The goal is the *text*, for a full-text index. Formatting, embedded
|
||
//! objects, revision history and deleted-but-retained text are all out of
|
||
//! scope, and the parsers deliberately read the minimum structure needed to
|
||
//! locate character data.
|
||
//!
|
||
//! # Hostile input
|
||
//!
|
||
//! Every offset in these formats comes from the file itself, including counts
|
||
//! that decide how much to allocate. A `.doc` claiming four billion text
|
||
//! pieces is a valid byte sequence. So: every read is bounds-checked against
|
||
//! the stream that actually exists, every declared length is clamped to what
|
||
//! remains, and nothing is preallocated from a declared count. A malformed
|
||
//! file yields `Err` — which lands as a `FAILED` row with a reason, visible in
|
||
//! `list-failed` — never a partial string of garbage, and never a panic.
|
||
|
||
use std::error::Error;
|
||
use std::fs::File;
|
||
use std::io::Read;
|
||
use std::path::Path;
|
||
|
||
/// Ceiling on extracted text from one legacy document.
|
||
///
|
||
/// The formats allow a document to declare far more text than it contains, and
|
||
/// the config's own `maximum_text_size` is applied later, by the caller. This
|
||
/// is the earlier, cruder bound that keeps a hostile header from turning into
|
||
/// an allocation.
|
||
const MAX_TEXT_BYTES: usize = 64 * 1024 * 1024;
|
||
|
||
pub fn extract_ole_text(path: &Path, extension: &str) -> Result<String, Box<dyn Error>> {
|
||
let mut cfb = cfb::CompoundFile::open(File::open(path)?)
|
||
.map_err(|e| format!("not a readable OLE2 compound file: {}", e))?;
|
||
match extension {
|
||
"doc" => doc::extract(&mut cfb),
|
||
"xls" => xls::extract(&mut cfb),
|
||
"ppt" => ppt::extract(&mut cfb),
|
||
other => Err(format!("no OLE2 parser for .{}", other).into()),
|
||
}
|
||
}
|
||
|
||
/// Read one named stream whole. `None` when the stream is absent, which is a
|
||
/// question several callers ask before falling back to another name.
|
||
fn stream<F: Read + std::io::Seek>(cfb: &mut cfb::CompoundFile<F>, name: &str) -> Option<Vec<u8>> {
|
||
let mut s = cfb.open_stream(name).ok()?;
|
||
let mut buf = Vec::new();
|
||
s.read_to_end(&mut buf).ok()?;
|
||
Some(buf)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bounds-checked little-endian reads
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Every one returns `Option` rather than panicking on a short slice: the
|
||
// offsets these are called with are attacker-controlled.
|
||
|
||
fn u8_at(b: &[u8], off: usize) -> Option<u8> {
|
||
b.get(off).copied()
|
||
}
|
||
|
||
fn u16_at(b: &[u8], off: usize) -> Option<u16> {
|
||
let bytes = b.get(off..off.checked_add(2)?)?;
|
||
Some(u16::from_le_bytes([bytes[0], bytes[1]]))
|
||
}
|
||
|
||
fn u32_at(b: &[u8], off: usize) -> Option<u32> {
|
||
let bytes = b.get(off..off.checked_add(4)?)?;
|
||
Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||
}
|
||
|
||
/// Decode `bytes` as windows-1252 — the "compressed"/8-bit form all three
|
||
/// formats use for text that fits it.
|
||
fn cp1252(bytes: &[u8]) -> String {
|
||
encoding_rs::WINDOWS_1252.decode(bytes).0.into_owned()
|
||
}
|
||
|
||
/// Decode `bytes` as UTF-16LE, the wide form. A trailing odd byte is dropped
|
||
/// rather than treated as an error: it means the declared length disagreed
|
||
/// with the stream, and half a code unit carries nothing.
|
||
fn utf16le(bytes: &[u8]) -> String {
|
||
let even = bytes.len() - (bytes.len() % 2);
|
||
encoding_rs::UTF_16LE.decode(&bytes[..even]).0.into_owned()
|
||
}
|
||
|
||
/// Map the control codes these formats use as structure into whitespace, and
|
||
/// drop the rest.
|
||
///
|
||
/// Word marks paragraphs with `\r` and table cells with `\x07`; both read as
|
||
/// line breaks. Field instructions live between `\x13` and `\x15` and are
|
||
/// markup, not prose — "HYPERLINK \\l foo" is not something a user searches
|
||
/// for. PowerPoint uses `\x0B` as a soft line break.
|
||
fn clean(raw: &str, out: &mut String) {
|
||
let mut in_field_instruction = false;
|
||
for ch in raw.chars() {
|
||
match ch {
|
||
'\u{13}' => in_field_instruction = true,
|
||
// 0x14 ends the instruction and begins the field's *result*, which
|
||
// is real text; 0x15 ends the field entirely.
|
||
'\u{14}' | '\u{15}' => in_field_instruction = false,
|
||
_ if in_field_instruction => {}
|
||
'\r' | '\u{07}' | '\u{0B}' => out.push('\n'),
|
||
'\t' | '\n' => out.push(ch),
|
||
// Picture anchors, chunk separators, and the rest of the C0 range.
|
||
c if (c as u32) < 0x20 => {}
|
||
c => out.push(c),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// .doc — Word 97-2003
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Word does not store its text contiguously. The `WordDocument` stream holds
|
||
// character data in arbitrarily ordered runs, and a *piece table* in the
|
||
// companion table stream says which run belongs where in the document. Reading
|
||
// the stream start-to-end therefore yields text in storage order, interleaved
|
||
// with whatever earlier edits left behind; only the piece table gives the
|
||
// document as it reads.
|
||
mod doc {
|
||
use super::*;
|
||
|
||
/// Offset of the flags word in the FIB base, whose bit 9 selects which of
|
||
/// the two table streams is live.
|
||
pub(super) const FIB_FLAGS: usize = 0x000A;
|
||
pub(super) const FLAG_WHICH_TBL_STM: u16 = 0x0200;
|
||
|
||
/// The FIB base is fixed-length; the variable-length arrays follow it.
|
||
pub(super) const FIB_BASE_LEN: usize = 32;
|
||
|
||
/// Index of the `fcClx`/`lcbClx` pair within `fibRgFcLcb97`, which is an
|
||
/// array of (u32 fc, u32 lcb) pairs. The CLX is where the piece table is.
|
||
pub(super) const CLX_PAIR_INDEX: usize = 33;
|
||
|
||
/// `Pcdt`, the piece-table element of a CLX.
|
||
pub(super) const CLXT_PCDT: u8 = 0x02;
|
||
/// `Prc`, a formatting element that precedes the piece table.
|
||
pub(super) const CLXT_PRC: u8 = 0x01;
|
||
|
||
/// A piece descriptor is 8 bytes; each CP in the accompanying array is 4.
|
||
pub(super) const PCD_LEN: usize = 8;
|
||
pub(super) const CP_LEN: usize = 4;
|
||
|
||
/// Set in a `PCD`'s `fc` field when the piece is 8-bit rather than UTF-16.
|
||
pub(super) const FC_COMPRESSED: u32 = 0x4000_0000;
|
||
pub(super) const FC_ADDRESS_MASK: u32 = 0x3FFF_FFFF;
|
||
|
||
pub fn extract<F: Read + std::io::Seek>(
|
||
cfb: &mut cfb::CompoundFile<F>,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
let doc = stream(cfb, "WordDocument").ok_or("no WordDocument stream")?;
|
||
let flags = u16_at(&doc, FIB_FLAGS).ok_or("truncated FIB")?;
|
||
// Word keeps two table streams and rewrites them alternately; the flag
|
||
// says which one the current FIB refers to. Reading the wrong one
|
||
// gives a piece table from a previous save.
|
||
let table_name = if flags & FLAG_WHICH_TBL_STM != 0 {
|
||
"1Table"
|
||
} else {
|
||
"0Table"
|
||
};
|
||
let table = stream(cfb, table_name)
|
||
.ok_or_else(|| format!("no {} stream (Word 6/95 file?)", table_name))?;
|
||
|
||
let (fc_clx, lcb_clx) = clx_location(&doc)?;
|
||
let clx = table
|
||
.get(fc_clx..fc_clx.checked_add(lcb_clx).ok_or("CLX length overflows")?)
|
||
.ok_or("CLX runs past the end of the table stream")?;
|
||
let pieces = piece_table(clx)?;
|
||
|
||
let mut out = String::new();
|
||
for piece in pieces {
|
||
if out.len() >= MAX_TEXT_BYTES {
|
||
break;
|
||
}
|
||
let Some(bytes) = doc.get(piece.start..piece.end) else {
|
||
// A piece pointing outside the stream is corruption, but the
|
||
// pieces before it were real: keep them rather than discarding
|
||
// a recoverable document.
|
||
break;
|
||
};
|
||
let text = if piece.compressed {
|
||
cp1252(bytes)
|
||
} else {
|
||
utf16le(bytes)
|
||
};
|
||
clean(&text, &mut out);
|
||
}
|
||
if out.trim().is_empty() {
|
||
return Err("no text found in the piece table".into());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Walk the FIB's variable-length sections to find `fcClx`/`lcbClx`.
|
||
///
|
||
/// The sections are self-describing — each is preceded by its own count —
|
||
/// so this works across the FIB versions without a version table.
|
||
fn clx_location(doc: &[u8]) -> Result<(usize, usize), Box<dyn Error>> {
|
||
// csw: count of 16-bit values in rgW97.
|
||
let csw = u16_at(doc, FIB_BASE_LEN).ok_or("truncated FIB (csw)")? as usize;
|
||
let after_rgw = FIB_BASE_LEN + 2 + csw * 2;
|
||
// cslw: count of 32-bit values in rgLw97.
|
||
let cslw = u16_at(doc, after_rgw).ok_or("truncated FIB (cslw)")? as usize;
|
||
let after_rglw = after_rgw + 2 + cslw * 4;
|
||
// cbRgFcLcb: count of (fc, lcb) *pairs*, not bytes.
|
||
let pairs = u16_at(doc, after_rglw).ok_or("truncated FIB (cbRgFcLcb)")? as usize;
|
||
if pairs <= CLX_PAIR_INDEX {
|
||
return Err("FIB has no fcClx entry (pre-Word 97 file?)".into());
|
||
}
|
||
let blob = after_rglw + 2;
|
||
let entry = blob + CLX_PAIR_INDEX * 8;
|
||
let fc = u32_at(doc, entry).ok_or("truncated FIB (fcClx)")? as usize;
|
||
let lcb = u32_at(doc, entry + 4).ok_or("truncated FIB (lcbClx)")? as usize;
|
||
if lcb == 0 {
|
||
return Err("document has an empty piece table".into());
|
||
}
|
||
Ok((fc, lcb))
|
||
}
|
||
|
||
/// One run of characters in the `WordDocument` stream.
|
||
struct Piece {
|
||
start: usize,
|
||
end: usize,
|
||
compressed: bool,
|
||
}
|
||
|
||
/// Locate the `Pcdt` inside the CLX and decode its `PlcPcd`.
|
||
fn piece_table(clx: &[u8]) -> Result<Vec<Piece>, Box<dyn Error>> {
|
||
let mut i = 0usize;
|
||
// The CLX is zero or more Prc elements followed by exactly one Pcdt.
|
||
loop {
|
||
match u8_at(clx, i).ok_or("CLX ends before its piece table")? {
|
||
CLXT_PRC => {
|
||
// Prc: type byte, i16 length, then that many bytes.
|
||
let len = u16_at(clx, i + 1).ok_or("truncated Prc")? as usize;
|
||
i = i
|
||
.checked_add(3)
|
||
.and_then(|i| i.checked_add(len))
|
||
.ok_or("Prc length overflows")?;
|
||
}
|
||
CLXT_PCDT => {
|
||
let len = u32_at(clx, i + 1).ok_or("truncated Pcdt")? as usize;
|
||
let start = i + 5;
|
||
let plc = clx
|
||
.get(start..start.checked_add(len).ok_or("Pcdt overflows")?)
|
||
.ok_or("Pcdt runs past the end of the CLX")?;
|
||
return decode_plc_pcd(plc);
|
||
}
|
||
other => return Err(format!("unknown CLX element 0x{:02x}", other).into()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A `PlcPcd` is `n+1` character positions followed by `n` piece
|
||
/// descriptors, so its length determines `n`.
|
||
fn decode_plc_pcd(plc: &[u8]) -> Result<Vec<Piece>, Box<dyn Error>> {
|
||
if plc.len() < CP_LEN + PCD_LEN {
|
||
return Err("piece table holds no pieces".into());
|
||
}
|
||
let n = (plc.len() - CP_LEN) / (CP_LEN + PCD_LEN);
|
||
let pcd_base = (n + 1) * CP_LEN;
|
||
|
||
let mut pieces = Vec::new();
|
||
for k in 0..n {
|
||
let cp = u32_at(plc, k * CP_LEN).ok_or("truncated CP array")? as usize;
|
||
let cp_next = u32_at(plc, (k + 1) * CP_LEN).ok_or("truncated CP array")? as usize;
|
||
// CPs must advance; a table that goes backwards is corrupt and
|
||
// would otherwise underflow the character count.
|
||
let chars = cp_next.saturating_sub(cp);
|
||
if chars == 0 {
|
||
continue;
|
||
}
|
||
let fc_raw = u32_at(plc, pcd_base + k * PCD_LEN + 2).ok_or("truncated PCD")?;
|
||
let compressed = fc_raw & FC_COMPRESSED != 0;
|
||
let address = (fc_raw & FC_ADDRESS_MASK) as usize;
|
||
// A compressed piece stores one byte per character at fc/2; a wide
|
||
// one stores two bytes per character at fc.
|
||
let (start, width) = if compressed {
|
||
(address / 2, 1)
|
||
} else {
|
||
(address, 2)
|
||
};
|
||
let end = start
|
||
.checked_add(chars.checked_mul(width).ok_or("piece length overflows")?)
|
||
.ok_or("piece end overflows")?;
|
||
pieces.push(Piece {
|
||
start,
|
||
end,
|
||
compressed,
|
||
});
|
||
}
|
||
if pieces.is_empty() {
|
||
return Err("piece table holds no non-empty pieces".into());
|
||
}
|
||
Ok(pieces)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// .xls — Excel 97-2003 (BIFF8)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// The workbook is a flat sequence of records. Cell text is not stored in the
|
||
// cells: repeated strings are pooled in a shared-string table (`SST`) and the
|
||
// cells hold indices into it. The SST is also the record most likely to
|
||
// overflow BIFF's 8224-byte record ceiling, in which case it continues into
|
||
// `CONTINUE` records — and a string may be cut mid-way, resuming with a fresh
|
||
// width flag. Getting that boundary wrong is the classic way to read an
|
||
// Excel file as mojibake.
|
||
mod xls {
|
||
use super::*;
|
||
|
||
pub(super) const REC_SST: u16 = 0x00FC;
|
||
pub(super) const REC_CONTINUE: u16 = 0x003C;
|
||
pub(super) const REC_LABELSST: u16 = 0x00FD;
|
||
pub(super) const REC_LABEL: u16 = 0x0204;
|
||
pub(super) const REC_RSTRING: u16 = 0x00D6;
|
||
pub(super) const REC_NUMBER: u16 = 0x0203;
|
||
pub(super) const REC_RK: u16 = 0x027E;
|
||
pub(super) const REC_EOF: u16 = 0x000A;
|
||
pub(super) const REC_BOF: u16 = 0x0809;
|
||
|
||
/// A record header is a 2-byte id and a 2-byte length.
|
||
pub(super) const REC_HEADER_LEN: usize = 4;
|
||
|
||
pub fn extract<F: Read + std::io::Seek>(
|
||
cfb: &mut cfb::CompoundFile<F>,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
// BIFF8 names the stream "Workbook"; BIFF5 and earlier used "Book".
|
||
let book = stream(cfb, "Workbook")
|
||
.or_else(|| stream(cfb, "Book"))
|
||
.ok_or("no Workbook stream")?;
|
||
|
||
let records = split_records(&book);
|
||
let strings = shared_strings(&records);
|
||
|
||
let mut out = String::new();
|
||
let mut row_open = false;
|
||
for rec in &records {
|
||
if out.len() >= MAX_TEXT_BYTES {
|
||
break;
|
||
}
|
||
let cell = match rec.id {
|
||
REC_LABELSST => u32_at(rec.body, 6)
|
||
.and_then(|i| strings.get(i as usize))
|
||
.cloned(),
|
||
// An inline string: cell coordinates, then the string itself.
|
||
REC_LABEL | REC_RSTRING => read_string(&[Segment(rec.body)], &mut 6).ok(),
|
||
REC_NUMBER => number_at(rec.body, 6).map(fmt_number),
|
||
REC_RK => rk_at(rec.body, 6).map(fmt_number),
|
||
// Sheet boundaries: end the line so cells from different
|
||
// sheets do not run together.
|
||
REC_EOF | REC_BOF => {
|
||
if row_open {
|
||
out.push('\n');
|
||
row_open = false;
|
||
}
|
||
None
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(text) = cell {
|
||
clean(&text, &mut out);
|
||
out.push(' ');
|
||
row_open = true;
|
||
}
|
||
}
|
||
if row_open {
|
||
out.push('\n');
|
||
}
|
||
if out.trim().is_empty() {
|
||
return Err("workbook holds no readable cell text".into());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
struct Record<'a> {
|
||
id: u16,
|
||
body: &'a [u8],
|
||
}
|
||
|
||
/// Split the stream into records, stopping at the first header that does
|
||
/// not fit — a truncated file keeps whatever records were whole.
|
||
fn split_records(book: &[u8]) -> Vec<Record<'_>> {
|
||
let mut records = Vec::new();
|
||
let mut i = 0usize;
|
||
while let (Some(id), Some(len)) = (u16_at(book, i), u16_at(book, i + 2)) {
|
||
let start = i + REC_HEADER_LEN;
|
||
let Some(body) = book.get(start..start + len as usize) else {
|
||
break;
|
||
};
|
||
records.push(Record { id, body });
|
||
i = start + len as usize;
|
||
}
|
||
records
|
||
}
|
||
|
||
/// One contiguous run of SST bytes. A string may straddle two of these,
|
||
/// and the width flag is re-read at every crossing.
|
||
struct Segment<'a>(&'a [u8]);
|
||
|
||
/// The shared-string table, in index order.
|
||
///
|
||
/// Missing or malformed is not fatal: a workbook of nothing but numbers
|
||
/// has no SST at all, and a damaged one still has readable inline strings.
|
||
fn shared_strings(records: &[Record<'_>]) -> Vec<String> {
|
||
let Some(sst_pos) = records.iter().position(|r| r.id == REC_SST) else {
|
||
return Vec::new();
|
||
};
|
||
// The SST and every CONTINUE immediately following it are one logical
|
||
// buffer, but the segment boundaries stay significant.
|
||
let mut segments = vec![Segment(records[sst_pos].body)];
|
||
for rec in &records[sst_pos + 1..] {
|
||
if rec.id != REC_CONTINUE {
|
||
break;
|
||
}
|
||
segments.push(Segment(rec.body));
|
||
}
|
||
|
||
// SST header: total string count, then unique string count.
|
||
let Some(unique) = u32_at(segments[0].0, 4) else {
|
||
return Vec::new();
|
||
};
|
||
let mut cursor = 8usize;
|
||
let mut strings = Vec::new();
|
||
// Bounded by the bytes that exist, not by the declared count: `unique`
|
||
// is attacker-controlled and would otherwise size the loop.
|
||
for _ in 0..unique {
|
||
match read_string(&segments, &mut cursor) {
|
||
Ok(s) => strings.push(s),
|
||
// A malformed entry ends the table; the ones before it are
|
||
// still correct, and cells indexing past the end are dropped.
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
strings
|
||
}
|
||
|
||
/// Read an `XLUnicodeRichExtendedString` starting at `*cursor`, a byte
|
||
/// offset into the concatenation of `segments`.
|
||
///
|
||
/// The width flag is per-segment, not per-string: when the character data
|
||
/// crosses into a `CONTINUE`, the continuation begins with a fresh flag
|
||
/// byte and the remaining characters use that width.
|
||
fn read_string(segments: &[Segment<'_>], cursor: &mut usize) -> Result<String, String> {
|
||
let mut at = Cursor {
|
||
segments,
|
||
pos: *cursor,
|
||
};
|
||
let cch = at.u16()? as usize;
|
||
let grbit = at.u8()?;
|
||
let mut wide = grbit & 0x01 != 0;
|
||
let rich = grbit & 0x08 != 0;
|
||
let ext = grbit & 0x04 != 0;
|
||
let runs = if rich { at.u16()? as usize } else { 0 };
|
||
let ext_len = if ext { at.u32()? as usize } else { 0 };
|
||
|
||
let mut text = String::new();
|
||
let mut remaining = cch;
|
||
while remaining > 0 {
|
||
// How many characters are left in the segment the cursor is in.
|
||
let in_segment = at.remaining_in_segment()? / if wide { 2 } else { 1 };
|
||
let take = remaining.min(in_segment.max(1));
|
||
let bytes = at.take(take * if wide { 2 } else { 1 })?;
|
||
text.push_str(&if wide { utf16le(bytes) } else { cp1252(bytes) });
|
||
remaining -= take;
|
||
if remaining > 0 {
|
||
// Crossed a CONTINUE boundary: the next byte is a new flag.
|
||
wide = at.u8()? & 0x01 != 0;
|
||
}
|
||
}
|
||
// Formatting runs and the extended (phonetic) block are not text.
|
||
at.skip(runs * 4)?;
|
||
at.skip(ext_len)?;
|
||
*cursor = at.pos;
|
||
Ok(text)
|
||
}
|
||
|
||
/// A byte cursor over the SST's segments, aware of where they join.
|
||
struct Cursor<'a, 'b> {
|
||
segments: &'b [Segment<'a>],
|
||
pos: usize,
|
||
}
|
||
|
||
impl<'a> Cursor<'a, '_> {
|
||
/// The segment containing `pos`, and the offset within it.
|
||
fn locate(&self) -> Result<(usize, usize), String> {
|
||
let mut left = self.pos;
|
||
for (i, seg) in self.segments.iter().enumerate() {
|
||
if left < seg.0.len() {
|
||
return Ok((i, left));
|
||
}
|
||
left -= seg.0.len();
|
||
}
|
||
Err("SST cursor past the end".to_string())
|
||
}
|
||
|
||
fn remaining_in_segment(&self) -> Result<usize, String> {
|
||
let (i, off) = self.locate()?;
|
||
Ok(self.segments[i].0.len() - off)
|
||
}
|
||
|
||
/// `n` bytes, which must not straddle a segment boundary. Callers size
|
||
/// their reads with [`Cursor::remaining_in_segment`] first.
|
||
fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
|
||
let (i, off) = self.locate()?;
|
||
let seg = self.segments[i].0;
|
||
let end = off.checked_add(n).ok_or("SST read overflows")?;
|
||
let slice = seg.get(off..end).ok_or("SST read crosses a segment")?;
|
||
self.pos += n;
|
||
Ok(slice)
|
||
}
|
||
|
||
fn u8(&mut self) -> Result<u8, String> {
|
||
Ok(self.take(1)?[0])
|
||
}
|
||
|
||
fn u16(&mut self) -> Result<u16, String> {
|
||
let b = self.take(2)?;
|
||
Ok(u16::from_le_bytes([b[0], b[1]]))
|
||
}
|
||
|
||
fn u32(&mut self) -> Result<u32, String> {
|
||
let b = self.take(4)?;
|
||
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||
}
|
||
|
||
fn skip(&mut self, n: usize) -> Result<(), String> {
|
||
self.pos = self.pos.checked_add(n).ok_or("SST skip overflows")?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
fn number_at(body: &[u8], off: usize) -> Option<f64> {
|
||
let b = body.get(off..off + 8)?;
|
||
Some(f64::from_le_bytes([
|
||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||
]))
|
||
}
|
||
|
||
/// An `RK` value packs a number into 32 bits: bit 0 says it was scaled by
|
||
/// 100, bit 1 says it is an integer rather than the top 30 bits of a
|
||
/// double's mantissa.
|
||
fn rk_at(body: &[u8], off: usize) -> Option<f64> {
|
||
let raw = u32_at(body, off)?;
|
||
let mut value = if raw & 0x02 != 0 {
|
||
((raw as i32) >> 2) as f64
|
||
} else {
|
||
f64::from_bits(((raw & 0xFFFF_FFFC) as u64) << 32)
|
||
};
|
||
if raw & 0x01 != 0 {
|
||
value /= 100.0;
|
||
}
|
||
Some(value)
|
||
}
|
||
|
||
/// Numbers are indexed as the user would type them: whole values without a
|
||
/// trailing `.0`, so a search for "2024" finds the cell holding 2024.
|
||
fn fmt_number(n: f64) -> String {
|
||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||
format!("{}", n as i64)
|
||
} else {
|
||
format!("{}", n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// .ppt — PowerPoint 97-2003
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// A tree of records, where containers nest and atoms hold data. Slide text
|
||
// sits in two atom types that differ only in width. Rather than follow the
|
||
// slide-persistence directory to visit slides in order, this walks the tree
|
||
// and takes every text atom it finds: order within the file is close enough
|
||
// for an index, and the simpler traversal has far less to get wrong.
|
||
mod ppt {
|
||
use super::*;
|
||
|
||
pub(super) const TEXT_CHARS_ATOM: u16 = 0x0FA0;
|
||
pub(super) const TEXT_BYTES_ATOM: u16 = 0x0FA8;
|
||
/// `CString`, used for titles and notes in some producers.
|
||
pub(super) const CSTRING_ATOM: u16 = 0x0FBA;
|
||
|
||
/// A record header is: version/instance u16, type u16, length u32.
|
||
pub(super) const REC_HEADER_LEN: usize = 8;
|
||
/// A record whose low nibble of the first word is 0xF holds child records
|
||
/// rather than data.
|
||
pub(super) const VERSION_CONTAINER: u16 = 0x000F;
|
||
|
||
/// Deepest container nesting followed. Real decks are a handful deep; the
|
||
/// bound exists so a file that claims to contain itself cannot recurse
|
||
/// until the stack runs out.
|
||
pub(super) const MAX_DEPTH: u32 = 32;
|
||
|
||
pub fn extract<F: Read + std::io::Seek>(
|
||
cfb: &mut cfb::CompoundFile<F>,
|
||
) -> Result<String, Box<dyn Error>> {
|
||
let doc = stream(cfb, "PowerPoint Document").ok_or("no PowerPoint Document stream")?;
|
||
let mut out = String::new();
|
||
walk(&doc, 0, &mut out);
|
||
if out.trim().is_empty() {
|
||
return Err("no text atoms found in the presentation".into());
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn walk(body: &[u8], depth: u32, out: &mut String) {
|
||
if depth > MAX_DEPTH || out.len() >= MAX_TEXT_BYTES {
|
||
return;
|
||
}
|
||
let mut i = 0usize;
|
||
while let (Some(version), Some(rec_type), Some(len)) =
|
||
(u16_at(body, i), u16_at(body, i + 2), u32_at(body, i + 4))
|
||
{
|
||
let start = i + REC_HEADER_LEN;
|
||
// A length that runs past the end is corruption; the records
|
||
// already read are still good.
|
||
let Some(payload) = body.get(start..start.saturating_add(len as usize)) else {
|
||
return;
|
||
};
|
||
if version & 0x000F == VERSION_CONTAINER {
|
||
walk(payload, depth + 1, out);
|
||
} else {
|
||
match rec_type {
|
||
TEXT_BYTES_ATOM | CSTRING_ATOM if rec_type == CSTRING_ATOM => {
|
||
clean(&utf16le(payload), out);
|
||
out.push('\n');
|
||
}
|
||
TEXT_BYTES_ATOM => {
|
||
clean(&cp1252(payload), out);
|
||
out.push('\n');
|
||
}
|
||
TEXT_CHARS_ATOM => {
|
||
clean(&utf16le(payload), out);
|
||
out.push('\n');
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// A zero-length record at depth would spin forever without this.
|
||
let next = start.saturating_add(len as usize);
|
||
if next <= i {
|
||
return;
|
||
}
|
||
i = next;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::io::{Cursor, Write};
|
||
|
||
/// An OLE2 container holding `streams`, written to a scratch file.
|
||
///
|
||
/// Built in-process rather than checked in as fixture blobs: the point of
|
||
/// most of these tests is a *malformed* file, and hand-editing binary
|
||
/// fixtures to be malformed in a specific way is unreviewable.
|
||
fn container(tag: &str, ext: &str, streams: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
|
||
let path = crate::testutil::scratch_dir(tag).join(format!("doc.{ext}"));
|
||
let mut cfb = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
|
||
for (name, body) in streams {
|
||
let mut s = cfb.create_stream(name).unwrap();
|
||
s.write_all(body).unwrap();
|
||
s.flush().unwrap();
|
||
}
|
||
std::fs::write(&path, cfb.into_inner().into_inner()).unwrap();
|
||
path
|
||
}
|
||
|
||
fn le16(v: u16) -> [u8; 2] {
|
||
v.to_le_bytes()
|
||
}
|
||
fn le32(v: u32) -> [u8; 4] {
|
||
v.to_le_bytes()
|
||
}
|
||
|
||
// -- .doc ------------------------------------------------------------
|
||
|
||
/// A minimal but structurally real Word 97 file: a FIB whose variable
|
||
/// sections lead to a CLX, a CLX holding one `Pcdt`, and a piece table
|
||
/// with `pieces` entries pointing into the character data.
|
||
///
|
||
/// `pieces` are `(text, compressed)`.
|
||
fn word_doc(pieces: &[(&str, bool)]) -> Vec<(&'static str, Vec<u8>)> {
|
||
// Character data starts after the FIB; 2048 is comfortably past it.
|
||
const TEXT_BASE: usize = 2048;
|
||
let mut doc = vec![0u8; TEXT_BASE];
|
||
doc[0..2].copy_from_slice(&le16(0xA5EC)); // wIdent
|
||
doc[doc::FIB_FLAGS..doc::FIB_FLAGS + 2].copy_from_slice(&le16(doc::FLAG_WHICH_TBL_STM));
|
||
|
||
// Variable sections: csw, rgW97, cslw, rgLw97, cbRgFcLcb, blob.
|
||
let csw = 14u16;
|
||
let cslw = 22u16;
|
||
let pairs = 93u16;
|
||
let mut off = doc::FIB_BASE_LEN;
|
||
doc[off..off + 2].copy_from_slice(&le16(csw));
|
||
off += 2 + csw as usize * 2;
|
||
doc[off..off + 2].copy_from_slice(&le16(cslw));
|
||
off += 2 + cslw as usize * 4;
|
||
doc[off..off + 2].copy_from_slice(&le16(pairs));
|
||
let blob = off + 2;
|
||
|
||
// Lay the pieces' character data into the document stream.
|
||
let mut cps = vec![0u32];
|
||
let mut pcds = Vec::new();
|
||
let mut cp = 0u32;
|
||
for (text, compressed) in pieces {
|
||
let start = doc.len();
|
||
let chars = if *compressed {
|
||
let bytes = encoding_rs::WINDOWS_1252.encode(text).0.into_owned();
|
||
doc.extend_from_slice(&bytes);
|
||
bytes.len()
|
||
} else {
|
||
let units: Vec<u16> = text.encode_utf16().collect();
|
||
for u in &units {
|
||
doc.extend_from_slice(&le16(*u));
|
||
}
|
||
units.len()
|
||
};
|
||
cp += chars as u32;
|
||
cps.push(cp);
|
||
// A compressed piece's fc is the byte offset doubled, with the
|
||
// compression bit set.
|
||
let fc = if *compressed {
|
||
((start as u32) * 2) | doc::FC_COMPRESSED
|
||
} else {
|
||
start as u32
|
||
};
|
||
pcds.extend_from_slice(&le16(0)); // flags
|
||
pcds.extend_from_slice(&le32(fc));
|
||
pcds.extend_from_slice(&le16(0)); // prm
|
||
}
|
||
|
||
let mut plc = Vec::new();
|
||
for c in &cps {
|
||
plc.extend_from_slice(&le32(*c));
|
||
}
|
||
plc.extend_from_slice(&pcds);
|
||
|
||
let mut clx = vec![doc::CLXT_PCDT];
|
||
clx.extend_from_slice(&le32(plc.len() as u32));
|
||
clx.extend_from_slice(&plc);
|
||
|
||
// The table stream: the CLX at a known offset.
|
||
let clx_at = 16usize;
|
||
let mut table = vec![0u8; clx_at];
|
||
table.extend_from_slice(&clx);
|
||
doc[blob + doc::CLX_PAIR_INDEX * 8..blob + doc::CLX_PAIR_INDEX * 8 + 4]
|
||
.copy_from_slice(&le32(clx_at as u32));
|
||
doc[blob + doc::CLX_PAIR_INDEX * 8 + 4..blob + doc::CLX_PAIR_INDEX * 8 + 8]
|
||
.copy_from_slice(&le32(clx.len() as u32));
|
||
|
||
vec![("WordDocument", doc), ("1Table", table)]
|
||
}
|
||
|
||
#[test]
|
||
fn doc_reads_a_compressed_piece() {
|
||
let p = container("doc-cp", "doc", &word_doc(&[("Hello from Word\r", true)]));
|
||
assert_eq!(extract_ole_text(&p, "doc").unwrap(), "Hello from Word\n");
|
||
}
|
||
|
||
#[test]
|
||
fn doc_reads_a_wide_piece() {
|
||
let p = container("doc-wide", "doc", &word_doc(&[("Καλημέρα\r", false)]));
|
||
assert_eq!(extract_ole_text(&p, "doc").unwrap(), "Καλημέρα\n");
|
||
}
|
||
|
||
/// The whole reason the piece table exists: text is assembled in CP order,
|
||
/// not in the order it happens to sit in the stream.
|
||
#[test]
|
||
fn doc_concatenates_pieces_in_document_order() {
|
||
let p = container(
|
||
"doc-mixed",
|
||
"doc",
|
||
&word_doc(&[("First ", true), ("δεύτερο ", false), ("third\r", true)]),
|
||
);
|
||
assert_eq!(
|
||
extract_ole_text(&p, "doc").unwrap(),
|
||
"First δεύτερο third\n"
|
||
);
|
||
}
|
||
|
||
/// Field instructions are markup. `HYPERLINK "http://…"` between 0x13 and
|
||
/// 0x14 must not reach the index, while the field's visible result must.
|
||
#[test]
|
||
fn doc_drops_field_instructions_but_keeps_results() {
|
||
let body = "See \u{13}HYPERLINK \"http://example.com\"\u{14}the site\u{15} now\r";
|
||
let p = container("doc-field", "doc", &word_doc(&[(body, true)]));
|
||
let text = extract_ole_text(&p, "doc").unwrap();
|
||
assert_eq!(text, "See the site now\n");
|
||
assert!(!text.contains("HYPERLINK"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn doc_without_a_table_stream_is_an_error() {
|
||
let streams = word_doc(&[("x\r", true)]);
|
||
let doc_only = vec![streams[0].clone()];
|
||
let p = container("doc-notable", "doc", &doc_only);
|
||
let err = extract_ole_text(&p, "doc").unwrap_err().to_string();
|
||
assert!(err.contains("1Table"), "{err}");
|
||
}
|
||
|
||
/// A piece whose byte range lies outside the stream. The pieces before it
|
||
/// are real text and are kept; nothing panics on the slice.
|
||
#[test]
|
||
fn doc_survives_a_piece_pointing_past_the_stream() {
|
||
let mut streams = word_doc(&[("Good text\r", true), ("later", true)]);
|
||
// Rewrite the second piece's fc to a wild offset.
|
||
let table = &mut streams[1].1;
|
||
let pcd_two = table.len() - 8;
|
||
table[pcd_two + 2..pcd_two + 6].copy_from_slice(&le32(0x3FFF_0000 | doc::FC_COMPRESSED));
|
||
let p = container("doc-oob", "doc", &streams);
|
||
assert_eq!(extract_ole_text(&p, "doc").unwrap(), "Good text\n");
|
||
}
|
||
|
||
#[test]
|
||
fn doc_with_a_truncated_fib_is_an_error() {
|
||
let p = container("doc-trunc", "doc", &[("WordDocument", vec![0u8; 4])]);
|
||
assert!(extract_ole_text(&p, "doc").is_err());
|
||
}
|
||
|
||
// -- .xls ------------------------------------------------------------
|
||
|
||
fn biff(id: u16, body: &[u8]) -> Vec<u8> {
|
||
let mut r = Vec::new();
|
||
r.extend_from_slice(&le16(id));
|
||
r.extend_from_slice(&le16(body.len() as u16));
|
||
r.extend_from_slice(body);
|
||
r
|
||
}
|
||
|
||
/// An `XLUnicodeRichExtendedString` with no rich or extended parts.
|
||
fn sst_string(s: &str, wide: bool) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
if wide {
|
||
let units: Vec<u16> = s.encode_utf16().collect();
|
||
out.extend_from_slice(&le16(units.len() as u16));
|
||
out.push(0x01);
|
||
for u in units {
|
||
out.extend_from_slice(&le16(u));
|
||
}
|
||
} else {
|
||
let bytes = encoding_rs::WINDOWS_1252.encode(s).0.into_owned();
|
||
out.extend_from_slice(&le16(bytes.len() as u16));
|
||
out.push(0x00);
|
||
out.extend_from_slice(&bytes);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn labelsst(index: u32) -> Vec<u8> {
|
||
let mut b = Vec::new();
|
||
b.extend_from_slice(&le16(0)); // row
|
||
b.extend_from_slice(&le16(0)); // col
|
||
b.extend_from_slice(&le16(0)); // ixfe
|
||
b.extend_from_slice(&le32(index));
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn xls_resolves_shared_strings() {
|
||
let mut sst = Vec::new();
|
||
sst.extend_from_slice(&le32(2)); // total
|
||
sst.extend_from_slice(&le32(2)); // unique
|
||
sst.extend_from_slice(&sst_string("Revenue", false));
|
||
sst.extend_from_slice(&sst_string("Ω omega", true));
|
||
|
||
let mut book = biff(xls::REC_SST, &sst);
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(1)));
|
||
|
||
let p = container("xls-sst", "xls", &[("Workbook", book)]);
|
||
let text = extract_ole_text(&p, "xls").unwrap();
|
||
assert!(text.contains("Revenue"), "{text}");
|
||
assert!(text.contains("Ω omega"), "{text}");
|
||
}
|
||
|
||
/// The classic correctness trap: a shared string cut across a `CONTINUE`
|
||
/// boundary, where the continuation carries its own width flag. Getting
|
||
/// this wrong reads the second half as the wrong encoding.
|
||
#[test]
|
||
fn xls_reads_a_string_split_across_a_continue_record() {
|
||
let mut sst = Vec::new();
|
||
sst.extend_from_slice(&le32(1));
|
||
sst.extend_from_slice(&le32(1));
|
||
// A 10-character compressed string, but only the first 4 characters
|
||
// fit in the SST record; the rest continue.
|
||
sst.extend_from_slice(&le16(10));
|
||
sst.push(0x00); // compressed
|
||
sst.extend_from_slice(b"ABCD");
|
||
|
||
let mut cont = vec![0x00u8]; // still compressed after the boundary
|
||
cont.extend_from_slice(b"EFGHIJ");
|
||
|
||
let mut book = biff(xls::REC_SST, &sst);
|
||
book.extend_from_slice(&biff(xls::REC_CONTINUE, &cont));
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
||
|
||
let p = container("xls-cont", "xls", &[("Workbook", book)]);
|
||
let text = extract_ole_text(&p, "xls").unwrap();
|
||
assert!(text.contains("ABCDEFGHIJ"), "{text}");
|
||
}
|
||
|
||
/// A continuation may also switch width mid-string.
|
||
#[test]
|
||
fn xls_honours_a_width_change_at_a_continue_boundary() {
|
||
let mut sst = Vec::new();
|
||
sst.extend_from_slice(&le32(1));
|
||
sst.extend_from_slice(&le32(1));
|
||
sst.extend_from_slice(&le16(6));
|
||
sst.push(0x00);
|
||
sst.extend_from_slice(b"abc");
|
||
|
||
let mut cont = vec![0x01u8]; // wide from here on
|
||
for u in "ΔΕΖ".encode_utf16() {
|
||
cont.extend_from_slice(&le16(u));
|
||
}
|
||
|
||
let mut book = biff(xls::REC_SST, &sst);
|
||
book.extend_from_slice(&biff(xls::REC_CONTINUE, &cont));
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
||
|
||
let p = container("xls-width", "xls", &[("Workbook", book)]);
|
||
let text = extract_ole_text(&p, "xls").unwrap();
|
||
assert!(text.contains("abcΔΕΖ"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn xls_indexes_numbers_as_typed() {
|
||
let mut number = Vec::new();
|
||
number.extend_from_slice(&le16(0));
|
||
number.extend_from_slice(&le16(0));
|
||
number.extend_from_slice(&le16(0));
|
||
number.extend_from_slice(&2024f64.to_le_bytes());
|
||
|
||
let book = biff(xls::REC_NUMBER, &number);
|
||
let p = container("xls-num", "xls", &[("Workbook", book)]);
|
||
let text = extract_ole_text(&p, "xls").unwrap();
|
||
assert!(text.contains("2024"), "{text}");
|
||
assert!(
|
||
!text.contains("2024.0"),
|
||
"whole numbers read as typed: {text}"
|
||
);
|
||
}
|
||
|
||
/// A cell indexing past the end of the shared-string table. Dropped, not
|
||
/// panicked on, and the valid cells around it survive.
|
||
#[test]
|
||
fn xls_drops_an_out_of_range_shared_string_index() {
|
||
let mut sst = Vec::new();
|
||
sst.extend_from_slice(&le32(1));
|
||
sst.extend_from_slice(&le32(1));
|
||
sst.extend_from_slice(&sst_string("only", false));
|
||
|
||
let mut book = biff(xls::REC_SST, &sst);
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(9999)));
|
||
|
||
let p = container("xls-oob", "xls", &[("Workbook", book)]);
|
||
assert_eq!(extract_ole_text(&p, "xls").unwrap().trim(), "only");
|
||
}
|
||
|
||
/// An SST whose declared count far exceeds the bytes present. The loop
|
||
/// must be bounded by the data, not the header.
|
||
#[test]
|
||
fn xls_ignores_a_lying_shared_string_count() {
|
||
let mut sst = Vec::new();
|
||
sst.extend_from_slice(&le32(u32::MAX));
|
||
sst.extend_from_slice(&le32(u32::MAX));
|
||
sst.extend_from_slice(&sst_string("real", false));
|
||
|
||
let mut book = biff(xls::REC_SST, &sst);
|
||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
||
|
||
let p = container("xls-liar", "xls", &[("Workbook", book)]);
|
||
assert_eq!(extract_ole_text(&p, "xls").unwrap().trim(), "real");
|
||
}
|
||
|
||
#[test]
|
||
fn xls_record_running_past_the_stream_is_not_fatal() {
|
||
// A record header claiming 5000 bytes in a 10-byte stream.
|
||
let mut book = Vec::new();
|
||
book.extend_from_slice(&le16(xls::REC_LABEL));
|
||
book.extend_from_slice(&le16(5000));
|
||
book.extend_from_slice(b"short");
|
||
let p = container("xls-past", "xls", &[("Workbook", book)]);
|
||
// No readable text, reported as an error rather than a panic.
|
||
assert!(extract_ole_text(&p, "xls").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn xls_without_a_workbook_stream_is_an_error() {
|
||
let p = container("xls-none", "xls", &[("Unrelated", vec![1, 2, 3])]);
|
||
assert!(extract_ole_text(&p, "xls").is_err());
|
||
}
|
||
|
||
// -- .ppt ------------------------------------------------------------
|
||
|
||
fn ppt_record(version: u16, rec_type: u16, payload: &[u8]) -> Vec<u8> {
|
||
let mut r = Vec::new();
|
||
r.extend_from_slice(&le16(version));
|
||
r.extend_from_slice(&le16(rec_type));
|
||
r.extend_from_slice(&le32(payload.len() as u32));
|
||
r.extend_from_slice(payload);
|
||
r
|
||
}
|
||
|
||
#[test]
|
||
fn ppt_reads_both_atom_widths() {
|
||
let bytes = encoding_rs::WINDOWS_1252
|
||
.encode("Slide title")
|
||
.0
|
||
.into_owned();
|
||
let mut wide = Vec::new();
|
||
for u in "Ωmega body".encode_utf16() {
|
||
wide.extend_from_slice(&le16(u));
|
||
}
|
||
let mut doc = ppt_record(0x0000, ppt::TEXT_BYTES_ATOM, &bytes);
|
||
doc.extend_from_slice(&ppt_record(0x0000, ppt::TEXT_CHARS_ATOM, &wide));
|
||
|
||
let p = container("ppt-atoms", "ppt", &[("PowerPoint Document", doc)]);
|
||
let text = extract_ole_text(&p, "ppt").unwrap();
|
||
assert!(text.contains("Slide title"), "{text}");
|
||
assert!(text.contains("Ωmega body"), "{text}");
|
||
}
|
||
|
||
/// Atoms live inside nested containers; the walk has to descend to them.
|
||
#[test]
|
||
fn ppt_descends_into_containers() {
|
||
let bytes = encoding_rs::WINDOWS_1252
|
||
.encode("Nested deep")
|
||
.0
|
||
.into_owned();
|
||
let atom = ppt_record(0x0000, ppt::TEXT_BYTES_ATOM, &bytes);
|
||
let inner = ppt_record(0x000F, 0x0FF0, &atom);
|
||
let outer = ppt_record(0x000F, 0x03E8, &inner);
|
||
|
||
let p = container("ppt-nest", "ppt", &[("PowerPoint Document", outer)]);
|
||
assert!(extract_ole_text(&p, "ppt").unwrap().contains("Nested deep"));
|
||
}
|
||
|
||
/// A container that claims to hold itself. The depth bound is what stops
|
||
/// this from exhausting the stack.
|
||
#[test]
|
||
fn ppt_bounds_container_recursion() {
|
||
// Each level wraps the last, well past MAX_DEPTH.
|
||
let bytes = encoding_rs::WINDOWS_1252.encode("buried").0.into_owned();
|
||
let mut rec = ppt_record(0x0000, ppt::TEXT_BYTES_ATOM, &bytes);
|
||
for _ in 0..(ppt::MAX_DEPTH + 20) {
|
||
rec = ppt_record(0x000F, 0x0FF0, &rec);
|
||
}
|
||
let p = container("ppt-deep", "ppt", &[("PowerPoint Document", rec)]);
|
||
// Too deep to reach the text — an error, not a stack overflow.
|
||
assert!(extract_ole_text(&p, "ppt").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn ppt_record_running_past_the_stream_is_not_fatal() {
|
||
let mut doc = Vec::new();
|
||
doc.extend_from_slice(&le16(0x0000));
|
||
doc.extend_from_slice(&le16(ppt::TEXT_BYTES_ATOM));
|
||
doc.extend_from_slice(&le32(u32::MAX));
|
||
doc.extend_from_slice(b"short");
|
||
let p = container("ppt-past", "ppt", &[("PowerPoint Document", doc)]);
|
||
assert!(extract_ole_text(&p, "ppt").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn ppt_without_its_stream_is_an_error() {
|
||
let p = container("ppt-none", "ppt", &[("Pictures", vec![0; 4])]);
|
||
assert!(extract_ole_text(&p, "ppt").is_err());
|
||
}
|
||
|
||
// -- container level --------------------------------------------------
|
||
|
||
#[test]
|
||
fn a_non_compound_file_is_an_error() {
|
||
let dir = crate::testutil::scratch_dir("ole-notcfb");
|
||
let p = dir.join("doc.doc");
|
||
crate::testutil::touch(&p, b"this is not an OLE2 compound file at all");
|
||
let err = extract_ole_text(&p, "doc").unwrap_err().to_string();
|
||
assert!(err.contains("compound file"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_file_is_an_error() {
|
||
let dir = crate::testutil::scratch_dir("ole-empty");
|
||
let p = dir.join("doc.xls");
|
||
crate::testutil::touch(&p, b"");
|
||
assert!(extract_ole_text(&p, "xls").is_err());
|
||
}
|
||
}
|