From 25dcaaa816eb3b6fad7c7794298a3dfcdae45fb2 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 15:56:04 +0530 Subject: [PATCH] fix(pdf): make encrypt/decrypt/permissions fail honestly instead of silent no-op --- src/main.js | 6 ++ src/main/PDFBatchOperations.js | 8 +-- src/main/PDFOperations.js | 35 ++++++++++ src/preload.js | 1 + src/renderer.js | 35 +++++++++- tests/main/PDFOperations.test.js | 107 +++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 5 deletions(-) diff --git a/src/main.js b/src/main.js index ef54aea..9041dfa 100644 --- a/src/main.js +++ b/src/main.js @@ -4760,6 +4760,12 @@ ipcMain.on('process-pdf-operation', async (event, data) => { }); } }); +// Reports pdf-lib's encryption capability (Task 27) so the renderer can +// disable the password-protection controls instead of letting the user +// fill the form only to see the operation fail. +ipcMain.handle('get-pdf-capabilities', async () => ({ + passwordProtection: await PDFOperations.pdfEncryptionSupported, +})); ipcMain.on('get-pdf-page-count', async (event, filePath) => { try { const count = await PDFOperations.getPageCount(filePath); diff --git a/src/main/PDFBatchOperations.js b/src/main/PDFBatchOperations.js index 8ae4194..4916959 100644 --- a/src/main/PDFBatchOperations.js +++ b/src/main/PDFBatchOperations.js @@ -22,10 +22,10 @@ * cannot supply (merge takes many inputs in one op; reorder needs each * file's full page order; fillForm's field values differ per file). * - formFields — a read-only query returning data, not a transform. - * - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) silently - * ignores encryption options in save() and cannot decrypt on load, so a - * batch run would either write unprotected files while reporting success - * or deterministically fail every file. + * - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) lacks + * encryption support, so since Task 27 these ops fail honestly with an + * "unavailable" result instead of silently writing unprotected files; + * a batch run would deterministically fail every file. * * @module PDFBatchOperations */ diff --git a/src/main/PDFOperations.js b/src/main/PDFOperations.js index 74697d7..5d9c89c 100644 --- a/src/main/PDFOperations.js +++ b/src/main/PDFOperations.js @@ -2,6 +2,30 @@ const fs = require('fs'); const path = require('path'); const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib'); +// pdf-lib 1.17.1 cannot encrypt: SaveOptions has no userPassword/ownerPassword/ +// permissions fields, so save() silently ignores them and writes an unprotected +// file, and PDFDocument.load() cannot open password-protected input (verified +// empirically in Task 22's review). Rather than trusting a pinned version +// string, probe the installed library once at module load: save a tiny +// in-memory document with a userPassword and check the raw bytes for an +// /Encrypt dictionary (which an unencrypted document never contains). A library +// that supports encryption passes the probe and the password ops re-enable +// automatically. Probe errors fail closed (treated as unsupported). +const pdfEncryptionSupported = (async () => { + try { + const probeDoc = await PDFDocument.create(); + const probeBytes = await probeDoc.save({ userPassword: 'encryption-capability-probe' }); + return Buffer.from(probeBytes).includes('/Encrypt'); + } catch { + return false; + } +})(); + +// Returned by the password ops when the probe reports no encryption support +// (Task 27): fail honestly instead of silently writing an unprotected file. +const PDF_ENCRYPTION_UNAVAILABLE_MESSAGE = + 'Password protection is not available in this build (pdf-lib lacks encryption support).'; + function parsePageRanges(rangeString, totalPages) { const pages = []; const ranges = rangeString.split(',').map((r) => r.trim()); @@ -301,6 +325,9 @@ async function pdfWatermark(data) { } async function pdfEncrypt(data) { + if (!(await pdfEncryptionSupported)) { + return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE }; + } try { const pdfBytes = fs.readFileSync(data.inputPath); const pdf = await PDFDocument.load(pdfBytes); @@ -335,6 +362,9 @@ async function pdfEncrypt(data) { } async function pdfDecrypt(data) { + if (!(await pdfEncryptionSupported)) { + return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE }; + } try { const pdfBytes = fs.readFileSync(data.inputPath); const pdf = await PDFDocument.load(pdfBytes, { password: data.password }); @@ -352,6 +382,9 @@ async function pdfDecrypt(data) { } async function pdfSetPermissions(data) { + if (!(await pdfEncryptionSupported)) { + return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE }; + } try { const pdfBytes = fs.readFileSync(data.inputPath); const loadOptions = data.currentPassword ? { password: data.currentPassword } : {}; @@ -701,6 +734,8 @@ async function getPageCount(filePath) { module.exports = { parsePageRanges, hexToRgb, + pdfEncryptionSupported, + PDF_ENCRYPTION_UNAVAILABLE_MESSAGE, pdfMerge, pdfSplit, pdfCompress, diff --git a/src/preload.js b/src/preload.js index a1aaccb..bf04258 100644 --- a/src/preload.js +++ b/src/preload.js @@ -86,6 +86,7 @@ const ALLOWED_SEND_CHANNELS = [ // PDF operations 'process-pdf-operation', 'get-pdf-page-count', + 'get-pdf-capabilities', 'select-pdf-folder', 'batch-pdf-operation', diff --git a/src/renderer.js b/src/renderer.js index cff77eb..9941c56 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -3922,8 +3922,41 @@ function updateMergeFilesList() { }); } +// Task 27: pdf-lib 1.17.1 cannot encrypt, so encrypt/decrypt/permissions fail +// honestly in the main process. Ask main once whether password protection is +// available and, when it is not, disable the three sections' controls with an +// explanatory hint instead of letting the user fill the form only to see the +// operation fail. If the capability query itself fails, leave the controls +// enabled — main still fails the operation honestly on submit. +async function applyPDFPasswordProtectionAvailability() { + let capabilities; + try { + capabilities = await ipcRenderer.invoke('get-pdf-capabilities'); + } catch { + return; + } + if (!capabilities || capabilities.passwordProtection !== false) return; + + const sectionIds = ['pdf-encrypt-section', 'pdf-decrypt-section', 'pdf-permissions-section']; + for (const sectionId of sectionIds) { + const section = document.getElementById(sectionId); + if (!section) continue; + section.querySelectorAll('input, select, button').forEach((control) => { + control.disabled = true; + }); + const hint = document.createElement('p'); + hint.className = 'warning-message pdf-unavailable-hint'; + hint.textContent = + 'Password protection is not available in this build (pdf-lib lacks encryption support).'; + section.prepend(hint); + } +} + // PDF Editor Event Listeners document.addEventListener('DOMContentLoaded', () => { + // Disable password-protection controls when pdf-lib lacks encryption support + applyPDFPasswordProtectionAvailability(); + // Close PDF Editor Dialog const pdfEditorClose = document.getElementById('pdf-editor-dialog-close'); if (pdfEditorClose) { @@ -4694,7 +4727,7 @@ ipcRenderer.on('pdf-operation-complete', (event, { success, error, message }) => } }, 800); } else { - showPDFStatus(`Error: ${error || 'PDF operation failed'}`, 'warning'); + showPDFStatus(`Error: ${error || message || 'PDF operation failed'}`, 'warning'); } }); diff --git a/tests/main/PDFOperations.test.js b/tests/main/PDFOperations.test.js index e52ad73..d4cfad7 100644 --- a/tests/main/PDFOperations.test.js +++ b/tests/main/PDFOperations.test.js @@ -385,3 +385,110 @@ describe('PDFOperations - Task 16 form field fill/flatten', () => { }); }); }); + +describe('PDFOperations - Task 27 honest encryption failure', () => { + let tmpDir, inputPath; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfops_pw_')); + inputPath = path.join(tmpDir, 'in.pdf'); + + const doc = await PDFDocument.create(); + doc.addPage([600, 800]); + fs.writeFileSync(inputPath, await doc.save()); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects the bundled pdf-lib as encryption-incapable via the module-load probe', async () => { + // Pins the Task 27 premise: pdf-lib 1.17.1's save() ignores password + // options (SaveOptions has no such fields), so the probe — which saves a + // tiny document with a userPassword and checks the bytes for /Encrypt — + // must report false. If this fails after a library swap, the probe + // re-enabled the ops and the honest-failure tests below no longer apply. + await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(false); + }); + + it('pdfEncrypt fails honestly without writing an output file', async () => { + const outputPath = path.join(tmpDir, 'encrypted.pdf'); + const result = await PDFOperations.pdfEncrypt({ + inputPath, + outputPath, + userPassword: 'secret', + ownerPassword: 'owner-secret', + permissions: { printing: true }, + }); + + expect(result.success).toBe(false); + expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE); + expect(fs.existsSync(outputPath)).toBe(false); + }); + + it('pdfDecrypt fails honestly without writing an output file', async () => { + const outputPath = path.join(tmpDir, 'decrypted.pdf'); + const result = await PDFOperations.pdfDecrypt({ + inputPath, + outputPath, + password: 'secret', + }); + + expect(result.success).toBe(false); + expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE); + expect(fs.existsSync(outputPath)).toBe(false); + }); + + it('pdfSetPermissions fails honestly without writing an output file', async () => { + const outputPath = path.join(tmpDir, 'permissions.pdf'); + const result = await PDFOperations.pdfSetPermissions({ + inputPath, + outputPath, + ownerPassword: 'owner-secret', + permissions: { printing: true }, + }); + + expect(result.success).toBe(false); + expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE); + expect(fs.existsSync(outputPath)).toBe(false); + }); + + it('fails honestly even before reading the input, so a missing input reports unavailability', async () => { + const result = await PDFOperations.pdfEncrypt({ + inputPath: path.join(tmpDir, 'missing.pdf'), + outputPath: path.join(tmpDir, 'never-written.pdf'), + userPassword: 'secret', + }); + + expect(result.success).toBe(false); + expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE); + expect(fs.existsSync(path.join(tmpDir, 'never-written.pdf'))).toBe(false); + }); + + it('executeOperation routes the password ops to the honest failure', async () => { + const result = await PDFOperations.executeOperation('encrypt', { + inputPath, + outputPath: path.join(tmpDir, 'exec-encrypted.pdf'), + userPassword: 'secret', + permissions: { printing: true }, + }); + + expect(result.success).toBe(false); + expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE); + }); + + it('the module-load probe does not affect other operations', async () => { + const outputPath = path.join(tmpDir, 'rotated.pdf'); + const result = await PDFOperations.pdfRotate({ + inputPath, + outputPath, + pages: '1', + angle: 90, + }); + + expect(result.success).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); + const rotated = await PDFDocument.load(fs.readFileSync(outputPath)); + expect(rotated.getPageCount()).toBe(1); + }); +});