use super::{
ArtRecord, LiveSlides, PPT_WARNING_SOURCE, collect_art_records, live_slide_offsets, read_stream, read_u32_le,
};
use crate::error::{Result, XbergError};
use crate::types::ProcessingWarning;
use std::borrow::Cow;
use std::io::{Cursor, Read};
const RT_EXTERNAL_OBJECT_LIST: u16 = 0x0409;
const RT_EXTERNAL_OLE_EMBED: u16 = 0x0FCC;
const RT_EXTERNAL_OLE_OBJECT_ATOM: u16 = 0x0FC3;
const RT_EXTERNAL_OLE_OBJECT_STG: u16 = 0x1011;
const RT_EXTERNAL_OBJECT_REF_ATOM: u16 = 0x0BC1;
const EX_OLE_OBJECT_TYPE_EMBEDDED: u32 = 0;
const EX_OLE_OBJECT_ATOM_TYPE: usize = 4;
const EX_OLE_OBJECT_ATOM_EX_OBJ_ID: usize = 8;
const EX_OLE_OBJECT_ATOM_PERSIST_ID_REF: usize = 16;
const EX_OLE_OBJECT_ATOM_LEN: usize = 24;
const EX_OLE_OBJECT_STG_COMPRESSED_INSTANCE: u16 = 1;
const EX_OLE_OBJECT_STG_SIZE_PREFIX: usize = 4;
pub(crate) struct PptEmbeddedObject {
pub ex_obj_id: u32,
pub slide_number: Option<u32>,
pub data: Vec<u8>,
}
pub(crate) fn extract_ppt_embedded_objects(
content: &[u8],
max_object_bytes: usize,
) -> Result<(Vec<PptEmbeddedObject>, Vec<ProcessingWarning>)> {
let mut comp = cfb::CompoundFile::open(Cursor::new(content))
.map_err(|e| XbergError::parsing(format!("Failed to open PPT as OLE container: {e}")))?;
let ppt_stream = read_stream(&mut comp, "/PowerPoint Document")?;
let current_user_stream = read_stream(&mut comp, "/Current User").unwrap_or_default();
let mut warnings = Vec::new();
let mut objects = Vec::new();
let Some(live) = live_slide_offsets(&ppt_stream, ¤t_user_stream) else {
for (offset, rec_instance) in all_object_storages(&ppt_stream) {
let Some(data) = read_storage(&ppt_stream, offset, rec_instance, max_object_bytes, &mut warnings) else {
continue;
};
objects.push(PptEmbeddedObject {
ex_obj_id: objects.len() as u32 + 1,
slide_number: None,
data,
});
}
return Ok((objects, warnings));
};
let slide_by_ex_obj_id = slide_numbers_by_ex_obj_id(&ppt_stream, &live);
for (ex_obj_id, persist_id_ref) in embedded_object_refs(&ppt_stream, &live) {
let Some(&offset) = live.persist.get(&persist_id_ref) else {
warnings.push(warning(format!(
"Skipped embedded object {ex_obj_id}: persist id {persist_id_ref} is not in the persist directory"
)));
continue;
};
let Some((rec_instance, rec_type)) = record_instance_and_type(&ppt_stream, offset) else {
warnings.push(warning(format!(
"Skipped embedded object {ex_obj_id}: storage offset {offset} is out of range"
)));
continue;
};
if rec_type != RT_EXTERNAL_OLE_OBJECT_STG {
warnings.push(warning(format!(
"Skipped embedded object {ex_obj_id}: persist id {persist_id_ref} resolves to record type {rec_type:#06x}, not ExOleObjStg"
)));
continue;
}
let Some(data) = read_storage(&ppt_stream, offset, rec_instance, max_object_bytes, &mut warnings) else {
continue;
};
objects.push(PptEmbeddedObject {
ex_obj_id,
slide_number: slide_by_ex_obj_id.get(&ex_obj_id).copied(),
data,
});
}
Ok((objects, warnings))
}
fn warning(message: String) -> ProcessingWarning {
ProcessingWarning {
source: Cow::Borrowed(PPT_WARNING_SOURCE),
message: Cow::Owned(message),
}
}
fn record_instance_and_type(data: &[u8], offset: usize) -> Option<(u16, u16)> {
let header = data.get(offset..offset.checked_add(8)?)?;
let ver_instance = u16::from_le_bytes([header[0], header[1]]);
let rec_type = u16::from_le_bytes([header[2], header[3]]);
Some((ver_instance >> 4, rec_type))
}
fn embedded_object_refs(ppt_stream: &[u8], live: &LiveSlides) -> Vec<(u32, u32)> {
let (start, end) = live.document_payload;
let mut records = Vec::new();
collect_art_records(ppt_stream, start, end, 0, &mut records);
let Some(list) = records.iter().find(|r| r.rec_type == RT_EXTERNAL_OBJECT_LIST) else {
return Vec::new();
};
let (list_start, list_end) = (list.content_start, list.content_end);
let embeds: Vec<&ArtRecord> = records
.iter()
.filter(|r| r.rec_type == RT_EXTERNAL_OLE_EMBED && r.content_start >= list_start && r.content_end <= list_end)
.collect();
embeds
.iter()
.filter_map(|embed| {
let atom = records.iter().find(|r| {
r.rec_type == RT_EXTERNAL_OLE_OBJECT_ATOM
&& r.content_start >= embed.content_start
&& r.content_end <= embed.content_end
})?;
if atom.content_end - atom.content_start < EX_OLE_OBJECT_ATOM_LEN {
return None;
}
let kind = read_u32_le(ppt_stream, atom.content_start + EX_OLE_OBJECT_ATOM_TYPE)?;
if kind != EX_OLE_OBJECT_TYPE_EMBEDDED {
return None;
}
let ex_obj_id = read_u32_le(ppt_stream, atom.content_start + EX_OLE_OBJECT_ATOM_EX_OBJ_ID)?;
let persist_id_ref = read_u32_le(ppt_stream, atom.content_start + EX_OLE_OBJECT_ATOM_PERSIST_ID_REF)?;
Some((ex_obj_id, persist_id_ref))
})
.collect()
}
fn slide_numbers_by_ex_obj_id(ppt_stream: &[u8], live: &LiveSlides) -> ahash::AHashMap<u32, u32> {
let mut map = ahash::AHashMap::new();
for (index, &slide_offset) in live.offsets.iter().enumerate() {
let Some(len) = read_u32_le(ppt_stream, slide_offset + 4) else {
continue;
};
let start = slide_offset + 8;
let Some(end) = start.checked_add(len as usize).filter(|&e| e <= ppt_stream.len()) else {
continue;
};
let mut records = Vec::new();
collect_art_records(ppt_stream, start, end, 0, &mut records);
for record in records.iter().filter(|r| r.rec_type == RT_EXTERNAL_OBJECT_REF_ATOM) {
if let Some(ex_obj_id) = read_u32_le(ppt_stream, record.content_start) {
map.entry(ex_obj_id).or_insert(index as u32 + 1);
}
}
}
map
}
fn all_object_storages(ppt_stream: &[u8]) -> Vec<(usize, u16)> {
let mut records = Vec::new();
collect_art_records(ppt_stream, 0, ppt_stream.len(), 0, &mut records);
records
.iter()
.filter(|r| r.rec_type == RT_EXTERNAL_OLE_OBJECT_STG)
.map(|r| (r.content_start - 8, r.rec_instance))
.collect()
}
fn read_storage(
ppt_stream: &[u8],
offset: usize,
rec_instance: u16,
max_object_bytes: usize,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<Vec<u8>> {
let len = read_u32_le(ppt_stream, offset + 4)? as usize;
let start = offset + 8;
let Some(payload) = start.checked_add(len).and_then(|end| ppt_stream.get(start..end)) else {
warnings.push(warning(format!(
"Skipped embedded object storage at {offset}: declared length {len} runs past the stream"
)));
return None;
};
if rec_instance != EX_OLE_OBJECT_STG_COMPRESSED_INSTANCE {
if payload.len() > max_object_bytes {
warnings.push(warning(format!(
"Skipped embedded object storage at {offset}: {} bytes exceeds cap {max_object_bytes} bytes",
payload.len()
)));
return None;
}
return Some(payload.to_vec());
}
let declared = read_u32_le(payload, 0)? as usize;
if declared > max_object_bytes {
warnings.push(warning(format!(
"Skipped embedded object storage at {offset}: declared {declared} bytes exceeds cap {max_object_bytes} bytes"
)));
return None;
}
let mut data = Vec::with_capacity(declared);
let read_cap = (max_object_bytes as u64).saturating_add(1);
let mut decoder = flate2::read::ZlibDecoder::new(&payload[EX_OLE_OBJECT_STG_SIZE_PREFIX..]).take(read_cap);
if let Err(e) = decoder.read_to_end(&mut data) {
warnings.push(warning(format!(
"Skipped embedded object storage at {offset}: zlib stream is invalid: {e}"
)));
return None;
}
if data.len() > max_object_bytes {
warnings.push(warning(format!(
"Skipped embedded object storage at {offset}: decompressed size exceeds cap {max_object_bytes} bytes"
)));
return None;
}
Some(data)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fixture() -> Vec<u8> {
std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ppt/embedded_word_object.ppt"),
)
.expect("fixture")
}
#[test]
fn extracts_the_embedded_word_storage_and_attributes_it_to_slide_two() {
let (objects, warnings) = extract_ppt_embedded_objects(&fixture(), 50 * 1024 * 1024).expect("parse");
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(objects.len(), 1);
let object = &objects[0];
assert_eq!(object.ex_obj_id, 1);
assert_eq!(object.slide_number, Some(2));
assert_eq!(&object.data[..8], &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
let mut inner = cfb::CompoundFile::open(Cursor::new(object.data.as_slice())).expect("inner CFB");
assert!(inner.exists("WordDocument"));
let mut word = Vec::new();
inner
.open_stream("WordDocument")
.unwrap()
.read_to_end(&mut word)
.unwrap();
assert!(!word.is_empty());
}
#[test]
fn skips_an_object_whose_declared_size_exceeds_the_cap() {
let (objects, warnings) = extract_ppt_embedded_objects(&fixture(), 1024).expect("parse");
assert!(objects.is_empty());
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].message.contains("exceeds cap 1024 bytes"),
"{}",
warnings[0].message
);
}
#[test]
fn bounds_the_inflated_size_by_the_cap_not_the_declaration() {
let big = vec![0u8; 8192];
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&big).unwrap();
let compressed = encoder.finish().unwrap();
let mut payload = 16u32.to_le_bytes().to_vec();
payload.extend_from_slice(&compressed);
let mut stream = Vec::new();
stream.extend_from_slice(&(EX_OLE_OBJECT_STG_COMPRESSED_INSTANCE << 4).to_le_bytes());
stream.extend_from_slice(&RT_EXTERNAL_OLE_OBJECT_STG.to_le_bytes());
stream.extend_from_slice(&(payload.len() as u32).to_le_bytes());
stream.extend_from_slice(&payload);
let mut warnings = Vec::new();
let result = read_storage(&stream, 0, EX_OLE_OBJECT_STG_COMPRESSED_INSTANCE, 4096, &mut warnings);
assert!(result.is_none());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("decompressed size exceeds cap"));
}
}