Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 6 additions & 177 deletions src-tauri/src/pdf_engine/source_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
//! Research prototype only; no Tauri command or UI invokes this module. The
//! source is read once through a hard cap, then both fingerprinted and parsed
//! from that same snapshot. Object values, object streams, and content stream
//! decoding are bounded. Bounded operator parsing and font/geometry support are
//! still incomplete. Complete #33's remaining resource bounds and compatibility
//! evaluation before exposing this API to user files or enabling editing.
//! decoding and content parsing are bounded. Font/geometry support is still
//! incomplete. Complete #33's remaining compatibility evaluation before exposing
//! this API to user files or enabling editing.

use crate::error::AppError;
use crate::pdf_engine::crop;
Expand Down Expand Up @@ -308,15 +308,9 @@ impl Walker<'_> {
visiting: &mut Vec<ObjectId>,
geom_unsafe: bool,
) -> Result<(), AppError> {
let inline_total = count_inline_images(bytes)?;
// BI…EI: do not trust a prefix Content::decode Ok; strip payloads first.
let stripped = if inline_total > 0 {
Some(strip_inline_image_payloads(bytes)?)
} else {
None
};
let decode_src = stripped.as_deref().unwrap_or(bytes);
let ops = match Content::decode(decode_src) {
let prepared = source_content_preflight::prepare_content(bytes)?;
let inline_total = prepared.inline_images;
let ops = match Content::decode(prepared.bytes()) {
Ok(c) => c.operations,
Err(_) => {
return Err(malformed_content(
Expand Down Expand Up @@ -1479,171 +1473,6 @@ fn spacing_advance(pieces: &[Vec<u8>], tc: f64, tw: f64) -> f64 {
extra
}

fn strip_inline_image_payloads(data: &[u8]) -> Result<Vec<u8>, AppError> {
let mut out = Vec::with_capacity(data.len());
let mut i = 0;
// Normal → Keys (after BI) → Payload (after ID). Copy BI/keys/ID/EI;
// drop only the raw bytes between ID and EI so Content::decode still
// yields BI at the CTM in force there.
let mut after_bi = false;
let mut after_id = false;
while i < data.len() {
if after_id {
if is_op_token(data, i, b"EI") {
out.extend_from_slice(b"EI");
i += 2;
after_id = false;
after_bi = false;
} else {
i += 1;
}
continue;
}
if data[i].is_ascii_whitespace() {
out.push(data[i]);
i += 1;
continue;
}
if data[i] == b'%' {
let start = i;
while i < data.len() && data[i] != b'\n' && data[i] != b'\r' {
i += 1;
}
out.extend_from_slice(&data[start..i]);
continue;
}
if data[i] == b'(' {
let start = i;
i = skip_literal(data, i)?;
out.extend_from_slice(&data[start..i]);
continue;
}
if data[i] == b'<' && data.get(i + 1) != Some(&b'<') {
let start = i;
i = skip_hex(data, i);
out.extend_from_slice(&data[start..i]);
continue;
}
if !after_bi && is_op_token(data, i, b"BI") {
out.extend_from_slice(b"BI");
i += 2;
after_bi = true;
continue;
}
if after_bi && is_op_token(data, i, b"ID") {
out.extend_from_slice(b"ID");
i += 2;
out.push(b' ');
after_id = true;
continue;
}
if after_bi && is_op_token(data, i, b"EI") {
out.extend_from_slice(b"EI");
i += 2;
after_bi = false;
continue;
}
out.push(data[i]);
i += 1;
}
if after_bi || after_id {
return Err(malformed_content("An inline image is unterminated."));
}
Ok(out)
}

fn count_inline_images(data: &[u8]) -> Result<usize, AppError> {
let mut count = 0;
let mut i = 0;
let mut in_inline = false;
while i < data.len() {
if data[i].is_ascii_whitespace() {
i += 1;
continue;
}
if !in_inline && data[i] == b'%' {
while i < data.len() && data[i] != b'\n' && data[i] != b'\r' {
i += 1;
}
continue;
}
if !in_inline && data[i] == b'(' {
i = skip_literal(data, i)?;
continue;
}
if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') {
i = skip_hex(data, i);
continue;
}
if !in_inline && is_op_token(data, i, b"BI") {
in_inline = true;
i += 2;
continue;
}
if in_inline && is_op_token(data, i, b"EI") {
count += 1;
in_inline = false;
i += 2;
continue;
}
i += 1;
}
if in_inline {
return Err(malformed_content("An inline image is unterminated."));
}
Ok(count)
}

fn is_delim(b: u8) -> bool {
b.is_ascii_whitespace()
|| matches!(
b,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}

fn is_op_token(data: &[u8], i: usize, token: &[u8]) -> bool {
if i + token.len() > data.len() || &data[i..i + token.len()] != token {
return false;
}
let before = i == 0 || is_delim(data[i - 1]);
let after = i + token.len() == data.len() || is_delim(data[i + token.len()]);
before && after
}

fn skip_literal(data: &[u8], start: usize) -> Result<usize, AppError> {
let mut i = start + 1;
let mut depth = 1;
while i < data.len() && depth > 0 {
match data[i] {
b'\\' => {
i = i.saturating_add(2).min(data.len());
continue;
}
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
i += 1;
}
if depth != 0 {
return Err(malformed_content("A literal string is unterminated."));
}
Ok(i)
}

fn skip_hex(data: &[u8], start: usize) -> usize {
let mut i = start + 1;
while i < data.len() && data[i] != b'>' {
i += 1;
}
if i < data.len() {
i + 1
} else {
i
}
}

fn document_is_signed(doc: &Document) -> bool {
if let Ok(cat) = doc.catalog() {
if cat.get(b"Perms").is_ok() {
Expand Down
43 changes: 42 additions & 1 deletion src-tauri/src/pdf_engine/source_content_decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,38 @@ pub(super) fn inflate_capped(data: &[u8], cap: usize) -> Result<Vec<u8>, DecodeE
}
}

/// Returns the encoded byte length of one complete zlib stream while bounding
/// its decoded output. Trailing bytes are intentionally left for the caller.
pub(super) fn inflate_end(data: &[u8], cap: usize) -> Result<usize, DecodeError> {
let mut decoder = Decompress::new(true);
let mut step = vec![0u8; 64 * 1024];
loop {
let input_before =
usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?;
let output_before =
usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?;
let input = data.get(input_before..).ok_or(DecodeError::Corrupt)?;
let status = decoder
.decompress(input, &mut step, FlushDecompress::None)
.map_err(|_| DecodeError::Corrupt)?;
let input_after = usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?;
let output_after =
usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?;
if output_after > cap {
return Err(DecodeError::TooLarge);
}
match status {
Status::StreamEnd => return Ok(input_after),
Status::Ok | Status::BufError
if input_after == input_before && output_after == output_before =>
{
return Err(DecodeError::Corrupt)
}
Status::Ok | Status::BufError => {}
}
}
}

fn is_pdf_whitespace(byte: u8) -> bool {
matches!(byte, 0 | 9 | 10 | 12 | 13 | 32)
}
Expand Down Expand Up @@ -249,9 +281,18 @@ mod tests {

#[test]
fn flate_is_strict_and_capped() {
let valid = stream(Object::Name(b"FlateDecode".to_vec()), flate(b"hello"));
let encoded = flate(b"hello");
let valid = stream(Object::Name(b"FlateDecode".to_vec()), encoded.clone());
assert_eq!(decode_stream(&valid, 5).unwrap(), b"hello");
assert_eq!(decode_stream(&valid, 4), Err(DecodeError::TooLarge));
let mut with_trailing = encoded.clone();
with_trailing.extend_from_slice(b" trailing");
assert_eq!(inflate_end(&with_trailing, 5).unwrap(), encoded.len());
assert_eq!(inflate_end(&with_trailing, 4), Err(DecodeError::TooLarge));
assert_eq!(
inflate_end(&encoded[..encoded.len() - 1], 5),
Err(DecodeError::Corrupt)
);

let corrupt = stream(
Object::Name(b"FlateDecode".to_vec()),
Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/pdf_engine/source_content_preflight.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! Resource bounds applied before lopdf parses source content.
//!
//! This bounds raw cross-reference data, object values, and object streams.
//! Bounded content-operator parsing remains separate follow-up work.
//! This bounds raw cross-reference data, object values, object streams, and
//! page/Form content before lopdf parses them.

mod content;
mod headers;
mod xref;

Expand Down Expand Up @@ -65,6 +66,10 @@ pub(super) fn load_document(bytes: &[u8], path: &str) -> Result<Document, AppErr
Ok(document)
}

pub(super) fn prepare_content(bytes: &[u8]) -> Result<content::PreparedContent<'_>, AppError> {
content::prepare(bytes)
}

fn object_stream_guard(id: (u32, u16), object: &mut Object) -> Option<((u32, u16), Object)> {
let Object::Stream(stream) = object else {
// lopdf uses this value for members of an object stream. At the top
Expand Down
Loading
Loading