How pdf-inspector Manages Encrypted and Password-Protected PDF Documents

pdf-inspector handles encrypted PDFs through an optional password field in PdfOptions, with automatic fallback to empty passwords for owner-only encryption and redacted logging to prevent credential leakage.

The firecrawl/pdf-inspector crate provides transparent encryption support across all its interfaces—Rust library, CLI, and WebAssembly—without complicating the API for unencrypted documents. The implementation in src/lib.rs relies on the lopdf crate for low-level PDF operations, wrapping its decryption capabilities in a consistent, secure abstraction.

How Password Protection Flows Through the API

The PdfOptions Builder Pattern

Encryption handling starts with the PdfOptions struct defined in src/lib.rs. The builder exposes a password(..) method that stores the supplied string in an Option<String> field:

// src/lib.rs#L85-L99
let opts = PdfOptions::new().password("secret123");

The custom Debug implementation redacts the password value, displaying "[REDACTED]" instead of the actual string. This prevents accidental credential exposure in logs, panic messages, or debugging output.

Document Loading with Optional Decryption

All public entry points—process_pdf, process_pdf_with_options, and process_pdf_mem_with_options—forward the optional password to internal loaders:

  • load_document_from_path_with_password (path-based, src/lib.rs#L24-L30)
  • load_document_from_mem_with_password (memory-based, src/lib.rs#L38-L46)

The path-based loader reads the file into a buffer, then delegates to the memory loader, ensuring both paths share identical decryption logic.

The Decryption Pipeline in Detail

Step 1: Initial Parse Attempt

The memory loader first applies fix_structure_tree_names to repair malformed structure trees, then attempts to parse bytes via load_document_bytes:

// src/lib.rs#L73-L82
fn load_document_bytes(
    bytes: &[u8],
    password: Option<&str>,
) -> Result<Document, PdfError> {
    match Document::load_mem(bytes) {
        Ok(doc) if !doc.is_encrypted() => Ok(doc),
        Ok(_) | Err(_) => decrypt_document_bytes(bytes, password),
    }
}

If lopdf reports encryption—either through is_encrypted() or an encryption-related error—the code falls back to explicit decryption.

Step 2: Decryption with Fallback

The decrypt_document_bytes function (src/lib.rs#L86-L96) implements a two-tier attempt:

  1. Primary attempt: Uses the user-supplied password via lopdf::LoadOptions::with_password(pw)
  2. Fallback: If the primary fails and a non-empty password was provided, retries with an empty password (handling owner-only encryption where the owner password is empty)
// Conceptual flow based on src/lib.rs#L86-L96
fn decrypt_document_bytes(bytes: &[u8], password: Option<&str>) -> Result<Document, PdfError> {
    if let Some(pw) = password {
        // Try provided password
        if let Ok(doc) = Document::load_mem(bytes, LoadOptions::with_password(pw)) {
            return Ok(doc);
        }
        // Fallback to empty owner password
        if let Ok(doc) = Document::load_mem(bytes, LoadOptions::with_password("")) {
            return Ok(doc);
        }
    }
    // Final error propagation
    Err(PdfError::Encrypted)
}

This graceful fallback eliminates a common user friction point—PDFs encrypted with owner-only protection that appear to require no password.

Step 3: Error Propagation

Failed decryption bubbles up as PdfError::Encrypted or the underlying lopdf::Error, allowing callers to implement retry logic or credential prompts.

Cross-Platform Interface Consistency

Command-Line Interface

The pdf2md binary (src/bin/pdf2md.rs#L242-L263) exposes a --password flag:

pdf2md --input protected.pdf --output out.md --password secret123

The parsed Option<&str> flows into extract_text_with_positions_pages_with_password, reusing the same internal path as the library API.

WebAssembly Bindings

The WASM wrapper (wasm/src/lib.rs#L71-L84) mirrors this behavior. The exported detectPdf function accepts an options object with an optional password field:

import { detectPdf } from "pdf-inspector-wasm";

const result = await detectPdf(pdfBytes, { password: "secret123" });

Verification Through Testing

The integration test suite validates all encryption scenarios in tests/integration_tests.rs#L4246-L4265:

  • Rejection of encrypted PDFs without passwords
  • Failure on incorrect passwords
  • Successful decryption and extraction with correct passwords

Complete Working Examples

Rust Library

use pdf_inspector::{process_pdf_with_options, PdfOptions};

fn main() -> Result<(), pdf_inspector::PdfError> {
    // Open with password
    let opts = PdfOptions::new().password("secret123");
    let result = process_pdf_with_options("encrypted.pdf", opts)?;
    
    // Owner-only encryption (empty password fallback)
    let opts = PdfOptions::new().password("user_password");
    let result = process_pdf_with_options("owner_locked.pdf", opts)?;
    
    Ok(())
}

CLI with Empty Password Flag


# Explicit empty password for owner-only encryption

pdf2md --input protected.pdf --password "" --output out.md

WebAssembly in Browser

const response = await fetch("protected.pdf");
const bytes = new Uint8Array(await response.arrayBuffer());

const result = await detectPdf(bytes, {
    password: "secret123"  // omit for unencrypted PDFs
});

Security and Design Decisions

Aspect Implementation Location
Password redaction Custom Debug impl shows "[REDACTED]" src/lib.rs#L85-L99
Owner-password fallback Empty string retry after failed attempt src/lib.rs#L86-L96
Unified decryption path All interfaces use load_document_*_with_password src/lib.rs#L24-L46
No password persistence Option<&str> borrows, never stores permanently Throughout API

Summary

  • Transparent handling: Encryption is optional in PdfOptions; unencrypted PDFs pass through unchanged
  • Automatic fallback: Failed password attempts retry with empty strings for owner-only protection
  • Credential protection: Debug output never exposes passwords
  • Unified implementation: Path, memory, CLI, and WASM interfaces share src/lib.rs core logic
  • Clear error signals: PdfError::Encrypted enables caller-side retry strategies

Frequently Asked Questions

What happens if I don't provide a password for an encrypted PDF?

pdf-inspector returns a PdfError::Encrypted error after attempting to parse the document. The error propagates from load_document_bytes through the public API, allowing your application to detect the condition and prompt for credentials.

Does pdf-inspector support PDFs with owner passwords but no user passwords?

Yes. When a non-empty password fails, the library automatically retries with an empty string (""), handling the common case where the document is encrypted but the owner password is empty. This is implemented in decrypt_document_bytes at src/lib.rs#L86-L96.

How does pdf-inspector prevent passwords from appearing in logs?

The PdfOptions struct implements a custom Debug trait that replaces the password field with "[REDACTED]". This ensures that even with RUST_BACKTRACE=1 or explicit debug printing, credentials remain protected.

Can I use pdf-inspector with password-protected PDFs in a WebAssembly environment?

Yes. The WASM wrapper in wasm/src/lib.rs#L71-L84 exposes a password option in the detectPdf function options object. The password flows through the same Rust core logic as native library calls, ensuring consistent behavior across platforms.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →