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 +71 -0
- package/README.md +3 -2
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/binding.gyp +7 -4
- package/dist/concurrency.js +1 -1
- package/dist/concurrency.js.map +1 -1
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -5
- package/dist/index.js.map +1 -1
- package/lib/errors.ts +54 -0
- package/lib/index.ts +16 -8
- package/package.json +17 -10
- package/scripts/download-harfbuzz.mjs +10 -19
- package/scripts/download-mozjpeg.mjs +10 -19
- package/scripts/download-qpdf.mjs +10 -19
- package/scripts/gen-prebuild-manifest.mjs +59 -0
- package/scripts/install.mjs +35 -4
- package/scripts/lib/integrity.mjs +132 -0
- package/scripts/native-deps.json +23 -0
- package/scripts/prebuilds.json +11 -0
- package/scripts/update-native-deps.mjs +129 -0
- package/scripts/verify-checksums.mjs +58 -0
- package/src/qpdf_addon.cc +28 -4
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
|
+
[](https://github.com/xonaman/nodejs-qpdf-compress/actions/workflows/ci.yml)
|
|
3
4
|
[](https://www.npmjs.com/package/qpdf-compress)
|
|
4
5
|
[](https://nodejs.org)
|
|
5
6
|
[](https://github.com/xonaman/nodejs-qpdf-compress/blob/main/LICENSE)
|
|
6
|
-
[]()
|
|
7
|
+
[](#-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
|
|
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=
|
|
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
|
|
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": "
|
|
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
|
|
112
|
+
"/std:<(cpp_std)"
|
|
110
113
|
]
|
|
111
114
|
},
|
|
112
115
|
"VCLinkerTool": {
|
package/dist/concurrency.js
CHANGED
package/dist/concurrency.js.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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": ">=
|
|
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": "^
|
|
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
|
-
"
|
|
103
|
+
"globals": "^17.0.0",
|
|
97
104
|
"husky": "^9.1.7",
|
|
98
|
-
"lint-staged": "^
|
|
105
|
+
"lint-staged": "^17.0.8",
|
|
99
106
|
"node-gyp": "^13.0.0",
|
|
100
107
|
"prettier": "^3.8.1",
|
|
101
|
-
"typescript": "^
|
|
108
|
+
"typescript": "^6.0.3",
|
|
102
109
|
"typescript-eslint": "^8.57.2",
|
|
103
|
-
"vitest": "^4.1.
|
|
110
|
+
"vitest": "^4.1.4"
|
|
104
111
|
}
|
|
105
112
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
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
|
|
8
|
-
const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
8
|
-
const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
8
|
-
const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
package/scripts/install.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
77
|
-
|
|
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
|
|