clear-excel 1.0.3 → 1.0.4
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 +28 -0
- package/README.md +26 -6
- package/dist/api/sanitize-excel-file.d.ts +0 -1
- package/dist/api/sanitize-excel-file.js +176 -0
- package/dist/api/sanitize-excel-file.js.map +1 -0
- package/dist/api/sanitize-excel.d.ts +0 -1
- package/dist/api/sanitize-excel.js +25 -0
- package/dist/api/sanitize-excel.js.map +1 -0
- package/dist/biff/workbook.d.ts +0 -1
- package/dist/biff/workbook.js +169 -0
- package/dist/biff/workbook.js.map +1 -0
- package/dist/constants.d.ts +0 -1
- package/dist/constants.js +28 -0
- package/dist/constants.js.map +1 -0
- package/dist/errors.d.ts +0 -1
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -0
- package/dist/formats.d.ts +0 -1
- package/dist/formats.js +16 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +4 -2776
- package/dist/index.js.map +1 -1
- package/dist/ooxml/cleanup.d.ts +0 -1
- package/dist/ooxml/cleanup.js +30 -0
- package/dist/ooxml/cleanup.js.map +1 -0
- package/dist/ooxml/constants.d.ts +0 -1
- package/dist/ooxml/constants.js +19 -0
- package/dist/ooxml/constants.js.map +1 -0
- package/dist/ooxml/parts.d.ts +1 -1
- package/dist/ooxml/parts.js +94 -0
- package/dist/ooxml/parts.js.map +1 -0
- package/dist/ooxml/relationships.d.ts +0 -1
- package/dist/ooxml/relationships.js +132 -0
- package/dist/ooxml/relationships.js.map +1 -0
- package/dist/ooxml/styles.d.ts +0 -1
- package/dist/ooxml/styles.js +67 -0
- package/dist/ooxml/styles.js.map +1 -0
- package/dist/ooxml/workbook.d.ts +0 -1
- package/dist/ooxml/workbook.js +119 -0
- package/dist/ooxml/workbook.js.map +1 -0
- package/dist/ooxml/xml.d.ts +26 -2
- package/dist/ooxml/xml.js +304 -0
- package/dist/ooxml/xml.js.map +1 -0
- package/dist/options.d.ts +0 -1
- package/dist/options.js +80 -0
- package/dist/options.js.map +1 -0
- package/dist/types.d.ts +0 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zip/central-directory.d.ts +0 -1
- package/dist/zip/central-directory.js +131 -0
- package/dist/zip/central-directory.js.map +1 -0
- package/package.json +5 -10
- package/dist/api/sanitize-excel-file.d.ts.map +0 -1
- package/dist/api/sanitize-excel.d.ts.map +0 -1
- package/dist/biff/workbook.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/formats.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/ooxml/cleanup.d.ts.map +0 -1
- package/dist/ooxml/constants.d.ts.map +0 -1
- package/dist/ooxml/parts.d.ts.map +0 -1
- package/dist/ooxml/relationships.d.ts.map +0 -1
- package/dist/ooxml/styles.d.ts.map +0 -1
- package/dist/ooxml/workbook.d.ts.map +0 -1
- package/dist/ooxml/xml.d.ts.map +0 -1
- package/dist/options.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/zip/central-directory.d.ts.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
All notable changes to this project are documented in this file. The project
|
|
4
4
|
follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.4] - 2026-10-09
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Preserve existing output files when exclusive creation fails and remove only
|
|
11
|
+
partial files created by the current write.
|
|
12
|
+
- Reject source-file aliases through symbolic links and hard links, and preserve
|
|
13
|
+
existing output permissions during atomic overwrite.
|
|
14
|
+
- Preserve worksheet text, CDATA, XML comments, and unrelated elements during
|
|
15
|
+
namespace-aware drawing and comment cleanup.
|
|
16
|
+
- Resolve sheet relationship IDs by namespace URI and reject missing or invalid
|
|
17
|
+
relationships before pruning styles.
|
|
18
|
+
- Decode UTF-8 and UTF-16 XML consistently and update encoding declarations when
|
|
19
|
+
writing UTF-8 output.
|
|
20
|
+
- Enforce the actual aggregate uncompressed size of all retained ZIP parts,
|
|
21
|
+
including binary VBA projects with forged ZIP size metadata.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Replace Vite with one NodeNext TypeScript build for modular ESM output and
|
|
26
|
+
declarations, eliminating the duplicate bundled `cfb` dependency.
|
|
27
|
+
- Clean build artifacts before compilation, embed sources in JavaScript maps,
|
|
28
|
+
and stop publishing declaration maps that reference unavailable source files.
|
|
29
|
+
- Move the transitive `uuid` override to pnpm workspace configuration.
|
|
30
|
+
- Verify the installed package with real `.xls`, `.xlsx`, and `.xlsm` workbooks
|
|
31
|
+
through both APIs, including retained values, formulas, merges, and macros.
|
|
32
|
+
|
|
6
33
|
## [1.0.0] - 2026-08-19
|
|
7
34
|
|
|
8
35
|
### Changed
|
|
@@ -21,5 +48,6 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
21
48
|
- Initial release with buffer and file APIs for sanitizing `.xls`, `.xlsx`,
|
|
22
49
|
and `.xlsm` workbooks.
|
|
23
50
|
|
|
51
|
+
[1.0.4]: https://www.npmjs.com/package/clear-excel/v/1.0.4
|
|
24
52
|
[1.0.0]: https://www.npmjs.com/package/clear-excel/v/1.0.0
|
|
25
53
|
[0.1.0]: https://www.npmjs.com/package/clear-excel/v/0.1.0
|
package/README.md
CHANGED
|
@@ -98,6 +98,11 @@ await sanitizeExcelFile("report.xlsx", "report.cleaned.xlsx", {
|
|
|
98
98
|
});
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
Paths that refer to the source through symbolic links or hard links are also
|
|
102
|
+
rejected. New outputs are created exclusively; a failed open leaves an existing
|
|
103
|
+
file intact. Overwrites use an atomic replacement and preserve the existing
|
|
104
|
+
output's file permissions.
|
|
105
|
+
|
|
101
106
|
## Options
|
|
102
107
|
|
|
103
108
|
Both sanitizing functions accept these options:
|
|
@@ -116,7 +121,7 @@ The exported `DEFAULT_LIMITS` object contains the defaults:
|
|
|
116
121
|
| -------------------------- | ---------------------------: |
|
|
117
122
|
| `maxEntryCount` | 10,000 entries |
|
|
118
123
|
| `maxUncompressedBytes` | 512 MiB |
|
|
119
|
-
| `maxXmlBytes` |
|
|
124
|
+
| `maxXmlBytes` | 512 MiB aggregate XML reads |
|
|
120
125
|
| `maxWorksheetXmlBytes` | 256 MiB per worksheet |
|
|
121
126
|
| `maxStylesXmlBytes` | 64 MiB |
|
|
122
127
|
| `maxMetadataXmlBytes` | 16 MiB per metadata part |
|
|
@@ -164,7 +169,7 @@ try {
|
|
|
164
169
|
| `ERR_INVALID_XLSX` | The ZIP/OOXML package is malformed or unsupported. |
|
|
165
170
|
| `ERR_LIMIT_EXCEEDED` | A configured ZIP or XML limit was exceeded. |
|
|
166
171
|
| `ERR_WORKSHEET_COUNT` | Exactly one worksheet was required. |
|
|
167
|
-
| `ERR_SAME_PATH` | Input and output
|
|
172
|
+
| `ERR_SAME_PATH` | Input and output refer to the same file. |
|
|
168
173
|
| `ERR_OUTPUT_EXISTS` | Output exists and `overwrite` is false. |
|
|
169
174
|
| `ERR_FILE_READ` | The input could not be read. |
|
|
170
175
|
| `ERR_FILE_WRITE` | The sanitized output could not be written. |
|
|
@@ -178,7 +183,16 @@ Before loading an OOXML archive, `clear-excel` validates the ZIP central
|
|
|
178
183
|
directory, rejects ZIP64 and multi-disk archives, rejects unsafe entry paths,
|
|
179
184
|
and checks declared entry counts and expansion sizes. Required XML parts are
|
|
180
185
|
inflated lazily with both per-part and aggregate runtime budgets, including
|
|
181
|
-
protection when ZIP size metadata has been forged.
|
|
186
|
+
protection when ZIP size metadata has been forged. Before generating the output,
|
|
187
|
+
all retained archive parts are streamed through one actual-byte
|
|
188
|
+
`maxUncompressedBytes` budget, including binary VBA projects and other retained
|
|
189
|
+
content.
|
|
190
|
+
|
|
191
|
+
Processed OOXML XML parts support UTF-8, UTF-16LE, and UTF-16BE. Rewritten parts
|
|
192
|
+
use UTF-8 with a matching XML declaration. XML cleanup resolves namespace URIs
|
|
193
|
+
independently of prefix names and preserves text, CDATA, XML comments, and
|
|
194
|
+
unrelated elements. Missing or invalid worksheet relationships are rejected
|
|
195
|
+
before style records can be removed.
|
|
182
196
|
|
|
183
197
|
For `.xls`, the library validates CFB entry and byte limits, parses the BIFF
|
|
184
198
|
record stream without evaluating formulas, removes drawing-layer records, and
|
|
@@ -200,9 +214,15 @@ pnpm run check
|
|
|
200
214
|
```
|
|
201
215
|
|
|
202
216
|
`pnpm run check` verifies Oxfmt formatting, runs type-aware Oxlint and strict
|
|
203
|
-
TypeScript checks, executes the tests, builds the ESM
|
|
204
|
-
inspects `pnpm pack --dry-run`, and installs a temporary tarball to
|
|
205
|
-
|
|
217
|
+
TypeScript checks, executes the tests, builds the ESM modules and declarations,
|
|
218
|
+
inspects `pnpm pack --dry-run`, and installs a temporary tarball to verify both
|
|
219
|
+
APIs with `.xls`, `.xlsx`, and `.xlsm` workbooks and a NodeNext TypeScript
|
|
220
|
+
consumer.
|
|
221
|
+
|
|
222
|
+
The build cleans `dist` and runs `tsc` once in NodeNext mode. It emits separate
|
|
223
|
+
ESM modules, TypeScript declarations, and JavaScript source maps with embedded
|
|
224
|
+
sources. Runtime dependencies are installed normally; the package does not
|
|
225
|
+
bundle a second copy of them.
|
|
206
226
|
|
|
207
227
|
Useful development commands:
|
|
208
228
|
|
|
@@ -2,4 +2,3 @@ import { isExcelFilename, isXlsxFilename } from "../formats.js";
|
|
|
2
2
|
import type { SanitizeExcelFileOptions } from "../types.js";
|
|
3
3
|
export { isExcelFilename, isXlsxFilename };
|
|
4
4
|
export declare function sanitizeExcelFile(inputPath: string, outputPath: string, options?: SanitizeExcelFileOptions): Promise<void>;
|
|
5
|
-
//# sourceMappingURL=sanitize-excel-file.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { hasCompoundFileSignature } from "../biff/workbook.js";
|
|
5
|
+
import { createError } from "../errors.js";
|
|
6
|
+
import { excelFileFormat, isExcelFilename, isXlsxFilename } from "../formats.js";
|
|
7
|
+
import { normalizeOptions } from "../options.js";
|
|
8
|
+
import { sanitizeExcel } from "./sanitize-excel.js";
|
|
9
|
+
export { isExcelFilename, isXlsxFilename };
|
|
10
|
+
export async function sanitizeExcelFile(inputPath, outputPath, options) {
|
|
11
|
+
const format = validateFilePaths(inputPath, outputPath);
|
|
12
|
+
const normalized = normalizeOptions(options, true);
|
|
13
|
+
const absoluteInputPath = resolve(inputPath);
|
|
14
|
+
const absoluteOutputPath = resolve(outputPath);
|
|
15
|
+
if (absoluteInputPath === absoluteOutputPath) {
|
|
16
|
+
throw createError("ERR_SAME_PATH", {
|
|
17
|
+
details: { inputPath: absoluteInputPath, outputPath: absoluteOutputPath },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const input = await readInputFile(absoluteInputPath);
|
|
21
|
+
await validateDistinctFiles(absoluteInputPath, absoluteOutputPath);
|
|
22
|
+
validateInputFormat(input, format);
|
|
23
|
+
const sanitized = await sanitizeExcel(input, {
|
|
24
|
+
requireSingleWorksheet: normalized.requireSingleWorksheet,
|
|
25
|
+
limits: normalized.limits,
|
|
26
|
+
});
|
|
27
|
+
if (normalized.overwrite) {
|
|
28
|
+
await replaceFileAtomically(absoluteOutputPath, sanitized);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
await writeNewFile(absoluteOutputPath, sanitized);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function validateFilePaths(inputPath, outputPath) {
|
|
35
|
+
if (typeof inputPath !== "string" ||
|
|
36
|
+
inputPath.length === 0 ||
|
|
37
|
+
typeof outputPath !== "string" ||
|
|
38
|
+
outputPath.length === 0) {
|
|
39
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
40
|
+
message: "inputPath and outputPath must be non-empty strings",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const inputFormat = excelFileFormat(inputPath);
|
|
44
|
+
const outputFormat = excelFileFormat(outputPath);
|
|
45
|
+
if (!inputFormat || !outputFormat || inputFormat !== outputFormat) {
|
|
46
|
+
throw createError("ERR_UNSUPPORTED_EXTENSION", {
|
|
47
|
+
details: { inputPath, outputPath, inputFormat, outputFormat },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return inputFormat;
|
|
51
|
+
}
|
|
52
|
+
function validateInputFormat(input, format) {
|
|
53
|
+
const isXls = hasCompoundFileSignature(input);
|
|
54
|
+
if ((format === "xls") !== isXls) {
|
|
55
|
+
throw createError(format === "xls" ? "ERR_INVALID_XLS" : "ERR_INVALID_XLSX", {
|
|
56
|
+
details: { format },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function readInputFile(inputPath) {
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(inputPath);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw createError("ERR_FILE_READ", {
|
|
66
|
+
cause: error,
|
|
67
|
+
details: { inputPath },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function writeNewFile(outputPath, contents) {
|
|
72
|
+
try {
|
|
73
|
+
await writeExclusiveFile(outputPath, contents);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
77
|
+
throw createError("ERR_OUTPUT_EXISTS", {
|
|
78
|
+
cause: error,
|
|
79
|
+
details: { outputPath },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
throw createError("ERR_FILE_WRITE", {
|
|
83
|
+
cause: error,
|
|
84
|
+
details: { outputPath },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function replaceFileAtomically(outputPath, contents) {
|
|
89
|
+
const temporaryPath = `${outputPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
90
|
+
let temporaryCreated = false;
|
|
91
|
+
try {
|
|
92
|
+
const existingOutput = await stat(outputPath).catch((error) => {
|
|
93
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
const outputMode = existingOutput ? existingOutput.mode & 0o777 : undefined;
|
|
99
|
+
await writeExclusiveFile(temporaryPath, contents, outputMode);
|
|
100
|
+
temporaryCreated = true;
|
|
101
|
+
await rename(temporaryPath, outputPath);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (temporaryCreated) {
|
|
105
|
+
await removeIfPresent(temporaryPath);
|
|
106
|
+
}
|
|
107
|
+
throw createError("ERR_FILE_WRITE", {
|
|
108
|
+
cause: error,
|
|
109
|
+
details: { outputPath },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function writeExclusiveFile(path, contents, mode) {
|
|
114
|
+
// A failed open does not establish ownership, so it must never trigger unlink.
|
|
115
|
+
const handle = await open(path, "wx", mode);
|
|
116
|
+
try {
|
|
117
|
+
await handle.writeFile(contents);
|
|
118
|
+
if (mode !== undefined) {
|
|
119
|
+
await handle.chmod(mode);
|
|
120
|
+
}
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
await handle.close().catch(() => undefined);
|
|
125
|
+
await removeIfPresent(path);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function validateDistinctFiles(inputPath, outputPath) {
|
|
130
|
+
let inputRealPath;
|
|
131
|
+
let inputStat;
|
|
132
|
+
try {
|
|
133
|
+
inputRealPath = await realpath(inputPath);
|
|
134
|
+
inputStat = await stat(inputPath);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw createError("ERR_FILE_READ", { cause: error, details: { inputPath } });
|
|
138
|
+
}
|
|
139
|
+
let sameFile;
|
|
140
|
+
try {
|
|
141
|
+
const outputStat = await stat(outputPath).catch((error) => {
|
|
142
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
const outputRealPath = outputStat
|
|
148
|
+
? await realpath(outputPath)
|
|
149
|
+
: await realpath(dirname(outputPath))
|
|
150
|
+
.then((parent) => join(parent, basename(outputPath)))
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
153
|
+
return outputPath;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
});
|
|
157
|
+
sameFile =
|
|
158
|
+
inputRealPath === outputRealPath ||
|
|
159
|
+
(outputStat !== undefined &&
|
|
160
|
+
inputStat.dev === outputStat.dev &&
|
|
161
|
+
inputStat.ino === outputStat.ino);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw createError("ERR_FILE_WRITE", { cause: error, details: { outputPath } });
|
|
165
|
+
}
|
|
166
|
+
if (sameFile) {
|
|
167
|
+
throw createError("ERR_SAME_PATH", { details: { inputPath, outputPath } });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function hasErrorCode(error, expectedCode) {
|
|
171
|
+
return (typeof error === "object" && error !== null && "code" in error && error.code === expectedCode);
|
|
172
|
+
}
|
|
173
|
+
async function removeIfPresent(path) {
|
|
174
|
+
await unlink(path).catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=sanitize-excel-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-excel-file.js","sourceRoot":"","sources":["../../src/api/sanitize-excel-file.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { open, readFile, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { basename, dirname, join, resolve } from \"node:path\";\n\nimport { hasCompoundFileSignature } from \"../biff/workbook.js\";\nimport { createError } from \"../errors.js\";\nimport { excelFileFormat, isExcelFilename, isXlsxFilename } from \"../formats.js\";\nimport type { ExcelFileFormat } from \"../formats.js\";\nimport { normalizeOptions } from \"../options.js\";\nimport type { SanitizeExcelFileOptions } from \"../types.js\";\nimport { sanitizeExcel } from \"./sanitize-excel.js\";\n\nexport { isExcelFilename, isXlsxFilename };\n\nexport async function sanitizeExcelFile(\n inputPath: string,\n outputPath: string,\n options?: SanitizeExcelFileOptions,\n): Promise<void> {\n const format = validateFilePaths(inputPath, outputPath);\n const normalized = normalizeOptions(options, true);\n const absoluteInputPath = resolve(inputPath);\n const absoluteOutputPath = resolve(outputPath);\n if (absoluteInputPath === absoluteOutputPath) {\n throw createError(\"ERR_SAME_PATH\", {\n details: { inputPath: absoluteInputPath, outputPath: absoluteOutputPath },\n });\n }\n\n const input = await readInputFile(absoluteInputPath);\n await validateDistinctFiles(absoluteInputPath, absoluteOutputPath);\n validateInputFormat(input, format);\n const sanitized = await sanitizeExcel(input, {\n requireSingleWorksheet: normalized.requireSingleWorksheet,\n limits: normalized.limits,\n });\n\n if (normalized.overwrite) {\n await replaceFileAtomically(absoluteOutputPath, sanitized);\n } else {\n await writeNewFile(absoluteOutputPath, sanitized);\n }\n}\n\nfunction validateFilePaths(inputPath: string, outputPath: string): ExcelFileFormat {\n if (\n typeof inputPath !== \"string\" ||\n inputPath.length === 0 ||\n typeof outputPath !== \"string\" ||\n outputPath.length === 0\n ) {\n throw createError(\"ERR_INVALID_ARGUMENT\", {\n message: \"inputPath and outputPath must be non-empty strings\",\n });\n }\n const inputFormat = excelFileFormat(inputPath);\n const outputFormat = excelFileFormat(outputPath);\n if (!inputFormat || !outputFormat || inputFormat !== outputFormat) {\n throw createError(\"ERR_UNSUPPORTED_EXTENSION\", {\n details: { inputPath, outputPath, inputFormat, outputFormat },\n });\n }\n return inputFormat;\n}\n\nfunction validateInputFormat(input: Buffer, format: ExcelFileFormat): void {\n const isXls = hasCompoundFileSignature(input);\n if ((format === \"xls\") !== isXls) {\n throw createError(format === \"xls\" ? \"ERR_INVALID_XLS\" : \"ERR_INVALID_XLSX\", {\n details: { format },\n });\n }\n}\n\nasync function readInputFile(inputPath: string): Promise<Buffer> {\n try {\n return await readFile(inputPath);\n } catch (error) {\n throw createError(\"ERR_FILE_READ\", {\n cause: error,\n details: { inputPath },\n });\n }\n}\n\nasync function writeNewFile(outputPath: string, contents: Buffer): Promise<void> {\n try {\n await writeExclusiveFile(outputPath, contents);\n } catch (error) {\n if (hasErrorCode(error, \"EEXIST\")) {\n throw createError(\"ERR_OUTPUT_EXISTS\", {\n cause: error,\n details: { outputPath },\n });\n }\n throw createError(\"ERR_FILE_WRITE\", {\n cause: error,\n details: { outputPath },\n });\n }\n}\n\nasync function replaceFileAtomically(outputPath: string, contents: Buffer): Promise<void> {\n const temporaryPath = `${outputPath}.${process.pid}.${randomUUID()}.tmp`;\n let temporaryCreated = false;\n try {\n const existingOutput = await stat(outputPath).catch((error: unknown) => {\n if (hasErrorCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n });\n const outputMode = existingOutput ? existingOutput.mode & 0o777 : undefined;\n await writeExclusiveFile(temporaryPath, contents, outputMode);\n temporaryCreated = true;\n await rename(temporaryPath, outputPath);\n } catch (error) {\n if (temporaryCreated) {\n await removeIfPresent(temporaryPath);\n }\n throw createError(\"ERR_FILE_WRITE\", {\n cause: error,\n details: { outputPath },\n });\n }\n}\n\nasync function writeExclusiveFile(path: string, contents: Buffer, mode?: number): Promise<void> {\n // A failed open does not establish ownership, so it must never trigger unlink.\n const handle = await open(path, \"wx\", mode);\n try {\n await handle.writeFile(contents);\n if (mode !== undefined) {\n await handle.chmod(mode);\n }\n await handle.close();\n } catch (error) {\n await handle.close().catch(() => undefined);\n await removeIfPresent(path);\n throw error;\n }\n}\n\nasync function validateDistinctFiles(inputPath: string, outputPath: string): Promise<void> {\n let inputRealPath: string;\n let inputStat: Awaited<ReturnType<typeof stat>>;\n try {\n inputRealPath = await realpath(inputPath);\n inputStat = await stat(inputPath);\n } catch (error) {\n throw createError(\"ERR_FILE_READ\", { cause: error, details: { inputPath } });\n }\n\n let sameFile: boolean;\n try {\n const outputStat = await stat(outputPath).catch((error: unknown) => {\n if (hasErrorCode(error, \"ENOENT\")) {\n return undefined;\n }\n throw error;\n });\n const outputRealPath = outputStat\n ? await realpath(outputPath)\n : await realpath(dirname(outputPath))\n .then((parent) => join(parent, basename(outputPath)))\n .catch((error: unknown) => {\n if (hasErrorCode(error, \"ENOENT\")) {\n return outputPath;\n }\n throw error;\n });\n sameFile =\n inputRealPath === outputRealPath ||\n (outputStat !== undefined &&\n inputStat.dev === outputStat.dev &&\n inputStat.ino === outputStat.ino);\n } catch (error) {\n throw createError(\"ERR_FILE_WRITE\", { cause: error, details: { outputPath } });\n }\n\n if (sameFile) {\n throw createError(\"ERR_SAME_PATH\", { details: { inputPath, outputPath } });\n }\n}\n\nfunction hasErrorCode(error: unknown, expectedCode: string): boolean {\n return (\n typeof error === \"object\" && error !== null && \"code\" in error && error.code === expectedCode\n );\n}\n\nasync function removeIfPresent(path: string): Promise<void> {\n await unlink(path).catch(() => undefined);\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { hasCompoundFileSignature, sanitizeBiffWorkbook } from "../biff/workbook.js";
|
|
2
|
+
import { ExcelSanitizationError, createError } from "../errors.js";
|
|
3
|
+
import { sanitizeOoxmlWorkbook } from "../ooxml/workbook.js";
|
|
4
|
+
import { normalizeOptions } from "../options.js";
|
|
5
|
+
export async function sanitizeExcel(buffer, options) {
|
|
6
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
7
|
+
throw createError("ERR_INVALID_ARGUMENT", {
|
|
8
|
+
message: "buffer must be a Node.js Buffer",
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
const normalized = normalizeOptions(options);
|
|
12
|
+
const isBiff = hasCompoundFileSignature(buffer);
|
|
13
|
+
try {
|
|
14
|
+
return isBiff
|
|
15
|
+
? await sanitizeBiffWorkbook(buffer, normalized)
|
|
16
|
+
: await sanitizeOoxmlWorkbook(buffer, normalized);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof ExcelSanitizationError) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
throw createError(isBiff ? "ERR_INVALID_XLS" : "ERR_INVALID_XLSX", { cause: error });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=sanitize-excel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-excel.js","sourceRoot":"","sources":["../../src/api/sanitize-excel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AACrF,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAGjD,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,MAAc,EACd,OAA8B;IAE9B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7B,MAAM,WAAW,CAAC,sBAAsB,EAAE;YACxC,OAAO,EAAE,iCAAiC;SAC3C,CAAC,CAAC;IACL,CAAC;IAED,MAAM,UAAU,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IAChD,IAAI,CAAC;QACH,OAAO,MAAM;YACX,CAAC,CAAC,MAAM,oBAAoB,CAAC,MAAM,EAAE,UAAU,CAAC;YAChD,CAAC,CAAC,MAAM,qBAAqB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACtD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,sBAAsB,EAAE,CAAC;YAC5C,MAAM,KAAK,CAAC;QACd,CAAC;QACD,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IACvF,CAAC;AACH,CAAC","sourcesContent":["import { hasCompoundFileSignature, sanitizeBiffWorkbook } from \"../biff/workbook.js\";\nimport { ExcelSanitizationError, createError } from \"../errors.js\";\nimport { sanitizeOoxmlWorkbook } from \"../ooxml/workbook.js\";\nimport { normalizeOptions } from \"../options.js\";\nimport type { SanitizeExcelOptions } from \"../types.js\";\n\nexport async function sanitizeExcel(\n buffer: Buffer,\n options?: SanitizeExcelOptions,\n): Promise<Buffer> {\n if (!Buffer.isBuffer(buffer)) {\n throw createError(\"ERR_INVALID_ARGUMENT\", {\n message: \"buffer must be a Node.js Buffer\",\n });\n }\n\n const normalized = normalizeOptions(options);\n const isBiff = hasCompoundFileSignature(buffer);\n try {\n return isBiff\n ? await sanitizeBiffWorkbook(buffer, normalized)\n : await sanitizeOoxmlWorkbook(buffer, normalized);\n } catch (error) {\n if (error instanceof ExcelSanitizationError) {\n throw error;\n }\n throw createError(isBiff ? \"ERR_INVALID_XLS\" : \"ERR_INVALID_XLSX\", { cause: error });\n }\n}\n"]}
|
package/dist/biff/workbook.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import type { NormalizedSanitizeOptions } from "../types.js";
|
|
2
2
|
export declare function hasCompoundFileSignature(buffer: Buffer): boolean;
|
|
3
3
|
export declare function sanitizeBiffWorkbook(buffer: Buffer, options: NormalizedSanitizeOptions): Promise<Buffer>;
|
|
4
|
-
//# sourceMappingURL=workbook.d.ts.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as CFB from "cfb";
|
|
2
|
+
import { createError, limitExceeded } from "../errors.js";
|
|
3
|
+
const COMPOUND_FILE_SIGNATURE = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
4
|
+
const RECORD_HEADER_BYTES = 4;
|
|
5
|
+
const CFB_STORAGE_ENTRY = 1;
|
|
6
|
+
const CFB_STREAM_ENTRY = 2;
|
|
7
|
+
const RECORD_CONTINUE = 0x003c;
|
|
8
|
+
const RECORD_BOUNDSHEET = 0x0085;
|
|
9
|
+
const RECORD_BOF_IDS = new Set([0x0009, 0x0209, 0x0409, 0x0809]);
|
|
10
|
+
// INDEX, DBCELL, and ExtSST cache absolute stream offsets. They are optional
|
|
11
|
+
// acceleration records and must be discarded after other records move.
|
|
12
|
+
const OFFSET_CACHE_RECORD_IDS = new Set([0x00d7, 0x00ff, 0x020b]);
|
|
13
|
+
// Drawing-layer records cover OfficeArt drawings, images, notes/comments,
|
|
14
|
+
// controls, embedded OLE objects, and header/footer or background pictures.
|
|
15
|
+
const DRAWING_RECORD_IDS = new Set([
|
|
16
|
+
0x001c, // Note
|
|
17
|
+
0x005d, // Obj
|
|
18
|
+
0x007f, // ImData
|
|
19
|
+
0x00e9, // BkHim
|
|
20
|
+
0x00eb, // MsoDrawingGroup
|
|
21
|
+
0x00ec, // MsoDrawing
|
|
22
|
+
0x00ed, // MsoDrawingSelection
|
|
23
|
+
0x01b6, // TxO
|
|
24
|
+
0x0866, // HFPicture
|
|
25
|
+
]);
|
|
26
|
+
export function hasCompoundFileSignature(buffer) {
|
|
27
|
+
return (buffer.length >= COMPOUND_FILE_SIGNATURE.length &&
|
|
28
|
+
buffer.subarray(0, COMPOUND_FILE_SIGNATURE.length).equals(COMPOUND_FILE_SIGNATURE));
|
|
29
|
+
}
|
|
30
|
+
export async function sanitizeBiffWorkbook(buffer, options) {
|
|
31
|
+
enforceInputSize(buffer, options);
|
|
32
|
+
const cfb = CFB.read(buffer, { type: "buffer" });
|
|
33
|
+
enforceContainerLimits(cfb, options);
|
|
34
|
+
const workbookEntry = CFB.find(cfb, "Workbook") ?? CFB.find(cfb, "Book");
|
|
35
|
+
if (!workbookEntry || workbookEntry.type !== CFB_STREAM_ENTRY) {
|
|
36
|
+
throw new Error("Compound file has no BIFF Workbook stream");
|
|
37
|
+
}
|
|
38
|
+
const workbookStream = Buffer.from(workbookEntry.content);
|
|
39
|
+
const records = parseBiffRecords(workbookStream);
|
|
40
|
+
rejectEncryptedWorkbook(records);
|
|
41
|
+
enforceWorksheetCount(records, options.requireSingleWorksheet);
|
|
42
|
+
const sanitizedStream = rebuildWorkbookStream(records);
|
|
43
|
+
workbookEntry.content = sanitizedStream;
|
|
44
|
+
workbookEntry.size = sanitizedStream.length;
|
|
45
|
+
removeEmbeddedObjectEntries(cfb);
|
|
46
|
+
return Buffer.from(CFB.write(cfb, { type: "buffer", fileType: "cfb" }));
|
|
47
|
+
}
|
|
48
|
+
function enforceInputSize(buffer, options) {
|
|
49
|
+
const configured = options.limits.maxUncompressedBytes;
|
|
50
|
+
if (buffer.length > configured) {
|
|
51
|
+
throw limitExceeded("maxUncompressedBytes", configured, buffer.length);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function enforceContainerLimits(cfb, options) {
|
|
55
|
+
const entries = cfb.FileIndex.filter((entry) => entry.type === CFB_STORAGE_ENTRY || entry.type === CFB_STREAM_ENTRY);
|
|
56
|
+
if (entries.length > options.limits.maxEntryCount) {
|
|
57
|
+
throw limitExceeded("maxEntryCount", options.limits.maxEntryCount, entries.length);
|
|
58
|
+
}
|
|
59
|
+
const totalBytes = entries.reduce((total, entry) => (entry.type === CFB_STREAM_ENTRY ? total + entry.content.length : total), 0);
|
|
60
|
+
if (totalBytes > options.limits.maxUncompressedBytes) {
|
|
61
|
+
throw limitExceeded("maxUncompressedBytes", options.limits.maxUncompressedBytes, totalBytes);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function parseBiffRecords(stream) {
|
|
65
|
+
const records = [];
|
|
66
|
+
let offset = 0;
|
|
67
|
+
let removeContinuation = false;
|
|
68
|
+
while (offset < stream.length) {
|
|
69
|
+
if (offset + RECORD_HEADER_BYTES > stream.length) {
|
|
70
|
+
throw new Error("Truncated BIFF record header");
|
|
71
|
+
}
|
|
72
|
+
const id = stream.readUInt16LE(offset);
|
|
73
|
+
const length = stream.readUInt16LE(offset + 2);
|
|
74
|
+
const nextOffset = offset + RECORD_HEADER_BYTES + length;
|
|
75
|
+
if (nextOffset > stream.length) {
|
|
76
|
+
throw new Error("Truncated BIFF record payload");
|
|
77
|
+
}
|
|
78
|
+
const remove = id === RECORD_CONTINUE
|
|
79
|
+
? removeContinuation
|
|
80
|
+
: DRAWING_RECORD_IDS.has(id) || OFFSET_CACHE_RECORD_IDS.has(id);
|
|
81
|
+
records.push({
|
|
82
|
+
id,
|
|
83
|
+
originalOffset: offset,
|
|
84
|
+
payload: stream.subarray(offset + RECORD_HEADER_BYTES, nextOffset),
|
|
85
|
+
remove,
|
|
86
|
+
});
|
|
87
|
+
if (id !== RECORD_CONTINUE) {
|
|
88
|
+
removeContinuation = remove;
|
|
89
|
+
}
|
|
90
|
+
offset = nextOffset;
|
|
91
|
+
}
|
|
92
|
+
if (records.length === 0 || !RECORD_BOF_IDS.has(records[0]?.id ?? -1)) {
|
|
93
|
+
throw new Error("Workbook stream does not start with a BIFF BOF record");
|
|
94
|
+
}
|
|
95
|
+
return records;
|
|
96
|
+
}
|
|
97
|
+
function rejectEncryptedWorkbook(records) {
|
|
98
|
+
if (records.some((record) => record.id === 0x002f)) {
|
|
99
|
+
throw createError("ERR_INVALID_XLS", {
|
|
100
|
+
message: "Encrypted XLS workbooks are not supported",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function enforceWorksheetCount(records, required) {
|
|
105
|
+
const count = records.filter((record) => record.id === RECORD_BOUNDSHEET).length;
|
|
106
|
+
if (count === 0) {
|
|
107
|
+
throw new Error("Workbook contains no sheets");
|
|
108
|
+
}
|
|
109
|
+
if (required && count !== 1) {
|
|
110
|
+
throw createError("ERR_WORKSHEET_COUNT", {
|
|
111
|
+
details: { expected: 1, actual: count },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function rebuildWorkbookStream(records) {
|
|
116
|
+
const newOffsets = new Map();
|
|
117
|
+
let outputLength = 0;
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
if (record.remove) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
newOffsets.set(record.originalOffset, outputLength);
|
|
123
|
+
outputLength += RECORD_HEADER_BYTES + record.payload.length;
|
|
124
|
+
}
|
|
125
|
+
const output = Buffer.allocUnsafe(outputLength);
|
|
126
|
+
let outputOffset = 0;
|
|
127
|
+
for (const record of records) {
|
|
128
|
+
if (record.remove) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const payload = patchBoundSheetOffset(record, newOffsets);
|
|
132
|
+
output.writeUInt16LE(record.id, outputOffset);
|
|
133
|
+
output.writeUInt16LE(payload.length, outputOffset + 2);
|
|
134
|
+
payload.copy(output, outputOffset + RECORD_HEADER_BYTES);
|
|
135
|
+
outputOffset += RECORD_HEADER_BYTES + payload.length;
|
|
136
|
+
}
|
|
137
|
+
return output;
|
|
138
|
+
}
|
|
139
|
+
function patchBoundSheetOffset(record, newOffsets) {
|
|
140
|
+
if (record.id !== RECORD_BOUNDSHEET) {
|
|
141
|
+
return record.payload;
|
|
142
|
+
}
|
|
143
|
+
if (record.payload.length < 4) {
|
|
144
|
+
throw new Error("Invalid BoundSheet record");
|
|
145
|
+
}
|
|
146
|
+
const originalSheetOffset = record.payload.readUInt32LE(0);
|
|
147
|
+
const newSheetOffset = newOffsets.get(originalSheetOffset);
|
|
148
|
+
if (newSheetOffset === undefined) {
|
|
149
|
+
throw new Error("BoundSheet record points outside the Workbook stream");
|
|
150
|
+
}
|
|
151
|
+
const payload = Buffer.from(record.payload);
|
|
152
|
+
payload.writeUInt32LE(newSheetOffset, 0);
|
|
153
|
+
return payload;
|
|
154
|
+
}
|
|
155
|
+
function removeEmbeddedObjectEntries(cfb) {
|
|
156
|
+
const paths = cfb.FullPaths.filter((path) => path
|
|
157
|
+
.split("/")
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.some((segment) => segment.toLowerCase() === "objectpool" ||
|
|
160
|
+
segment.toLowerCase() === "ctls" ||
|
|
161
|
+
/^mbd[0-9a-f]{8}$/i.test(segment)));
|
|
162
|
+
for (let index = paths.length - 1; index >= 0; index -= 1) {
|
|
163
|
+
CFB.utils.cfb_del(cfb, paths[index] ?? "");
|
|
164
|
+
}
|
|
165
|
+
if (paths.length > 0) {
|
|
166
|
+
CFB.utils.cfb_gc(cfb);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=workbook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workbook.js","sourceRoot":"","sources":["../../src/biff/workbook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAE3B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG1D,MAAM,uBAAuB,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;AAC9F,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAC9B,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAC5B,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAC3B,MAAM,eAAe,GAAG,MAAM,CAAC;AAC/B,MAAM,iBAAiB,GAAG,MAAM,CAAC;AACjC,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAEjE,6EAA6E;AAC7E,uEAAuE;AACvE,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAElE,0EAA0E;AAC1E,4EAA4E;AAC5E,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC;IACjC,MAAM,EAAE,OAAO;IACf,MAAM,EAAE,MAAM;IACd,MAAM,EAAE,SAAS;IACjB,MAAM,EAAE,QAAQ;IAChB,MAAM,EAAE,kBAAkB;IAC1B,MAAM,EAAE,aAAa;IACrB,MAAM,EAAE,sBAAsB;IAC9B,MAAM,EAAE,MAAM;IACd,MAAM,EAAE,YAAY;CACrB,CAAC,CAAC;AASH,MAAM,UAAU,wBAAwB,CAAC,MAAc;IACrD,OAAO,CACL,MAAM,CAAC,MAAM,IAAI,uBAAuB,CAAC,MAAM;QAC/C,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,uBAAuB,CAAC,CACnF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,MAAc,EACd,OAAkC;IAElC,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,GAAG,GAAG,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;IACjD,sBAAsB,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAErC,MAAM,aAAa,GAAG,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACzE,IAAI,CAAC,aAAa,IAAI,aAAa,CAAC,IAAI,KAAK,gBAAgB,EAAE,CAAC;QAC9D,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;IAC/D,CAAC;IAED,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC1D,MAAM,OAAO,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;IACjD,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAE/D,MAAM,eAAe,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;IACvD,aAAa,CAAC,OAAO,GAAG,eAAe,CAAC;IACxC,aAAa,CAAC,IAAI,GAAG,eAAe,CAAC,MAAM,CAAC;IAC5C,2BAA2B,CAAC,GAAG,CAAC,CAAC;IAEjC,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,gBAAgB,CAAC,MAAc,EAAE,OAAkC;IAC1E,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,oBAAoB,CAAC;IACvD,IAAI,MAAM,CAAC,MAAM,GAAG,UAAU,EAAE,CAAC;QAC/B,MAAM,aAAa,CAAC,sBAAsB,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IACzE,CAAC;AACH,CAAC;AAED,SAAS,sBAAsB,CAAC,GAAsB,EAAE,OAAkC;IACxF,MAAM,OAAO,GAAG,GAAG,CAAC,SAAS,CAAC,MAAM,CAClC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK,iBAAiB,IAAI,KAAK,CAAC,IAAI,KAAK,gBAAgB,CAC/E,CAAC;IACF,IAAI,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC;QAClD,MAAM,aAAa,CAAC,eAAe,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACrF,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAC/B,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,gBAAgB,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,EAC1F,CAAC,CACF,CAAC;IACF,IAAI,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,oBAAoB,EAAE,CAAC;QACrD,MAAM,aAAa,CAAC,sBAAsB,EAAE,OAAO,CAAC,MAAM,CAAC,oBAAoB,EAAE,UAAU,CAAC,CAAC;IAC/F,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,MAAc;IACtC,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,kBAAkB,GAAG,KAAK,CAAC;IAE/B,OAAO,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QAC9B,IAAI,MAAM,GAAG,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YACjD,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClD,CAAC;QAED,MAAM,EAAE,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QACvC,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC/C,MAAM,UAAU,GAAG,MAAM,GAAG,mBAAmB,GAAG,MAAM,CAAC;QACzD,IAAI,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YAC/B,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAC;QACnD,CAAC;QAED,MAAM,MAAM,GACV,EAAE,KAAK,eAAe;YACpB,CAAC,CAAC,kBAAkB;YACpB,CAAC,CAAC,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,uBAAuB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACpE,OAAO,CAAC,IAAI,CAAC;YACX,EAAE;YACF,cAAc,EAAE,MAAM;YACtB,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,mBAAmB,EAAE,UAAU,CAAC;YAClE,MAAM;SACP,CAAC,CAAC;QACH,IAAI,EAAE,KAAK,eAAe,EAAE,CAAC;YAC3B,kBAAkB,GAAG,MAAM,CAAC;QAC9B,CAAC;QACD,MAAM,GAAG,UAAU,CAAC;IACtB,CAAC;IAED,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,MAAM,IAAI,KAAK,CAAC,uDAAuD,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,uBAAuB,CAAC,OAA8B;IAC7D,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC;QACnD,MAAM,WAAW,CAAC,iBAAiB,EAAE;YACnC,OAAO,EAAE,2CAA2C;SACrD,CAAC,CAAC;IACL,CAAC;AACH,CAAC;AAED,SAAS,qBAAqB,CAAC,OAA8B,EAAE,QAAiB;IAC9E,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,iBAAiB,CAAC,CAAC,MAAM,CAAC;IACjF,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACjD,CAAC;IACD,IAAI,QAAQ,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,WAAW,CAAC,qBAAqB,EAAE;YACvC,OAAO,EAAE,EAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE;SACxC,CAAC,CAAC;IACL,CAAC;AACH,CAAC;AAED,SAAS,qBAAqB,CAAC,OAA8B;IAC3D,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC7C,IAAI,YAAY,GAAG,CAAC,CAAC;IACrB,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YAClB,SAAS;QACX,CAAC;QACD,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QACpD,YAAY,IAAI,mBAAmB,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;IAC9D,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,WAAW,CAAC,YAAY,CAAC,CAAC;IAChD,IAAI,YAAY,GAAG,CAAC,CAAC;IACrB,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YAClB,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,qBAAqB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QAC1D,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC;QAC9C,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,CAAC,CAAC,CAAC;QACvD,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,mBAAmB,CAAC,CAAC;QACzD,YAAY,IAAI,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IACvD,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,qBAAqB,CAC5B,MAAkB,EAClB,UAAuC;IAEvC,IAAI,MAAM,CAAC,EAAE,KAAK,iBAAiB,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,OAAO,CAAC;IACxB,CAAC;IACD,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAC/C,CAAC;IAED,MAAM,mBAAmB,GAAG,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAC3D,MAAM,cAAc,GAAG,UAAU,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IAC3D,IAAI,cAAc,KAAK,SAAS,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,aAAa,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC;IACzC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,2BAA2B,CAAC,GAAsB;IACzD,MAAM,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAC1C,IAAI;SACD,KAAK,CAAC,GAAG,CAAC;SACV,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CACH,CAAC,OAAO,EAAE,EAAE,CACV,OAAO,CAAC,WAAW,EAAE,KAAK,YAAY;QACtC,OAAO,CAAC,WAAW,EAAE,KAAK,MAAM;QAChC,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,CACpC,CACJ,CAAC;IAEF,KAAK,IAAI,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1D,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrB,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;AACH,CAAC","sourcesContent":["import * as CFB from \"cfb\";\n\nimport { createError, limitExceeded } from \"../errors.js\";\nimport type { NormalizedSanitizeOptions } from \"../types.js\";\n\nconst COMPOUND_FILE_SIGNATURE = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);\nconst RECORD_HEADER_BYTES = 4;\nconst CFB_STORAGE_ENTRY = 1;\nconst CFB_STREAM_ENTRY = 2;\nconst RECORD_CONTINUE = 0x003c;\nconst RECORD_BOUNDSHEET = 0x0085;\nconst RECORD_BOF_IDS = new Set([0x0009, 0x0209, 0x0409, 0x0809]);\n\n// INDEX, DBCELL, and ExtSST cache absolute stream offsets. They are optional\n// acceleration records and must be discarded after other records move.\nconst OFFSET_CACHE_RECORD_IDS = new Set([0x00d7, 0x00ff, 0x020b]);\n\n// Drawing-layer records cover OfficeArt drawings, images, notes/comments,\n// controls, embedded OLE objects, and header/footer or background pictures.\nconst DRAWING_RECORD_IDS = new Set([\n 0x001c, // Note\n 0x005d, // Obj\n 0x007f, // ImData\n 0x00e9, // BkHim\n 0x00eb, // MsoDrawingGroup\n 0x00ec, // MsoDrawing\n 0x00ed, // MsoDrawingSelection\n 0x01b6, // TxO\n 0x0866, // HFPicture\n]);\n\ninterface BiffRecord {\n id: number;\n originalOffset: number;\n payload: Buffer;\n remove: boolean;\n}\n\nexport function hasCompoundFileSignature(buffer: Buffer): boolean {\n return (\n buffer.length >= COMPOUND_FILE_SIGNATURE.length &&\n buffer.subarray(0, COMPOUND_FILE_SIGNATURE.length).equals(COMPOUND_FILE_SIGNATURE)\n );\n}\n\nexport async function sanitizeBiffWorkbook(\n buffer: Buffer,\n options: NormalizedSanitizeOptions,\n): Promise<Buffer> {\n enforceInputSize(buffer, options);\n const cfb = CFB.read(buffer, { type: \"buffer\" });\n enforceContainerLimits(cfb, options);\n\n const workbookEntry = CFB.find(cfb, \"Workbook\") ?? CFB.find(cfb, \"Book\");\n if (!workbookEntry || workbookEntry.type !== CFB_STREAM_ENTRY) {\n throw new Error(\"Compound file has no BIFF Workbook stream\");\n }\n\n const workbookStream = Buffer.from(workbookEntry.content);\n const records = parseBiffRecords(workbookStream);\n rejectEncryptedWorkbook(records);\n enforceWorksheetCount(records, options.requireSingleWorksheet);\n\n const sanitizedStream = rebuildWorkbookStream(records);\n workbookEntry.content = sanitizedStream;\n workbookEntry.size = sanitizedStream.length;\n removeEmbeddedObjectEntries(cfb);\n\n return Buffer.from(CFB.write(cfb, { type: \"buffer\", fileType: \"cfb\" }));\n}\n\nfunction enforceInputSize(buffer: Buffer, options: NormalizedSanitizeOptions): void {\n const configured = options.limits.maxUncompressedBytes;\n if (buffer.length > configured) {\n throw limitExceeded(\"maxUncompressedBytes\", configured, buffer.length);\n }\n}\n\nfunction enforceContainerLimits(cfb: CFB.CFB$Container, options: NormalizedSanitizeOptions): void {\n const entries = cfb.FileIndex.filter(\n (entry) => entry.type === CFB_STORAGE_ENTRY || entry.type === CFB_STREAM_ENTRY,\n );\n if (entries.length > options.limits.maxEntryCount) {\n throw limitExceeded(\"maxEntryCount\", options.limits.maxEntryCount, entries.length);\n }\n\n const totalBytes = entries.reduce(\n (total, entry) => (entry.type === CFB_STREAM_ENTRY ? total + entry.content.length : total),\n 0,\n );\n if (totalBytes > options.limits.maxUncompressedBytes) {\n throw limitExceeded(\"maxUncompressedBytes\", options.limits.maxUncompressedBytes, totalBytes);\n }\n}\n\nfunction parseBiffRecords(stream: Buffer): BiffRecord[] {\n const records: BiffRecord[] = [];\n let offset = 0;\n let removeContinuation = false;\n\n while (offset < stream.length) {\n if (offset + RECORD_HEADER_BYTES > stream.length) {\n throw new Error(\"Truncated BIFF record header\");\n }\n\n const id = stream.readUInt16LE(offset);\n const length = stream.readUInt16LE(offset + 2);\n const nextOffset = offset + RECORD_HEADER_BYTES + length;\n if (nextOffset > stream.length) {\n throw new Error(\"Truncated BIFF record payload\");\n }\n\n const remove: boolean =\n id === RECORD_CONTINUE\n ? removeContinuation\n : DRAWING_RECORD_IDS.has(id) || OFFSET_CACHE_RECORD_IDS.has(id);\n records.push({\n id,\n originalOffset: offset,\n payload: stream.subarray(offset + RECORD_HEADER_BYTES, nextOffset),\n remove,\n });\n if (id !== RECORD_CONTINUE) {\n removeContinuation = remove;\n }\n offset = nextOffset;\n }\n\n if (records.length === 0 || !RECORD_BOF_IDS.has(records[0]?.id ?? -1)) {\n throw new Error(\"Workbook stream does not start with a BIFF BOF record\");\n }\n return records;\n}\n\nfunction rejectEncryptedWorkbook(records: readonly BiffRecord[]): void {\n if (records.some((record) => record.id === 0x002f)) {\n throw createError(\"ERR_INVALID_XLS\", {\n message: \"Encrypted XLS workbooks are not supported\",\n });\n }\n}\n\nfunction enforceWorksheetCount(records: readonly BiffRecord[], required: boolean): void {\n const count = records.filter((record) => record.id === RECORD_BOUNDSHEET).length;\n if (count === 0) {\n throw new Error(\"Workbook contains no sheets\");\n }\n if (required && count !== 1) {\n throw createError(\"ERR_WORKSHEET_COUNT\", {\n details: { expected: 1, actual: count },\n });\n }\n}\n\nfunction rebuildWorkbookStream(records: readonly BiffRecord[]): Buffer {\n const newOffsets = new Map<number, number>();\n let outputLength = 0;\n for (const record of records) {\n if (record.remove) {\n continue;\n }\n newOffsets.set(record.originalOffset, outputLength);\n outputLength += RECORD_HEADER_BYTES + record.payload.length;\n }\n\n const output = Buffer.allocUnsafe(outputLength);\n let outputOffset = 0;\n for (const record of records) {\n if (record.remove) {\n continue;\n }\n\n const payload = patchBoundSheetOffset(record, newOffsets);\n output.writeUInt16LE(record.id, outputOffset);\n output.writeUInt16LE(payload.length, outputOffset + 2);\n payload.copy(output, outputOffset + RECORD_HEADER_BYTES);\n outputOffset += RECORD_HEADER_BYTES + payload.length;\n }\n return output;\n}\n\nfunction patchBoundSheetOffset(\n record: BiffRecord,\n newOffsets: ReadonlyMap<number, number>,\n): Buffer {\n if (record.id !== RECORD_BOUNDSHEET) {\n return record.payload;\n }\n if (record.payload.length < 4) {\n throw new Error(\"Invalid BoundSheet record\");\n }\n\n const originalSheetOffset = record.payload.readUInt32LE(0);\n const newSheetOffset = newOffsets.get(originalSheetOffset);\n if (newSheetOffset === undefined) {\n throw new Error(\"BoundSheet record points outside the Workbook stream\");\n }\n\n const payload = Buffer.from(record.payload);\n payload.writeUInt32LE(newSheetOffset, 0);\n return payload;\n}\n\nfunction removeEmbeddedObjectEntries(cfb: CFB.CFB$Container): void {\n const paths = cfb.FullPaths.filter((path) =>\n path\n .split(\"/\")\n .filter(Boolean)\n .some(\n (segment) =>\n segment.toLowerCase() === \"objectpool\" ||\n segment.toLowerCase() === \"ctls\" ||\n /^mbd[0-9a-f]{8}$/i.test(segment),\n ),\n );\n\n for (let index = paths.length - 1; index >= 0; index -= 1) {\n CFB.utils.cfb_del(cfb, paths[index] ?? \"\");\n }\n if (paths.length > 0) {\n CFB.utils.cfb_gc(cfb);\n }\n}\n"]}
|
package/dist/constants.d.ts
CHANGED
|
@@ -3,4 +3,3 @@ export declare const DEFAULT_LIMITS: Readonly<ExcelSanitizationLimits>;
|
|
|
3
3
|
export declare const SANITIZE_OPTION_NAMES: ReadonlySet<string>;
|
|
4
4
|
export declare const FILE_OPTION_NAMES: ReadonlySet<string>;
|
|
5
5
|
export declare const LIMIT_NAMES: ReadonlySet<string>;
|
|
6
|
-
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
2
|
+
maxEntryCount: 10_000,
|
|
3
|
+
maxUncompressedBytes: 512 * 1024 * 1024,
|
|
4
|
+
maxXmlBytes: 512 * 1024 * 1024,
|
|
5
|
+
maxWorksheetXmlBytes: 256 * 1024 * 1024,
|
|
6
|
+
maxStylesXmlBytes: 64 * 1024 * 1024,
|
|
7
|
+
maxMetadataXmlBytes: 16 * 1024 * 1024,
|
|
8
|
+
maxRelationshipsXmlBytes: 8 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
export const SANITIZE_OPTION_NAMES = new Set([
|
|
11
|
+
"requireSingleWorksheet",
|
|
12
|
+
"limits",
|
|
13
|
+
]);
|
|
14
|
+
export const FILE_OPTION_NAMES = new Set([
|
|
15
|
+
"requireSingleWorksheet",
|
|
16
|
+
"limits",
|
|
17
|
+
"overwrite",
|
|
18
|
+
]);
|
|
19
|
+
export const LIMIT_NAMES = new Set([
|
|
20
|
+
"maxEntryCount",
|
|
21
|
+
"maxUncompressedBytes",
|
|
22
|
+
"maxXmlBytes",
|
|
23
|
+
"maxWorksheetXmlBytes",
|
|
24
|
+
"maxStylesXmlBytes",
|
|
25
|
+
"maxMetadataXmlBytes",
|
|
26
|
+
"maxRelationshipsXmlBytes",
|
|
27
|
+
]);
|
|
28
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,cAAc,GAAsC,MAAM,CAAC,MAAM,CAAC;IAC7E,aAAa,EAAE,MAAM;IACrB,oBAAoB,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI;IACvC,WAAW,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI;IAC9B,oBAAoB,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI;IACvC,iBAAiB,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI;IACnC,mBAAmB,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI;IACrC,wBAAwB,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;CAC1C,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,qBAAqB,GAAwB,IAAI,GAAG,CAAC;IAChE,wBAAwB;IACxB,QAAQ;CACT,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC;IAC5D,wBAAwB;IACxB,QAAQ;IACR,WAAW;CACZ,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,WAAW,GAAwB,IAAI,GAAG,CAAC;IACtD,eAAe;IACf,sBAAsB;IACtB,aAAa;IACb,sBAAsB;IACtB,mBAAmB;IACnB,qBAAqB;IACrB,0BAA0B;CAC3B,CAAC,CAAC","sourcesContent":["import type { ExcelSanitizationLimits } from \"./types.js\";\n\nexport const DEFAULT_LIMITS: Readonly<ExcelSanitizationLimits> = Object.freeze({\n maxEntryCount: 10_000,\n maxUncompressedBytes: 512 * 1024 * 1024,\n maxXmlBytes: 512 * 1024 * 1024,\n maxWorksheetXmlBytes: 256 * 1024 * 1024,\n maxStylesXmlBytes: 64 * 1024 * 1024,\n maxMetadataXmlBytes: 16 * 1024 * 1024,\n maxRelationshipsXmlBytes: 8 * 1024 * 1024,\n});\n\nexport const SANITIZE_OPTION_NAMES: ReadonlySet<string> = new Set([\n \"requireSingleWorksheet\",\n \"limits\",\n]);\n\nexport const FILE_OPTION_NAMES: ReadonlySet<string> = new Set([\n \"requireSingleWorksheet\",\n \"limits\",\n \"overwrite\",\n]);\n\nexport const LIMIT_NAMES: ReadonlySet<string> = new Set([\n \"maxEntryCount\",\n \"maxUncompressedBytes\",\n \"maxXmlBytes\",\n \"maxWorksheetXmlBytes\",\n \"maxStylesXmlBytes\",\n \"maxMetadataXmlBytes\",\n \"maxRelationshipsXmlBytes\",\n]);\n"]}
|
package/dist/errors.d.ts
CHANGED
|
@@ -17,4 +17,3 @@ export declare class ExcelSanitizationError extends Error {
|
|
|
17
17
|
export declare function createError(code: ExcelSanitizationErrorCode, options?: CreateErrorOptions): ExcelSanitizationError;
|
|
18
18
|
export declare function limitExceeded(limit: SafetyLimitName, configured: number, actual: number, partName?: string): ExcelSanitizationError;
|
|
19
19
|
export {};
|
|
20
|
-
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const ERROR_MESSAGES = Object.freeze({
|
|
2
|
+
ERR_INVALID_ARGUMENT: "Invalid argument",
|
|
3
|
+
ERR_UNSUPPORTED_EXTENSION: "Supported extensions are .xls, .xlsx, and .xlsm; input and output formats must match",
|
|
4
|
+
ERR_INVALID_XLS: "Invalid or unsupported XLS workbook",
|
|
5
|
+
ERR_INVALID_XLSX: "Invalid or unsupported OOXML workbook",
|
|
6
|
+
ERR_LIMIT_EXCEEDED: "Excel workbook exceeds a configured safety limit",
|
|
7
|
+
ERR_WORKSHEET_COUNT: "The workbook must contain exactly one worksheet",
|
|
8
|
+
ERR_SAME_PATH: "Input and output paths must be different",
|
|
9
|
+
ERR_OUTPUT_EXISTS: "The output file already exists",
|
|
10
|
+
ERR_FILE_READ: "Unable to read the input Excel file",
|
|
11
|
+
ERR_FILE_WRITE: "Unable to write the sanitized Excel file",
|
|
12
|
+
});
|
|
13
|
+
export class ExcelSanitizationError extends Error {
|
|
14
|
+
name = "ExcelSanitizationError";
|
|
15
|
+
code;
|
|
16
|
+
details;
|
|
17
|
+
constructor(code, message, options = {}) {
|
|
18
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
19
|
+
this.code = code;
|
|
20
|
+
if (options.details !== undefined) {
|
|
21
|
+
this.details = Object.freeze({ ...options.details });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function createError(code, options = {}) {
|
|
26
|
+
return new ExcelSanitizationError(code, options.message ?? ERROR_MESSAGES[code], options);
|
|
27
|
+
}
|
|
28
|
+
export function limitExceeded(limit, configured, actual, partName) {
|
|
29
|
+
return createError("ERR_LIMIT_EXCEEDED", {
|
|
30
|
+
details: {
|
|
31
|
+
limit,
|
|
32
|
+
configured,
|
|
33
|
+
actual,
|
|
34
|
+
...(partName === undefined ? {} : { partName }),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=errors.js.map
|