qpdf-compress 0.6.4 → 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
@@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
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
+
27
+ ## [0.7.0] - 2026-06-27
28
+
29
+ ### Added
30
+
31
+ - Typed error hierarchy: `QpdfError` with `QpdfFileError`, `QpdfFormatError`, and `QpdfPasswordError` subclasses for actionable compression failures
32
+ - `.nvmrc`, `.editorconfig`, and a `packageManager` field for reproducible toolchains
33
+ - `typecheck` CI step and a weekly `windows-latest` canary job that re-tests the `windows-2022` pin
34
+ - `./package.json` to the package `exports` map
35
+
36
+ ### Changed
37
+
38
+ - Raised the Node.js `engines` floor to `>=22.0.0`, dropping end-of-life Node 20
39
+ - Modernized `tsconfig`: `nodenext` module resolution, `es2023` target, and stricter type-checking (`verbatimModuleSyntax`, `exactOptionalPropertyTypes`, `noUnusedLocals`/`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`)
40
+ - Reworked ESLint to type-aware linting for `lib/`, adopted the `globals` package, and dropped `eslint-plugin-prettier`; switched coverage to Vitest's v8 provider
41
+ - Bumped the native build to C++20 and Node-API 9 (C++ standard defined once via a gyp variable)
42
+ - Hardened and sped up CI/CD: pinned all GitHub Actions to commit SHAs, added npm and native-dependency caching, a concurrency group, job timeouts, and top-level least-privilege permissions
43
+ - Migrated npm publish to OIDC trusted publishing (dropped `NPM_TOKEN`)
44
+ - Switched QPDF's source download to the official byte-stable release tarball; HarfBuzz uses the SHA-256-pinned GitHub git-archive `.tar.gz` (its only release asset is `.tar.xz`, which hangs Windows `tar` during extraction)
45
+
46
+ ### Security
47
+
48
+ - Pin and verify the SHA-256 of every downloaded native dependency (`scripts/native-deps.json`) via a hardened downloader (timeout, retry, atomic writes, origin pinning), with an `npm run verify:checksums` tripwire
49
+ - Added OpenSSF Scorecard scanning, Dependabot updates, and a `SECURITY.md` policy (CodeQL code scanning runs via GitHub's default setup)
50
+
51
+ ### Fixed
52
+
53
+ - **Windows build**: upgraded node-gyp to 13 and dropped Node 20 from the CI matrix; pinned Windows CI to `windows-2022` to avoid the VS 2026 MSVC internal compiler error (C1001); fixed Windows zlib detection and addon linking
54
+
55
+ ## [0.6.4] - 2026-06-26
56
+
57
+ ### Fixed
58
+
59
+ - **Double-free on teardown**: fixed a double free of the addon instance data during Node.js environment teardown
60
+
7
61
  ## [0.6.3] - 2026-04-26
8
62
 
9
63
  ### Changed
@@ -156,3 +210,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
156
210
  - Release pipeline with prebuild generation and npm provenance
157
211
  - QEMU-based cross-compilation for Linux arm64/arm and musl variants
158
212
  - vcpkg integration for Windows static linking
213
+
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
216
+ [0.7.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.4...v0.7.0
217
+ [0.6.4]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.3...v0.6.4
218
+ [0.6.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.2...v0.6.3
219
+ [0.6.2]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.1...v0.6.2
220
+ [0.6.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.0...v0.6.1
221
+ [0.6.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.5.0...v0.6.0
222
+ [0.5.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.4.0...v0.5.0
223
+ [0.4.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.3.0...v0.4.0
224
+ [0.3.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.2.0...v0.3.0
225
+ [0.2.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.3...v0.2.0
226
+ [0.1.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.2...v0.1.3
227
+ [0.1.2]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.1...v0.1.2
228
+ [0.1.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.0...v0.1.1
229
+ [0.1.0]: https://github.com/xonaman/nodejs-qpdf-compress/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # qpdf-compress
2
2
 
3
+ [![CI](https://github.com/xonaman/nodejs-qpdf-compress/actions/workflows/ci.yml/badge.svg)](https://github.com/xonaman/nodejs-qpdf-compress/actions/workflows/ci.yml)
3
4
  [![npm version](https://img.shields.io/npm/v/qpdf-compress)](https://www.npmjs.com/package/qpdf-compress)
4
5
  [![Node.js](https://img.shields.io/node/v/qpdf-compress)](https://nodejs.org)
5
6
  [![License](https://img.shields.io/npm/l/qpdf-compress)](https://github.com/xonaman/nodejs-qpdf-compress/blob/main/LICENSE)
6
- [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)]()
7
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)](#-supported-platforms)
7
8
 
8
9
  Native PDF compression for Node.js — powered by [QPDF](https://qpdf.sourceforge.io/), the industry-standard PDF transformation library. Built as a C++ addon with N-API for ABI stability across Node.js versions.
9
10
 
@@ -40,7 +41,7 @@ const smaller = await compress(pdfBuffer, { lossy: true });
40
41
  **🔒 Reliability**
41
42
 
42
43
  - Built on QPDF — the most widely used PDF structural transformation library
43
- - ABI-stable via N-API — works across Node.js 18–24 without recompilation
44
+ - ABI-stable via N-API — works across Node.js 22+ without recompilation
44
45
  - Automatic repair — damaged PDFs are reconstructed during compression
45
46
  - Apache-2.0 licensed — no AGPL/copyleft concerns
46
47
 
@@ -0,0 +1,44 @@
1
+ # Third-Party Notices
2
+
3
+ `qpdf-compress` is distributed under the [Apache-2.0](./LICENSE) license. The
4
+ published package and its prebuilt native binaries statically link the
5
+ third-party libraries listed below. Each remains under its own license, and the
6
+ full license text of each is available from the upstream project linked.
7
+
8
+ | Library | Version | License | Source |
9
+ | -------- | ------------------ | --------------------------------------------------------- | -------------------------------------- |
10
+ | QPDF | 12.3.2 | Apache-2.0 | <https://github.com/qpdf/qpdf> |
11
+ | mozjpeg | 4.1.1 | BSD-3-Clause and IJG (with zlib-licensed SIMD components) | <https://github.com/mozilla/mozjpeg> |
12
+ | HarfBuzz | 14.2.1 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
13
+ | zlib | bundled on Windows | zlib License | <https://github.com/madler/zlib> |
14
+
15
+ ## Details
16
+
17
+ ### QPDF
18
+
19
+ Copyright Jay Berkenbilt and QPDF contributors. Licensed under the Apache
20
+ License, Version 2.0. QPDF performs the structural PDF transformation and
21
+ compression. See <https://github.com/qpdf/qpdf/blob/main/LICENSE.txt>.
22
+
23
+ ### mozjpeg
24
+
25
+ Copyright the Mozilla Foundation and contributors; derived from libjpeg-turbo
26
+ and the Independent JPEG Group (IJG) library. mozjpeg is covered by three
27
+ licenses: the IJG License, a BSD-3-Clause-style license for libjpeg-turbo
28
+ modifications, and the zlib License for the SIMD extensions. mozjpeg is used for
29
+ lossless JPEG Huffman optimization and lossy JPEG re-encoding. See
30
+ <https://github.com/mozilla/mozjpeg/blob/master/LICENSE.md>.
31
+
32
+ ### HarfBuzz
33
+
34
+ Copyright the HarfBuzz contributors. Licensed under the "Old MIT" license. Used
35
+ for TrueType/OpenType font handling and subsetting (`harfbuzz` and
36
+ `harfbuzz-subset`). See
37
+ <https://github.com/harfbuzz/harfbuzz/blob/main/COPYING>.
38
+
39
+ ### zlib
40
+
41
+ Copyright Jean-loup Gailly and Mark Adler. Licensed under the zlib License.
42
+ zlib provides DEFLATE compression and is statically linked into the Windows
43
+ build (via vcpkg); on macOS and Linux the system zlib is used at build time.
44
+ See <https://github.com/madler/zlib/blob/master/LICENSE>.
package/binding.gyp CHANGED
@@ -2,6 +2,9 @@
2
2
  "targets": [
3
3
  {
4
4
  "target_name": "qpdf_compress",
5
+ "variables": {
6
+ "cpp_std%": "c++20"
7
+ },
5
8
  "sources": [
6
9
  "src/qpdf_addon.cc",
7
10
  "src/jpeg.cc",
@@ -20,7 +23,7 @@
20
23
  "src"
21
24
  ],
22
25
  "defines": [
23
- "NAPI_VERSION=8"
26
+ "NAPI_VERSION=9"
24
27
  ],
25
28
  "cflags!": [
26
29
  "-fno-exceptions"
@@ -35,7 +38,7 @@
35
38
  "-fno-exceptions"
36
39
  ],
37
40
  "cflags_cc": [
38
- "-std=c++17",
41
+ "-std=<(cpp_std)",
39
42
  "-fvisibility=hidden"
40
43
  ],
41
44
  "conditions": [
@@ -49,7 +52,7 @@
49
52
  ],
50
53
  "xcode_settings": {
51
54
  "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
52
- "CLANG_CXX_LANGUAGE_STANDARD": "c++17",
55
+ "CLANG_CXX_LANGUAGE_STANDARD": "<(cpp_std)",
53
56
  "GCC_SYMBOLS_PRIVATE_EXTERN": "YES",
54
57
  "DEAD_CODE_STRIPPING": "YES",
55
58
  "LLVM_LTO": "YES",
@@ -106,7 +109,7 @@
106
109
  "WholeProgramOptimization": "true",
107
110
  "RuntimeLibrary": 0,
108
111
  "AdditionalOptions": [
109
- "/std:c++17"
112
+ "/std:<(cpp_std)"
110
113
  ]
111
114
  },
112
115
  "VCLinkerTool": {
@@ -1,5 +1,5 @@
1
1
  import { availableParallelism } from 'node:os';
2
- import pLimit from 'p-limit';
2
+ import pLimit, {} from 'p-limit';
3
3
  let limit = pLimit(availableParallelism());
4
4
  /**
5
5
  * Gets or sets the maximum number of concurrent operations.
@@ -1 +1 @@
1
- {"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,MAA8B,MAAM,SAAS,CAAC;AAErD,IAAI,KAAK,GAAkB,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;QACpE,CAAC;QACD,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,WAAW,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,EAAoB;IACrD,OAAO,KAAK,CAAC,EAAE,CAAC,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,MAAM,EAAE,EAAsB,MAAM,SAAS,CAAC;AAErD,IAAI,KAAK,GAAkB,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;QACpE,CAAC;QACD,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,WAAW,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,EAAoB;IACrD,OAAO,KAAK,CAAC,EAAE,CAAC,CAAC;AACnB,CAAC"}
@@ -0,0 +1,20 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export declare class QpdfError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ /** Thrown when an input or output file cannot be read or written. */
7
+ export declare class QpdfFileError extends QpdfError {
8
+ constructor(message: string);
9
+ }
10
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
11
+ export declare class QpdfFormatError extends QpdfError {
12
+ constructor(message: string);
13
+ }
14
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
15
+ export declare class QpdfPasswordError extends QpdfError {
16
+ constructor(message: string);
17
+ }
18
+ /** Decode the native "CODE:message" convention into a typed error. */
19
+ export declare function parseNativeError(err: unknown): QpdfError;
20
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../lib/errors.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,qBAAa,SAAU,SAAQ,KAAK;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBACV,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK1C;AAED,qEAAqE;AACrE,qBAAa,aAAc,SAAQ,SAAS;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAED,6EAA6E;AAC7E,qBAAa,eAAgB,SAAQ,SAAS;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED,gFAAgF;AAChF,qBAAa,iBAAkB,SAAQ,SAAS;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAkBxD"}
package/dist/errors.js ADDED
@@ -0,0 +1,50 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export class QpdfError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = 'QpdfError';
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** Thrown when an input or output file cannot be read or written. */
11
+ export class QpdfFileError extends QpdfError {
12
+ constructor(message) {
13
+ super('FILE', message);
14
+ this.name = 'QpdfFileError';
15
+ }
16
+ }
17
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
18
+ export class QpdfFormatError extends QpdfError {
19
+ constructor(message) {
20
+ super('FORMAT', message);
21
+ this.name = 'QpdfFormatError';
22
+ }
23
+ }
24
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
25
+ export class QpdfPasswordError extends QpdfError {
26
+ constructor(message) {
27
+ super('PASSWORD', message);
28
+ this.name = 'QpdfPasswordError';
29
+ }
30
+ }
31
+ /** Decode the native "CODE:message" convention into a typed error. */
32
+ export function parseNativeError(err) {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ const colonIdx = msg.indexOf(':');
35
+ if (colonIdx === -1)
36
+ return new QpdfError('UNKNOWN', msg);
37
+ const code = msg.slice(0, colonIdx);
38
+ const text = msg.slice(colonIdx + 1);
39
+ switch (code) {
40
+ case 'FILE':
41
+ return new QpdfFileError(text);
42
+ case 'FORMAT':
43
+ return new QpdfFormatError(text);
44
+ case 'PASSWORD':
45
+ return new QpdfPasswordError(text);
46
+ default:
47
+ return new QpdfError(code, text);
48
+ }
49
+ }
50
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../lib/errors.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,MAAM,OAAO,SAAU,SAAQ,KAAK;IACzB,IAAI,CAAS;IACtB,YAAY,IAAY,EAAE,OAAe;QACvC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,qEAAqE;AACrE,MAAM,OAAO,aAAc,SAAQ,SAAS;IAC1C,YAAY,OAAe;QACzB,KAAK,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED,6EAA6E;AAC7E,MAAM,OAAO,eAAgB,SAAQ,SAAS;IAC5C,YAAY,OAAe;QACzB,KAAK,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF;AAED,gFAAgF;AAChF,MAAM,OAAO,iBAAkB,SAAQ,SAAS;IAC9C,YAAY,OAAe;QACzB,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF;AAED,sEAAsE;AACtE,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,QAAQ,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,SAAS,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAE1D,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;IAErC,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,MAAM;YACT,OAAO,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;QACjC,KAAK,QAAQ;YACX,OAAO,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,UAAU;YACb,OAAO,IAAI,iBAAiB,CAAC,IAAI,CAAC,CAAC;QACrC;YACE,OAAO,IAAI,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrC,CAAC;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -17,5 +17,6 @@ export declare function compress(input: PdfInput, options: CompressOptions & {
17
17
  }): Promise<void>;
18
18
  export declare function compress(input: PdfInput, options?: CompressOptions): Promise<Buffer>;
19
19
  export { concurrency } from './concurrency.js';
20
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
20
21
  export type { CompressOptions } from './types.js';
21
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAY/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AAuBtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAc/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AA2BtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -2,12 +2,15 @@ import { createRequire } from 'node:module';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { withConcurrency } from './concurrency.js';
5
+ import { parseNativeError } from './errors.js';
5
6
  const require = createRequire(import.meta.url);
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const addonDir = resolve(__dirname, '..', 'build', 'Release');
8
9
  if (process.platform === 'win32') {
9
10
  process.env.PATH = `${addonDir};${process.env.PATH ?? ''}`;
10
11
  }
12
+ // The native addon is loaded through createRequire, so its export is `any` at
13
+ // the module boundary; assert it to the typed surface once, here.
11
14
  const addon = require('../build/Release/qpdf_compress.node');
12
15
  export async function compress(input, options) {
13
16
  if (Buffer.isBuffer(input)) {
@@ -24,11 +27,17 @@ export async function compress(input, options) {
24
27
  throw new TypeError('Input must be a Buffer or file path string');
25
28
  }
26
29
  const stripMetadata = options?.stripMetadata ?? true;
27
- return withConcurrency(() => addon.compress(input, {
28
- ...(options?.lossy ? { lossy: true } : {}),
29
- ...(stripMetadata ? { stripMetadata: true } : {}),
30
- ...(options?.output ? { output: options.output } : {}),
31
- }));
30
+ try {
31
+ return await withConcurrency(() => addon.compress(input, {
32
+ ...(options?.lossy ? { lossy: true } : {}),
33
+ ...(stripMetadata ? { stripMetadata: true } : {}),
34
+ ...(options?.output ? { output: options.output } : {}),
35
+ }));
36
+ }
37
+ catch (err) {
38
+ throw parseNativeError(err);
39
+ }
32
40
  }
33
41
  export { concurrency } from './concurrency.js';
42
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
34
43
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGnD,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,MAAM,KAAK,GAAgB,OAAO,CAAC,qCAAqC,CAAC,CAAC;AAqB1E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,OAAO,eAAe,CAAC,GAAG,EAAE,CAC1B,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;QACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC,CACuB,CAAC;AAC9B,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,8EAA8E;AAC9E,kEAAkE;AAClE,MAAM,KAAK,GAAG,OAAO,CAAC,qCAAqC,CAAgB,CAAC;AAqB5E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,IAAI,CAAC;QACH,OAAO,MAAM,eAAe,CAAC,GAAG,EAAE,CAChC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;YACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvD,CAAC,CACH,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,gBAAgB,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
package/lib/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export class QpdfError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string) {
5
+ super(message);
6
+ this.name = 'QpdfError';
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ /** Thrown when an input or output file cannot be read or written. */
12
+ export class QpdfFileError extends QpdfError {
13
+ constructor(message: string) {
14
+ super('FILE', message);
15
+ this.name = 'QpdfFileError';
16
+ }
17
+ }
18
+
19
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
20
+ export class QpdfFormatError extends QpdfError {
21
+ constructor(message: string) {
22
+ super('FORMAT', message);
23
+ this.name = 'QpdfFormatError';
24
+ }
25
+ }
26
+
27
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
28
+ export class QpdfPasswordError extends QpdfError {
29
+ constructor(message: string) {
30
+ super('PASSWORD', message);
31
+ this.name = 'QpdfPasswordError';
32
+ }
33
+ }
34
+
35
+ /** Decode the native "CODE:message" convention into a typed error. */
36
+ export function parseNativeError(err: unknown): QpdfError {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ const colonIdx = msg.indexOf(':');
39
+ if (colonIdx === -1) return new QpdfError('UNKNOWN', msg);
40
+
41
+ const code = msg.slice(0, colonIdx);
42
+ const text = msg.slice(colonIdx + 1);
43
+
44
+ switch (code) {
45
+ case 'FILE':
46
+ return new QpdfFileError(text);
47
+ case 'FORMAT':
48
+ return new QpdfFormatError(text);
49
+ case 'PASSWORD':
50
+ return new QpdfPasswordError(text);
51
+ default:
52
+ return new QpdfError(code, text);
53
+ }
54
+ }
package/lib/index.ts CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { withConcurrency } from './concurrency.js';
5
+ import { parseNativeError } from './errors.js';
5
6
  import type { CompressOptions, NativeAddon } from './types.js';
6
7
 
7
8
  const require = createRequire(import.meta.url);
@@ -12,7 +13,9 @@ if (process.platform === 'win32') {
12
13
  process.env.PATH = `${addonDir};${process.env.PATH ?? ''}`;
13
14
  }
14
15
 
15
- const addon: NativeAddon = require('../build/Release/qpdf_compress.node');
16
+ // The native addon is loaded through createRequire, so its export is `any` at
17
+ // the module boundary; assert it to the typed surface once, here.
18
+ const addon = require('../build/Release/qpdf_compress.node') as NativeAddon;
16
19
 
17
20
  type PdfInput = Buffer | string;
18
21
 
@@ -46,14 +49,19 @@ export async function compress(input: PdfInput, options?: CompressOptions): Prom
46
49
  throw new TypeError('Input must be a Buffer or file path string');
47
50
  }
48
51
  const stripMetadata = options?.stripMetadata ?? true;
49
- return withConcurrency(() =>
50
- addon.compress(input, {
51
- ...(options?.lossy ? { lossy: true } : {}),
52
- ...(stripMetadata ? { stripMetadata: true } : {}),
53
- ...(options?.output ? { output: options.output } : {}),
54
- }),
55
- ) as Promise<Buffer | void>;
52
+ try {
53
+ return await withConcurrency(() =>
54
+ addon.compress(input, {
55
+ ...(options?.lossy ? { lossy: true } : {}),
56
+ ...(stripMetadata ? { stripMetadata: true } : {}),
57
+ ...(options?.output ? { output: options.output } : {}),
58
+ }),
59
+ );
60
+ } catch (err) {
61
+ throw parseNativeError(err);
62
+ }
56
63
  }
57
64
 
58
65
  export { concurrency } from './concurrency.js';
66
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
59
67
  export type { CompressOptions } from './types.js';
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.6.4",
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",
7
+ "packageManager": "npm@11.17.0",
7
8
  "engines": {
8
- "node": ">=20.11.0"
9
+ "node": ">=22.0.0"
9
10
  },
10
11
  "keywords": [
11
12
  "pdf",
@@ -31,7 +32,8 @@
31
32
  ".": {
32
33
  "types": "./dist/index.d.ts",
33
34
  "default": "./dist/index.js"
34
- }
35
+ },
36
+ "./package.json": "./package.json"
35
37
  },
36
38
  "files": [
37
39
  "dist",
@@ -41,6 +43,7 @@
41
43
  "binding.gyp",
42
44
  "README.md",
43
45
  "CHANGELOG.md",
46
+ "THIRD-PARTY-NOTICES.md",
44
47
  "LICENSE"
45
48
  ],
46
49
  "repository": {
@@ -73,7 +76,10 @@
73
76
  "download:mozjpeg": "node scripts/download-mozjpeg.mjs",
74
77
  "download:qpdf": "node scripts/download-qpdf.mjs",
75
78
  "download:harfbuzz": "node scripts/download-harfbuzz.mjs",
79
+ "verify:checksums": "node scripts/verify-checksums.mjs",
76
80
  "test": "vitest run",
81
+ "test:coverage": "vitest run --coverage",
82
+ "typecheck": "tsc --noEmit",
77
83
  "lint": "eslint --fix .",
78
84
  "lint:check": "eslint .",
79
85
  "format": "prettier --write .",
@@ -81,8 +87,8 @@
81
87
  "prepare": "husky && tsc"
82
88
  },
83
89
  "lint-staged": {
84
- "*.{ts,js,json,md,yaml,yml}": "prettier --write",
85
- "*.{ts,js}": "eslint --max-warnings 0"
90
+ "*.{ts,js,mjs,cjs,json,md,yaml,yml}": "prettier --write",
91
+ "*.{ts,js,mjs,cjs}": "eslint --max-warnings 0"
86
92
  },
87
93
  "dependencies": {
88
94
  "node-addon-api": "^8.0.0",
@@ -90,16 +96,17 @@
90
96
  },
91
97
  "devDependencies": {
92
98
  "@eslint/js": "^10.0.1",
93
- "@types/node": "^25.5.0",
99
+ "@types/node": "^22.0.0",
100
+ "@vitest/coverage-v8": "^4.1.4",
94
101
  "eslint": "^10.1.0",
95
102
  "eslint-config-prettier": "^10.1.8",
96
- "eslint-plugin-prettier": "^5.5.5",
103
+ "globals": "^17.0.0",
97
104
  "husky": "^9.1.7",
98
- "lint-staged": "^16.4.0",
105
+ "lint-staged": "^17.0.8",
99
106
  "node-gyp": "^13.0.0",
100
107
  "prettier": "^3.8.1",
101
- "typescript": "^5.9.3",
108
+ "typescript": "^6.0.3",
102
109
  "typescript-eslint": "^8.57.2",
103
- "vitest": "^4.1.2"
110
+ "vitest": "^4.1.4"
104
111
  }
105
112
  }
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const HARFBUZZ_VERSION = '14.1.0';
8
- const BASE_URL = 'https://github.com/harfbuzz/harfbuzz/archive/refs/tags';
6
+ const { harfbuzz: dep } = loadManifest();
7
+ const HARFBUZZ_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'harfbuzz');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(HARFBUZZ_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/${HARFBUZZ_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `harfbuzz-${HARFBUZZ_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `harfbuzz-${HARFBUZZ_VERSION}`);
27
25
  const buildDir = join(root, 'build-harfbuzz');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading HarfBuzz ${HARFBUZZ_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `harfbuzz-{tag}` — find it
47
38
  if (!existsSync(srcDir)) {
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const MOZJPEG_VERSION = '4.1.1';
8
- const BASE_URL = 'https://github.com/mozilla/mozjpeg/archive/refs/tags';
6
+ const { mozjpeg: dep } = loadManifest();
7
+ const MOZJPEG_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'mozjpeg');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(MOZJPEG_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/v${MOZJPEG_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `mozjpeg-${MOZJPEG_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `mozjpeg-${MOZJPEG_VERSION}`);
27
25
  const buildDir = join(root, 'build-mozjpeg');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading mozjpeg ${MOZJPEG_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `mozjpeg-{tag}` — find the directory
47
38
  if (!existsSync(srcDir)) {
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const QPDF_VERSION = '12.3.2';
8
- const BASE_URL = 'https://github.com/qpdf/qpdf/archive/refs/tags';
6
+ const { qpdf: dep } = loadManifest();
7
+ const QPDF_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'qpdf');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(QPDF_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/v${QPDF_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `qpdf-${QPDF_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `qpdf-${QPDF_VERSION}`);
27
25
  const buildDir = join(root, 'build-qpdf');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading QPDF ${QPDF_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `qpdf-{tag}` (without 'v' prefix)
47
38
  // but may also be `qpdf-qpdf-{ver}` depending on repo naming — find it
@@ -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
+ }
@@ -6,16 +6,30 @@
6
6
  * Musl (Alpine) naming: qpdf-compress-v{version}-linux-musl-{arch}.tar.gz
7
7
  * Contents: build/Release/qpdf_compress.node
8
8
  */
9
- import { execFileSync, execSync } from 'node:child_process';
9
+ import { execSync } from 'node:child_process';
10
10
  import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
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, verifyPrebuild } from './lib/integrity.mjs';
14
15
 
15
16
  const root = join(import.meta.dirname, '..');
16
17
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
17
18
  const version = pkg.version;
18
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
+
19
33
  // validate version to prevent SSRF via malicious package.json
20
34
  if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) {
21
35
  console.error(`Invalid version format: ${version}`);
@@ -57,7 +71,7 @@ async function tryDownload() {
57
71
  if (releaseUrl.origin !== releaseOrigin) {
58
72
  throw new Error(`Unexpected URL origin: ${releaseUrl.origin}`);
59
73
  }
60
- const res = await fetch(releaseUrl, { redirect: 'follow' });
74
+ const res = await fetch(releaseUrl, { redirect: 'follow', signal: AbortSignal.timeout(60000) });
61
75
  if (!res.ok) {
62
76
  console.log(`No prebuilt binary found (HTTP ${res.status}), will compile from source.`);
63
77
  return false;
@@ -73,8 +87,25 @@ async function tryDownload() {
73
87
 
74
88
  await pipeline(Readable.fromWeb(body), fileStream);
75
89
 
76
- // extract tar.gz into project root
77
- execFileSync('tar', ['xzf', tmpTar, '-C', root], { stdio: 'inherit' });
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
+
107
+ // extract tar.gz into project root (hardened: no -P, no archived ownership)
108
+ extractTarball(tmpTar, root);
78
109
  unlinkSync(tmpTar);
79
110
 
80
111
  // verify the .node file exists
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared download/verify helpers for the install and dependency-build scripts.
3
+ *
4
+ * Goals:
5
+ * - never trust the network blindly: verify a pinned SHA-256 before use,
6
+ * - survive flaky connections: timeout + exponential-backoff retry,
7
+ * - never leave a half-written file behind: download to `<dest>.part` and
8
+ * rename atomically only after the checksum passes,
9
+ * - extract archives without restoring archived ownership or honouring
10
+ * absolute/`..` paths (tar's default, kept by NOT passing -P).
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import {
15
+ createReadStream,
16
+ createWriteStream,
17
+ mkdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ renameSync,
21
+ } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { Readable } from 'node:stream';
24
+ import { pipeline } from 'node:stream/promises';
25
+
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+
28
+ /** Compute the SHA-256 of a file as a lowercase hex string. */
29
+ export async function sha256File(path) {
30
+ const hash = createHash('sha256');
31
+ await pipeline(createReadStream(path), hash);
32
+ return hash.digest('hex');
33
+ }
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
+
58
+ /** Read the native-dependency manifest (scripts/native-deps.json). */
59
+ export function loadManifest() {
60
+ return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'native-deps.json'), 'utf8'));
61
+ }
62
+
63
+ /**
64
+ * Download `url` to `dest` with a per-attempt timeout and exponential-backoff
65
+ * retry. Writes to `<dest>.part` (same filesystem, so the rename is atomic) and
66
+ * only promotes it once the optional `expectedSha256` matches. A checksum
67
+ * mismatch or unexpected origin fails immediately (not retried).
68
+ *
69
+ * @param {string} url
70
+ * @param {string} dest
71
+ * @param {{ expectedSha256?: string, allowedOrigin?: string, timeoutMs?: number, retries?: number }} [opts]
72
+ */
73
+ export async function downloadFile(url, dest, opts = {}) {
74
+ const { expectedSha256, allowedOrigin, timeoutMs = 60000, retries = 4 } = opts;
75
+
76
+ const u = new URL(url);
77
+ if (allowedOrigin && u.origin !== allowedOrigin) {
78
+ throw new Error(`Refusing to download from unexpected origin: ${u.origin}`);
79
+ }
80
+
81
+ mkdirSync(dirname(dest), { recursive: true });
82
+ const tmp = `${dest}.part`;
83
+
84
+ let lastErr;
85
+ for (let attempt = 1; attempt <= retries; attempt++) {
86
+ try {
87
+ const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
88
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
89
+ if (!res.body) throw new Error('empty response body');
90
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
91
+
92
+ if (expectedSha256) {
93
+ const actual = await sha256File(tmp);
94
+ if (actual !== expectedSha256.toLowerCase()) {
95
+ throw new Error(
96
+ `checksum mismatch for ${u.href}\n expected ${expectedSha256}\n actual ${actual}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ renameSync(tmp, dest);
102
+ return dest;
103
+ } catch (err) {
104
+ lastErr = err;
105
+ rmSync(tmp, { force: true });
106
+ const msg = String(err?.message ?? err);
107
+ // integrity failures are not transient — surface them immediately
108
+ if (msg.includes('checksum mismatch') || msg.includes('unexpected origin')) throw err;
109
+ if (attempt < retries) {
110
+ const backoff = Math.min(1000 * 2 ** (attempt - 1), 8000);
111
+ console.warn(` attempt ${attempt}/${retries} failed (${msg}); retrying in ${backoff}ms`);
112
+ await sleep(backoff);
113
+ }
114
+ }
115
+ }
116
+ throw new Error(`Failed to download ${u.href} after ${retries} attempts: ${lastErr?.message}`);
117
+ }
118
+
119
+ /**
120
+ * Extract a .tar.gz / .tar.xz into `destDir`. Compression is auto-detected.
121
+ * Not passing `-P` keeps tar's default refusal of absolute and `..` paths;
122
+ * `--no-same-owner` avoids restoring archived uid/gid.
123
+ *
124
+ * stdin is redirected from /dev/null: tar never reads it for `-xf`, and on
125
+ * Windows an inherited console stdin can deadlock a spawned decompressor.
126
+ */
127
+ export function extractTarball(tarball, destDir, { strip } = {}) {
128
+ mkdirSync(destDir, { recursive: true });
129
+ const args = ['-xf', tarball, '-C', destDir, '--no-same-owner'];
130
+ if (strip) args.push('--strip-components', String(strip));
131
+ execFileSync('tar', args, { stdio: ['ignore', 'inherit', 'inherit'] });
132
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "qpdf": {
3
+ "version": "12.3.2",
4
+ "origin": "https://github.com",
5
+ "url": "https://github.com/qpdf/qpdf/releases/download/v12.3.2/qpdf-12.3.2.tar.gz",
6
+ "sha256": "6cba2f9f2cd887d905faeb99e0e51a307b217920d1bbf3e9cfbb2e8178a2deda",
7
+ "checksumSource": "official PGP-signed release manifest (qpdf-12.3.2.sha256)"
8
+ },
9
+ "harfbuzz": {
10
+ "version": "14.2.1",
11
+ "origin": "https://github.com",
12
+ "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.2.1.tar.gz",
13
+ "sha256": "3c2a9006a7e1bf58737e557014d7882c554c628fb379a9f00008f5ea53dbbdfb",
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
+ },
16
+ "mozjpeg": {
17
+ "version": "4.1.1",
18
+ "origin": "https://github.com",
19
+ "url": "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.1.1.tar.gz",
20
+ "sha256": "66b1b8d6b55d263f35f27f55acaaa3234df2a401232de99b6d099e2bb0a9d196",
21
+ "checksumSource": "self-computed from the GitHub archive tarball (mozilla/mozjpeg publishes no release asset or checksum)"
22
+ }
23
+ }
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Verify that the SHA-256 hashes pinned in native-deps.json still match what
3
+ * the upstream URLs serve. Use after a version bump, or in CI as a supply-chain
4
+ * tripwire. By default only this platform's artifacts are checked; pass --all
5
+ * to verify every pinned target (large download for prebuilt-binary deps).
6
+ */
7
+ import { rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { downloadFile, loadManifest } from './lib/integrity.mjs';
11
+
12
+ const all = process.argv.includes('--all');
13
+ const manifest = loadManifest();
14
+ const platform = process.platform;
15
+ const arch = process.arch;
16
+
17
+ const checks = [];
18
+ for (const [name, dep] of Object.entries(manifest)) {
19
+ if (dep.targets) {
20
+ for (const [key, t] of Object.entries(dep.targets)) {
21
+ if (!all && key !== `${platform}-${arch}` && key !== `${platform}-musl-${arch}`) continue;
22
+ checks.push({
23
+ label: `${name} ${key}`,
24
+ url: `${dep.baseUrl}/${encodeURIComponent(dep.version)}/${t.asset}`,
25
+ sha256: t.sha256,
26
+ origin: dep.origin,
27
+ });
28
+ }
29
+ } else {
30
+ checks.push({
31
+ label: `${name} ${dep.version}`,
32
+ url: dep.url,
33
+ sha256: dep.sha256,
34
+ origin: dep.origin,
35
+ });
36
+ }
37
+ }
38
+
39
+ let failed = 0;
40
+ for (const c of checks) {
41
+ const tmp = join(tmpdir(), `verify-${c.label.replace(/\W+/g, '-')}`);
42
+ try {
43
+ process.stdout.write(`Verifying ${c.label} ... `);
44
+ await downloadFile(c.url, tmp, { expectedSha256: c.sha256, allowedOrigin: c.origin });
45
+ console.log('OK');
46
+ } catch (err) {
47
+ console.log(`FAIL\n ${err.message}`);
48
+ failed++;
49
+ } finally {
50
+ rmSync(tmp, { force: true });
51
+ }
52
+ }
53
+
54
+ if (failed) {
55
+ console.error(`\n${failed} checksum(s) did not match.`);
56
+ process.exit(1);
57
+ }
58
+ console.log(`\nAll ${checks.length} checksum(s) verified.`);
package/src/qpdf_addon.cc CHANGED
@@ -1,6 +1,8 @@
1
1
  #include <napi.h>
2
2
 
3
+ #include <algorithm>
3
4
  #include <atomic>
5
+ #include <cctype>
4
6
  #include <cerrno>
5
7
  #include <cstdio>
6
8
  #include <cstdlib>
@@ -52,6 +54,28 @@ static std::string writeToFile(const std::string &path, const uint8_t *data,
52
54
  return {};
53
55
  }
54
56
 
57
+ // ---------------------------------------------------------------------------
58
+ // Error classification — prefix a native error message with a stable CODE so
59
+ // the JS layer (parseNativeError) can map it to a typed QpdfError subclass.
60
+ // Mirrors the "CODE:message" convention used by pdfium-native.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ static std::string classifyError(const std::string &what) {
64
+ std::string lower = what;
65
+ std::transform(lower.begin(), lower.end(), lower.begin(),
66
+ [](unsigned char c) { return std::tolower(c); });
67
+ auto has = [&](const char *needle) {
68
+ return lower.find(needle) != std::string::npos;
69
+ };
70
+ if (has("password") || has("encrypted"))
71
+ return "PASSWORD:" + what;
72
+ if (has("damaged") || has("not a pdf") || has("not well-formed") ||
73
+ has("invalid pdf") || has("corrupt") || has("xref") ||
74
+ has("unable to find") || has("/root"))
75
+ return "FORMAT:" + what;
76
+ return "COMPRESS:" + what;
77
+ }
78
+
55
79
  // ---------------------------------------------------------------------------
56
80
  // Per-environment alive flag for worker thread safety.
57
81
  // When a Node.js worker thread is terminated, the V8 isolate tears down but
@@ -125,7 +149,7 @@ protected:
125
149
 
126
150
  if (useFile_) {
127
151
  if (access(filePath_.c_str(), F_OK) != 0) {
128
- SetError("Input file not found: " + filePath_);
152
+ SetError("FILE:Input file not found: " + filePath_);
129
153
  return;
130
154
  }
131
155
  qpdf->processFile(filePath_.c_str());
@@ -192,7 +216,7 @@ protected:
192
216
  auto err = writeToFile(outputPath_, writerBuf_->getBuffer(),
193
217
  writerBuf_->getSize());
194
218
  if (!err.empty()) {
195
- SetError(err);
219
+ SetError("FILE:" + err);
196
220
  return;
197
221
  }
198
222
  writerBuf_.reset();
@@ -204,7 +228,7 @@ protected:
204
228
  qpdf.reset();
205
229
  } catch (...) {
206
230
  }
207
- SetError(e.what());
231
+ SetError(classifyError(e.what()));
208
232
  } catch (...) {
209
233
  // on macOS/libc++, catch(std::exception&) may fail to match QPDFExc
210
234
  // due to typeinfo visibility mismatch between the static library and
@@ -213,7 +237,7 @@ protected:
213
237
  qpdf.reset();
214
238
  } catch (...) {
215
239
  }
216
- SetError("PDF compression failed");
240
+ SetError("UNKNOWN:PDF compression failed");
217
241
  }
218
242
  }
219
243