Skip to content

RFC: Unify password/error handling across parsePdfFile, streamPdfFile, and getPdfMetadata #155

Description

@l2ysho

Problem

Password handling is split across two divergent implementations with no shared code:

  • getPdfMetadata uses loadingTask.onPassword — correctly detects encryption, sets an isEncrypted flag, and throws a manually-constructed Error with name = 'PasswordException'. But it uses magic numbers (reason === 1, reason === 2) instead of pdfjs's exported PasswordResponses constants.
  • parsePdfFile and streamPdfFile bypass onPassword entirely by passing documentInitParameters.password directly to pdfjs. This means: (a) callers get a raw pdfjs PasswordException rather than a library-owned error; (b) there is no way to detect whether a successfully-parsed PDF was encrypted; (c) passing the password via documentInitParameters triggers a latent DataCloneError in some pdfjs versions.

The seam between these two paths is fragile: a bug fix or pdfjs version bump in one place doesn't fix the other, and callers can't write consistent catch logic across the library's functions.

Proposed Interface

Two new error classes (exported from index.ts):

export class PdfPasswordRequiredError extends Error {
  readonly name = 'PasswordException'; // preserved for backward compat
  readonly code = 'NEED_PASSWORD' as const;
  constructor() {
    super('No password given');
  }
}

export class PdfPasswordIncorrectError extends Error {
  readonly name = 'PasswordException'; // preserved for backward compat
  readonly code = 'INCORRECT_PASSWORD' as const;
  constructor() {
    super('Incorrect Password');
  }
}

Preserving name = 'PasswordException' and existing message strings means callers catching by err.name or err.message continue to work. The new value is that instanceof PdfPasswordRequiredError and instanceof PdfPasswordIncorrectError now narrow correctly in TypeScript.

One shared internal helper (attachPasswordHandler):

// Internal — not exported
function attachPasswordHandler(
  loadingTask: PDFDocumentLoadingTask,
  password?: string,
): { getIsEncrypted: () => boolean } {
  let isEncrypted = false;
  loadingTask.onPassword = (updateCallback, reason) => {
    isEncrypted = true;
    if (reason === PasswordResponses.NEED_PASSWORD && password) {
      updateCallback(password);
    } else if (reason === PasswordResponses.INCORRECT_PASSWORD) {
      throw new PdfPasswordIncorrectError();
    } else {
      throw new PdfPasswordRequiredError();
    }
  };
  return { getIsEncrypted: () => isEncrypted };
}

Usage — callers catch typed errors across all functions:

import { pdf2string, getPdfMetadata, PdfPasswordRequiredError, PdfPasswordIncorrectError } from 'afpp';

try {
  const pages = await pdf2string('./secure.pdf', { password: 'secret' });
} catch (err) {
  if (err instanceof PdfPasswordRequiredError) {
    // encrypted, no password supplied
  } else if (err instanceof PdfPasswordIncorrectError) {
    // wrong password
  }
}

// getPdfMetadata continues to return isEncrypted: boolean as before
const meta = await getPdfMetadata('./secure.pdf', { password: 'secret' });
meta.isEncrypted; // true

Dependency Strategy

True external (Mock boundary) — pdfjs is a third-party library with a callback-based password API (onPassword). The strategy is:

  • Remove documentInitParameters.password direct assignment in validateParameters
  • Always register loadingTask.onPassword after getDocument() in all three call sites (parsePdfFile, streamPdfFile, getPdfMetadata)
  • Use pdfjs's PasswordResponses.NEED_PASSWORD and PasswordResponses.INCORRECT_PASSWORD constants instead of numeric literals
  • The pdfjs dependency is injected via getDocument as it is today — no change to how pdfjs is imported or configured

Testing Strategy

New boundary tests to write:

  • parsePdfFile / pdf2string / pdf2image throw PdfPasswordRequiredError for an encrypted PDF with no password
  • parsePdfFile / pdf2string / pdf2image throw PdfPasswordIncorrectError for an encrypted PDF with a wrong password
  • parsePdfFile / pdf2string / pdf2image succeed for an encrypted PDF with the correct password
  • streamPdfFile variants throw the same typed errors in the same scenarios
  • getPdfMetadata throws PdfPasswordRequiredError / PdfPasswordIncorrectError (replacing current name: 'PasswordException' assertions)
  • All of the above: err instanceof PdfPasswordRequiredError returns true (verifies instanceof works, not just name comparison)

Old tests to update (not delete):

  • getPdfMetadata.test.ts: assertions on err.name === 'PasswordException' and message strings remain valid (names/messages are preserved), but add instanceof assertions alongside them
  • pdf2string.test.ts, pdf2image.test.ts, parsePdf.test.ts, streamPdf.test.ts: add encrypted-PDF test cases that were previously untested (these functions had no onPassword handler)

Test environment: Existing encrypted PDF fixtures are sufficient. No new fixtures needed.

Implementation Recommendations

What the unified password module should own:

  • The onPassword callback registration for every PDFDocumentLoadingTask created in this library
  • The mapping from pdfjs's numeric PasswordResponses to typed library errors
  • The isEncrypted detection flag (via closure, returned as a getter)

What it should hide:

  • pdfjs's PasswordResponses enum values
  • The onPassword callback contract and its updateCallback mechanism
  • The difference between "password passed upfront" vs "password passed via callback" (callers use options.password: string in both cases)

What it should expose:

  • PdfPasswordRequiredError — thrown when a password-protected PDF is opened without a password
  • PdfPasswordIncorrectError — thrown when the supplied password is rejected
  • Both exported from index.ts as first-class public API

Migration: No migration needed. password?: string in AfppParseOptions is unchanged. Error name and message strings are preserved. The only observable difference for existing callers is that instanceof PdfPasswordRequiredError and instanceof PdfPasswordIncorrectError now work, which is additive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions