diff --git a/src/detector.rs b/src/detector.rs index afffb69..e897bd4 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -72,6 +72,8 @@ pub fn detect_pdf_type_with_config>( path: P, config: DetectionConfig, ) -> Result { + crate::validate_pdf_file(&path)?; + // First, load metadata only (fast operation) let metadata = Document::load_metadata(&path)?; @@ -92,6 +94,8 @@ pub fn detect_pdf_type_mem_with_config( buffer: &[u8], config: DetectionConfig, ) -> Result { + crate::validate_pdf_bytes(buffer)?; + // Load metadata first (fast) let metadata = Document::load_metadata_mem(buffer)?; diff --git a/src/extractor.rs b/src/extractor.rs index 09648e2..ae140b9 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -842,12 +842,14 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool { /// Extract text from PDF file as plain string pub fn extract_text>(path: P) -> Result { + crate::validate_pdf_file(&path)?; let doc = Document::load(path)?; extract_text_from_doc(&doc) } /// Extract text from PDF memory buffer pub fn extract_text_mem(buffer: &[u8]) -> Result { + crate::validate_pdf_bytes(buffer)?; let doc = Document::load_mem(buffer)?; extract_text_from_doc(&doc) } @@ -865,6 +867,7 @@ fn extract_text_from_doc(doc: &Document) -> Result { pub fn extract_text_with_positions>(path: P) -> Result, PdfError> { // Read the raw PDF bytes for ToUnicode extraction let pdf_bytes = std::fs::read(path.as_ref())?; + crate::validate_pdf_bytes(&pdf_bytes)?; let font_cmaps = FontCMaps::from_pdf_bytes(&pdf_bytes); let doc = Document::load_mem(&pdf_bytes)?; @@ -873,6 +876,7 @@ pub fn extract_text_with_positions>(path: P) -> Result Result, PdfError> { + crate::validate_pdf_bytes(buffer)?; // Extract ToUnicode CMaps from raw PDF bytes let font_cmaps = FontCMaps::from_pdf_bytes(buffer); diff --git a/src/lib.rs b/src/lib.rs index d86c95d..d4df0f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,8 @@ pub struct PdfProcessResult { pub fn process_pdf>(path: P) -> Result { let start = std::time::Instant::now(); + validate_pdf_file(&path)?; + // Step 1: Smart detection (fast, no full load) let detection = detect_pdf_type(&path)?; @@ -91,6 +93,8 @@ pub fn process_pdf>(path: P) -> Result Result { let start = std::time::Instant::now(); + validate_pdf_bytes(buffer)?; + // Step 1: Smart detection (fast, no full load) let detection = detector::detect_pdf_type_mem(buffer)?; @@ -142,10 +146,148 @@ pub enum PdfError { Encrypted, #[error("Invalid PDF structure")] InvalidStructure, + #[error("Not a PDF: {0}")] + NotAPdf(String), } impl From for PdfError { fn from(e: lopdf::Error) -> Self { - PdfError::Parse(e.to_string()) + match e { + lopdf::Error::IO(io_err) => PdfError::Io(io_err), + lopdf::Error::Decryption(_) + | lopdf::Error::InvalidPassword + | lopdf::Error::AlreadyEncrypted + | lopdf::Error::UnsupportedSecurityHandler(_) => PdfError::Encrypted, + lopdf::Error::Parse(ref pe) if pe.to_string().contains("invalid file header") => { + PdfError::NotAPdf("invalid PDF file header".to_string()) + } + lopdf::Error::MissingXrefEntry + | lopdf::Error::Xref(_) + | lopdf::Error::IndirectObject { .. } + | lopdf::Error::ObjectIdMismatch + | lopdf::Error::InvalidObjectStream(_) + | lopdf::Error::InvalidOffset(_) => PdfError::InvalidStructure, + other => PdfError::Parse(other.to_string()), + } } } + +// --------------------------------------------------------------------------- +// PDF validation helpers +// --------------------------------------------------------------------------- + +/// Strip UTF-8 BOM and leading ASCII whitespace from a byte slice. +fn strip_bom_and_whitespace(bytes: &[u8]) -> &[u8] { + let b = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + &bytes[3..] + } else { + bytes + }; + let start = b + .iter() + .position(|&c| !c.is_ascii_whitespace()) + .unwrap_or(b.len()); + &b[start..] +} + +/// Case-insensitive prefix check on byte slices. +fn starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool { + if haystack.len() < needle.len() { + return false; + } + haystack[..needle.len()] + .iter() + .zip(needle) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) +} + +/// Try to identify what kind of file the bytes represent. +fn detect_file_type_hint(bytes: &[u8]) -> String { + if bytes.is_empty() { + return "file is empty".to_string(); + } + + let trimmed = strip_bom_and_whitespace(bytes); + + // HTML + if starts_with_ci(trimmed, b" sample.len() * 3 / 4 { + return "file appears to be plain text".to_string(); + } + + "file is not a PDF".to_string() +} + +/// Validate that a byte buffer looks like a PDF (has `%PDF-` magic). +/// +/// Scans the first 1024 bytes, allowing for a UTF-8 BOM and leading whitespace. +pub(crate) fn validate_pdf_bytes(buffer: &[u8]) -> Result<(), PdfError> { + if buffer.is_empty() { + return Err(PdfError::NotAPdf(detect_file_type_hint(buffer))); + } + + let header = &buffer[..buffer.len().min(1024)]; + let trimmed = strip_bom_and_whitespace(header); + + if trimmed.starts_with(b"%PDF-") { + Ok(()) + } else { + Err(PdfError::NotAPdf(detect_file_type_hint(buffer))) + } +} + +/// Validate that a file on disk looks like a PDF. +/// +/// Reads only the first 1024 bytes and delegates to [`validate_pdf_bytes`]. +pub(crate) fn validate_pdf_file>(path: P) -> Result<(), PdfError> { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut buf = [0u8; 1024]; + let n = file.read(&mut buf)?; + validate_pdf_bytes(&buf[..n]) +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4f7f537..2a3a3c3 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4,7 +4,7 @@ use pdf_inspector::detector::DetectionConfig; use pdf_inspector::extractor::{group_into_lines, TextLine}; use pdf_inspector::{ detect_pdf_type, extract_text, extract_text_with_positions, to_markdown, MarkdownOptions, - PdfType, TextItem, + PdfError, PdfType, TextItem, }; // Helper to create test TextItems @@ -730,3 +730,109 @@ fn test_trailing_newline() { assert!(md.ends_with('\n')); assert!(!md.ends_with("\n\n")); } + +// ============================================================================ +// NotAPdf Detection Tests +// ============================================================================ + +/// Helper: assert that an error is NotAPdf and its message contains the given substring. +fn assert_not_a_pdf(result: Result, expected_hint: &str) { + match result { + Err(PdfError::NotAPdf(msg)) => { + assert!( + msg.to_lowercase().contains(&expected_hint.to_lowercase()), + "Expected hint '{}' in NotAPdf message, got: '{}'", + expected_hint, + msg, + ); + } + other => panic!( + "Expected Err(NotAPdf) containing '{}', got: {:?}", + expected_hint, other, + ), + } +} + +#[test] +fn test_not_a_pdf_html_input() { + let html = b"Hello"; + let result = pdf_inspector::process_pdf_mem(html); + assert_not_a_pdf(result, "HTML"); +} + +#[test] +fn test_not_a_pdf_xml_input() { + let xml = b"data"; + let result = pdf_inspector::process_pdf_mem(xml); + assert_not_a_pdf(result, "XML"); +} + +#[test] +fn test_not_a_pdf_json_input() { + let json = b"{\"error\": \"download failed\"}"; + let result = pdf_inspector::process_pdf_mem(json); + assert_not_a_pdf(result, "JSON"); +} + +#[test] +fn test_not_a_pdf_plain_text_input() { + let text = b"This is a plain text file that is not a PDF at all."; + let result = pdf_inspector::process_pdf_mem(text); + assert_not_a_pdf(result, "plain text"); +} + +#[test] +fn test_not_a_pdf_empty_buffer() { + let result = pdf_inspector::process_pdf_mem(b""); + assert_not_a_pdf(result, "empty"); +} + +#[test] +fn test_valid_pdf_header_not_rejected() { + // A truncated but valid PDF header should NOT produce NotAPdf — + // it should fail with Parse or InvalidStructure instead. + let truncated_pdf = b"%PDF-1.4\ntruncated content"; + let result = pdf_inspector::process_pdf_mem(truncated_pdf); + match result { + Err(PdfError::NotAPdf(_)) => panic!("Valid PDF header should not be rejected as NotAPdf"), + _ => {} // Parse or InvalidStructure is fine + } +} + +#[test] +fn test_bom_prefixed_pdf_header_not_rejected() { + // UTF-8 BOM + %PDF- should still be recognized as a PDF + let mut bom_pdf = vec![0xEF, 0xBB, 0xBF]; + bom_pdf.extend_from_slice(b"%PDF-1.7\ntruncated"); + let result = pdf_inspector::process_pdf_mem(&bom_pdf); + match result { + Err(PdfError::NotAPdf(_)) => { + panic!("BOM-prefixed PDF header should not be rejected as NotAPdf") + } + _ => {} // Parse or InvalidStructure is fine + } +} + +#[test] +fn test_not_a_pdf_detect_pdf_type_mem() { + // Verify detect_pdf_type_mem is also guarded + let html = b"Not a PDF"; + let result = pdf_inspector::detector::detect_pdf_type_mem(html); + assert_not_a_pdf(result, "HTML"); +} + +#[test] +fn test_not_a_pdf_extract_text_with_positions_mem() { + // Verify extract_text_with_positions_mem is also guarded + let html = b"content"; + let result = pdf_inspector::extractor::extract_text_with_positions_mem(html); + assert_not_a_pdf(result, "HTML"); +} + +#[test] +fn test_not_a_pdf_extract_text_mem() { + // Verify extract_text_mem is also guarded + let xml = b""; + let result = pdf_inspector::extractor::extract_text_mem(xml); + assert_not_a_pdf(result, "XML"); +}