diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2993b4b..d8291ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ fs2 = "0.4" # Read/write PDF page boxes (used for cropping). lopdf = "0.34" +flate2 = "1" sha2 = "0.10" # Glyph metrics for the bundled editor font (Noto Sans). @@ -45,9 +46,6 @@ image = { version = "0.25", default-features = false, features = [ # app so users do not need to install a system codec or upload their photos. heif-rs = { path = "vendor/heif-rs" } -[dev-dependencies] -flate2 = "1" - [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/pdf_engine/mod.rs b/src-tauri/src/pdf_engine/mod.rs index 3fc5b4f..7436fa0 100644 --- a/src-tauri/src/pdf_engine/mod.rs +++ b/src-tauri/src/pdf_engine/mod.rs @@ -37,6 +37,7 @@ pub mod poster; pub mod qpdf; pub mod render; pub mod source_content; +mod source_content_decode; #[cfg(test)] mod source_edit_fixtures; #[cfg(test)] diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 0bf7035..579bbb2 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -5,13 +5,14 @@ //! //! 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. Decoded-size checks still run after allocation, -//! and font/geometry support is incomplete. Complete #33's remaining resource -//! bounds and compatibility evaluation before exposing this API to user files -//! or enabling editing. +//! from that same snapshot. Content streams are decoded through a hard output +//! cap. Bounded object loading/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. use crate::error::AppError; use crate::pdf_engine::crop; +use crate::pdf_engine::source_content_decode::{self, DecodeError}; use lopdf::{content::Content, Dictionary, Document, Object, ObjectId, Stream}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -334,9 +335,12 @@ impl Walker<'_> { for (op_index, op) in ops.iter().enumerate() { match op.operator.as_str() { "q" => { - if stack.len() < MAX_GSTATE_STACK { - stack.push(gs.clone()); + if stack.len() >= MAX_GSTATE_STACK { + return Err(malformed_content( + "Graphics state nesting is deeper than 64 levels.", + )); } + stack.push(gs.clone()); } "Q" => { if let Some(prev) = stack.pop() { @@ -984,16 +988,15 @@ fn matrix_from_dict(dict: &Dictionary) -> [f64; 6] { } fn decompress_stream(stream: &Stream) -> Result, AppError> { - let data = match stream.decompressed_content() { - Ok(d) => d, - Err(_) => stream.content.clone(), - }; - if data.len() > MAX_STREAM_BYTES { - return Err(malformed_content( - "A content stream is larger than 32 MB decompressed.", - )); - } - Ok(data) + source_content_decode::decode_stream(stream, MAX_STREAM_BYTES).map_err(|error| match error { + DecodeError::UnsupportedFilter(name) => unsupported_filter(&name), + DecodeError::Corrupt => { + malformed_content("A filtered content stream could not be decompressed.") + } + DecodeError::TooLarge => { + malformed_content("A content stream is larger than 32 MB decompressed.") + } + }) } fn inspect_text( @@ -1728,6 +1731,15 @@ fn malformed_content(message: impl Into) -> AppError { .with_suggestion("Open the file in a PDF editor that can repair it, or use a different PDF.") } +fn unsupported_filter(name: &str) -> AppError { + AppError::new( + "UNSUPPORTED_FILTER", + "This PDF uses an unsupported content filter", + format!("The source classifier cannot safely decode {name}."), + ) + .with_suggestion("Use a PDF editor to rewrite the file, then try again.") +} + fn encrypted() -> AppError { AppError::new( "ENCRYPTED", diff --git a/src-tauri/src/pdf_engine/source_content_decode.rs b/src-tauri/src/pdf_engine/source_content_decode.rs new file mode 100644 index 0000000..4093cd2 --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_decode.rs @@ -0,0 +1,293 @@ +//! Bounded decoding for the read-only source classifier. +//! +//! Filtered streams never fall back to their encoded bytes. Every supported +//! decoder checks its output limit while producing data. + +use flate2::{Decompress, FlushDecompress, Status}; +use lopdf::{Dictionary, Object, Stream}; +use std::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DecodeError { + UnsupportedFilter(String), + Corrupt, + TooLarge, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Filter { + Flate, + AsciiHex, + Ascii85, +} + +pub(super) fn decode_stream(stream: &Stream, cap: usize) -> Result, DecodeError> { + let filters = filters_of(&stream.dict)?; + let mut data: Cow<'_, [u8]> = Cow::Borrowed(&stream.content); + for filter in filters { + let decoded = match filter { + Filter::Flate => inflate_capped(&data, cap)?, + Filter::AsciiHex => ascii_hex_decode(&data, cap)?, + Filter::Ascii85 => ascii85_decode(&data, cap)?, + }; + data = Cow::Owned(decoded); + } + if data.len() > cap { + return Err(DecodeError::TooLarge); + } + Ok(data.into_owned()) +} + +fn filters_of(dict: &Dictionary) -> Result, DecodeError> { + for key in [&b"F"[..], b"FFilter", b"FDecodeParms"] { + if dict.has(key) { + return Err(DecodeError::UnsupportedFilter( + "external file filter".into(), + )); + } + } + let names: Vec<&[u8]> = match dict.get(b"Filter").ok() { + None | Some(Object::Null) => Vec::new(), + Some(Object::Name(name)) => vec![name], + Some(Object::Array(items)) if items.len() <= 4 => items + .iter() + .map(|item| match item { + Object::Name(name) => Ok(name.as_slice()), + _ => Err(DecodeError::UnsupportedFilter("malformed /Filter".into())), + }) + .collect::>()?, + Some(Object::Array(_)) => { + return Err(DecodeError::UnsupportedFilter( + "more than four chained filters".into(), + )) + } + Some(_) => return Err(DecodeError::UnsupportedFilter("malformed /Filter".into())), + }; + check_decode_parms(dict.get(b"DecodeParms").ok())?; + names.into_iter().map(filter_from_name).collect() +} + +fn filter_from_name(name: &[u8]) -> Result { + match name { + b"FlateDecode" | b"Fl" => Ok(Filter::Flate), + b"ASCIIHexDecode" | b"AHx" => Ok(Filter::AsciiHex), + b"ASCII85Decode" | b"A85" => Ok(Filter::Ascii85), + other => Err(DecodeError::UnsupportedFilter( + String::from_utf8_lossy(other).into_owned(), + )), + } +} + +fn check_decode_parms(parms: Option<&Object>) -> Result<(), DecodeError> { + let check = |dict: &Dictionary| match dict.get(b"Predictor").ok() { + None | Some(Object::Integer(1)) => Ok(()), + Some(_) => Err(DecodeError::UnsupportedFilter("predictor".into())), + }; + match parms { + None | Some(Object::Null) => Ok(()), + Some(Object::Dictionary(dict)) => check(dict), + Some(Object::Array(items)) => items.iter().try_for_each(|item| match item { + Object::Null => Ok(()), + Object::Dictionary(dict) => check(dict), + _ => Err(DecodeError::UnsupportedFilter( + "malformed /DecodeParms".into(), + )), + }), + Some(_) => Err(DecodeError::UnsupportedFilter( + "malformed /DecodeParms".into(), + )), + } +} + +fn inflate_capped(data: &[u8], cap: usize) -> Result, DecodeError> { + let mut decoder = Decompress::new(true); + let mut output = Vec::new(); + 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)?; + let produced = output_after + .checked_sub(output_before) + .ok_or(DecodeError::Corrupt)?; + if output.len().saturating_add(produced) > cap { + return Err(DecodeError::TooLarge); + } + output.extend_from_slice(step.get(..produced).ok_or(DecodeError::Corrupt)?); + match status { + Status::StreamEnd => return Ok(output), + Status::Ok | Status::BufError if produced == 0 && input_after == input_before => { + return Err(DecodeError::Corrupt) + } + Status::Ok | Status::BufError => {} + } + } +} + +fn is_pdf_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn ascii_hex_decode(data: &[u8], cap: usize) -> Result, DecodeError> { + let mut output = Vec::new(); + let mut high = None; + for &byte in data { + if byte == b'>' { + break; + } + if is_pdf_whitespace(byte) { + continue; + } + let value = hex_value(byte).ok_or(DecodeError::Corrupt)?; + match high.take() { + None => high = Some(value), + Some(first) => push_capped(&mut output, (first << 4) | value, cap)?, + } + } + if let Some(first) = high { + push_capped(&mut output, first << 4, cap)?; + } + Ok(output) +} + +fn ascii85_decode(data: &[u8], cap: usize) -> Result, DecodeError> { + let body = data.strip_prefix(b"<~").unwrap_or(data); + let mut output = Vec::new(); + let mut group = [0u8; 5]; + let mut count = 0usize; + let mut bytes = body.iter().copied().peekable(); + while let Some(byte) = bytes.next() { + match byte { + byte if is_pdf_whitespace(byte) => {} + b'~' if bytes.next() == Some(b'>') => break, + b'z' if count == 0 => extend_capped(&mut output, &[0, 0, 0, 0], cap)?, + b'!'..=b'u' => { + group[count] = byte - b'!'; + count += 1; + if count == 5 { + extend_capped(&mut output, &ascii85_group(&group)?, cap)?; + count = 0; + } + } + _ => return Err(DecodeError::Corrupt), + } + } + match count { + 0 => {} + 1 => return Err(DecodeError::Corrupt), + count => { + for slot in group.iter_mut().skip(count) { + *slot = 84; + } + let decoded = ascii85_group(&group)?; + extend_capped(&mut output, &decoded[..count - 1], cap)?; + } + } + Ok(output) +} + +fn ascii85_group(group: &[u8; 5]) -> Result<[u8; 4], DecodeError> { + let value = group + .iter() + .fold(0u64, |acc, digit| acc * 85 + u64::from(*digit)); + Ok(u32::try_from(value) + .map_err(|_| DecodeError::Corrupt)? + .to_be_bytes()) +} + +fn push_capped(output: &mut Vec, byte: u8, cap: usize) -> Result<(), DecodeError> { + if output.len() >= cap { + return Err(DecodeError::TooLarge); + } + output.push(byte); + Ok(()) +} + +fn extend_capped(output: &mut Vec, bytes: &[u8], cap: usize) -> Result<(), DecodeError> { + if output.len().saturating_add(bytes.len()) > cap { + return Err(DecodeError::TooLarge); + } + output.extend_from_slice(bytes); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + fn flate(data: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() + } + + fn stream(filter: Object, data: Vec) -> Stream { + let mut dict = Dictionary::new(); + dict.set("Filter", filter); + Stream::new(dict, data) + } + + #[test] + fn flate_is_strict_and_capped() { + let valid = stream(Object::Name(b"FlateDecode".to_vec()), flate(b"hello")); + assert_eq!(decode_stream(&valid, 5).unwrap(), b"hello"); + assert_eq!(decode_stream(&valid, 4), Err(DecodeError::TooLarge)); + + let corrupt = stream( + Object::Name(b"FlateDecode".to_vec()), + b"not zlib data".to_vec(), + ); + assert_eq!(decode_stream(&corrupt, 1024), Err(DecodeError::Corrupt)); + + let mut truncated_data = flate(b"hello"); + truncated_data.truncate(truncated_data.len().saturating_sub(2)); + let truncated = stream(Object::Name(b"FlateDecode".to_vec()), truncated_data); + assert_eq!(decode_stream(&truncated, 1024), Err(DecodeError::Corrupt)); + } + + #[test] + fn common_ascii_filter_chain_is_bounded() { + let chained = stream( + Object::Array(vec![ + Object::Name(b"ASCIIHexDecode".to_vec()), + Object::Name(b"FlateDecode".to_vec()), + ]), + flate(b"hello") + .into_iter() + .flat_map(|byte| format!("{byte:02x}").into_bytes()) + .chain([b'>']) + .collect(), + ); + assert_eq!(decode_stream(&chained, 1024).unwrap(), b"hello"); + assert_eq!(decode_stream(&chained, 4), Err(DecodeError::TooLarge)); + } + + #[test] + fn unsupported_filters_fail_closed() { + let lzw = stream(Object::Name(b"LZWDecode".to_vec()), vec![0; 8]); + assert!(matches!( + decode_stream(&lzw, 1024), + Err(DecodeError::UnsupportedFilter(name)) if name == "LZWDecode" + )); + } +} diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index aff8f4b..0d85005 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -723,6 +723,38 @@ fn classify_oversize_sparse_file_is_file_too_large() { ); } +#[test] +fn classify_rejects_graphics_state_stack_overflow() { + let scratch = Scratch::new("q-overflow"); + let path = scratch.file("q-overflow.pdf"); + let mut content = b"q\n".repeat(65); + content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hidden state) Tj ET\n"); + write_helvetica_page(&path, &content); + expect_err_code( + classify_source_content(&path), + "MALFORMED_CONTENT", + "CLASSIFY-BOUNDS: graphics state stack overflow", + ); +} + +#[test] +fn classify_rejects_filtered_stream_when_decompression_fails() { + let scratch = Scratch::new("bad-filter"); + let path = scratch.file("bad-filter.pdf"); + let mut dict = Dictionary::new(); + dict.set("Filter", "FlateDecode"); + write_helvetica_page_with_stream_dict( + &path, + b"BT /F1 12 Tf 72 720 Td (Raw fallback must not run) Tj ET\n", + dict, + ); + expect_err_code( + classify_source_content(&path), + "MALFORMED_CONTENT", + "CLASSIFY-BOUNDS: invalid filtered stream", + ); +} + // --- PR 97 review fold (R1–R5) --------------------------------------------- // Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. @@ -787,12 +819,13 @@ fn helvetica_resources() -> Dictionary { } fn write_helvetica_page(path: &Path, content: &[u8]) { + write_helvetica_page_with_stream_dict(path, content, Dictionary::new()); +} + +fn write_helvetica_page_with_stream_dict(path: &Path, content: &[u8], stream_dict: Dictionary) { let mut doc = Document::with_version("1.7"); let pages_id = doc.new_object_id(); - let content_id = doc.add_object(Object::Stream(Stream::new( - Dictionary::new(), - content.to_vec(), - ))); + let content_id = doc.add_object(Object::Stream(Stream::new(stream_dict, content.to_vec()))); let mut page = Dictionary::new(); page.set("Type", "Page"); page.set("Parent", pages_id);