@windsland52/maa-log-tools 1.2.1 → 1.3.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 CHANGED
@@ -47,6 +47,18 @@ The concrete adapter is provided by `@windsland52/maa-log-adapter`.
47
47
 
48
48
  When `focus` is provided, the helpers scan candidate primary and history log files and only merge files whose content matches the keywords and/or timestamp boundaries. If `focus` is omitted, the previous default loading behavior is preserved.
49
49
 
50
+ ## Input resource limits
51
+
52
+ ZIP, file, and directory helpers apply the repository archive limits by default. ZIP inputs are
53
+ checked for compressed size, entry/path metadata, selected file/image sizes, total extracted size,
54
+ and compression ratio before selected entries are expanded. Regular files are checked with
55
+ filesystem metadata before reading and again using the returned byte length; directory walks and
56
+ cumulative text reads are bounded as well.
57
+
58
+ Programmatic callers can tighten a subset of these limits with `archiveLimits` on the analyze and
59
+ input-helper options. The defaults are exported as `DEFAULT_ARCHIVE_LIMITS`; limit failures throw
60
+ `ArchiveLimitError` with a stable `code` such as `compressed-size` or `compression-ratio`.
61
+
50
62
  ## CLI
51
63
 
52
64
  ```bash
@@ -71,7 +83,7 @@ process-start-bounded interval contains the timestamp; otherwise it returns `nul
71
83
 
72
84
  ## Runtime inspection
73
85
 
74
- `buildRuntimeInspection(kernelOutput, frameworkExtraction, sourceSegments?)` emits
86
+ `buildRuntimeInspection(kernelOutput, frameworkExtraction, sourceSegments)` emits
75
87
  `mla-runtime-inspection/v1`. It nests task executions under their runtime session and keeps three
76
88
  different semantics separate:
77
89
 
@@ -0,0 +1,59 @@
1
+ import { type Unzipped } from 'fflate';
2
+ export interface ArchiveLimits {
3
+ maxVolumes: number;
4
+ maxCompressedBytes: number;
5
+ maxEntries: number;
6
+ maxPathBytes: number;
7
+ maxTotalPathBytes: number;
8
+ maxFileBytes: number;
9
+ maxImageBytes: number;
10
+ maxExtractedBytes: number;
11
+ maxCompressionRatio: number;
12
+ compressionRatioMinBytes: number;
13
+ }
14
+ export type ArchiveLimitCode = 'volume-count' | 'compressed-size' | 'entry-count' | 'path-size' | 'total-path-size' | 'file-size' | 'image-size' | 'extracted-size' | 'compression-ratio';
15
+ export declare class ArchiveLimitError extends Error {
16
+ readonly code: ArchiveLimitCode;
17
+ readonly actual: number;
18
+ readonly limit: number;
19
+ readonly name = "ArchiveLimitError";
20
+ constructor(code: ArchiveLimitCode, actual: number, limit: number);
21
+ }
22
+ export type ArchiveFormatCode = 'invalid-structure' | 'unsupported-archive' | 'invalid-path' | 'duplicate-path' | 'local-entry-mismatch' | 'declared-size-mismatch' | 'actual-size-mismatch' | 'unsupported-compression' | 'missing-entry';
23
+ export declare class ArchiveFormatError extends Error {
24
+ readonly code: ArchiveFormatCode;
25
+ readonly entryName: string;
26
+ readonly name = "ArchiveFormatError";
27
+ constructor(code: ArchiveFormatCode, entryName: string, message: string);
28
+ }
29
+ export interface ArchiveEntryMetadata {
30
+ name: string;
31
+ size: number;
32
+ originalSize: number;
33
+ compression: number;
34
+ }
35
+ export interface ArchiveDirectoryBudget {
36
+ entryCount: number;
37
+ totalPathBytes: number;
38
+ }
39
+ export interface ExtractionBudget {
40
+ extractedBytes: number;
41
+ }
42
+ export declare const DEFAULT_ARCHIVE_LIMITS: Readonly<ArchiveLimits>;
43
+ export declare const resolveArchiveLimits: (overrides?: Partial<ArchiveLimits>) => Readonly<ArchiveLimits>;
44
+ export declare const EMPTY_ARCHIVE_DIRECTORY_BUDGET: Readonly<ArchiveDirectoryBudget>;
45
+ export declare const EMPTY_EXTRACTION_BUDGET: Readonly<ExtractionBudget>;
46
+ export declare const assertArchiveInputsWithinLimits: (inputs: readonly {
47
+ size: number;
48
+ }[], limits?: Readonly<ArchiveLimits>) => void;
49
+ export declare const addArchiveDirectoryEntry: (current: Readonly<ArchiveDirectoryBudget>, entry: Pick<ArchiveEntryMetadata, "name" | "size" | "originalSize" | "compression">, limits?: Readonly<ArchiveLimits>) => ArchiveDirectoryBudget;
50
+ export declare const inspectZipDirectory: (data: Uint8Array, limits?: Readonly<ArchiveLimits>) => ArchiveEntryMetadata[];
51
+ export declare const addSelectedEntry: (current: Readonly<ExtractionBudget>, entry: Pick<ArchiveEntryMetadata, "name" | "size" | "originalSize">, limits?: Readonly<ArchiveLimits>, checkCompressionRatio?: boolean) => ExtractionBudget;
52
+ export declare const assertSelectedEntriesWithinLimits: (entries: readonly Pick<ArchiveEntryMetadata, "name" | "size" | "originalSize">[], limits?: Readonly<ArchiveLimits>) => void;
53
+ export interface ExtractedZipEntries {
54
+ files: Unzipped;
55
+ entries: ArchiveEntryMetadata[];
56
+ }
57
+ export declare const extractInspectedZipEntriesWithinLimits: (data: Uint8Array, entries: readonly ArchiveEntryMetadata[], shouldExtract: (entryName: string) => boolean, limits?: Readonly<ArchiveLimits>) => ExtractedZipEntries;
58
+ export declare const extractZipEntriesWithinLimits: (data: Uint8Array, shouldExtract: (entryName: string) => boolean, limits?: Readonly<ArchiveLimits>) => ExtractedZipEntries;
59
+ export declare const createStoredFileMetadata: (name: string, size: number) => ArchiveEntryMetadata;
@@ -0,0 +1,522 @@
1
+ import { Unzip, UnzipInflate, unzipSync, } from 'fflate';
2
+ export class ArchiveLimitError extends Error {
3
+ constructor(code, actual, limit) {
4
+ super(`Input ${code} exceeds the configured limit (${actual} > ${limit})`);
5
+ this.code = code;
6
+ this.actual = actual;
7
+ this.limit = limit;
8
+ this.name = 'ArchiveLimitError';
9
+ }
10
+ }
11
+ export class ArchiveFormatError extends Error {
12
+ constructor(code, entryName, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.entryName = entryName;
16
+ this.name = 'ArchiveFormatError';
17
+ }
18
+ }
19
+ // Keep these build-time defaults aligned with config/archive-limits.json. The
20
+ // package test suite reads that shared file and fails if either copy drifts.
21
+ export const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
22
+ maxVolumes: 16,
23
+ maxCompressedBytes: 268435456,
24
+ maxEntries: 10000,
25
+ maxPathBytes: 4096,
26
+ maxTotalPathBytes: 8388608,
27
+ maxFileBytes: 268435456,
28
+ maxImageBytes: 33554432,
29
+ maxExtractedBytes: 536870912,
30
+ maxCompressionRatio: 500,
31
+ compressionRatioMinBytes: 1048576,
32
+ });
33
+ const integerLimitKeys = [
34
+ 'maxVolumes',
35
+ 'maxCompressedBytes',
36
+ 'maxEntries',
37
+ 'maxPathBytes',
38
+ 'maxTotalPathBytes',
39
+ 'maxFileBytes',
40
+ 'maxImageBytes',
41
+ 'maxExtractedBytes',
42
+ 'compressionRatioMinBytes',
43
+ ];
44
+ const validateLimits = (limits) => {
45
+ for (const key of integerLimitKeys) {
46
+ const value = limits[key];
47
+ if (!Number.isSafeInteger(value) || value < 0) {
48
+ throw new RangeError(`Archive limit ${key} must be a non-negative safe integer`);
49
+ }
50
+ }
51
+ if (!Number.isFinite(limits.maxCompressionRatio) || limits.maxCompressionRatio < 0) {
52
+ throw new RangeError('Archive limit maxCompressionRatio must be a non-negative finite number');
53
+ }
54
+ return Object.freeze(limits);
55
+ };
56
+ export const resolveArchiveLimits = (overrides = {}) => validateLimits({
57
+ ...DEFAULT_ARCHIVE_LIMITS,
58
+ ...overrides,
59
+ });
60
+ export const EMPTY_ARCHIVE_DIRECTORY_BUDGET = Object.freeze({
61
+ entryCount: 0,
62
+ totalPathBytes: 0,
63
+ });
64
+ export const EMPTY_EXTRACTION_BUDGET = Object.freeze({
65
+ extractedBytes: 0,
66
+ });
67
+ const assertMetadataInteger = (value, label) => {
68
+ if (!Number.isSafeInteger(value) || value < 0) {
69
+ throw new Error(`Invalid input metadata: ${label} must be a non-negative safe integer`);
70
+ }
71
+ };
72
+ const addSize = (total, value, label) => {
73
+ assertMetadataInteger(total, `${label} total`);
74
+ assertMetadataInteger(value, label);
75
+ const next = total + value;
76
+ if (!Number.isSafeInteger(next)) {
77
+ throw new Error(`Invalid input metadata: ${label} total exceeds the safe integer range`);
78
+ }
79
+ return next;
80
+ };
81
+ const throwLimitError = (code, actual, limit) => {
82
+ throw new ArchiveLimitError(code, actual, limit);
83
+ };
84
+ export const assertArchiveInputsWithinLimits = (inputs, limits = DEFAULT_ARCHIVE_LIMITS) => {
85
+ if (inputs.length > limits.maxVolumes) {
86
+ throwLimitError('volume-count', inputs.length, limits.maxVolumes);
87
+ }
88
+ let total = 0;
89
+ for (const input of inputs) {
90
+ total = addSize(total, input.size, 'compressed size');
91
+ if (total > limits.maxCompressedBytes) {
92
+ throwLimitError('compressed-size', total, limits.maxCompressedBytes);
93
+ }
94
+ }
95
+ };
96
+ const utf8Encoder = new TextEncoder();
97
+ const throwFormatError = (code, entryName, message) => {
98
+ throw new ArchiveFormatError(code, entryName, message);
99
+ };
100
+ const canonicalizeArchivePath = (rawPath) => {
101
+ if (rawPath.length === 0 || rawPath.includes('\\') || rawPath.normalize('NFC') !== rawPath) {
102
+ throwFormatError('invalid-path', rawPath, `Archive entry uses a non-canonical path: ${rawPath}`);
103
+ }
104
+ if (/[\u0000-\u001f\u007f]/u.test(rawPath) || rawPath.startsWith('/')) {
105
+ throwFormatError('invalid-path', rawPath, `Archive entry uses an unsafe path: ${rawPath}`);
106
+ }
107
+ const isDirectory = rawPath.endsWith('/');
108
+ const canonical = isDirectory ? rawPath.slice(0, -1) : rawPath;
109
+ if (canonical.length === 0) {
110
+ throwFormatError('invalid-path', rawPath, `Archive entry uses an empty path: ${rawPath}`);
111
+ }
112
+ const segments = canonical.split('/');
113
+ for (const [index, segment] of segments.entries()) {
114
+ if (segment.length === 0
115
+ || segment === '.'
116
+ || segment === '..'
117
+ || segment.endsWith('.')
118
+ || segment.endsWith(' ')
119
+ || segment.includes(':')
120
+ || (index === 0 && /^[a-z]:$/iu.test(segment))) {
121
+ throwFormatError('invalid-path', rawPath, `Archive entry uses a path alias: ${rawPath}`);
122
+ }
123
+ }
124
+ return {
125
+ canonical,
126
+ identity: canonical.toLowerCase(),
127
+ };
128
+ };
129
+ export const addArchiveDirectoryEntry = (current, entry, limits = DEFAULT_ARCHIVE_LIMITS) => {
130
+ if (typeof entry.name !== 'string') {
131
+ throw new Error('Invalid input metadata: entry name must be a string');
132
+ }
133
+ assertMetadataInteger(entry.size, 'compressed entry size');
134
+ assertMetadataInteger(entry.originalSize, 'original entry size');
135
+ assertMetadataInteger(entry.compression, 'compression method');
136
+ const entryCount = addSize(current.entryCount, 1, 'entry count');
137
+ if (entryCount > limits.maxEntries) {
138
+ throwLimitError('entry-count', entryCount, limits.maxEntries);
139
+ }
140
+ const pathBytes = utf8Encoder.encode(entry.name).byteLength;
141
+ if (pathBytes > limits.maxPathBytes) {
142
+ throwLimitError('path-size', pathBytes, limits.maxPathBytes);
143
+ }
144
+ const totalPathBytes = addSize(current.totalPathBytes, pathBytes, 'path size');
145
+ if (totalPathBytes > limits.maxTotalPathBytes) {
146
+ throwLimitError('total-path-size', totalPathBytes, limits.maxTotalPathBytes);
147
+ }
148
+ return { entryCount, totalPathBytes };
149
+ };
150
+ const copyEntryMetadata = (entry) => ({
151
+ name: entry.name,
152
+ size: entry.size,
153
+ originalSize: entry.originalSize,
154
+ compression: entry.compression,
155
+ });
156
+ const readU16 = (data, offset) => {
157
+ if (offset < 0 || offset + 2 > data.byteLength) {
158
+ throwFormatError('invalid-structure', '', 'ZIP record is truncated');
159
+ }
160
+ return data[offset] | (data[offset + 1] << 8);
161
+ };
162
+ const readU32 = (data, offset) => {
163
+ if (offset < 0 || offset + 4 > data.byteLength) {
164
+ throwFormatError('invalid-structure', '', 'ZIP record is truncated');
165
+ }
166
+ return (data[offset]
167
+ | (data[offset + 1] << 8)
168
+ | (data[offset + 2] << 16)
169
+ | (data[offset + 3] << 24)) >>> 0;
170
+ };
171
+ const findEndOfCentralDirectory = (data) => {
172
+ const minimumOffset = Math.max(0, data.byteLength - 65557);
173
+ for (let offset = data.byteLength - 22; offset >= minimumOffset; offset -= 1) {
174
+ if (readU32(data, offset) !== 101010256)
175
+ continue;
176
+ const commentBytes = readU16(data, offset + 20);
177
+ if (offset + 22 + commentBytes === data.byteLength)
178
+ return offset;
179
+ }
180
+ return throwFormatError('invalid-structure', '', 'ZIP end-of-central-directory record is missing');
181
+ };
182
+ const equalBytes = (left, right) => {
183
+ if (left.byteLength !== right.byteLength)
184
+ return false;
185
+ for (let index = 0; index < left.byteLength; index += 1) {
186
+ if (left[index] !== right[index])
187
+ return false;
188
+ }
189
+ return true;
190
+ };
191
+ const parseAndValidateRawZipRecords = (data) => {
192
+ const eocdOffset = findEndOfCentralDirectory(data);
193
+ const diskNumber = readU16(data, eocdOffset + 4);
194
+ const centralDisk = readU16(data, eocdOffset + 6);
195
+ const entriesOnDisk = readU16(data, eocdOffset + 8);
196
+ const totalEntries = readU16(data, eocdOffset + 10);
197
+ const centralSize = readU32(data, eocdOffset + 12);
198
+ const centralOffset = readU32(data, eocdOffset + 16);
199
+ if (diskNumber !== 0
200
+ || centralDisk !== 0
201
+ || entriesOnDisk !== totalEntries
202
+ || totalEntries === 0xffff
203
+ || centralSize === 4294967295
204
+ || centralOffset === 4294967295) {
205
+ throwFormatError('unsupported-archive', '', 'Multi-disk and ZIP64 archives are not supported');
206
+ }
207
+ if (centralOffset + centralSize !== eocdOffset) {
208
+ throwFormatError('invalid-structure', '', 'ZIP central-directory bounds are inconsistent');
209
+ }
210
+ const entries = [];
211
+ let offset = centralOffset;
212
+ for (let index = 0; index < totalEntries; index += 1) {
213
+ if (readU32(data, offset) !== 33639248) {
214
+ throwFormatError('invalid-structure', '', 'ZIP central-directory entry is malformed');
215
+ }
216
+ const nameBytes = readU16(data, offset + 28);
217
+ const extraBytes = readU16(data, offset + 30);
218
+ const commentBytes = readU16(data, offset + 32);
219
+ const recordEnd = offset + 46 + nameBytes + extraBytes + commentBytes;
220
+ if (recordEnd > eocdOffset) {
221
+ throwFormatError('invalid-structure', '', 'ZIP central-directory entry is truncated');
222
+ }
223
+ const size = readU32(data, offset + 20);
224
+ const originalSize = readU32(data, offset + 24);
225
+ const localHeaderOffset = readU32(data, offset + 42);
226
+ if (size === 4294967295 || originalSize === 4294967295 || localHeaderOffset === 4294967295) {
227
+ throwFormatError('unsupported-archive', '', 'ZIP64 entries are not supported');
228
+ }
229
+ entries.push({
230
+ flags: readU16(data, offset + 8),
231
+ compression: readU16(data, offset + 10),
232
+ crc32: readU32(data, offset + 16),
233
+ size,
234
+ originalSize,
235
+ localHeaderOffset,
236
+ rawName: data.subarray(offset + 46, offset + 46 + nameBytes),
237
+ });
238
+ offset = recordEnd;
239
+ }
240
+ if (offset !== eocdOffset) {
241
+ throwFormatError('invalid-structure', '', 'ZIP central-directory size does not match its entries');
242
+ }
243
+ const localRanges = [];
244
+ const localOffsets = new Set();
245
+ for (const entry of entries) {
246
+ const localOffset = entry.localHeaderOffset;
247
+ if (localOffsets.has(localOffset) || readU32(data, localOffset) !== 67324752) {
248
+ throwFormatError('local-entry-mismatch', '', 'ZIP local-header offsets are invalid or duplicated');
249
+ }
250
+ localOffsets.add(localOffset);
251
+ const localFlags = readU16(data, localOffset + 6);
252
+ const localCompression = readU16(data, localOffset + 8);
253
+ const localCrc32 = readU32(data, localOffset + 14);
254
+ const localSize = readU32(data, localOffset + 18);
255
+ const localOriginalSize = readU32(data, localOffset + 22);
256
+ const nameBytes = readU16(data, localOffset + 26);
257
+ const extraBytes = readU16(data, localOffset + 28);
258
+ const payloadOffset = localOffset + 30 + nameBytes + extraBytes;
259
+ const rawLocalName = data.subarray(localOffset + 30, localOffset + 30 + nameBytes);
260
+ if (payloadOffset > centralOffset
261
+ || localFlags !== entry.flags
262
+ || localCompression !== entry.compression
263
+ || !equalBytes(rawLocalName, entry.rawName)) {
264
+ throwFormatError('local-entry-mismatch', '', 'ZIP local and central entry declarations differ');
265
+ }
266
+ if ((localFlags & 1) !== 0) {
267
+ throwFormatError('unsupported-archive', '', 'Encrypted ZIP entries are not supported');
268
+ }
269
+ const usesDescriptor = (localFlags & 8) !== 0;
270
+ if (!usesDescriptor && (localCrc32 !== entry.crc32
271
+ || localSize !== entry.size
272
+ || localOriginalSize !== entry.originalSize)) {
273
+ throwFormatError('declared-size-mismatch', '', 'ZIP local and central sizes differ');
274
+ }
275
+ if (usesDescriptor && ((localCrc32 !== 0 && localCrc32 !== entry.crc32)
276
+ || (localSize !== 0 && localSize !== entry.size)
277
+ || (localOriginalSize !== 0 && localOriginalSize !== entry.originalSize))) {
278
+ throwFormatError('declared-size-mismatch', '', 'ZIP streaming local sizes conflict with the central directory');
279
+ }
280
+ const payloadEnd = payloadOffset + entry.size;
281
+ if (!Number.isSafeInteger(payloadEnd) || payloadEnd > centralOffset) {
282
+ throwFormatError('invalid-structure', '', 'ZIP entry payload exceeds the local-file area');
283
+ }
284
+ let recordEnd = payloadEnd;
285
+ if (usesDescriptor) {
286
+ const hasSignature = readU32(data, recordEnd) === 134695760;
287
+ if (hasSignature)
288
+ recordEnd += 4;
289
+ const descriptorCrc32 = readU32(data, recordEnd);
290
+ const descriptorSize = readU32(data, recordEnd + 4);
291
+ const descriptorOriginalSize = readU32(data, recordEnd + 8);
292
+ recordEnd += 12;
293
+ if (descriptorCrc32 !== entry.crc32
294
+ || descriptorSize !== entry.size
295
+ || descriptorOriginalSize !== entry.originalSize) {
296
+ throwFormatError('declared-size-mismatch', '', 'ZIP data descriptor conflicts with the central directory');
297
+ }
298
+ }
299
+ if (recordEnd > centralOffset) {
300
+ throwFormatError('invalid-structure', '', 'ZIP local entry overlaps the central directory');
301
+ }
302
+ localRanges.push({ start: localOffset, end: recordEnd });
303
+ }
304
+ localRanges.sort((left, right) => left.start - right.start);
305
+ for (let index = 1; index < localRanges.length; index += 1) {
306
+ if (localRanges[index].start < localRanges[index - 1].end) {
307
+ throwFormatError('invalid-structure', '', 'ZIP local entries overlap');
308
+ }
309
+ }
310
+ return entries;
311
+ };
312
+ export const inspectZipDirectory = (data, limits = DEFAULT_ARCHIVE_LIMITS) => {
313
+ assertArchiveInputsWithinLimits([{ size: data.byteLength }], limits);
314
+ const rawEntries = parseAndValidateRawZipRecords(data);
315
+ const entries = [];
316
+ let directoryBudget = EMPTY_ARCHIVE_DIRECTORY_BUDGET;
317
+ const rawPaths = new Set();
318
+ const canonicalPaths = new Set();
319
+ unzipSync(data, {
320
+ filter: (entry) => {
321
+ const metadata = copyEntryMetadata(entry);
322
+ const rawEntry = rawEntries[entries.length];
323
+ if (!rawEntry
324
+ || rawEntry.size !== metadata.size
325
+ || rawEntry.originalSize !== metadata.originalSize
326
+ || rawEntry.compression !== metadata.compression) {
327
+ throwFormatError('local-entry-mismatch', metadata.name, `ZIP parsed metadata is inconsistent for ${metadata.name}`);
328
+ }
329
+ const archivePath = canonicalizeArchivePath(metadata.name);
330
+ if (rawPaths.has(metadata.name) || canonicalPaths.has(archivePath.identity)) {
331
+ throwFormatError('duplicate-path', metadata.name, `Archive contains duplicate or aliased entry paths: ${metadata.name}`);
332
+ }
333
+ rawPaths.add(metadata.name);
334
+ canonicalPaths.add(archivePath.identity);
335
+ directoryBudget = addArchiveDirectoryEntry(directoryBudget, metadata, limits);
336
+ entries.push(metadata);
337
+ return false;
338
+ },
339
+ });
340
+ if (entries.length !== rawEntries.length) {
341
+ throwFormatError('invalid-structure', '', 'ZIP entry count is inconsistent');
342
+ }
343
+ return entries;
344
+ };
345
+ const isImageEntry = (name) => /\.(?:png|jpe?g)$/i.test(name);
346
+ export const addSelectedEntry = (current, entry, limits = DEFAULT_ARCHIVE_LIMITS, checkCompressionRatio = true) => {
347
+ assertMetadataInteger(current.extractedBytes, 'extracted size total');
348
+ assertMetadataInteger(entry.size, 'compressed entry size');
349
+ assertMetadataInteger(entry.originalSize, 'original entry size');
350
+ if (entry.originalSize > limits.maxFileBytes) {
351
+ throwLimitError('file-size', entry.originalSize, limits.maxFileBytes);
352
+ }
353
+ if (isImageEntry(entry.name) && entry.originalSize > limits.maxImageBytes) {
354
+ throwLimitError('image-size', entry.originalSize, limits.maxImageBytes);
355
+ }
356
+ const extractedBytes = addSize(current.extractedBytes, entry.originalSize, 'extracted size');
357
+ if (extractedBytes > limits.maxExtractedBytes) {
358
+ throwLimitError('extracted-size', extractedBytes, limits.maxExtractedBytes);
359
+ }
360
+ if (checkCompressionRatio
361
+ && entry.originalSize >= limits.compressionRatioMinBytes
362
+ && entry.originalSize > 0) {
363
+ const ratio = entry.size === 0 ? Number.POSITIVE_INFINITY : entry.originalSize / entry.size;
364
+ if (ratio > limits.maxCompressionRatio) {
365
+ throwLimitError('compression-ratio', ratio, limits.maxCompressionRatio);
366
+ }
367
+ }
368
+ return { extractedBytes };
369
+ };
370
+ export const assertSelectedEntriesWithinLimits = (entries, limits = DEFAULT_ARCHIVE_LIMITS) => {
371
+ let budget = EMPTY_EXTRACTION_BUDGET;
372
+ for (const entry of entries) {
373
+ budget = addSelectedEntry(budget, entry, limits);
374
+ }
375
+ };
376
+ const STREAM_INPUT_CHUNK_BYTES = 16 * 1024;
377
+ const assertLocalEntryMatchesCentral = (file, central) => {
378
+ if (file.compression !== central.compression) {
379
+ throwFormatError('local-entry-mismatch', file.name, `ZIP local and central compression methods differ for ${file.name}`);
380
+ }
381
+ if (file.size !== undefined && file.size !== central.size) {
382
+ throwFormatError('declared-size-mismatch', file.name, `ZIP local and central compressed sizes differ for ${file.name}`);
383
+ }
384
+ if (file.originalSize !== undefined && file.originalSize !== central.originalSize) {
385
+ throwFormatError('declared-size-mismatch', file.name, `ZIP local and central original sizes differ for ${file.name}`);
386
+ }
387
+ };
388
+ const extractSelectedEntriesStreaming = (data, entries, selectedNames, limits) => {
389
+ const centralByName = new Map(entries.map((entry) => [entry.name, entry]));
390
+ const seenLocalNames = new Set();
391
+ const completedNames = new Set();
392
+ const files = Object.create(null);
393
+ let actualExtractedBytes = 0;
394
+ let fatalError = null;
395
+ const fail = (error) => {
396
+ if (fatalError == null)
397
+ fatalError = error;
398
+ };
399
+ const unzipper = new Unzip((file) => {
400
+ if (fatalError != null)
401
+ return;
402
+ if (seenLocalNames.has(file.name)) {
403
+ fail(new ArchiveFormatError('duplicate-path', file.name, `ZIP local headers contain a duplicate entry: ${file.name}`));
404
+ return;
405
+ }
406
+ seenLocalNames.add(file.name);
407
+ const central = centralByName.get(file.name);
408
+ if (!central) {
409
+ fail(new ArchiveFormatError('local-entry-mismatch', file.name, `ZIP local entry is missing from the central directory: ${file.name}`));
410
+ return;
411
+ }
412
+ try {
413
+ canonicalizeArchivePath(file.name);
414
+ assertLocalEntryMatchesCentral(file, central);
415
+ }
416
+ catch (error) {
417
+ fail(error);
418
+ return;
419
+ }
420
+ if (!selectedNames.has(file.name))
421
+ return;
422
+ if (central.compression !== 0 && central.compression !== 8) {
423
+ fail(new ArchiveFormatError('unsupported-compression', file.name, `Unsupported ZIP compression method ${central.compression} for ${file.name}`));
424
+ return;
425
+ }
426
+ const output = new Uint8Array(central.originalSize);
427
+ let outputOffset = 0;
428
+ file.ondata = (error, chunk, final) => {
429
+ if (fatalError != null)
430
+ return;
431
+ if (error) {
432
+ fail(error);
433
+ return;
434
+ }
435
+ const abortOutput = (outputError) => {
436
+ fail(outputError);
437
+ throw outputError;
438
+ };
439
+ const nextFileSize = outputOffset + chunk.byteLength;
440
+ const nextTotalSize = actualExtractedBytes + chunk.byteLength;
441
+ if (!Number.isSafeInteger(nextFileSize) || nextFileSize > central.originalSize) {
442
+ abortOutput(new ArchiveFormatError('actual-size-mismatch', file.name, `ZIP entry output exceeds its declared original size: ${file.name}`));
443
+ }
444
+ if (nextFileSize > limits.maxFileBytes) {
445
+ abortOutput(new ArchiveLimitError('file-size', nextFileSize, limits.maxFileBytes));
446
+ }
447
+ if (isImageEntry(file.name) && nextFileSize > limits.maxImageBytes) {
448
+ abortOutput(new ArchiveLimitError('image-size', nextFileSize, limits.maxImageBytes));
449
+ }
450
+ if (!Number.isSafeInteger(nextTotalSize) || nextTotalSize > limits.maxExtractedBytes) {
451
+ abortOutput(new ArchiveLimitError('extracted-size', nextTotalSize, limits.maxExtractedBytes));
452
+ }
453
+ if (nextFileSize >= limits.compressionRatioMinBytes && nextFileSize > 0) {
454
+ const actualRatio = central.size === 0
455
+ ? Number.POSITIVE_INFINITY
456
+ : nextFileSize / central.size;
457
+ if (actualRatio > limits.maxCompressionRatio) {
458
+ abortOutput(new ArchiveLimitError('compression-ratio', actualRatio, limits.maxCompressionRatio));
459
+ }
460
+ }
461
+ output.set(chunk, outputOffset);
462
+ outputOffset = nextFileSize;
463
+ actualExtractedBytes = nextTotalSize;
464
+ if (!final)
465
+ return;
466
+ if (outputOffset !== central.originalSize) {
467
+ abortOutput(new ArchiveFormatError('actual-size-mismatch', file.name, `ZIP entry output does not match its declared original size: ${file.name}`));
468
+ }
469
+ if (file.originalSize !== undefined && outputOffset !== file.originalSize) {
470
+ abortOutput(new ArchiveFormatError('actual-size-mismatch', file.name, `ZIP entry output does not match its local declared size: ${file.name}`));
471
+ }
472
+ files[file.name] = output;
473
+ completedNames.add(file.name);
474
+ };
475
+ try {
476
+ file.start();
477
+ }
478
+ catch (error) {
479
+ fail(error);
480
+ }
481
+ });
482
+ unzipper.register(UnzipInflate);
483
+ try {
484
+ for (let offset = 0; offset < data.byteLength; offset += STREAM_INPUT_CHUNK_BYTES) {
485
+ const end = Math.min(offset + STREAM_INPUT_CHUNK_BYTES, data.byteLength);
486
+ unzipper.push(data.subarray(offset, end), end === data.byteLength);
487
+ if (fatalError != null)
488
+ throw fatalError;
489
+ }
490
+ }
491
+ catch (error) {
492
+ if (fatalError != null)
493
+ throw fatalError;
494
+ throw error;
495
+ }
496
+ for (const entry of entries) {
497
+ if (!seenLocalNames.has(entry.name)) {
498
+ throwFormatError('missing-entry', entry.name, `ZIP central directory entry has no matching local header: ${entry.name}`);
499
+ }
500
+ if (selectedNames.has(entry.name) && !completedNames.has(entry.name)) {
501
+ throwFormatError('missing-entry', entry.name, `ZIP selected entry did not finish streaming: ${entry.name}`);
502
+ }
503
+ }
504
+ return files;
505
+ };
506
+ export const extractInspectedZipEntriesWithinLimits = (data, entries, shouldExtract, limits = DEFAULT_ARCHIVE_LIMITS) => {
507
+ const selectedEntries = entries.filter((entry) => shouldExtract(entry.name));
508
+ assertSelectedEntriesWithinLimits(selectedEntries, limits);
509
+ const selectedNames = new Set(selectedEntries.map((entry) => entry.name));
510
+ const files = extractSelectedEntriesStreaming(data, entries, selectedNames, limits);
511
+ return { files, entries: [...entries] };
512
+ };
513
+ export const extractZipEntriesWithinLimits = (data, shouldExtract, limits = DEFAULT_ARCHIVE_LIMITS) => {
514
+ const entries = inspectZipDirectory(data, limits);
515
+ return extractInspectedZipEntriesWithinLimits(data, entries, shouldExtract, limits);
516
+ };
517
+ export const createStoredFileMetadata = (name, size) => ({
518
+ name,
519
+ size,
520
+ originalSize: size,
521
+ compression: 0,
522
+ });
@@ -0,0 +1,25 @@
1
+ import { type FileHandle } from 'node:fs/promises';
2
+ import type { Stats } from 'node:fs';
3
+ export type InputFileErrorCode = 'symlink' | 'not-regular-file' | 'not-directory' | 'path-escape' | 'identity-changed' | 'content-changed' | 'size-changed';
4
+ export declare class InputFileError extends Error {
5
+ readonly code: InputFileErrorCode;
6
+ readonly filePath: string;
7
+ readonly name = "InputFileError";
8
+ constructor(code: InputFileErrorCode, filePath: string, message: string);
9
+ }
10
+ export interface FileIdentity {
11
+ dev: number;
12
+ ino: number;
13
+ }
14
+ export interface BoundedFileReadProgress {
15
+ handle: FileHandle;
16
+ bytesRead: number;
17
+ chunkCount: number;
18
+ }
19
+ export interface BoundedFileReadOptions {
20
+ expectedIdentity?: Readonly<FileIdentity>;
21
+ onChunkRead?: (progress: BoundedFileReadProgress) => void | Promise<void>;
22
+ }
23
+ export declare const getFileIdentity: (stats: Stats) => FileIdentity;
24
+ export declare const sameFileIdentity: (left: Readonly<FileIdentity>, right: Readonly<FileIdentity>) => boolean;
25
+ export declare const readBoundedRegularFile: (filePath: string, maxBytes: number, createLimitError: (actualBytes: number) => Error, options?: BoundedFileReadOptions) => Promise<Uint8Array>;