qpdf-compress 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] - 2026-07-15
10
+
11
+ ### Security
12
+
13
+ - Verify the downloaded prebuilt binary against a per-platform SHA-256 pin shipped in the package before use, falling back to the checksum-pinned source build on mismatch or a missing pin. Closes the prebuilt-binary trust gap where a swapped GitHub release asset could be executed unverified.
14
+
15
+ ### Changed
16
+
17
+ - The `update-native-deps` PR body now describes `verify-checksums` as a consistency check against the same source rather than "independent" verification.
18
+
19
+ ### Fixed
20
+
21
+ - Corrected the HarfBuzz version recorded in `THIRD-PARTY-NOTICES.md` (14.1.0 → 14.2.1) to match the pinned dependency.
22
+
23
+ ### Added
24
+
25
+ - Hermetic test coverage for prebuilt verification and manifest generation (`test/prebuild.test.ts`).
26
+
9
27
  ## [0.7.0] - 2026-06-27
10
28
 
11
29
  ### Added
@@ -193,7 +211,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
193
211
  - QEMU-based cross-compilation for Linux arm64/arm and musl variants
194
212
  - vcpkg integration for Windows static linking
195
213
 
196
- [Unreleased]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.0...HEAD
214
+ [Unreleased]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.1...HEAD
215
+ [0.7.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.0...v0.7.1
197
216
  [0.7.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.4...v0.7.0
198
217
  [0.6.4]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.3...v0.6.4
199
218
  [0.6.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.2...v0.6.3
@@ -9,7 +9,7 @@ full license text of each is available from the upstream project linked.
9
9
  | -------- | ------------------ | --------------------------------------------------------- | -------------------------------------- |
10
10
  | QPDF | 12.3.2 | Apache-2.0 | <https://github.com/qpdf/qpdf> |
11
11
  | mozjpeg | 4.1.1 | BSD-3-Clause and IJG (with zlib-licensed SIMD components) | <https://github.com/mozilla/mozjpeg> |
12
- | HarfBuzz | 14.1.0 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
12
+ | HarfBuzz | 14.2.1 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
13
13
  | zlib | bundled on Windows | zlib License | <https://github.com/madler/zlib> |
14
14
 
15
15
  ## Details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Native PDF compression for Node.js, powered by QPDF",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -96,16 +96,16 @@
96
96
  },
97
97
  "devDependencies": {
98
98
  "@eslint/js": "^10.0.1",
99
- "@types/node": "^25.5.0",
99
+ "@types/node": "^22.0.0",
100
100
  "@vitest/coverage-v8": "^4.1.4",
101
101
  "eslint": "^10.1.0",
102
102
  "eslint-config-prettier": "^10.1.8",
103
103
  "globals": "^17.0.0",
104
104
  "husky": "^9.1.7",
105
- "lint-staged": "^16.4.0",
105
+ "lint-staged": "^17.0.8",
106
106
  "node-gyp": "^13.0.0",
107
107
  "prettier": "^3.8.1",
108
- "typescript": "^5.9.3",
108
+ "typescript": "^6.0.3",
109
109
  "typescript-eslint": "^8.57.2",
110
110
  "vitest": "^4.1.4"
111
111
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Generate `scripts/prebuilds.json`: a map of "<platform>-<arch>" -> SHA-256 of
3
+ * each prebuilt release tarball.
4
+ *
5
+ * Run in the release workflow AFTER the prebuild artifacts are downloaded and
6
+ * BEFORE `npm publish`, so the pins ship *inside* the published (SLSA-attested)
7
+ * npm package. `install.mjs` then verifies a downloaded prebuilt against the
8
+ * pin baked into the package, closing the prebuilt-binary trust gap: a swapped
9
+ * GitHub release asset will not match the pin and falls back to the
10
+ * checksum-pinned source build.
11
+ *
12
+ * Usage: node scripts/gen-prebuild-manifest.mjs <artifacts-dir>
13
+ */
14
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { sha256File } from './lib/integrity.mjs';
18
+
19
+ /**
20
+ * Map every "<prefix>-v<version>-<platform>-<arch>.tar.gz" in `artifactsDir` to
21
+ * its SHA-256, keyed by "<platform>-<arch>" (matching install.mjs's lookup).
22
+ *
23
+ * @param {string} artifactsDir
24
+ * @param {string} version
25
+ * @returns {Promise<Record<string, string>>}
26
+ */
27
+ export async function computePins(artifactsDir, version) {
28
+ // strip the "<prefix>-v<version>-" head and ".tar.gz" tail to recover the key
29
+ const headRe = new RegExp('^.*-v' + version.replace(/[.]/g, '\\.') + '-');
30
+ const files = readdirSync(artifactsDir)
31
+ .filter((f) => f.endsWith('.tar.gz'))
32
+ .sort();
33
+ const pins = {};
34
+ for (const file of files) {
35
+ const key = file.replace(/\.tar\.gz$/, '').replace(headRe, '');
36
+ pins[key] = await sha256File(join(artifactsDir, file));
37
+ }
38
+ return pins;
39
+ }
40
+
41
+ // CLI entrypoint (skipped when this module is imported, e.g. by tests)
42
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
43
+ const artifactsDir = process.argv[2];
44
+ if (!artifactsDir) {
45
+ console.error('usage: node scripts/gen-prebuild-manifest.mjs <artifacts-dir>');
46
+ process.exit(1);
47
+ }
48
+ const root = join(import.meta.dirname, '..');
49
+ const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
50
+ const pins = await computePins(artifactsDir, version);
51
+ if (Object.keys(pins).length === 0) {
52
+ console.error(`No .tar.gz artifacts found in ${artifactsDir}`);
53
+ process.exit(1);
54
+ }
55
+ for (const [key, sha] of Object.entries(pins)) console.log(` ${key} ${sha}`);
56
+ const out = join(root, 'scripts', 'prebuilds.json');
57
+ writeFileSync(out, JSON.stringify(pins, null, 2) + '\n');
58
+ console.log(`Wrote ${Object.keys(pins).length} prebuild pin(s) to ${out}`);
59
+ }
@@ -11,12 +11,25 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } fr
11
11
  import { join } from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { pipeline } from 'node:stream/promises';
14
- import { extractTarball } from './lib/integrity.mjs';
14
+ import { extractTarball, verifyPrebuild } from './lib/integrity.mjs';
15
15
 
16
16
  const root = join(import.meta.dirname, '..');
17
17
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
18
18
  const version = pkg.version;
19
19
 
20
+ // Per-platform SHA-256 pins for the prebuilt tarballs, generated at release
21
+ // time (scripts/gen-prebuild-manifest.mjs) and shipped inside this package.
22
+ // The prebuilt binary is the code that actually runs, so it is verified against
23
+ // this pin before use; a swapped release asset fails the check and we fall back
24
+ // to the checksum-pinned source build.
25
+ function loadPrebuildPins() {
26
+ try {
27
+ return JSON.parse(readFileSync(join(import.meta.dirname, 'prebuilds.json'), 'utf8'));
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
20
33
  // validate version to prevent SSRF via malicious package.json
21
34
  if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) {
22
35
  console.error(`Invalid version format: ${version}`);
@@ -74,6 +87,23 @@ async function tryDownload() {
74
87
 
75
88
  await pipeline(Readable.fromWeb(body), fileStream);
76
89
 
90
+ // verify the prebuilt against the pinned checksum before trusting it
91
+ const pinKey = `${platformKey}-${arch}`;
92
+ const check = await verifyPrebuild(tmpTar, pinKey, loadPrebuildPins());
93
+ if (!check.ok) {
94
+ if (check.reason === 'no-pin') {
95
+ console.log(
96
+ `No pinned checksum for ${pinKey}; refusing unverified prebuilt, will compile from source.`,
97
+ );
98
+ } else {
99
+ console.log(
100
+ `Prebuilt checksum mismatch for ${pinKey} (expected ${check.expected}, got ${check.actual}); will compile from source.`,
101
+ );
102
+ }
103
+ unlinkSync(tmpTar);
104
+ return false;
105
+ }
106
+
77
107
  // extract tar.gz into project root (hardened: no -P, no archived ownership)
78
108
  extractTarball(tmpTar, root);
79
109
  unlinkSync(tmpTar);
@@ -32,6 +32,29 @@ export async function sha256File(path) {
32
32
  return hash.digest('hex');
33
33
  }
34
34
 
35
+ /**
36
+ * Decide whether a downloaded prebuilt tarball is trusted by comparing its
37
+ * SHA-256 to the pin recorded for `pinKey` in `pins` (scripts/prebuilds.json).
38
+ * Returns a discriminated result so the installer can log and fall back to a
39
+ * source build instead of running an unverified binary.
40
+ *
41
+ * @param {string} tarPath
42
+ * @param {string} pinKey e.g. "darwin-arm64" / "linux-musl-x64"
43
+ * @param {Record<string, string>} pins
44
+ * @returns {Promise<{ok: true, pinKey: string, sha256: string}
45
+ * | {ok: false, reason: 'no-pin', pinKey: string}
46
+ * | {ok: false, reason: 'mismatch', pinKey: string, expected: string, actual: string}>}
47
+ */
48
+ export async function verifyPrebuild(tarPath, pinKey, pins) {
49
+ const expected = pins[pinKey];
50
+ if (!expected) return { ok: false, reason: 'no-pin', pinKey };
51
+ const actual = await sha256File(tarPath);
52
+ if (actual !== expected.toLowerCase()) {
53
+ return { ok: false, reason: 'mismatch', pinKey, expected, actual };
54
+ }
55
+ return { ok: true, pinKey, sha256: actual };
56
+ }
57
+
35
58
  /** Read the native-dependency manifest (scripts/native-deps.json). */
36
59
  export function loadManifest() {
37
60
  return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'native-deps.json'), 'utf8'));
@@ -7,10 +7,10 @@
7
7
  "checksumSource": "official PGP-signed release manifest (qpdf-12.3.2.sha256)"
8
8
  },
9
9
  "harfbuzz": {
10
- "version": "14.1.0",
10
+ "version": "14.2.1",
11
11
  "origin": "https://github.com",
12
- "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.1.0.tar.gz",
13
- "sha256": "9deffe9dded3c367c7d08265fd0a64193fad505a362a4889d898568c1cd46812",
12
+ "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.2.1.tar.gz",
13
+ "sha256": "3c2a9006a7e1bf58737e557014d7882c554c628fb379a9f00008f5ea53dbbdfb",
14
14
  "checksumSource": "self-computed from the GitHub git-archive tarball; HarfBuzz's only release asset is .tar.xz, whose extraction hangs Windows bsdtar — the gzip git-archive builds identically"
15
15
  },
16
16
  "mozjpeg": {
@@ -0,0 +1,11 @@
1
+ {
2
+ "darwin-arm64": "2af510fe83270db0874eef4accafe24a8aefa37af4e5f7d621eb2bb4702491ed",
3
+ "darwin-x64": "5b55c91245c220ad287bc20a02a72c34edc11765ed1f71630fb174554c402251",
4
+ "linux-arm": "302480ffb46576cc009386a3621099946bc1435387afe680a07c8d4abe90920b",
5
+ "linux-arm64": "ae3ae7629a6ead5e61b0f67167ef4ad6bc3309f84bcae70d0e30cd3690246161",
6
+ "linux-musl-arm64": "075a23fae7a2cc3d4b01fde42aa09e9d52d024cdb003236adfa4d1b1b5435d81",
7
+ "linux-musl-x64": "2aa7f761de96acbf604ef2ed7d9f45068257ca12ac735fa0c5529a64b9e9c34b",
8
+ "linux-x64": "85743113468dcdc2f676e9def2dba2f8edca8fd3084fb4ea032542a5ce1b3ce2",
9
+ "win32-arm64": "ce651ad9d9e1e4b57e549ef1379aeef44f013a5912b0890bcc8605050e531328",
10
+ "win32-x64": "d7ccbcb0b089996f116100fcaf0b0554c8b9c1a423f43955be2c16b9ce499906"
11
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Check each native dependency in native-deps.json for a newer upstream GitHub
3
+ * release and, when found, re-pin the version and re-hash the asset(s).
4
+ *
5
+ * This is the piece Dependabot cannot do: the prebuilt/source native libraries
6
+ * are upstream release tarballs, not a package-manager ecosystem. Runs in CI
7
+ * (see .github/workflows/update-native-deps.yml) and emits a markdown summary
8
+ * for the pull-request body via GITHUB_OUTPUT.
9
+ *
10
+ * Supports both manifest shapes:
11
+ * - per-platform "targets" (baseUrl + { asset, sha256 } map), version == full tag
12
+ * - single "url" + "sha256", version == numeric (tag with any v/n prefix stripped)
13
+ *
14
+ * Only upstreams that publish a GitHub Release are tracked; a dependency whose
15
+ * repo has no usable release is skipped with a warning (kept manual) rather than
16
+ * guessed from noisy git tags.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { Readable } from 'node:stream';
22
+ import { pipeline } from 'node:stream/promises';
23
+
24
+ const manifestPath = join(import.meta.dirname, 'native-deps.json');
25
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
26
+
27
+ const token = process.env.GITHUB_TOKEN;
28
+ const apiHeaders = {
29
+ accept: 'application/vnd.github+json',
30
+ 'user-agent': 'update-native-deps',
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
32
+ };
33
+
34
+ // derive "OWNER/REPO" from a github.com baseUrl (targets shape) or url (single)
35
+ function repoFor(dep) {
36
+ const source = dep.baseUrl ?? dep.url;
37
+ const match = /^https:\/\/github\.com\/([^/]+\/[^/]+)/.exec(source ?? '');
38
+ if (!match) throw new Error(`cannot derive a github.com OWNER/REPO from: ${source}`);
39
+ return match[1];
40
+ }
41
+
42
+ // returns the latest release tag, or null when the repo publishes no release
43
+ async function latestReleaseTag(repo) {
44
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
45
+ headers: apiHeaders,
46
+ signal: AbortSignal.timeout(30000),
47
+ });
48
+ if (res.status === 404) return null;
49
+ if (!res.ok) throw new Error(`GitHub API returned HTTP ${res.status} for ${repo} latest release`);
50
+ const { tag_name: tag } = await res.json();
51
+ if (!tag) throw new Error(`latest release for ${repo} has no tag_name`);
52
+ return tag;
53
+ }
54
+
55
+ async function sha256Url(url, allowedOrigin) {
56
+ const parsed = new URL(url);
57
+ if (allowedOrigin && parsed.origin !== allowedOrigin) {
58
+ throw new Error(`refusing to fetch from unexpected origin: ${parsed.origin}`);
59
+ }
60
+ const res = await fetch(parsed, { redirect: 'follow', signal: AbortSignal.timeout(120000) });
61
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${parsed.href}`);
62
+ if (!res.body) throw new Error(`empty response body for ${parsed.href}`);
63
+ const hash = createHash('sha256');
64
+ await pipeline(Readable.fromWeb(res.body), hash);
65
+ return hash.digest('hex');
66
+ }
67
+
68
+ const changes = [];
69
+ for (const [name, dep] of Object.entries(manifest)) {
70
+ const repo = repoFor(dep);
71
+ const tag = await latestReleaseTag(repo);
72
+ if (tag === null) {
73
+ console.warn(`${name}: ${repo} publishes no GitHub release; skipping (update manually)`);
74
+ continue;
75
+ }
76
+
77
+ if (dep.targets) {
78
+ // targets shape: version is the full tag (e.g. "chromium/7920")
79
+ if (tag === dep.version) {
80
+ console.log(`${name}: up to date (${dep.version})`);
81
+ continue;
82
+ }
83
+ console.log(
84
+ `${name}: ${dep.version} -> ${tag}; re-hashing ${Object.keys(dep.targets).length} targets`,
85
+ );
86
+ for (const [key, target] of Object.entries(dep.targets)) {
87
+ const url = `${dep.baseUrl}/${encodeURIComponent(tag)}/${target.asset}`;
88
+ target.sha256 = await sha256Url(url, dep.origin);
89
+ console.log(` ${key}: ${target.sha256}`);
90
+ }
91
+ changes.push({ name, from: dep.version, to: tag });
92
+ dep.version = tag;
93
+ } else {
94
+ // single-url shape: manifest version is numeric; the tag may carry a v/n
95
+ // prefix. The url embeds the version string, so a plain substitution rebuilds
96
+ // both the tag segment and the filename.
97
+ const version = tag.replace(/^[^0-9]*/, '');
98
+ if (version === dep.version) {
99
+ console.log(`${name}: up to date (${dep.version})`);
100
+ continue;
101
+ }
102
+ const url = dep.url.replaceAll(dep.version, version);
103
+ console.log(`${name}: ${dep.version} -> ${version}; re-hashing`);
104
+ dep.sha256 = await sha256Url(url, dep.origin);
105
+ dep.url = url;
106
+ changes.push({ name, from: dep.version, to: version });
107
+ dep.version = version;
108
+ }
109
+ }
110
+
111
+ if (changes.length === 0) {
112
+ console.log('\nAll native dependencies are up to date.');
113
+ } else {
114
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
115
+ const summary = changes.map((c) => `- **${c.name}**: \`${c.from}\` → \`${c.to}\``).join('\n');
116
+ console.log(`\n${summary}`);
117
+
118
+ if (process.env.GITHUB_OUTPUT) {
119
+ const body = [
120
+ 'Automated native dependency update.',
121
+ '',
122
+ summary,
123
+ '',
124
+ 'Checksums were recomputed from the upstream release assets. `verify-checksums` re-downloads each asset and confirms it matches the recorded hash — a consistency check against the same source, not independent provenance.',
125
+ ].join('\n');
126
+ appendFileSync(process.env.GITHUB_OUTPUT, 'changed=true\n');
127
+ appendFileSync(process.env.GITHUB_OUTPUT, `body<<PR_BODY_EOF\n${body}\nPR_BODY_EOF\n`);
128
+ }
129
+ }