From c92ab8cf49c6a9dd0e24ce1ff60f110f7c92f0f3 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:20:24 +0000 Subject: [PATCH] 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 --- .../formats/pdf/pdf-format-writer.test.ts | 40 ++++++++++++++++++- .../formats/pdf/pdf-format-writer.ts | 7 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/server/src/modules/file-write/formats/pdf/pdf-format-writer.test.ts b/server/src/modules/file-write/formats/pdf/pdf-format-writer.test.ts index 664b53f6d..e00473479 100644 --- a/server/src/modules/file-write/formats/pdf/pdf-format-writer.test.ts +++ b/server/src/modules/file-write/formats/pdf/pdf-format-writer.test.ts @@ -48,12 +48,13 @@ const mockPdfNameOf = PDFName.of as MockedFunction; const mockBuildXmp = buildXmp as MockedFunction; 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(); + }); }); diff --git a/server/src/modules/file-write/formats/pdf/pdf-format-writer.ts b/server/src/modules/file-write/formats/pdf/pdf-format-writer.ts index 7f2b2cfd3..128419f27 100644 --- a/server/src/modules/file-write/formats/pdf/pdf-format-writer.ts +++ b/server/src/modules/file-write/formats/pdf/pdf-format-writer.ts @@ -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));