Files
SnapOtter/packages/doc-engine/tests/pdf-ops.test.ts
T
SnapOtterandGitHub 50d12c6aba fix(doc-engine): keep protect-pdf passwords out of qpdf's argv (#708)
qpdf expands argument files before parsing options, so a bare positional password
beginning with @ was resolved as a path and the file's contents became the
encryption key: exit 0, and the user's own password no longer opened the PDF.

Drive the encrypt through a job-JSON file so neither password reaches argv. The
=-joined flag form needs qpdf 11.7+, and the released image carries 11.3.0 which
rejects it; job JSON works on both (verified 11.3.0 and 12.1.0, R = 6 each).
2026-08-01 14:54:56 +08:00

315 lines
12 KiB
TypeScript

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { settleClose } from "./helpers/fake-child.js";
import { makeSpawnHelpers } from "./helpers/spawn-capture.js";
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
const mockSpawn = vi.mocked(spawn);
const h = makeSpawnHelpers(mockSpawn);
beforeEach(() => {
vi.clearAllMocks();
process.env.QPDF_PATH = "/usr/bin/qpdf";
delete process.env.SUBPROCESS_MEMORY_LIMIT_MB;
});
afterEach(() => {
delete process.env.QPDF_PATH;
});
describe("assertValidRange", () => {
it("accepts digit/comma/hyphen ranges", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
expect(() => assertValidRange("1-3")).not.toThrow();
expect(() => assertValidRange("1,3,5")).not.toThrow();
expect(() => assertValidRange("2-z")).not.toThrow();
expect(() => assertValidRange("r1-r2")).not.toThrow();
expect(() => assertValidRange("z")).not.toThrow();
});
it("rejects a range that does not start with a digit/r/z", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
expect(() => assertValidRange("-1")).toThrow("Invalid page range: -1");
});
it("rejects letters outside the r/z grammar", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
expect(() => assertValidRange("1-a")).toThrow("Invalid page range");
});
it("rejects a range longer than 200 characters", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
const longRange = `1${",1".repeat(120)}`; // > 200 chars, all valid characters
expect(longRange.length).toBeGreaterThan(200);
expect(() => assertValidRange(longRange)).toThrow("Invalid page range");
});
it("truncates the offending range to 50 chars in the message", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
const bad = `x${"y".repeat(80)}`;
try {
assertValidRange(bad);
throw new Error("should have thrown");
} catch (e) {
const msg = (e as Error).message;
expect(msg).toBe(`Invalid page range: ${bad.slice(0, 50)}`);
expect(msg.length).toBeLessThan(bad.length);
}
});
it("accepts exactly 200 characters of valid grammar", async () => {
const { assertValidRange } = await import("../src/pdf-ops.js");
const exactly200 = "1".repeat(200);
expect(exactly200.length).toBe(200);
expect(() => assertValidRange(exactly200)).not.toThrow();
});
});
describe("qpdfMerge", () => {
it("builds --empty --pages <inputs> -- <out>", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) =>
m.qpdfMerge(["/a.pdf", "/b.pdf", "/c.pdf"], "/out.pdf"),
);
expect(h.lastArgs()).toEqual([
"--empty",
"--pages",
"/a.pdf",
"/b.pdf",
"/c.pdf",
"--",
"/out.pdf",
]);
});
it("throws when given fewer than two inputs, without spawning", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfMerge(["/only.pdf"], "/out.pdf")),
).rejects.toThrow("qpdfMerge needs at least two inputs");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("throws on an empty input list", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfMerge([], "/out.pdf")),
).rejects.toThrow("qpdfMerge needs at least two inputs");
});
});
describe("qpdfSplitRanges", () => {
it("builds <in> --pages . <range> -- <out>", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfSplitRanges("/in.pdf", "2-5", "/out.pdf"));
expect(h.lastArgs()).toEqual(["/in.pdf", "--pages", ".", "2-5", "--", "/out.pdf"]);
});
it("validates the range before spawning", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfSplitRanges("/in.pdf", "bad!", "/out.pdf")),
).rejects.toThrow("Invalid page range");
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe("qpdfRotate", () => {
it("builds --rotate=+<angle>:<range> for 90 degrees", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfRotate("/in.pdf", 90, "1-z", "/out.pdf"));
expect(h.lastArgs()).toEqual(["--rotate=+90:1-z", "/in.pdf", "/out.pdf"]);
});
it("encodes the exact angle in the flag for 180 and 270", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfRotate("/in.pdf", 180, "2", "/out.pdf"));
expect(h.lastArgs()[0]).toBe("--rotate=+180:2");
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfRotate("/in.pdf", 270, "3-4", "/out.pdf"));
expect(h.lastArgs()[0]).toBe("--rotate=+270:3-4");
});
it("validates the range before spawning", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfRotate("/in.pdf", 90, "??", "/out.pdf")),
).rejects.toThrow("Invalid page range");
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe("qpdfEncrypt", () => {
it("drives qpdf through a job-JSON file rather than argv", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) =>
m.qpdfEncrypt("/in.pdf", "userpw", "ownerpw", "/out.pdf"),
);
const args = h.lastArgs();
expect(args).toHaveLength(1);
expect(args[0]).toMatch(/^--job-json-file=.*\.json$/);
});
// qpdf runs an argument-file pre-pass over every argv element before it parses
// options, so a bare positional value starting with `@` is read as a path and that
// file's lines are spliced into qpdf's own argv. A one-line file then encrypts the
// document under the file's contents, at exit 0, instead of the chosen password.
// Keeping passwords out of argv entirely is what closes it.
it.each([
["argument-file sigil", "@/etc/hostname"],
["leading dashes", "--allow-insecure"],
])("never puts a password with %s into argv", async (_label, password) => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) =>
m.qpdfEncrypt("/in.pdf", password, "ownerpw", "/out.pdf"),
);
expect(JSON.stringify(h.lastArgs())).not.toContain(password);
});
it("writes the literal passwords into the job file and removes it afterwards", async () => {
// A child that never settles on its own, so the job file can be read while qpdf
// is notionally still running. It is unlinked once the promise resolves.
const child = h.nextManual();
const { qpdfEncrypt } = await import("../src/pdf-ops.js");
const pending = qpdfEncrypt("/in.pdf", "@/etc/hostname", "ownerpw", "/out.pdf");
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
const jobPath = h.lastArgs()[0].replace("--job-json-file=", "");
const contents = JSON.parse(await readFile(jobPath, "utf8"));
settleClose(child, { code: 0 });
await pending;
expect(contents.inputFile).toBe("/in.pdf");
expect(contents.outputFile).toBe("/out.pdf");
expect(contents.encrypt).toMatchObject({
userPassword: "@/etc/hostname",
ownerPassword: "ownerpw",
"256bit": {},
});
expect(existsSync(jobPath)).toBe(false);
});
it("rejects an empty user password before spawning", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfEncrypt("/in.pdf", "", "owner", "/out.pdf")),
).rejects.toThrow("Password must be 1-256 characters");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("rejects an owner password longer than 256 characters", async () => {
await expect(
import("../src/pdf-ops.js").then((m) =>
m.qpdfEncrypt("/in.pdf", "user", "o".repeat(257), "/out.pdf"),
),
).rejects.toThrow("Password must be 1-256 characters");
});
it("accepts a 256-character password", async () => {
h.nextClose({ code: 0 });
await expect(
import("../src/pdf-ops.js").then((m) =>
m.qpdfEncrypt("/in.pdf", "u".repeat(256), "o".repeat(256), "/out.pdf"),
),
).resolves.toBeUndefined();
});
});
describe("qpdfDecrypt", () => {
it("builds --password=<pw> --decrypt <in> <out>", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfDecrypt("/in.pdf", "secret", "/out.pdf"));
expect(h.lastArgs()).toEqual(["--password=secret", "--decrypt", "/in.pdf", "/out.pdf"]);
});
it("rejects an empty password before spawning", async () => {
await expect(
import("../src/pdf-ops.js").then((m) => m.qpdfDecrypt("/in.pdf", "", "/out.pdf")),
).rejects.toThrow("Password must be 1-256 characters");
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe("qpdfPagesSpec", () => {
it("builds <in> --pages . <spec> -- <out>", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfPagesSpec("/in.pdf", "3,1,2", "/out.pdf"));
expect(h.lastArgs()).toEqual(["/in.pdf", "--pages", ".", "3,1,2", "--", "/out.pdf"]);
});
it("enforces the 200-char length cap", async () => {
await expect(
import("../src/pdf-ops.js").then((m) =>
m.qpdfPagesSpec("/in.pdf", "1".repeat(201), "/out.pdf"),
),
).rejects.toThrow("Invalid page range");
expect(mockSpawn).not.toHaveBeenCalled();
});
});
describe("qpdfPagesSpecUnchecked", () => {
it("builds the same argv as qpdfPagesSpec", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) =>
m.qpdfPagesSpecUnchecked("/in.pdf", "1-9", "/out.pdf"),
);
expect(h.lastArgs()).toEqual(["/in.pdf", "--pages", ".", "1-9", "--", "/out.pdf"]);
});
it("still enforces the character grammar", async () => {
await expect(
import("../src/pdf-ops.js").then((m) =>
m.qpdfPagesSpecUnchecked("/in.pdf", "1;2", "/out.pdf"),
),
).rejects.toThrow("Invalid page range");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("does NOT enforce the 200-char cap (accepts a very long valid spec)", async () => {
h.nextClose({ code: 0 });
const longSpec = "1".repeat(300);
await expect(
import("../src/pdf-ops.js").then((m) =>
m.qpdfPagesSpecUnchecked("/in.pdf", longSpec, "/out.pdf"),
),
).resolves.toBeUndefined();
expect(h.lastArgs()[3]).toBe(longSpec);
});
});
describe("qpdfLinearize", () => {
it("builds --linearize <in> <out>", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfLinearize("/in.pdf", "/out.pdf"));
expect(h.lastArgs()).toEqual(["--linearize", "/in.pdf", "/out.pdf"]);
});
});
describe("qpdfRepair", () => {
it("builds <in> <out> (plain rewrite)", async () => {
h.nextClose({ code: 0 });
await import("../src/pdf-ops.js").then((m) => m.qpdfRepair("/in.pdf", "/out.pdf"));
expect(h.lastArgs()).toEqual(["/in.pdf", "/out.pdf"]);
});
});
describe("qpdf pdf-ops use the 60s timeout", () => {
it("linearize times out at 60s, not 30s", async () => {
const mod = await import("../src/pdf-ops.js");
vi.useFakeTimers();
try {
const child = h.nextManual();
const settled = expect(mod.qpdfLinearize("/in.pdf", "/out.pdf")).rejects.toThrow(
"qpdf timed out after 60s",
);
await vi.advanceTimersByTimeAsync(30_000);
expect(child.killed).toBe(false); // still alive at 30s
await vi.advanceTimersByTimeAsync(30_000);
await settled;
expect(child.killSignals).toContain("SIGKILL");
} finally {
vi.useRealTimers();
}
});
});