fix(library): skip file write for encrypted PDFs

pdf-lib cannot re-encrypt files, so saving an encrypted PDF strips its encryption but writes back still-encrypted byte streams, silently corrupting the file. Skipping the file write entirely prevents this corruption while allowing DB metadata to persist separately.

Fixes #368
This commit is contained in:
neon
2026-06-18 01:20:24 +00:00
parent 39e53a5d36
commit c92ab8cf49
2 changed files with 46 additions and 1 deletions
@@ -48,12 +48,13 @@ const mockPdfNameOf = PDFName.of as MockedFunction<typeof PDFName.of>;
const mockBuildXmp = buildXmp as MockedFunction<typeof buildXmp>;
describe('PdfFormatWriter', () => {
function makePdfDoc() {
function makePdfDoc(overrides: { isEncrypted?: boolean } = {}) {
const stream = vi.fn().mockReturnValue('stream-ref');
const register = vi.fn().mockReturnValue('registered-stream');
const set = vi.fn();
const doc = {
isEncrypted: overrides.isEncrypted ?? false,
setTitle: vi.fn(),
setAuthor: vi.fn(),
setSubject: vi.fn(),
@@ -145,4 +146,41 @@ describe('PdfFormatWriter', () => {
expect(mockUnlink).toHaveBeenCalledWith('/books/.tmp-abc-uuid.pdf');
});
it('skips encrypted PDFs without mutating or rewriting the file', async () => {
const pdfDoc = makePdfDoc({ isEncrypted: true });
mockPdfLoad.mockResolvedValue(pdfDoc as never);
const writer = new PdfFormatWriter();
const result = await writer.write('/books/secret.pdf', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false });
expect(result.status).toBe('skipped');
expect(result.reason).toBe('encrypted-pdf');
expect(result.fieldsWritten).toEqual([]);
expect(typeof result.durationMs).toBe('number');
// The file must be left untouched: no metadata applied, nothing saved or replaced.
expect(pdfDoc.setTitle).not.toHaveBeenCalled();
expect(pdfDoc.save).not.toHaveBeenCalled();
expect(mockBuildXmp).not.toHaveBeenCalled();
expect(mockWriteFile).not.toHaveBeenCalled();
expect(mockRename).not.toHaveBeenCalled();
});
it('checks encryption before mutating, even when no writable fields are in the mask', async () => {
const pdfDoc = makePdfDoc({ isEncrypted: true });
mockPdfLoad.mockResolvedValue(pdfDoc as never);
const writer = new PdfFormatWriter();
const result = await writer.write('/books/secret.pdf', { title: 'Dune' }, { fieldMask: new Set([]), dryRun: false });
expect(mockPdfLoad).toHaveBeenCalledWith(Buffer.from('pdf-bytes'), { ignoreEncryption: true });
expect(result.status).toBe('skipped');
expect(result.reason).toBe('encrypted-pdf');
expect(pdfDoc.setCreator).not.toHaveBeenCalled();
expect(pdfDoc.save).not.toHaveBeenCalled();
expect(mockWriteFile).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,13 @@ export class PdfFormatWriter implements FormatWriter {
const originalBytes = await readFile(filePath);
const pdfDoc = await PDFDocument.load(originalBytes, { ignoreEncryption: true });
// pdf-lib cannot re-encrypt, so saving an encrypted PDF would strip its
// encryption and write back still-encrypted byte streams, silently corrupting
// the file. Skip the file write (DB metadata is persisted separately).
if (pdfDoc.isEncrypted) {
return { status: 'skipped', reason: 'encrypted-pdf', fieldsWritten: [], durationMs: Date.now() - start };
}
applyInfoDict(pdfDoc, payload, pdfFieldMask);
embedXmp(pdfDoc, buildXmp(payload, pdfFieldMask));