feat: update GitHub Actions workflow for PDF engine and viewer

This commit is contained in:
alam00000 committed 2026-10-19 13:17:07 +05:30
1 parent 231c012e99
commit 70dc47d25c
16 files changed
+840 -88

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFObject,
PDFRawStream,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
vi.mock('../js/utils/helpers.js', async () => {
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
const wasmPath = resolve(
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
);
const options = {
locateFile: () => wasmPath,
wasmBinary: readFileSync(wasmPath),
print: () => {},
printErr: () => {},
};
const qpdf = await createModule(options);
return { initializeQpdf: async () => qpdf };
});
const { redactDocumentData } =
await import('../js/utils/redact-document-data.js');
const SECRET = 'SECRET-4471';
async function buildDocument(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const ctx = doc.context;
doc.setTitle(`Report for ${SECRET}`);
doc.setAuthor('Keep Author');
doc.catalog.set(
PDFName.of('Metadata'),
ctx.register(
ctx.flateStream(`<x:xmpmeta><dc:title>${SECRET}</dc:title></x:xmpmeta>`, {
Type: 'Metadata',
Subtype: 'XML',
})
)
);
const outlines = ctx.nextRef();
const first = ctx.nextRef();
const second = ctx.nextRef();
ctx.assign(
first,
ctx.obj({
Title: PDFString.of(`${SECRET} chapter`),
Parent: outlines,
Next: second,
})
);
ctx.assign(
second,
ctx.obj({
Title: PDFString.of('Keep chapter'),
Parent: outlines,
Prev: first,
})
);
ctx.assign(
outlines,
ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 })
);
doc.catalog.set(PDFName.of('Outlines'), outlines);
doc.catalog.set(
PDFName.of('StructTreeRoot'),
ctx.register(
ctx.obj({
Type: 'StructTreeRoot',
K: ctx.obj({
S: 'Figure',
Alt: PDFHexString.fromText(`Photo of ${SECRET}`),
}),
})
)
);
const secretNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [10, 10, 30, 30],
Contents: PDFString.of(`note about ${secret()}`),
})
);
const keepNote = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Text',
Rect: [40, 10, 60, 30],
Contents: PDFString.of('keep note'),
})
);
const appearance = ctx.register(
ctx.flateStream(`BT /Helv 10 Tf (${SECRET}) Tj ET`, {
Type: 'XObject',
Subtype: 'Form',
BBox: [0, 0, 100, 20],
})
);
const widget = ctx.register(
ctx.obj({
Type: 'Annot',
Subtype: 'Widget',
FT: 'Tx',
T: PDFString.of('ssn'),
V: PDFString.of(SECRET),
Rect: [10, 100, 110, 120],
AP: { N: appearance },
})
);
doc.catalog.set(PDFName.of('AcroForm'), ctx.obj({ Fields: [widget] }));
page.node.set(PDFName.of('Annots'), ctx.obj([secretNote, keepNote, widget]));
page.node.set(
PDFName.of('Thumb'),
ctx.register(ctx.flateStream(SECRET, { Width: 1, Height: 1 }))
);
await doc.attach(
new Uint8Array(Buffer.from(`payroll ${SECRET}`)),
'payroll.txt',
{
mimeType: 'text/plain',
}
);
await doc.attach(new Uint8Array(Buffer.from('harmless')), 'readme.txt', {
mimeType: 'text/plain',
});
return doc.save();
}
function secret() {
return SECRET.toLowerCase();
}
function collectText(value: PDFObject, out: string[]) {
if (value instanceof PDFString || value instanceof PDFHexString) {
out.push(value.decodeText());
} else if (value instanceof PDFDict) {
for (const [, v] of value.entries()) collectText(v, out);
} else if (value instanceof PDFArray) {
for (const v of value.asArray()) collectText(v, out);
}
}
async function allText(bytes: Uint8Array): Promise<string> {
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
const out: string[] = [new TextDecoder('latin1').decode(bytes)];
for (const [, object] of doc.context.enumerateIndirectObjects()) {
if (object instanceof PDFRawStream) {
collectText(object.dict, out);
try {
out.push(
new TextDecoder('latin1').decode(decodePDFRawStream(object).decode())
);
} catch {
continue;
}
} else {
collectText(object, out);
}
}
return out.join('\n');
}
describe('redactDocumentData', () => {
it('removes the redacted text from every place outside the page content', async () => {
const input = await buildDocument();
expect((await allText(input)).toLowerCase()).toContain(
SECRET.toLowerCase()
);
const output = await redactDocumentData(input, secret());
const text = await allText(output);
expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase());
expect(text).toContain('Keep chapter');
expect(text).toContain('keep note');
expect(text).toContain('Keep Author');
expect(text).toContain('harmless');
expect(text).toContain('readme.txt');
const result = await PDFDocument.load(output, { updateMetadata: false });
const annots = result.getPages()[0].node.Annots();
expect(annots?.size()).toBe(2);
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
});
it('only drops thumbnails and leftovers when no search text is given', async () => {
const input = await buildDocument();
const output = await redactDocumentData(input);
const result = await PDFDocument.load(output, { updateMetadata: false });
expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined();
expect(result.getTitle()).toBe(`Report for ${SECRET}`);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import forge from 'node-forge';
import { validateSignature } from '@/js/logic/validate-signature-pdf';
interface Identity {
cert: forge.pki.Certificate;
key: forge.pki.rsa.PrivateKey;
}
let serial = 1;
function makeIdentity(
commonName: string,
isCa: boolean,
issuer?: Identity
): Identity {
const keys = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = (serial++).toString(16).padStart(2, '0');
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
const subject = [{ name: 'commonName', value: commonName }];
cert.setSubject(subject);
cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject);
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create());
return { cert, key: keys.privateKey };
}
function signContent(
content: Uint8Array,
signer: Identity,
bundle: forge.pki.Certificate[]
): Uint8Array {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(
String.fromCharCode(...Array.from(content))
);
for (const cert of bundle) p7.addCertificate(cert);
p7.addSigner({
key: signer.key,
certificate: signer.cert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
return Uint8Array.from(der, (c) => c.charCodeAt(0));
}
async function check(
signer: Identity,
bundle: forge.pki.Certificate[],
trusted: forge.pki.Certificate
) {
const pdfBytes = new TextEncoder().encode('%PDF-1.7 signed content');
const contents = signContent(pdfBytes, signer, bundle);
return validateSignature(
{
index: 0,
contents,
byteRange: [0, pdfBytes.length, pdfBytes.length, 0],
},
pdfBytes,
trusted
);
}
describe('validateSignature trust chain', () => {
const root = makeIdentity('Company Root CA', true);
const intermediate = makeIdentity('Company Issuing CA', true, root);
const employee = makeIdentity('Real Employee', false, root);
const chainedEmployee = makeIdentity('Chained Employee', false, intermediate);
const attacker = makeIdentity('John Smith', false);
it('does not trust a self-made signer bundled with an unrelated company certificate', async () => {
const result = await check(
attacker,
[attacker.cert, employee.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('identifies the real signer even when another certificate is listed first', async () => {
const result = await check(
attacker,
[employee.cert, attacker.cert],
root.cert
);
expect(result.signerName).toBe('John Smith');
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer issued directly by the trusted certificate', async () => {
const result = await check(employee, [employee.cert], root.cert);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('trusts a signer that chains to the trusted certificate through an intermediate CA', async () => {
const result = await check(
chainedEmployee,
[chainedEmployee.cert, intermediate.cert],
root.cert
);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('does not accept a non-CA certificate as an intermediate', async () => {
const issuedByLeaf = makeIdentity('Issued By Leaf', false, employee);
const result = await check(
issuedByLeaf,
[issuedByLeaf.cert, employee.cert],
root.cert
);
expect(result.isTrusted).toBe(false);
});
it('trusts a signer whose own certificate is the trusted certificate', async () => {
const result = await check(attacker, [attacker.cert], attacker.cert);
expect(result.isTrusted).toBe(true);
});
});