Compare commits

...
Author SHA1 Message Date
Abimael MartellandClaude Fable 5 0159910b5f review: redact password in PdfOptions Debug; tighten decrypt test
- Replace derived Debug on PdfOptions with a manual impl that renders the
  password as Some("[REDACTED]") so it can't leak via logging or panics
  (Clone retained). Add a test asserting the redaction.
- Drop the `|| md.len() > 200` escape hatch in the correct-password test
  so a garbled-but-long extraction can't mask the encrypted-stream
  regression; assert the fixture token only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:11:49 -07:00
Abimael MartellandClaude Fable 5 661233628c feat: add --password support for encrypted PDFs
Adds a `password` field to PdfOptions (builder `.password()`) and a
`--password` flag to pdf2md, threaded into the document loader. Also
fixes a latent bug: some encrypted PDFs load structurally with
is_encrypted() still true (streams undecrypted → garbled text read as
"Scanned"); the loader now detects that and re-decrypts, falling back
to the empty password when no/likewise-rejected password is given.

Adds an encrypted fixture and an integration test covering
no-password / wrong-password (rejected) and correct-password (decrypts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:48:20 -07:00
4 changed files with 124 additions and 10 deletions
+12
View File
@@ -208,6 +208,7 @@ fn main() {
eprintln!(" --raw Output only markdown (no headers)");
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
eprintln!(" --password PW Password for an encrypted PDF");
eprintln!(" --detect-only Only detect PDF type (no extraction)");
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
process::exit(1);
@@ -221,6 +222,16 @@ fn main() {
let detect_only = args.iter().any(|a| a == "--detect-only");
let analyze = args.iter().any(|a| a == "--analyze");
// Parse --password value
let password = args.iter().position(|a| a == "--password").map(|i| {
args.get(i + 1)
.unwrap_or_else(|| {
eprintln!("Error: --password requires a value");
process::exit(1);
})
.clone()
});
// Parse --select-pages value
let page_filter = args
.iter()
@@ -269,6 +280,7 @@ fn main() {
if let Some(pages) = page_filter {
options.page_filter = Some(pages);
}
options.password = password;
match process_pdf_with_options(pdf_path, options) {
Ok(result) => {
+70 -10
View File
@@ -125,7 +125,7 @@ pub struct PdfProcessResult {
/// .mode(ProcessMode::Analyze)
/// .pages([1, 3, 5]);
/// ```
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct PdfOptions {
/// How far the pipeline should run (default: [`ProcessMode::Full`]).
pub mode: ProcessMode,
@@ -135,6 +135,23 @@ pub struct PdfOptions {
pub markdown: MarkdownOptions,
/// Optional set of 1-indexed pages to process. `None` = all pages.
pub page_filter: Option<HashSet<u32>>,
/// Password for decrypting an encrypted PDF. `None` falls back to the
/// empty password (owner-only encryption).
pub password: Option<String>,
}
// Manual `Debug` so the password is never leaked through debug logging or a
// panic that formats the options; it renders as `Some("[REDACTED]")`.
impl std::fmt::Debug for PdfOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PdfOptions")
.field("mode", &self.mode)
.field("detection", &self.detection)
.field("markdown", &self.markdown)
.field("page_filter", &self.page_filter)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
impl Default for PdfOptions {
@@ -144,6 +161,7 @@ impl Default for PdfOptions {
detection: DetectionConfig::default(),
markdown: MarkdownOptions::default(),
page_filter: None,
password: None,
}
}
}
@@ -185,6 +203,12 @@ impl PdfOptions {
self.page_filter = Some(pages.into_iter().collect());
self
}
/// Set the password used to decrypt an encrypted PDF.
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
}
// =========================================================================
@@ -217,7 +241,8 @@ pub fn process_pdf_with_options<P: AsRef<Path>>(
validate_pdf_file(&path)?;
// Load the document once — shared by detection AND extraction.
let (doc, page_count) = load_document_from_path(&path)?;
let (doc, page_count) =
load_document_from_path_with_password(&path, options.password.as_deref())?;
process_document(doc, page_count, options, start)
}
@@ -242,7 +267,8 @@ pub fn process_pdf_mem_with_options(
let start = std::time::Instant::now();
validate_pdf_bytes(buffer)?;
let (doc, page_count) = load_document_from_mem(buffer)?;
let (doc, page_count) =
load_document_from_mem_with_password(buffer, options.password.as_deref())?;
process_document(doc, page_count, options, start)
}
@@ -3267,24 +3293,40 @@ fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
/// page count from it directly to avoid the metadata-only round-trip.
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
path: P,
) -> Result<(Document, u32), PdfError> {
load_document_from_path_with_password(path, None)
}
/// Load a PDF file, decrypting with `password` if the file is encrypted.
pub(crate) fn load_document_from_path_with_password<P: AsRef<Path>>(
path: P,
password: Option<&str>,
) -> Result<(Document, u32), PdfError> {
let buffer = std::fs::read(&path)?;
load_document_from_mem(&buffer)
load_document_from_mem_with_password(&buffer, password)
}
/// Load a PDF from a memory buffer.
pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
load_document_from_mem_with_password(buffer, None)
}
/// Load a PDF from a memory buffer, decrypting with `password` if encrypted.
pub(crate) fn load_document_from_mem_with_password(
buffer: &[u8],
password: Option<&str>,
) -> Result<(Document, u32), PdfError> {
// Fix malformed struct element names before parsing. Some PDF generators
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
// causes lopdf to silently drop the entire object.
let fixed = structure_tree::fix_bare_struct_names(buffer);
let buf = fixed.as_ref();
let doc = match load_document_bytes(buf) {
let doc = match load_document_bytes(buf, password) {
Ok(doc) => doc,
Err(first_err) => {
for repaired in repair_pdf_container_candidates(buf) {
match load_document_bytes(&repaired) {
match load_document_bytes(&repaired, password) {
Ok(doc) => {
log::debug!("loaded PDF after repairing malformed container bytes");
let page_count = doc.get_pages().len() as u32;
@@ -3304,16 +3346,34 @@ pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), P
Ok((doc, page_count))
}
fn load_document_bytes(buf: &[u8]) -> Result<Document, lopdf::Error> {
fn load_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
match Document::load_mem(buf) {
// Some encrypted PDFs load structurally but leave their streams
// encrypted (`is_encrypted()` stays true); reading them yields garbage
// until we re-load with a password. Others fail load_mem outright with
// an encryption error. Handle both by re-loading with the password.
Ok(doc) if doc.is_encrypted() => decrypt_document_bytes(buf, password),
Ok(doc) => Ok(doc),
Err(ref e) if is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
}
Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
Err(e) => Err(e),
}
}
/// Re-load an encrypted PDF, decrypting with `password`. Falls back to the
/// empty password (owner-only encryption, the common "protected" case) when a
/// non-empty password was supplied but rejected.
fn decrypt_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
let pw = password.unwrap_or("");
match Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(pw)) {
Ok(doc) => Ok(doc),
Err(inner) if !pw.is_empty() => {
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
.map_err(|_| inner)
}
Err(inner) => Err(inner),
}
}
fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
let mut candidates = Vec::new();
Binary file not shown.
+42
View File
@@ -3606,3 +3606,45 @@ fn test_markdown_options_default_has_include_images_false() {
let opts = MarkdownOptions::default();
assert!(!opts.include_images);
}
#[test]
fn encrypted_pdf_decrypts_with_correct_password() {
let path = "tests/fixtures/encrypted-secret123.pdf";
// No password: the file is encrypted and can't be read.
let no_pw = process_pdf_with_options(path, PdfOptions::new());
assert!(
matches!(no_pw, Err(PdfError::Encrypted)),
"expected Encrypted without a password, got {no_pw:?}"
);
// Wrong password: still rejected.
let wrong = process_pdf_with_options(path, PdfOptions::new().password("wrong"));
assert!(
matches!(wrong, Err(PdfError::Encrypted)),
"expected Encrypted with a wrong password, got {wrong:?}"
);
// Correct password: decrypts and extracts real content.
let ok = process_pdf_with_options(path, PdfOptions::new().password("secret123"))
.expect("correct password should decrypt");
let md = ok.markdown.unwrap_or_default();
// Assert a stable fixture token so a garbled-but-long extraction (the
// encrypted-stream regression this guards) still fails the test.
assert!(
md.contains("Procurement"),
"decrypted markdown should contain the fixture's real text, got {} chars",
md.len()
);
}
#[test]
fn pdf_options_debug_redacts_password() {
let opts = PdfOptions::new().password("secret123");
let dbg = format!("{opts:?}");
assert!(
!dbg.contains("secret123"),
"password leaked in Debug: {dbg}"
);
assert!(dbg.contains("REDACTED"), "expected redaction marker: {dbg}");
}