@bendyline/squisq-formats 1.4.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/chunk-2KPARF2P.js +99 -0
- package/dist/chunk-2KPARF2P.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
- package/dist/chunk-ABVI556T.js.map +1 -0
- package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
- package/dist/chunk-CRAVSMPZ.js.map +1 -0
- package/dist/chunk-FE6OJV6O.js +560 -0
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-QRVN6A6E.js +127 -0
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/chunk-U32AG3G3.js +195 -0
- package/dist/chunk-U32AG3G3.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
- package/dist/chunk-VJJM2SSH.js.map +1 -0
- package/dist/chunk-VSYHZECT.js +778 -0
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/chunk-XKUMNGBW.js +131 -0
- package/dist/chunk-XKUMNGBW.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/container/index.d.ts +13 -3
- package/dist/container/index.js +23 -20
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-D2NkylDT.d.ts +69 -0
- package/dist/extract-H6RXJMHP.js +15 -0
- package/dist/extract-H6RXJMHP.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-K8mfc0fz.d.ts +136 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -12
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-BHrgZ5FS.d.ts +150 -0
- package/dist/layouts-QVPK3ZCU.js +12 -0
- package/dist/layouts-QVPK3ZCU.js.map +1 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-B9L8Ucbj.d.ts +140 -0
- package/dist/registry/index.d.ts +60 -8
- package/dist/registry/index.js +9 -4
- package/dist/themeReader-DJKErl_j.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +310 -1
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +76 -2
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +37 -32
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +23 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +199 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +129 -29
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1222 -0
- package/src/registry/convert.ts +168 -53
- package/src/registry/defaultFormats.ts +135 -45
- package/src/registry/index.ts +6 -1
- package/src/registry/types.ts +69 -6
- package/src/shared/boundedZipArchive.ts +383 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +151 -0
- package/src/shared/zipLimits.ts +296 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Narrow EOCD preflight for rejecting record-count bombs before JSZip allocation. */
|
|
2
|
+
|
|
3
|
+
import { throwIfZipAborted } from './zipLimits.js';
|
|
4
|
+
|
|
5
|
+
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
|
|
6
|
+
const ZIP64_END_OF_CENTRAL_DIRECTORY = 0x06064b50;
|
|
7
|
+
const ZIP64_END_LOCATOR = 0x07064b50;
|
|
8
|
+
const UINT16_MAX = 0xffff;
|
|
9
|
+
const MIN_EOCD_BYTES = 22;
|
|
10
|
+
const MAX_ZIP_COMMENT_BYTES = 0xffff;
|
|
11
|
+
|
|
12
|
+
export type ZipInput = ArrayBuffer | Uint8Array | Blob;
|
|
13
|
+
|
|
14
|
+
/** Materialize Blob input once when possible so preflight bytes feed JSZip too. */
|
|
15
|
+
export async function prepareZipInput(
|
|
16
|
+
data: ZipInput,
|
|
17
|
+
signal?: AbortSignal,
|
|
18
|
+
): Promise<{ input: ZipInput; bytes?: Uint8Array }> {
|
|
19
|
+
throwIfZipAborted(signal);
|
|
20
|
+
if (ArrayBuffer.isView(data)) {
|
|
21
|
+
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
22
|
+
return { input: bytes, bytes };
|
|
23
|
+
}
|
|
24
|
+
if (!isBlobLike(data)) {
|
|
25
|
+
const bytes = new Uint8Array(data);
|
|
26
|
+
return { input: bytes, bytes };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bytes = await readBlobBytes(data, signal);
|
|
30
|
+
throwIfZipAborted(signal);
|
|
31
|
+
return bytes ? { input: bytes, bytes } : { input: data };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the declared total central-directory record count when unambiguous.
|
|
36
|
+
* Returns undefined for absent, trailing, malformed, or unsupported metadata;
|
|
37
|
+
* JSZip remains authoritative in those cases.
|
|
38
|
+
*/
|
|
39
|
+
export function declaredZipEntryCount(bytes: Uint8Array): number | undefined {
|
|
40
|
+
const eocdOffset = findEocd(bytes);
|
|
41
|
+
if (eocdOffset === undefined) return undefined;
|
|
42
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
43
|
+
const count = view.getUint16(eocdOffset + 10, true);
|
|
44
|
+
if (count !== UINT16_MAX) return count;
|
|
45
|
+
return readZip64EntryCount(view, eocdOffset);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findEocd(bytes: Uint8Array): number | undefined {
|
|
49
|
+
if (bytes.byteLength < MIN_EOCD_BYTES) return undefined;
|
|
50
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
51
|
+
const earliest = Math.max(0, bytes.byteLength - MIN_EOCD_BYTES - MAX_ZIP_COMMENT_BYTES);
|
|
52
|
+
for (let offset = bytes.byteLength - MIN_EOCD_BYTES; offset >= earliest; offset--) {
|
|
53
|
+
if (view.getUint32(offset, true) !== END_OF_CENTRAL_DIRECTORY) continue;
|
|
54
|
+
const commentLength = view.getUint16(offset + 20, true);
|
|
55
|
+
if (offset + MIN_EOCD_BYTES + commentLength === bytes.byteLength) return offset;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readZip64EntryCount(view: DataView, eocdOffset: number): number | undefined {
|
|
61
|
+
const locatorOffset = eocdOffset - 20;
|
|
62
|
+
if (locatorOffset < 0 || view.getUint32(locatorOffset, true) !== ZIP64_END_LOCATOR) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const recordOffset = readSafeUint64(view, locatorOffset + 8);
|
|
66
|
+
if (
|
|
67
|
+
recordOffset === undefined ||
|
|
68
|
+
recordOffset < 0 ||
|
|
69
|
+
recordOffset + 40 > view.byteLength ||
|
|
70
|
+
view.getUint32(recordOffset, true) !== ZIP64_END_OF_CENTRAL_DIRECTORY
|
|
71
|
+
) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return readSafeUint64(view, recordOffset + 32);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readSafeUint64(view: DataView, offset: number): number | undefined {
|
|
78
|
+
if (offset < 0 || offset + 8 > view.byteLength) return undefined;
|
|
79
|
+
const low = view.getUint32(offset, true);
|
|
80
|
+
const high = view.getUint32(offset + 4, true);
|
|
81
|
+
const value = high * 0x1_0000_0000 + low;
|
|
82
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isBlobLike(data: ZipInput): data is Blob {
|
|
86
|
+
const candidate = data as Blob;
|
|
87
|
+
return typeof candidate.size === 'number' && typeof candidate.slice === 'function';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readBlobBytes(blob: Blob, signal?: AbortSignal): Promise<Uint8Array | undefined> {
|
|
91
|
+
const arrayBuffer = (blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer;
|
|
92
|
+
if (typeof arrayBuffer === 'function') {
|
|
93
|
+
return new Uint8Array(await raceWithAbort(arrayBuffer.call(blob), signal));
|
|
94
|
+
}
|
|
95
|
+
if (typeof FileReader === 'undefined') return undefined;
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const reader = new FileReader();
|
|
98
|
+
let settled = false;
|
|
99
|
+
const cleanup = (): void => signal?.removeEventListener('abort', handleAbort);
|
|
100
|
+
const finish = (work: () => void): void => {
|
|
101
|
+
if (settled) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
cleanup();
|
|
104
|
+
work();
|
|
105
|
+
};
|
|
106
|
+
const handleAbort = (): void => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
const reason = signal?.reason ?? new Error('ZIP operation was cancelled');
|
|
109
|
+
// Mark the promise settled before FileReader.abort(): browsers are
|
|
110
|
+
// allowed to dispatch `abort` synchronously and re-enter this handler.
|
|
111
|
+
finish(() => reject(reason));
|
|
112
|
+
try {
|
|
113
|
+
reader.abort();
|
|
114
|
+
} catch {
|
|
115
|
+
// Preserve the caller's reason if a platform reader is already done.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
reader.onload = () => finish(() => resolve(new Uint8Array(reader.result as ArrayBuffer)));
|
|
119
|
+
reader.onerror = () =>
|
|
120
|
+
finish(() => reject(reader.error ?? new Error('Could not read ZIP blob.')));
|
|
121
|
+
reader.onabort = handleAbort;
|
|
122
|
+
signal?.addEventListener('abort', handleAbort, { once: true });
|
|
123
|
+
if (signal?.aborted) {
|
|
124
|
+
handleAbort();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
reader.readAsArrayBuffer(blob);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function raceWithAbort<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
132
|
+
throwIfZipAborted(signal);
|
|
133
|
+
if (!signal) return work;
|
|
134
|
+
return new Promise<T>((resolve, reject) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
const finish = (complete: () => void): void => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
signal.removeEventListener('abort', handleAbort);
|
|
140
|
+
complete();
|
|
141
|
+
};
|
|
142
|
+
const handleAbort = (): void =>
|
|
143
|
+
finish(() => reject(signal.reason ?? new Error('ZIP operation was cancelled')));
|
|
144
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
145
|
+
if (signal.aborted) handleAbort();
|
|
146
|
+
work.then(
|
|
147
|
+
(value) => finish(() => resolve(value)),
|
|
148
|
+
(error: unknown) => finish(() => reject(error)),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** ZIP safety policy, structured errors, path checks, and metadata validation. */
|
|
2
|
+
|
|
3
|
+
import type JSZip from 'jszip';
|
|
4
|
+
|
|
5
|
+
export interface ZipSafetyLimits {
|
|
6
|
+
/** Cooperatively cancel archive opening, validation, and member traversal. */
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
/** Maximum number of archive records, including directories. Default: 10,000. */
|
|
9
|
+
maxEntries?: number;
|
|
10
|
+
/** Maximum uncompressed bytes for one member. Defaults to maxUncompressedBytes. */
|
|
11
|
+
maxEntryUncompressedBytes?: number;
|
|
12
|
+
/** Maximum total uncompressed bytes across the archive. Default: 512 MiB. */
|
|
13
|
+
maxUncompressedBytes?: number;
|
|
14
|
+
/** Maximum uncompressed/compressed ratio for a member. Default: 1,000. */
|
|
15
|
+
maxCompressionRatio?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ZipSafetyErrorCode =
|
|
19
|
+
| 'invalid-limit'
|
|
20
|
+
| 'invalid-archive'
|
|
21
|
+
| 'unsafe-path'
|
|
22
|
+
| 'invalid-entry-metadata'
|
|
23
|
+
| 'too-many-entries'
|
|
24
|
+
| 'entry-too-large'
|
|
25
|
+
| 'archive-too-large'
|
|
26
|
+
| 'compression-ratio-exceeded'
|
|
27
|
+
| 'size-mismatch'
|
|
28
|
+
| 'crc-mismatch'
|
|
29
|
+
| 'decompression-failed';
|
|
30
|
+
|
|
31
|
+
export interface ZipSafetyErrorOptions {
|
|
32
|
+
path?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
actual?: number;
|
|
35
|
+
cause?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A stable, machine-readable error raised while validating or reading a ZIP. */
|
|
39
|
+
export class ZipSafetyError extends Error {
|
|
40
|
+
readonly code: ZipSafetyErrorCode;
|
|
41
|
+
readonly path?: string;
|
|
42
|
+
readonly limit?: number;
|
|
43
|
+
readonly actual?: number;
|
|
44
|
+
|
|
45
|
+
constructor(code: ZipSafetyErrorCode, message: string, options: ZipSafetyErrorOptions = {}) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'ZipSafetyError';
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.path = options.path;
|
|
50
|
+
this.limit = options.limit;
|
|
51
|
+
this.actual = options.actual;
|
|
52
|
+
if (options.cause !== undefined) {
|
|
53
|
+
(this as Error & { cause?: unknown }).cause = options.cause;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ValidatedZipEntry {
|
|
59
|
+
path: string;
|
|
60
|
+
entry: JSZip.JSZipObject;
|
|
61
|
+
declaredSize?: number;
|
|
62
|
+
compressedSize?: number;
|
|
63
|
+
crc32?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedZipSafetyLimits {
|
|
67
|
+
maxEntries: number;
|
|
68
|
+
maxEntryUncompressedBytes: number;
|
|
69
|
+
maxUncompressedBytes: number;
|
|
70
|
+
maxCompressionRatio: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_MAX_ZIP_ENTRIES = 10_000;
|
|
74
|
+
export const DEFAULT_MAX_ZIP_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
75
|
+
export const DEFAULT_MAX_ZIP_COMPRESSION_RATIO = 1_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate central-directory metadata before any member is decompressed.
|
|
79
|
+
* JSZip normalizes traversal names, so inspect `unsafeOriginalName` too.
|
|
80
|
+
*/
|
|
81
|
+
export function validateZipArchive(zip: JSZip, limits: ZipSafetyLimits = {}): ValidatedZipEntry[] {
|
|
82
|
+
throwIfZipAborted(limits.signal);
|
|
83
|
+
const resolved = resolveZipSafetyLimits(limits);
|
|
84
|
+
const files: ValidatedZipEntry[] = [];
|
|
85
|
+
let memberCount = 0;
|
|
86
|
+
let declaredTotal = 0;
|
|
87
|
+
let oversizedEntry: { path: string; actual: number; limit: number } | undefined;
|
|
88
|
+
|
|
89
|
+
zip.forEach((relativePath, entry) => {
|
|
90
|
+
throwIfZipAborted(limits.signal);
|
|
91
|
+
memberCount++;
|
|
92
|
+
if (entry.dir) return;
|
|
93
|
+
const originalPath = originalEntryPath(entry, relativePath);
|
|
94
|
+
if (!isSafeArchivePath(originalPath)) {
|
|
95
|
+
throw new ZipSafetyError(
|
|
96
|
+
'unsafe-path',
|
|
97
|
+
`ZIP import: archive contains an unsafe path: ${originalPath}.`,
|
|
98
|
+
{ path: originalPath },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const metadata = compressedMetadata(entry, originalPath);
|
|
103
|
+
if (
|
|
104
|
+
metadata.uncompressedSize !== undefined &&
|
|
105
|
+
metadata.uncompressedSize > resolved.maxEntryUncompressedBytes
|
|
106
|
+
) {
|
|
107
|
+
oversizedEntry ??= {
|
|
108
|
+
path: originalPath,
|
|
109
|
+
actual: metadata.uncompressedSize,
|
|
110
|
+
limit: resolved.maxEntryUncompressedBytes,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ratio =
|
|
115
|
+
metadata.uncompressedSize === undefined || metadata.compressedSize === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: compressionRatio(metadata.uncompressedSize, metadata.compressedSize);
|
|
118
|
+
if (ratio !== undefined && ratio > resolved.maxCompressionRatio) {
|
|
119
|
+
throw compressionRatioError(originalPath, ratio, resolved.maxCompressionRatio);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
declaredTotal += metadata.uncompressedSize ?? 0;
|
|
123
|
+
if (!Number.isSafeInteger(declaredTotal)) throw invalidMetadataError(originalPath);
|
|
124
|
+
files.push({
|
|
125
|
+
path: relativePath,
|
|
126
|
+
entry,
|
|
127
|
+
...(metadata.uncompressedSize !== undefined
|
|
128
|
+
? { declaredSize: metadata.uncompressedSize }
|
|
129
|
+
: {}),
|
|
130
|
+
...(metadata.compressedSize !== undefined ? { compressedSize: metadata.compressedSize } : {}),
|
|
131
|
+
...(metadata.crc32 !== undefined ? { crc32: metadata.crc32 } : {}),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (memberCount > resolved.maxEntries) {
|
|
136
|
+
throw tooManyEntriesError(memberCount, resolved.maxEntries);
|
|
137
|
+
}
|
|
138
|
+
if (declaredTotal > resolved.maxUncompressedBytes) {
|
|
139
|
+
throw archiveTooLargeError(declaredTotal, resolved.maxUncompressedBytes);
|
|
140
|
+
}
|
|
141
|
+
if (oversizedEntry) {
|
|
142
|
+
throw entryTooLargeError(oversizedEntry.path, oversizedEntry.actual, oversizedEntry.limit);
|
|
143
|
+
}
|
|
144
|
+
return files;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Preserve the caller's exact cancellation reason across ZIP/OOXML layers. */
|
|
148
|
+
export function throwIfZipAborted(signal?: AbortSignal): void {
|
|
149
|
+
if (!signal?.aborted) return;
|
|
150
|
+
throw signal.reason ?? new Error('ZIP operation was cancelled');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reject a path that could be interpreted outside an archive's logical root. */
|
|
154
|
+
export function assertSafeZipPath(path: string): void {
|
|
155
|
+
if (!isSafeArchivePath(path)) {
|
|
156
|
+
throw new ZipSafetyError('unsafe-path', `ZIP archive path is unsafe: ${path}.`, { path });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveZipSafetyLimits(limits: ZipSafetyLimits): ResolvedZipSafetyLimits {
|
|
161
|
+
const maxEntries = limits.maxEntries ?? DEFAULT_MAX_ZIP_ENTRIES;
|
|
162
|
+
const maxUncompressedBytes = limits.maxUncompressedBytes ?? DEFAULT_MAX_ZIP_UNCOMPRESSED_BYTES;
|
|
163
|
+
const maxEntryUncompressedBytes = limits.maxEntryUncompressedBytes ?? maxUncompressedBytes;
|
|
164
|
+
const maxCompressionRatio = limits.maxCompressionRatio ?? DEFAULT_MAX_ZIP_COMPRESSION_RATIO;
|
|
165
|
+
|
|
166
|
+
assertNonNegativeInteger('maxEntries', maxEntries);
|
|
167
|
+
assertNonNegativeFinite('maxUncompressedBytes', maxUncompressedBytes);
|
|
168
|
+
assertNonNegativeFinite('maxEntryUncompressedBytes', maxEntryUncompressedBytes);
|
|
169
|
+
if (!Number.isFinite(maxCompressionRatio) || maxCompressionRatio < 1) {
|
|
170
|
+
throw invalidLimitError(
|
|
171
|
+
'maxCompressionRatio must be a finite number greater than or equal to 1',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
maxEntries,
|
|
176
|
+
maxEntryUncompressedBytes,
|
|
177
|
+
maxUncompressedBytes,
|
|
178
|
+
maxCompressionRatio,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function assertNonNegativeFinite(name: string, value: number): void {
|
|
183
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
184
|
+
throw invalidLimitError(`${name} must be a non-negative finite number`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function entryTooLargeError(path: string, actual: number, limit: number): ZipSafetyError {
|
|
189
|
+
return new ZipSafetyError(
|
|
190
|
+
'entry-too-large',
|
|
191
|
+
`ZIP import: archive member ${path} exceeds ${limit} byte per-entry limit.`,
|
|
192
|
+
{ path, limit, actual },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function tooManyEntriesError(actual: number, limit: number): ZipSafetyError {
|
|
197
|
+
return new ZipSafetyError(
|
|
198
|
+
'too-many-entries',
|
|
199
|
+
`ZIP import: archive has ${actual} files; limit is ${limit}.`,
|
|
200
|
+
{ limit, actual },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function archiveTooLargeError(actual: number, limit: number): ZipSafetyError {
|
|
205
|
+
return new ZipSafetyError(
|
|
206
|
+
'archive-too-large',
|
|
207
|
+
`ZIP import: uncompressed content exceeds ${limit} byte limit.`,
|
|
208
|
+
{ limit, actual },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function compressionRatioError(path: string, actual: number, limit: number): ZipSafetyError {
|
|
213
|
+
return new ZipSafetyError(
|
|
214
|
+
'compression-ratio-exceeded',
|
|
215
|
+
`ZIP import: archive member ${path} exceeds ${limit}:1 compression-ratio limit.`,
|
|
216
|
+
{ path, limit, actual },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function compressionRatio(uncompressedBytes: number, compressedBytes: number): number {
|
|
221
|
+
if (uncompressedBytes === 0) return 0;
|
|
222
|
+
if (compressedBytes === 0) return Number.POSITIVE_INFINITY;
|
|
223
|
+
return uncompressedBytes / compressedBytes;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function assertNonNegativeInteger(name: string, value: number): void {
|
|
227
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
228
|
+
throw invalidLimitError(`${name} must be a non-negative integer`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function invalidLimitError(detail: string): ZipSafetyError {
|
|
233
|
+
return new ZipSafetyError('invalid-limit', `ZIP import: ${detail}.`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function originalEntryPath(entry: JSZip.JSZipObject, relativePath: string): string {
|
|
237
|
+
return (
|
|
238
|
+
(
|
|
239
|
+
entry as JSZip.JSZipObject & {
|
|
240
|
+
unsafeOriginalName?: string;
|
|
241
|
+
}
|
|
242
|
+
).unsafeOriginalName ?? relativePath
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function compressedMetadata(
|
|
247
|
+
entry: JSZip.JSZipObject,
|
|
248
|
+
path: string,
|
|
249
|
+
): { uncompressedSize?: number; compressedSize?: number; crc32?: number } {
|
|
250
|
+
const data = (
|
|
251
|
+
entry as unknown as {
|
|
252
|
+
_data?: { uncompressedSize?: number; compressedSize?: number; crc32?: number };
|
|
253
|
+
}
|
|
254
|
+
)._data;
|
|
255
|
+
const uncompressedSize = data?.uncompressedSize;
|
|
256
|
+
const compressedSize = data?.compressedSize;
|
|
257
|
+
const crc32 = data?.crc32;
|
|
258
|
+
if (
|
|
259
|
+
(uncompressedSize !== undefined && !isValidMetadataInteger(uncompressedSize)) ||
|
|
260
|
+
(compressedSize !== undefined && !isValidMetadataInteger(compressedSize)) ||
|
|
261
|
+
(crc32 !== undefined && !Number.isInteger(crc32))
|
|
262
|
+
) {
|
|
263
|
+
throw invalidMetadataError(path);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...(uncompressedSize !== undefined ? { uncompressedSize } : {}),
|
|
267
|
+
...(compressedSize !== undefined ? { compressedSize } : {}),
|
|
268
|
+
...(crc32 !== undefined ? { crc32 } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isValidMetadataInteger(value: number): boolean {
|
|
273
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function invalidMetadataError(path: string): ZipSafetyError {
|
|
277
|
+
return new ZipSafetyError(
|
|
278
|
+
'invalid-entry-metadata',
|
|
279
|
+
`ZIP import: archive member has invalid size or checksum metadata: ${path}.`,
|
|
280
|
+
{ path },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isSafeArchivePath(path: string): boolean {
|
|
285
|
+
const segments = path.split('/');
|
|
286
|
+
return !(
|
|
287
|
+
path.startsWith('/') ||
|
|
288
|
+
/^[a-zA-Z]:[\\/]/.test(path) ||
|
|
289
|
+
path.includes('\\') ||
|
|
290
|
+
Array.from(path).some((character) => {
|
|
291
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
292
|
+
return codePoint < 0x20 || codePoint === 0x7f;
|
|
293
|
+
}) ||
|
|
294
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
295
|
+
);
|
|
296
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Stable facade for ZIP validation and bounded JSZip member reads. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
ZipSafetyError,
|
|
5
|
+
DEFAULT_MAX_ZIP_ENTRIES,
|
|
6
|
+
DEFAULT_MAX_ZIP_UNCOMPRESSED_BYTES,
|
|
7
|
+
DEFAULT_MAX_ZIP_COMPRESSION_RATIO,
|
|
8
|
+
assertSafeZipPath,
|
|
9
|
+
validateZipArchive,
|
|
10
|
+
} from './zipLimits.js';
|
|
11
|
+
export type {
|
|
12
|
+
ZipSafetyLimits,
|
|
13
|
+
ZipSafetyErrorCode,
|
|
14
|
+
ZipSafetyErrorOptions,
|
|
15
|
+
ValidatedZipEntry,
|
|
16
|
+
} from './zipLimits.js';
|
|
17
|
+
|
|
18
|
+
export { createBoundedZipArchive, openBoundedZipArchive } from './boundedZipArchive.js';
|
|
19
|
+
export type { BoundedZipArchive } from './boundedZipArchive.js';
|
package/src/xlsx/import.ts
CHANGED
|
@@ -16,10 +16,11 @@ import type {
|
|
|
16
16
|
MarkdownTableRow,
|
|
17
17
|
} from '@bendyline/squisq/markdown';
|
|
18
18
|
import { getPartXml, getPartRelationships, openPackage } from '../ooxml/reader.js';
|
|
19
|
+
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
19
20
|
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
20
21
|
import { NS_R, NS_SML } from '../ooxml/namespaces.js';
|
|
21
22
|
|
|
22
|
-
export interface XlsxImportOptions {
|
|
23
|
+
export interface XlsxImportOptions extends OoxmlOpenOptions {
|
|
23
24
|
/** Which sheet to import (0-based index or sheet name). Default: all sheets. */
|
|
24
25
|
sheet?: number | string;
|
|
25
26
|
}
|
|
@@ -133,7 +134,7 @@ export async function xlsxToMarkdownDoc(
|
|
|
133
134
|
data: ArrayBuffer | Blob,
|
|
134
135
|
options: XlsxImportOptions = {},
|
|
135
136
|
): Promise<MarkdownDocument> {
|
|
136
|
-
const pkg = await openPackage(data);
|
|
137
|
+
const pkg = await openPackage(data, options);
|
|
137
138
|
const sheets = await readSheets(pkg);
|
|
138
139
|
const shared = await readSharedStrings(pkg);
|
|
139
140
|
|