pdf-visual-compare 3.4.0 → 4.0.0
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/README.md +326 -72
- package/out/comparePdf.d.ts +48 -6
- package/out/comparePdf.js +44 -78
- package/out/const.d.ts +2 -2
- package/out/const.js +5 -3
- package/out/errors/ComparePdfComparisonError.d.ts +6 -0
- package/out/errors/ComparePdfComparisonError.js +10 -0
- package/out/errors/ComparePdfConfigurationError.d.ts +6 -0
- package/out/errors/ComparePdfConfigurationError.js +10 -0
- package/out/errors/ComparePdfError.d.ts +6 -0
- package/out/errors/ComparePdfError.js +13 -0
- package/out/errors/ComparePdfInputError.d.ts +6 -0
- package/out/errors/ComparePdfInputError.js +10 -0
- package/out/errors/ComparePdfRenderingError.d.ts +6 -0
- package/out/errors/ComparePdfRenderingError.js +10 -0
- package/out/index.d.ts +16 -2
- package/out/index.js +14 -2
- package/out/internal/adapters/comparePngOptions.d.ts +3 -0
- package/out/internal/adapters/comparePngOptions.js +41 -0
- package/out/internal/adapters/pdfRenderOptions.d.ts +3 -0
- package/out/internal/adapters/pdfRenderOptions.js +19 -0
- package/out/internal/comparePlannedPage.d.ts +3 -0
- package/out/internal/comparePlannedPage.js +72 -0
- package/out/internal/diffOutputGuards.d.ts +56 -0
- package/out/internal/diffOutputGuards.js +176 -0
- package/out/internal/normalizeComparisonOptions.d.ts +2 -0
- package/out/internal/normalizeComparisonOptions.js +104 -0
- package/out/internal/normalizePdfInput.d.ts +13 -0
- package/out/internal/normalizePdfInput.js +110 -0
- package/out/internal/pageComparisonPlan.d.ts +4 -0
- package/out/internal/pageComparisonPlan.js +35 -0
- package/out/internal/renderOutputFolderGuards.d.ts +22 -0
- package/out/internal/renderOutputFolderGuards.js +70 -0
- package/out/internal/renderPdfPages.d.ts +10 -0
- package/out/internal/renderPdfPages.js +105 -0
- package/out/internal/securePath.d.ts +34 -0
- package/out/internal/securePath.js +75 -0
- package/out/internal/types.d.ts +26 -0
- package/out/internal/types.js +2 -0
- package/out/types/ComparePdfDetailedResult.d.ts +35 -0
- package/out/types/ComparePdfDetailedResult.js +2 -0
- package/out/types/ComparePdfOptions.d.ts +33 -11
- package/out/types/ComparePdfPageResult.d.ts +42 -0
- package/out/types/ComparePdfPageResult.js +2 -0
- package/out/types/ComparePdfPageStatus.d.ts +4 -0
- package/out/types/ComparePdfPageStatus.js +2 -0
- package/out/types/ExcludedPageArea.d.ts +3 -33
- package/out/types/PageArea.d.ts +13 -0
- package/out/types/PageArea.js +2 -0
- package/out/types/PageExclusion.d.ts +46 -0
- package/out/types/PageExclusion.js +2 -0
- package/out/types/PdfInput.d.ts +11 -0
- package/out/types/PdfInput.js +2 -0
- package/out/types/PdfRenderOptions.d.ts +51 -0
- package/out/types/PdfRenderOptions.js +2 -0
- package/out/types/RgbColor.d.ts +11 -0
- package/out/types/RgbColor.js +2 -0
- package/package.json +86 -74
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateDiffsOutputFolder = validateDiffsOutputFolder;
|
|
4
|
+
exports.ensureDiffOutputDirectory = ensureDiffOutputDirectory;
|
|
5
|
+
exports.assertDiffOutputPathUsesRealFilesystemEntries = assertDiffOutputPathUsesRealFilesystemEntries;
|
|
6
|
+
exports.createSecureDiffOutputTempfile = createSecureDiffOutputTempfile;
|
|
7
|
+
exports.publishDiffOutputTempfile = publishDiffOutputTempfile;
|
|
8
|
+
exports.discardDiffOutputLeaf = discardDiffOutputLeaf;
|
|
9
|
+
exports.assertCanonicalDiffOutputPath = assertCanonicalDiffOutputPath;
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_path_1 = require("node:path");
|
|
13
|
+
const ComparePdfConfigurationError_js_1 = require("../errors/ComparePdfConfigurationError.js");
|
|
14
|
+
const const_js_1 = require("../const.js");
|
|
15
|
+
const securePath_js_1 = require("./securePath.js");
|
|
16
|
+
function validateDiffsOutputFolder(diffsOutputFolder) {
|
|
17
|
+
const outputFolder = diffsOutputFolder === undefined ? (0, const_js_1.getDefaultDiffsFolder)() : diffsOutputFolder;
|
|
18
|
+
if (typeof outputFolder !== 'string' || outputFolder.trim() === '') {
|
|
19
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('diffsOutputFolder must be a non-empty string.');
|
|
20
|
+
}
|
|
21
|
+
const resolvedOutputFolder = (0, node_path_1.resolve)(outputFolder);
|
|
22
|
+
if ((0, node_fs_1.existsSync)(resolvedOutputFolder) && !(0, node_fs_1.statSync)(resolvedOutputFolder).isDirectory()) {
|
|
23
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`diffsOutputFolder must point to a directory when it already exists: ${outputFolder}`);
|
|
24
|
+
}
|
|
25
|
+
(0, securePath_js_1.assertPathAndAncestorsAreNotSymbolicLinks)(resolvedOutputFolder, `Diff output path must stay within diffsOutputFolder: ${(0, node_path_1.resolve)(outputFolder)}`);
|
|
26
|
+
return resolvedOutputFolder;
|
|
27
|
+
}
|
|
28
|
+
function ensureDiffOutputDirectory(outputPath, diffsOutputFolder) {
|
|
29
|
+
try {
|
|
30
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(outputPath), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`diffsOutputFolder must point to a writable directory: ${diffsOutputFolder}`, { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function assertDiffOutputPathUsesRealFilesystemEntries(diffFilePath, diffsOutputFolder) {
|
|
37
|
+
const resolvedDiffsOutputFolder = (0, node_path_1.resolve)(diffsOutputFolder);
|
|
38
|
+
const diffsOutputFolderErrorMessage = `Diff output path must stay within diffsOutputFolder: ${(0, node_path_1.resolve)(diffsOutputFolder)}`;
|
|
39
|
+
if ((0, securePath_js_1.pathExistsWithoutFollowingSymlinks)(resolvedDiffsOutputFolder)) {
|
|
40
|
+
(0, securePath_js_1.assertPathIsNotSymbolicLink)(resolvedDiffsOutputFolder, diffsOutputFolderErrorMessage);
|
|
41
|
+
}
|
|
42
|
+
const relativeDiffPath = (0, node_path_1.relative)(resolvedDiffsOutputFolder, (0, node_path_1.resolve)(diffFilePath));
|
|
43
|
+
if (relativeDiffPath === '' || (0, node_path_1.isAbsolute)(relativeDiffPath) || relativeDiffPath.startsWith('..')) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let currentPath = resolvedDiffsOutputFolder;
|
|
47
|
+
for (const segment of relativeDiffPath.split(node_path_1.sep)) {
|
|
48
|
+
currentPath = (0, node_path_1.resolve)(currentPath, segment);
|
|
49
|
+
if (!(0, securePath_js_1.pathExistsWithoutFollowingSymlinks)(currentPath)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
(0, securePath_js_1.assertPathIsNotSymbolicLink)(currentPath, diffsOutputFolderErrorMessage);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Atomically creates a fresh, randomly-named tempfile in the same directory as the diff leaf
|
|
57
|
+
* and returns its path. The third-party comparator is then directed at this tempfile, and the
|
|
58
|
+
* result is later promoted into `diffFilePath` via {@link publishDiffOutputTempfile}.
|
|
59
|
+
*
|
|
60
|
+
* This two-step "stage then rename" pattern is materially stronger than writing directly to
|
|
61
|
+
* `diffFilePath`:
|
|
62
|
+
* - The tempfile name carries an unpredictable random suffix, so an attacker with write
|
|
63
|
+
* access in `diffsOutputFolder` cannot pre-plant a symlink at the future tempfile path.
|
|
64
|
+
* - The tempfile is opened with `O_CREAT | O_EXCL | O_NOFOLLOW`, so any racing attempt to
|
|
65
|
+
* pre-create a regular file or symlink at the same path during the open is rejected
|
|
66
|
+
* (CWE-61 / CWE-367).
|
|
67
|
+
* - The eventual atomic `renameSync` (see {@link publishDiffOutputTempfile}) replaces any
|
|
68
|
+
* pre-existing entry at `diffFilePath` — including a symlink planted there during the
|
|
69
|
+
* write window — without ever following the destination's symlink.
|
|
70
|
+
*/
|
|
71
|
+
function createSecureDiffOutputTempfile(diffFilePath, diffsOutputFolder) {
|
|
72
|
+
const tempfilePath = buildSecureTempfilePath(diffFilePath);
|
|
73
|
+
let fileDescriptor;
|
|
74
|
+
try {
|
|
75
|
+
fileDescriptor = (0, node_fs_1.openSync)(tempfilePath, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_CREAT | node_fs_1.constants.O_EXCL | node_fs_1.constants.O_NOFOLLOW, 0o600);
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
if (isSymbolicLinkAtLeafError(cause) || isExistingLeafError(cause)) {
|
|
79
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output path must stay within diffsOutputFolder: ${(0, node_path_1.resolve)(diffsOutputFolder)}`, { cause });
|
|
80
|
+
}
|
|
81
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`diffsOutputFolder must point to a writable directory: ${diffsOutputFolder}`, { cause });
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
if (fileDescriptor !== undefined) {
|
|
85
|
+
(0, node_fs_1.closeSync)(fileDescriptor);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return tempfilePath;
|
|
89
|
+
}
|
|
90
|
+
function buildSecureTempfilePath(diffFilePath) {
|
|
91
|
+
const parentDir = (0, node_path_1.dirname)(diffFilePath);
|
|
92
|
+
const leafName = (0, node_path_1.basename)(diffFilePath);
|
|
93
|
+
// 128 bits of randomness make pre-planting the tempfile path infeasible even for an
|
|
94
|
+
// attacker who can race writes inside the diffs folder.
|
|
95
|
+
const randomSuffix = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
96
|
+
return (0, node_path_1.resolve)(parentDir, `.${leafName}.${randomSuffix}.tmp`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Promotes the staged tempfile to `diffFilePath` via atomic `renameSync`. This is the
|
|
100
|
+
* core anti-TOCTOU primitive: `rename()` replaces any pre-existing file or symlink at
|
|
101
|
+
* the destination as a single directory-entry operation without ever following the
|
|
102
|
+
* destination. A symlink planted at `diffFilePath` during the write window is therefore
|
|
103
|
+
* harmlessly overwritten — the attacker's chosen target is never opened by this library.
|
|
104
|
+
*
|
|
105
|
+
* Throws `ComparePdfConfigurationError` when:
|
|
106
|
+
* - the tempfile is no longer a regular file (symlink swap during the comparator's write
|
|
107
|
+
* — bytes may have leaked through the swapped symlink to an attacker-chosen target);
|
|
108
|
+
* - a diff was expected but the tempfile is missing or zero bytes;
|
|
109
|
+
* - the tempfile cannot be inspected, or rename fails for reasons other than absence.
|
|
110
|
+
*/
|
|
111
|
+
function publishDiffOutputTempfile(tempfilePath, diffFilePath, diffsOutputFolder, options) {
|
|
112
|
+
let tempStats;
|
|
113
|
+
try {
|
|
114
|
+
tempStats = (0, node_fs_1.lstatSync)(tempfilePath);
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
if (isMissingLeafError(cause)) {
|
|
118
|
+
if (options.expectDiffWritten) {
|
|
119
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output was expected but is missing: ${(0, node_path_1.resolve)(diffFilePath)}`, { cause });
|
|
120
|
+
}
|
|
121
|
+
// Comparator skipped writing AND there is no tempfile — also drop any stale
|
|
122
|
+
// leaf at the published path so the on-disk shape matches pre-fix behavior.
|
|
123
|
+
discardDiffOutputLeaf(diffFilePath);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output path must stay within diffsOutputFolder: ${(0, node_path_1.resolve)(diffsOutputFolder)}`, { cause });
|
|
127
|
+
}
|
|
128
|
+
if (!tempStats.isFile()) {
|
|
129
|
+
discardDiffOutputLeaf(tempfilePath);
|
|
130
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output tempfile was replaced during the write window: ${(0, node_path_1.resolve)(tempfilePath)}`);
|
|
131
|
+
}
|
|
132
|
+
if (tempStats.size === 0) {
|
|
133
|
+
discardDiffOutputLeaf(tempfilePath);
|
|
134
|
+
if (options.expectDiffWritten) {
|
|
135
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output was expected but is empty: ${(0, node_path_1.resolve)(diffFilePath)}`);
|
|
136
|
+
}
|
|
137
|
+
discardDiffOutputLeaf(diffFilePath);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
(0, node_fs_1.renameSync)(tempfilePath, diffFilePath);
|
|
142
|
+
}
|
|
143
|
+
catch (cause) {
|
|
144
|
+
discardDiffOutputLeaf(tempfilePath);
|
|
145
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Failed to publish diff output to ${(0, node_path_1.resolve)(diffFilePath)}`, { cause });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Best-effort removal of a diff output file. Callers use this to roll back the staged
|
|
150
|
+
* tempfile when the third-party comparator throws before writing, and to clear stale
|
|
151
|
+
* leaves at the published path when no diff was produced for matching pages.
|
|
152
|
+
*/
|
|
153
|
+
function discardDiffOutputLeaf(diffFilePath) {
|
|
154
|
+
try {
|
|
155
|
+
(0, node_fs_1.unlinkSync)(diffFilePath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Best-effort cleanup only.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function isSymbolicLinkAtLeafError(cause) {
|
|
162
|
+
return cause instanceof Error && 'code' in cause && (cause.code === 'ELOOP' || cause.code === 'EMLINK');
|
|
163
|
+
}
|
|
164
|
+
function isExistingLeafError(cause) {
|
|
165
|
+
return cause instanceof Error && 'code' in cause && cause.code === 'EEXIST';
|
|
166
|
+
}
|
|
167
|
+
function isMissingLeafError(cause) {
|
|
168
|
+
return cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
|
|
169
|
+
}
|
|
170
|
+
function assertCanonicalDiffOutputPath(diffFilePath, diffsOutputFolder) {
|
|
171
|
+
const canonicalDiffsOutputFolder = (0, node_fs_1.realpathSync)(diffsOutputFolder);
|
|
172
|
+
const canonicalDiffFileParent = (0, node_fs_1.realpathSync)((0, node_path_1.dirname)(diffFilePath));
|
|
173
|
+
if (!(0, securePath_js_1.isPathWithinRoot)(canonicalDiffFileParent, canonicalDiffsOutputFolder)) {
|
|
174
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Diff output path must stay within diffsOutputFolder: ${(0, node_path_1.resolve)(diffsOutputFolder)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeComparisonOptions = normalizeComparisonOptions;
|
|
4
|
+
const comparePngOptions_js_1 = require("./adapters/comparePngOptions.js");
|
|
5
|
+
const pdfRenderOptions_js_1 = require("./adapters/pdfRenderOptions.js");
|
|
6
|
+
const diffOutputGuards_js_1 = require("./diffOutputGuards.js");
|
|
7
|
+
const normalizePdfInput_js_1 = require("./normalizePdfInput.js");
|
|
8
|
+
const renderOutputFolderGuards_js_1 = require("./renderOutputFolderGuards.js");
|
|
9
|
+
const ComparePdfConfigurationError_js_1 = require("../errors/ComparePdfConfigurationError.js");
|
|
10
|
+
const UNSUPPORTED_PDF_RENDER_OPTIONS = [
|
|
11
|
+
'returnPageContent',
|
|
12
|
+
'returnMetadataOnly',
|
|
13
|
+
'processPagesInParallel',
|
|
14
|
+
'concurrencyLimit',
|
|
15
|
+
];
|
|
16
|
+
function normalizeComparisonOptions(opts) {
|
|
17
|
+
const compareOptions = validateComparePdfOptions(opts);
|
|
18
|
+
const allowedInputRoot = (0, normalizePdfInput_js_1.validateAllowedInputRoot)(compareOptions.allowedInputRoot);
|
|
19
|
+
validatePdfRenderOptions(compareOptions.pdfToPngConvertOptions);
|
|
20
|
+
const pdfToPngConvertOpts = (0, pdfRenderOptions_js_1.toPdfToPngOptions)(compareOptions.pdfToPngConvertOptions);
|
|
21
|
+
if (!pdfToPngConvertOpts.viewportScale) {
|
|
22
|
+
pdfToPngConvertOpts.viewportScale = 2.0;
|
|
23
|
+
}
|
|
24
|
+
if (!pdfToPngConvertOpts.outputFileMaskFunc) {
|
|
25
|
+
pdfToPngConvertOpts.outputFileMaskFunc = (pageNumber) => `comparePdf_${pageNumber}.png`;
|
|
26
|
+
}
|
|
27
|
+
pdfToPngConvertOpts.outputFolder = (0, renderOutputFolderGuards_js_1.validateRenderOutputFolder)(pdfToPngConvertOpts.outputFolder);
|
|
28
|
+
const writeDiffs = compareOptions.writeDiffs ?? false;
|
|
29
|
+
const diffsOutputFolder = (0, diffOutputGuards_js_1.validateDiffsOutputFolder)(compareOptions.diffsOutputFolder);
|
|
30
|
+
const compareThreshold = compareOptions.compareThreshold ?? 0;
|
|
31
|
+
const excludedAreas = validateExcludedAreas(compareOptions.excludedAreas);
|
|
32
|
+
validateThreshold(compareThreshold, 'Compare Threshold');
|
|
33
|
+
for (const pageExclusion of excludedAreas) {
|
|
34
|
+
validatePageNumber(pageExclusion.pageNumber);
|
|
35
|
+
validateThreshold(pageExclusion.matchingThreshold, 'Matching Threshold');
|
|
36
|
+
if (pageExclusion.diffFilePath !== undefined) {
|
|
37
|
+
validateDiffFilePath(pageExclusion.diffFilePath);
|
|
38
|
+
}
|
|
39
|
+
if (writeDiffs && pageExclusion.diffFilePath !== undefined) {
|
|
40
|
+
(0, comparePngOptions_js_1.toComparePngOptions)(pageExclusion, diffsOutputFolder, `comparePdf_${pageExclusion.pageNumber}.png`, true);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
allowedInputRoot,
|
|
45
|
+
compareThreshold,
|
|
46
|
+
diffsOutputFolder,
|
|
47
|
+
excludedAreas,
|
|
48
|
+
pdfToPngConvertOpts,
|
|
49
|
+
writeDiffs,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function validateThreshold(value, thresholdName) {
|
|
53
|
+
if (value === undefined) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
57
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`${thresholdName} must be a finite non-negative integer.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function validatePageNumber(pageNumber) {
|
|
61
|
+
if (!Number.isFinite(pageNumber) || !Number.isInteger(pageNumber) || pageNumber <= 0) {
|
|
62
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('Page Number must be a finite positive integer.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateComparePdfOptions(opts) {
|
|
66
|
+
if (opts === undefined) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
70
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('Options must be an object.');
|
|
71
|
+
}
|
|
72
|
+
return opts;
|
|
73
|
+
}
|
|
74
|
+
function validateExcludedAreas(excludedAreas) {
|
|
75
|
+
if (excludedAreas === undefined) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(excludedAreas)) {
|
|
79
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('excludedAreas must be an array.');
|
|
80
|
+
}
|
|
81
|
+
for (const pageExclusion of excludedAreas) {
|
|
82
|
+
if (pageExclusion === null || typeof pageExclusion !== 'object' || Array.isArray(pageExclusion)) {
|
|
83
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('Each excludedAreas entry must be an object.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return excludedAreas;
|
|
87
|
+
}
|
|
88
|
+
function validatePdfRenderOptions(pdfRenderOptions) {
|
|
89
|
+
if (pdfRenderOptions === undefined) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (pdfRenderOptions === null || typeof pdfRenderOptions !== 'object' || Array.isArray(pdfRenderOptions)) {
|
|
93
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('pdfToPngConvertOptions must be an object.');
|
|
94
|
+
}
|
|
95
|
+
const unsupportedOptions = UNSUPPORTED_PDF_RENDER_OPTIONS.filter((optionName) => optionName in pdfRenderOptions);
|
|
96
|
+
if (unsupportedOptions.length > 0) {
|
|
97
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`Unsupported pdfToPngConvertOptions properties: ${unsupportedOptions.join(', ')}. comparePdf always renders page content sequentially.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function validateDiffFilePath(diffFilePath) {
|
|
101
|
+
if (typeof diffFilePath !== 'string' || diffFilePath.trim() === '') {
|
|
102
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('diffFilePath must be a non-empty string.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComparePdfOptions } from '../types/ComparePdfOptions.js';
|
|
2
|
+
import type { PdfInput } from '../types/PdfInput.js';
|
|
3
|
+
import type { AllowedInputRoot } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validates the type of the input file.
|
|
6
|
+
*
|
|
7
|
+
* Accepts a `Buffer`, `ArrayBuffer`, `SharedArrayBuffer`, or a string path. When
|
|
8
|
+
* `allowedInputRoot` is configured, string paths must also resolve within that directory.
|
|
9
|
+
* String paths are resolved and read directly so downstream filesystem errors become the
|
|
10
|
+
* single source of truth for file access failures. Throws for any other input.
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizePdfInput(inputFile: unknown, inputLabel: 'actualPdf' | 'expectedPdf', allowedInputRoot?: AllowedInputRoot): PdfInput;
|
|
13
|
+
export declare function validateAllowedInputRoot(allowedInputRoot: ComparePdfOptions['allowedInputRoot']): AllowedInputRoot | undefined;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizePdfInput = normalizePdfInput;
|
|
4
|
+
exports.validateAllowedInputRoot = validateAllowedInputRoot;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const ComparePdfConfigurationError_js_1 = require("../errors/ComparePdfConfigurationError.js");
|
|
8
|
+
const ComparePdfInputError_js_1 = require("../errors/ComparePdfInputError.js");
|
|
9
|
+
const securePath_js_1 = require("./securePath.js");
|
|
10
|
+
/**
|
|
11
|
+
* Validates the type of the input file.
|
|
12
|
+
*
|
|
13
|
+
* Accepts a `Buffer`, `ArrayBuffer`, `SharedArrayBuffer`, or a string path. When
|
|
14
|
+
* `allowedInputRoot` is configured, string paths must also resolve within that directory.
|
|
15
|
+
* String paths are resolved and read directly so downstream filesystem errors become the
|
|
16
|
+
* single source of truth for file access failures. Throws for any other input.
|
|
17
|
+
*/
|
|
18
|
+
function normalizePdfInput(inputFile, inputLabel, allowedInputRoot) {
|
|
19
|
+
if (Buffer.isBuffer(inputFile) || inputFile instanceof ArrayBuffer || inputFile instanceof SharedArrayBuffer) {
|
|
20
|
+
return inputFile;
|
|
21
|
+
}
|
|
22
|
+
if (typeof inputFile === 'string') {
|
|
23
|
+
const resolvedInputPath = (0, node_path_1.resolve)(inputFile);
|
|
24
|
+
assertStringPathWithinAllowedInputRoot(resolvedInputPath, inputLabel, allowedInputRoot);
|
|
25
|
+
let inputFileDescriptor;
|
|
26
|
+
try {
|
|
27
|
+
const canonicalInputPath = (0, node_fs_1.realpathSync)(resolvedInputPath);
|
|
28
|
+
assertStringPathWithinAllowedInputRoot(canonicalInputPath, inputLabel, allowedInputRoot);
|
|
29
|
+
const canonicalInputStats = (0, node_fs_1.statSync)(canonicalInputPath);
|
|
30
|
+
if (!canonicalInputStats.isFile()) {
|
|
31
|
+
throw new ComparePdfInputError_js_1.ComparePdfInputError(`PDF path is not a file: ${inputFile}`);
|
|
32
|
+
}
|
|
33
|
+
inputFileDescriptor = (0, node_fs_1.openSync)(canonicalInputPath, openFlagsFor(allowedInputRoot));
|
|
34
|
+
if (allowedInputRoot) {
|
|
35
|
+
const openedInputStats = (0, node_fs_1.fstatSync)(inputFileDescriptor);
|
|
36
|
+
if (openedInputStats.dev !== canonicalInputStats.dev ||
|
|
37
|
+
openedInputStats.ino !== canonicalInputStats.ino) {
|
|
38
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`${inputLabel} must resolve within allowedInputRoot: ${allowedInputRoot.displayPath}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return (0, node_fs_1.readFileSync)(inputFileDescriptor);
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
throw wrapPdfInputReadError(cause, inputFile, inputLabel, allowedInputRoot);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (inputFileDescriptor !== undefined) {
|
|
48
|
+
(0, node_fs_1.closeSync)(inputFileDescriptor);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new ComparePdfInputError_js_1.ComparePdfInputError('Unknown input file type.');
|
|
53
|
+
}
|
|
54
|
+
function validateAllowedInputRoot(allowedInputRoot) {
|
|
55
|
+
if (allowedInputRoot === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (typeof allowedInputRoot !== 'string' || allowedInputRoot.trim().length === 0) {
|
|
59
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('allowedInputRoot must be a non-empty string.');
|
|
60
|
+
}
|
|
61
|
+
const resolvedRootPath = (0, node_path_1.resolve)(allowedInputRoot);
|
|
62
|
+
if (!(0, node_fs_1.existsSync)(resolvedRootPath)) {
|
|
63
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`allowedInputRoot does not exist: ${allowedInputRoot}`);
|
|
64
|
+
}
|
|
65
|
+
const canonicalRootPath = (0, node_fs_1.realpathSync)(resolvedRootPath);
|
|
66
|
+
if (!(0, node_fs_1.statSync)(canonicalRootPath).isDirectory()) {
|
|
67
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`allowedInputRoot must point to an existing directory: ${allowedInputRoot}`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
displayPath: allowedInputRoot,
|
|
71
|
+
resolvedPath: resolvedRootPath,
|
|
72
|
+
canonicalPath: canonicalRootPath,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function openFlagsFor(allowedInputRoot) {
|
|
76
|
+
// When allowedInputRoot is configured, refuse to follow a symlink at the canonical leaf.
|
|
77
|
+
// This closes a TOCTOU window where an attacker swaps the resolved file with a symlink
|
|
78
|
+
// between realpathSync and openSync, which would otherwise traverse out of the sandbox.
|
|
79
|
+
return allowedInputRoot ? node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NOFOLLOW : node_fs_1.constants.O_RDONLY;
|
|
80
|
+
}
|
|
81
|
+
function wrapPdfInputReadError(cause, inputFile, inputLabel, allowedInputRoot) {
|
|
82
|
+
if (cause instanceof ComparePdfInputError_js_1.ComparePdfInputError) {
|
|
83
|
+
throw cause;
|
|
84
|
+
}
|
|
85
|
+
if (cause instanceof ComparePdfConfigurationError_js_1.ComparePdfConfigurationError) {
|
|
86
|
+
throw cause;
|
|
87
|
+
}
|
|
88
|
+
if (allowedInputRoot && isSymbolicLinkOpenError(cause)) {
|
|
89
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`${inputLabel} must resolve within allowedInputRoot: ${allowedInputRoot.displayPath}`, { cause });
|
|
90
|
+
}
|
|
91
|
+
if (isMissingPdfInputError(cause)) {
|
|
92
|
+
return new ComparePdfInputError_js_1.ComparePdfInputError(`PDF file not found: ${inputFile}`, { cause });
|
|
93
|
+
}
|
|
94
|
+
return new ComparePdfInputError_js_1.ComparePdfInputError(`Failed to read PDF file: ${inputFile}`, { cause });
|
|
95
|
+
}
|
|
96
|
+
function isMissingPdfInputError(cause) {
|
|
97
|
+
return cause instanceof Error && 'code' in cause && (cause.code === 'ENOENT' || cause.code === 'ENOTDIR');
|
|
98
|
+
}
|
|
99
|
+
function isSymbolicLinkOpenError(cause) {
|
|
100
|
+
return cause instanceof Error && 'code' in cause && (cause.code === 'ELOOP' || cause.code === 'EMLINK');
|
|
101
|
+
}
|
|
102
|
+
function assertStringPathWithinAllowedInputRoot(inputPath, inputLabel, allowedInputRoot) {
|
|
103
|
+
if (!allowedInputRoot) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!(0, securePath_js_1.isPathWithinRoot)(inputPath, allowedInputRoot.resolvedPath) &&
|
|
107
|
+
!(0, securePath_js_1.isPathWithinRoot)(inputPath, allowedInputRoot.canonicalPath)) {
|
|
108
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`${inputLabel} must resolve within allowedInputRoot: ${allowedInputRoot.displayPath}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PageExclusion } from '../types/PageExclusion.js';
|
|
2
|
+
import type { PageComparisonPlanEntry, RenderedPngPageOutput } from './types.js';
|
|
3
|
+
export declare function buildPageComparisonPlan(actualPdfPngPages: readonly RenderedPngPageOutput[], expectedPdfPngPages: readonly RenderedPngPageOutput[], excludedAreas: readonly PageExclusion[]): PageComparisonPlanEntry[];
|
|
4
|
+
export declare function buildPageNumberComparisonPlan(actualPageNumbers: readonly number[], expectedPageNumbers: readonly number[], excludedAreas: readonly PageExclusion[]): PageComparisonPlanEntry[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildPageComparisonPlan = buildPageComparisonPlan;
|
|
4
|
+
exports.buildPageNumberComparisonPlan = buildPageNumberComparisonPlan;
|
|
5
|
+
function buildPageComparisonPlan(actualPdfPngPages, expectedPdfPngPages, excludedAreas) {
|
|
6
|
+
const actualPagesByNumber = indexPagesByNumber(actualPdfPngPages);
|
|
7
|
+
const expectedPagesByNumber = indexPagesByNumber(expectedPdfPngPages);
|
|
8
|
+
return buildPageNumberComparisonPlan([...actualPagesByNumber.keys()], [...expectedPagesByNumber.keys()], excludedAreas).map((entry) => ({
|
|
9
|
+
...entry,
|
|
10
|
+
actualPage: actualPagesByNumber.get(entry.pageNumber),
|
|
11
|
+
expectedPage: expectedPagesByNumber.get(entry.pageNumber),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
function buildPageNumberComparisonPlan(actualPageNumbers, expectedPageNumbers, excludedAreas) {
|
|
15
|
+
const exclusionsByPageNumber = indexPageExclusionsByNumber(excludedAreas);
|
|
16
|
+
const pageNumbers = new Set([...actualPageNumbers, ...expectedPageNumbers]);
|
|
17
|
+
return Array.from(pageNumbers)
|
|
18
|
+
.sort((leftPageNumber, rightPageNumber) => leftPageNumber - rightPageNumber)
|
|
19
|
+
.map((pageNumber) => ({
|
|
20
|
+
pageNumber,
|
|
21
|
+
pageExclusion: exclusionsByPageNumber.get(pageNumber),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function indexPagesByNumber(pages) {
|
|
25
|
+
return new Map(pages.map((page) => [page.pageNumber, page]));
|
|
26
|
+
}
|
|
27
|
+
function indexPageExclusionsByNumber(excludedAreas) {
|
|
28
|
+
const exclusionsByPageNumber = new Map();
|
|
29
|
+
for (const excludedArea of excludedAreas) {
|
|
30
|
+
if (!exclusionsByPageNumber.has(excludedArea.pageNumber)) {
|
|
31
|
+
exclusionsByPageNumber.set(excludedArea.pageNumber, excludedArea);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return exclusionsByPageNumber;
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PdfRenderOptions } from '../types/PdfRenderOptions.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates `pdfToPngConvertOptions.outputFolder` with the same sandbox-parity contract
|
|
4
|
+
* applied to `diffsOutputFolder` and `allowedInputRoot`:
|
|
5
|
+
*
|
|
6
|
+
* - rejects non-string / empty / whitespace-only values
|
|
7
|
+
* - rejects a path that already exists as a non-directory
|
|
8
|
+
* - rejects a path whose existing chain contains any symbolic link (closes CWE-59 /
|
|
9
|
+
* CWE-61 redirect attacks where an attacker pre-plants a symlink so the renderer
|
|
10
|
+
* writes intermediate PNGs to a target outside the intended workspace)
|
|
11
|
+
* - takes library ownership of leaf-directory creation for the resolved path AND the
|
|
12
|
+
* `actual/` / `expected/` namespaces that `renderPdfPages` writes into, then re-asserts
|
|
13
|
+
* each leaf is a real (non-symlink) directory. This closes the residual validate→render
|
|
14
|
+
* TOCTOU window that the path walker cannot cover on its own: once a non-symlink
|
|
15
|
+
* directory exists at every future write destination, an attacker can no longer plant a
|
|
16
|
+
* symlink there between validation and the renderer's first write (CWE-367 / CWE-61).
|
|
17
|
+
*
|
|
18
|
+
* Returns the resolved absolute path so downstream renderer calls operate on a stable
|
|
19
|
+
* location, regardless of process cwd changes between configuration and render time.
|
|
20
|
+
* Returns `undefined` when no `outputFolder` was supplied (in-memory render).
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateRenderOutputFolder(outputFolder: PdfRenderOptions['outputFolder']): string | undefined;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateRenderOutputFolder = validateRenderOutputFolder;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const ComparePdfConfigurationError_js_1 = require("../errors/ComparePdfConfigurationError.js");
|
|
7
|
+
const securePath_js_1 = require("./securePath.js");
|
|
8
|
+
/**
|
|
9
|
+
* `renderPdfPages` namespaces the renderer output under these subdirectories so the
|
|
10
|
+
* actual and expected PDFs cannot collide on a shared `outputFileMaskFunc`. The validator
|
|
11
|
+
* pre-creates and lstat-verifies both leaves so the future write destinations cannot be
|
|
12
|
+
* replaced by symbolic links between validation and the renderer's first write.
|
|
13
|
+
*/
|
|
14
|
+
const RENDER_OUTPUT_NAMESPACES = ['actual', 'expected'];
|
|
15
|
+
/**
|
|
16
|
+
* Validates `pdfToPngConvertOptions.outputFolder` with the same sandbox-parity contract
|
|
17
|
+
* applied to `diffsOutputFolder` and `allowedInputRoot`:
|
|
18
|
+
*
|
|
19
|
+
* - rejects non-string / empty / whitespace-only values
|
|
20
|
+
* - rejects a path that already exists as a non-directory
|
|
21
|
+
* - rejects a path whose existing chain contains any symbolic link (closes CWE-59 /
|
|
22
|
+
* CWE-61 redirect attacks where an attacker pre-plants a symlink so the renderer
|
|
23
|
+
* writes intermediate PNGs to a target outside the intended workspace)
|
|
24
|
+
* - takes library ownership of leaf-directory creation for the resolved path AND the
|
|
25
|
+
* `actual/` / `expected/` namespaces that `renderPdfPages` writes into, then re-asserts
|
|
26
|
+
* each leaf is a real (non-symlink) directory. This closes the residual validate→render
|
|
27
|
+
* TOCTOU window that the path walker cannot cover on its own: once a non-symlink
|
|
28
|
+
* directory exists at every future write destination, an attacker can no longer plant a
|
|
29
|
+
* symlink there between validation and the renderer's first write (CWE-367 / CWE-61).
|
|
30
|
+
*
|
|
31
|
+
* Returns the resolved absolute path so downstream renderer calls operate on a stable
|
|
32
|
+
* location, regardless of process cwd changes between configuration and render time.
|
|
33
|
+
* Returns `undefined` when no `outputFolder` was supplied (in-memory render).
|
|
34
|
+
*/
|
|
35
|
+
function validateRenderOutputFolder(outputFolder) {
|
|
36
|
+
if (outputFolder === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (typeof outputFolder !== 'string' || outputFolder.trim() === '') {
|
|
40
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError('pdfToPngConvertOptions.outputFolder must be a non-empty string.');
|
|
41
|
+
}
|
|
42
|
+
const resolvedOutputFolder = (0, node_path_1.resolve)(outputFolder);
|
|
43
|
+
if ((0, node_fs_1.existsSync)(resolvedOutputFolder) && !(0, node_fs_1.statSync)(resolvedOutputFolder).isDirectory()) {
|
|
44
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`pdfToPngConvertOptions.outputFolder must point to a directory when it already exists: ${outputFolder}`);
|
|
45
|
+
}
|
|
46
|
+
(0, securePath_js_1.assertPathAndAncestorsAreNotSymbolicLinks)(resolvedOutputFolder, `pdfToPngConvertOptions.outputFolder must not traverse a symbolic link: ${(0, node_path_1.resolve)(outputFolder)}`);
|
|
47
|
+
ensureRealDirectory(resolvedOutputFolder);
|
|
48
|
+
for (const namespace of RENDER_OUTPUT_NAMESPACES) {
|
|
49
|
+
ensureRealDirectory((0, node_path_1.join)(resolvedOutputFolder, namespace));
|
|
50
|
+
}
|
|
51
|
+
return resolvedOutputFolder;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Creates `pathToCheck` as a real directory and re-asserts the on-disk entry is not a
|
|
55
|
+
* symbolic link. `mkdirSync({ recursive: true })` is a no-op when the target already
|
|
56
|
+
* exists, and crucially it follows existing symlinks-to-directories silently — the
|
|
57
|
+
* post-mkdir `lstat` is what catches a pre-planted symlink at this exact location.
|
|
58
|
+
*/
|
|
59
|
+
function ensureRealDirectory(pathToCheck) {
|
|
60
|
+
try {
|
|
61
|
+
(0, node_fs_1.mkdirSync)(pathToCheck, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`pdfToPngConvertOptions.outputFolder must point to a writable directory: ${pathToCheck}`, { cause });
|
|
65
|
+
}
|
|
66
|
+
const stats = (0, node_fs_1.lstatSync)(pathToCheck);
|
|
67
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
68
|
+
throw new ComparePdfConfigurationError_js_1.ComparePdfConfigurationError(`pdfToPngConvertOptions.outputFolder must not traverse a symbolic link: ${pathToCheck}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PdfInput } from '../types/PdfInput.js';
|
|
2
|
+
import type { RenderedPngPageOutput } from './types.js';
|
|
3
|
+
import type { PdfToPngOptions, PngPageOutput } from 'pdf-to-png-converter';
|
|
4
|
+
export type PlannedPdfPages = {
|
|
5
|
+
pageNumbers: number[];
|
|
6
|
+
prefetchedPages: Map<number, PngPageOutput>;
|
|
7
|
+
};
|
|
8
|
+
export declare function listRenderedPageNumbers(pdfFile: PdfInput, pdfToPngConvertOpts: PdfToPngOptions, sourceLabel: 'actual' | 'expected'): Promise<PlannedPdfPages>;
|
|
9
|
+
export declare function renderPdfPages(pdfFile: PdfInput, pdfToPngConvertOpts: PdfToPngOptions, sourceLabel: 'actual' | 'expected'): Promise<RenderedPngPageOutput[]>;
|
|
10
|
+
export declare function renderPdfPage(pdfFile: PdfInput, pdfToPngConvertOpts: PdfToPngOptions, pageNumber: number, sourceLabel: 'actual' | 'expected'): Promise<RenderedPngPageOutput | undefined>;
|