fix(pdf): make encrypt/decrypt/permissions fail honestly instead of silent no-op

This commit is contained in:
2026-08-23 19:31:33 +05:30
parent c6ec1cef64
commit 25dcaaa816
6 changed files with 187 additions and 5 deletions
+6
View File
@@ -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);
+4 -4
View File
@@ -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
*/
+35
View File
@@ -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,
+1
View File
@@ -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',
+34 -1
View File
@@ -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');
}
});
+107
View File
@@ -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);
});
});