neozipkit 0.8.0 → 1.0.5
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/NEOZIP_APPNOTE.md +527 -0
- package/README.md +57 -21
- package/WHATS_NEW.md +146 -0
- package/dist/browser/ZipkitBrowser.d.ts.map +1 -1
- package/dist/browser/ZipkitBrowser.js +4 -6
- package/dist/browser/ZipkitBrowser.js.map +1 -1
- package/dist/browser/zstd-browser-shim.d.ts +28 -0
- package/dist/browser/zstd-browser-shim.d.ts.map +1 -0
- package/dist/browser/zstd-browser-shim.js +66 -0
- package/dist/browser/zstd-browser-shim.js.map +1 -0
- package/dist/browser-esm/index.js +2110 -41755
- package/dist/browser-esm/index.js.map +4 -4
- package/dist/browser-umd/index.js +2110 -41755
- package/dist/browser-umd/index.js.map +4 -4
- package/dist/browser-umd/index.min.js +3 -21
- package/dist/core/ZipCompress.d.ts +2 -7
- package/dist/core/ZipCompress.d.ts.map +1 -1
- package/dist/core/ZipCompress.js +57 -33
- package/dist/core/ZipCompress.js.map +1 -1
- package/dist/core/ZipDecompress.d.ts +1 -3
- package/dist/core/ZipDecompress.d.ts.map +1 -1
- package/dist/core/ZipDecompress.js +27 -17
- package/dist/core/ZipDecompress.js.map +1 -1
- package/dist/core/ZipEntry.d.ts +6 -0
- package/dist/core/ZipEntry.d.ts.map +1 -1
- package/dist/core/ZipEntry.js +7 -1
- package/dist/core/ZipEntry.js.map +1 -1
- package/dist/core/Zipkit.d.ts +43 -11
- package/dist/core/Zipkit.d.ts.map +1 -1
- package/dist/core/Zipkit.js +106 -63
- package/dist/core/Zipkit.js.map +1 -1
- package/dist/core/components/HashCalculator.d.ts +22 -1
- package/dist/core/components/HashCalculator.d.ts.map +1 -1
- package/dist/core/components/HashCalculator.js +49 -6
- package/dist/core/components/HashCalculator.js.map +1 -1
- package/dist/core/components/Util.d.ts +3 -4
- package/dist/core/components/Util.d.ts.map +1 -1
- package/dist/core/components/Util.js +10 -8
- package/dist/core/components/Util.js.map +1 -1
- package/dist/core/constants/Headers.d.ts +1 -3
- package/dist/core/constants/Headers.d.ts.map +1 -1
- package/dist/core/constants/Headers.js +14 -5
- package/dist/core/constants/Headers.js.map +1 -1
- package/dist/core/constants/MetaPaths.d.ts +38 -0
- package/dist/core/constants/MetaPaths.d.ts.map +1 -0
- package/dist/core/constants/MetaPaths.js +79 -0
- package/dist/core/constants/MetaPaths.js.map +1 -0
- package/dist/core/encryption/AesCrypto.d.ts +55 -11
- package/dist/core/encryption/AesCrypto.d.ts.map +1 -1
- package/dist/core/encryption/AesCrypto.js +223 -68
- package/dist/core/encryption/AesCrypto.js.map +1 -1
- package/dist/core/encryption/index.d.ts +1 -1
- package/dist/core/encryption/index.d.ts.map +1 -1
- package/dist/core/encryption/index.js +3 -1
- package/dist/core/encryption/index.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +14 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/merkle/MerkleRoot.d.ts +109 -0
- package/dist/core/merkle/MerkleRoot.d.ts.map +1 -0
- package/dist/core/merkle/MerkleRoot.js +275 -0
- package/dist/core/merkle/MerkleRoot.js.map +1 -0
- package/dist/core/version.js +1 -1
- package/dist/node/LegacyZstd.d.ts +67 -0
- package/dist/node/LegacyZstd.d.ts.map +1 -0
- package/dist/node/LegacyZstd.js +209 -0
- package/dist/node/LegacyZstd.js.map +1 -0
- package/dist/node/ZipCompressNode.d.ts +9 -7
- package/dist/node/ZipCompressNode.d.ts.map +1 -1
- package/dist/node/ZipCompressNode.js +105 -75
- package/dist/node/ZipCompressNode.js.map +1 -1
- package/dist/node/ZipDecompressNode.d.ts +7 -3
- package/dist/node/ZipDecompressNode.d.ts.map +1 -1
- package/dist/node/ZipDecompressNode.js +68 -56
- package/dist/node/ZipDecompressNode.js.map +1 -1
- package/dist/node/ZipkitNode.d.ts +23 -0
- package/dist/node/ZipkitNode.d.ts.map +1 -1
- package/dist/node/ZipkitNode.js +195 -41
- package/dist/node/ZipkitNode.js.map +1 -1
- package/dist/node/ZstdNode.d.ts +39 -0
- package/dist/node/ZstdNode.d.ts.map +1 -0
- package/dist/node/ZstdNode.js +171 -0
- package/dist/node/ZstdNode.js.map +1 -0
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +11 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +4 -3
- package/src/browser/ZipkitBrowser.ts +4 -6
- package/src/browser/zstd-browser-shim.ts +76 -0
- package/src/core/ZipCompress.ts +71 -42
- package/src/core/ZipDecompress.ts +35 -18
- package/src/core/ZipEntry.ts +8 -1
- package/src/core/Zipkit.ts +149 -76
- package/src/core/components/HashCalculator.ts +58 -6
- package/src/core/components/Util.ts +16 -7
- package/src/core/constants/Headers.ts +14 -4
- package/src/core/constants/MetaPaths.ts +86 -0
- package/src/core/encryption/AesCrypto.ts +265 -86
- package/src/core/encryption/index.ts +1 -1
- package/src/core/index.ts +14 -0
- package/src/core/merkle/MerkleRoot.ts +312 -0
- package/src/core/version.ts +1 -1
- package/src/node/LegacyZstd.ts +270 -0
- package/src/node/ZipCompressNode.ts +127 -84
- package/src/node/ZipDecompressNode.ts +93 -67
- package/src/node/ZipkitNode.ts +230 -42
- package/src/node/ZstdNode.ts +155 -0
- package/src/node/index.ts +10 -0
- package/dist/core/ZstdManager.d.ts +0 -56
- package/dist/core/ZstdManager.d.ts.map +0 -1
- package/dist/core/ZstdManager.js +0 -144
- package/dist/core/ZstdManager.js.map +0 -1
- package/src/core/ZstdManager.ts +0 -165
package/src/core/ZipCompress.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
|
|
16
16
|
const pako = require('pako');
|
|
17
|
-
import {
|
|
17
|
+
import { ZstdNode } from '../node/ZstdNode';
|
|
18
18
|
import Zipkit from './Zipkit';
|
|
19
19
|
import { Logger } from './components/Logger';
|
|
20
20
|
import ZipEntry from './ZipEntry';
|
|
@@ -31,9 +31,9 @@ import { NeoCrypto, NEO_CRYPTO_ALGORITHM_AES256_V1 } from './encryption/NeoCrypt
|
|
|
31
31
|
export interface CompressOptions {
|
|
32
32
|
level?: number; // Compression level (1-9, 0=store)
|
|
33
33
|
password?: string | null; // Password for encryption
|
|
34
|
-
encryptionMethod?: 'aes256' | 'zipcrypto' | 'neo-aes256'; // default
|
|
34
|
+
encryptionMethod?: 'aes256' | 'zipcrypto' | 'neo-aes256'; // default NeoEncrypt when password set; 'aes256' = WinZip AE (method 99); 'neo-aes256' = Extra Field 0x024E
|
|
35
35
|
useSHA256?: boolean; // Whether to calculate SHA256 hash default is false
|
|
36
|
-
useZstd?: boolean; //
|
|
36
|
+
useZstd?: boolean; // Use Zstandard (method 93); default false (Deflate). Node ≥ 22.15 required when true.
|
|
37
37
|
bufferSize?: number; // Override default buffer size
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -132,20 +132,19 @@ export class ZipCompress {
|
|
|
132
132
|
// Calculate hashes if needed
|
|
133
133
|
if (hashCalculator) {
|
|
134
134
|
hashCalculator.update(data);
|
|
135
|
-
|
|
136
|
-
entry.crc
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
entry.sha256 = hashCalculator.finalizeSHA256();
|
|
140
|
-
}
|
|
135
|
+
hashCalculator.applyToEntry(entry, {
|
|
136
|
+
setCrc: !entry.crc || entry.crc === 0,
|
|
137
|
+
setSha256: !!(options?.useSHA256 && !entry.sha256),
|
|
138
|
+
});
|
|
141
139
|
this.log(`Final hashes: CRC32=0x${entry.crc.toString(16).padStart(8, '0')}, SHA256=${entry.sha256 || 'N/A'}`);
|
|
142
140
|
}
|
|
143
141
|
|
|
144
142
|
// Encrypt if password provided
|
|
145
143
|
const enc = options?.encryptionMethod;
|
|
146
144
|
const useZipCrypto = enc === 'zipcrypto';
|
|
147
|
-
|
|
148
|
-
const useAes =
|
|
145
|
+
// Explicit WinZip AE only when encryptionMethod is 'aes256'. Otherwise NeoEncrypt (default).
|
|
146
|
+
const useAes = enc === 'aes256';
|
|
147
|
+
const useNeo = !!options?.password && !useZipCrypto && !useAes;
|
|
149
148
|
if (options?.password) {
|
|
150
149
|
const label = useZipCrypto ? 'ZipCrypto' : useNeo ? 'NeoEncrypt (AES-256)' : 'WinZip AES-256';
|
|
151
150
|
this.log(`Encrypting compressed data for entry: ${entry.filename} (method: ${label})`);
|
|
@@ -275,13 +274,8 @@ export class ZipCompress {
|
|
|
275
274
|
}
|
|
276
275
|
|
|
277
276
|
/**
|
|
278
|
-
* Compress data using Zstandard
|
|
279
|
-
*
|
|
280
|
-
* @param options - Compression options
|
|
281
|
-
* @param bufferSize - Buffer size for chunked processing
|
|
282
|
-
* @param entry - ZIP entry being compressed
|
|
283
|
-
* @param onOutputBuffer - Optional callback for streaming output
|
|
284
|
-
* @returns Buffer containing compressed data
|
|
277
|
+
* Compress data using Zstandard via Node native zlib chunked streaming.
|
|
278
|
+
* Not available in browser builds (throws).
|
|
285
279
|
*/
|
|
286
280
|
async zstdCompress(
|
|
287
281
|
input: Buffer | { totalSize: number, readChunk: (position: number, size: number) => Buffer },
|
|
@@ -291,33 +285,66 @@ export class ZipCompress {
|
|
|
291
285
|
onOutputBuffer?: (data: Buffer) => Promise<void>
|
|
292
286
|
): Promise<Buffer> {
|
|
293
287
|
this.log(`zstdCompress() called - entry: ${entry?.filename ?? 'unknown'}, bufferSize: ${bufferSize}, level: ${options?.level ?? 6}`);
|
|
294
|
-
|
|
288
|
+
|
|
295
289
|
const effectiveBufferSize = bufferSize || this.zipkit.getBufferSize();
|
|
296
290
|
const level = options?.level ?? 6;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
throw new Error('Chunked reader mode not supported in ZipCompress');
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Validate input
|
|
305
|
-
if (!input || input.length === 0) {
|
|
291
|
+
const isBuffer = Buffer.isBuffer(input);
|
|
292
|
+
const totalSize = isBuffer ? input.length : input.totalSize;
|
|
293
|
+
|
|
294
|
+
if (!totalSize) {
|
|
306
295
|
throw new Error('ZSTD compression: empty input buffer');
|
|
307
296
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
297
|
+
|
|
298
|
+
if (options?.level === 0) {
|
|
299
|
+
if (isBuffer) {
|
|
300
|
+
if (onOutputBuffer) {
|
|
301
|
+
await onOutputBuffer(input);
|
|
302
|
+
}
|
|
303
|
+
return input;
|
|
304
|
+
}
|
|
305
|
+
const chunks: Buffer[] = [];
|
|
306
|
+
let position = 0;
|
|
307
|
+
while (position < totalSize) {
|
|
308
|
+
const size = Math.min(effectiveBufferSize, totalSize - position);
|
|
309
|
+
const chunk = input.readChunk(position, size);
|
|
310
|
+
if (onOutputBuffer) {
|
|
311
|
+
await onOutputBuffer(chunk);
|
|
312
|
+
} else {
|
|
313
|
+
chunks.push(chunk);
|
|
314
|
+
}
|
|
315
|
+
position += chunk.length;
|
|
316
|
+
}
|
|
317
|
+
return onOutputBuffer ? Buffer.alloc(0) : Buffer.concat(chunks);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const readChunk = isBuffer
|
|
321
|
+
? (position: number, size: number) => input.subarray(position, position + size)
|
|
322
|
+
: input.readChunk.bind(input);
|
|
323
|
+
|
|
324
|
+
const collected: Buffer[] = [];
|
|
325
|
+
const compressedSize = await ZstdNode.compressChunks(
|
|
326
|
+
readChunk,
|
|
327
|
+
totalSize,
|
|
328
|
+
effectiveBufferSize,
|
|
329
|
+
level,
|
|
330
|
+
undefined,
|
|
331
|
+
async (compressed) => {
|
|
332
|
+
if (onOutputBuffer) {
|
|
333
|
+
await onOutputBuffer(compressed);
|
|
334
|
+
} else {
|
|
335
|
+
collected.push(compressed);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
if (entry) {
|
|
341
|
+
entry.compressedSize = compressedSize;
|
|
342
|
+
}
|
|
343
|
+
|
|
316
344
|
if (onOutputBuffer) {
|
|
317
|
-
|
|
345
|
+
return Buffer.alloc(0);
|
|
318
346
|
}
|
|
319
|
-
|
|
320
|
-
return compressedBuffer;
|
|
347
|
+
return Buffer.concat(collected);
|
|
321
348
|
}
|
|
322
349
|
|
|
323
350
|
/**
|
|
@@ -397,9 +424,11 @@ export class ZipCompress {
|
|
|
397
424
|
hashCalculator.update(fileData);
|
|
398
425
|
|
|
399
426
|
// Set hashes
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
427
|
+
hashCalculator.applyToEntry(entry, {
|
|
428
|
+
setCrc: true,
|
|
429
|
+
setSha256: !!cmpOptions?.useSHA256,
|
|
430
|
+
});
|
|
431
|
+
if (entry.sha256) {
|
|
403
432
|
this.log(`SHA-256 calculated: ${entry.sha256}`);
|
|
404
433
|
}
|
|
405
434
|
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
//
|
|
15
15
|
|
|
16
16
|
const pako = require('pako');
|
|
17
|
-
import {
|
|
17
|
+
import { ZstdNode } from '../node/ZstdNode';
|
|
18
|
+
import { repairLegacyWasmZstdPlaintext } from '../node/LegacyZstd';
|
|
18
19
|
import Zipkit from './Zipkit';
|
|
19
20
|
import { Logger } from './components/Logger';
|
|
20
21
|
import ZipEntry from './ZipEntry';
|
|
@@ -22,6 +23,7 @@ import Errors from './constants/Errors';
|
|
|
22
23
|
import { CMP_METHOD } from './constants/Headers';
|
|
23
24
|
import { ZipCrypto } from './encryption/ZipCrypto';
|
|
24
25
|
import { AesCrypto } from './encryption/AesCrypto';
|
|
26
|
+
import { HashCalculator } from './components/HashCalculator';
|
|
25
27
|
|
|
26
28
|
export interface DecompressionResult {
|
|
27
29
|
success: boolean;
|
|
@@ -128,18 +130,28 @@ class ZipDecompress {
|
|
|
128
130
|
|
|
129
131
|
|
|
130
132
|
/**
|
|
131
|
-
*
|
|
132
|
-
* ZSTD codec is guaranteed to be initialized via factory method
|
|
133
|
-
* Internal method only
|
|
133
|
+
* Zstd decompress via Node native zlib, repairing legacy WASM +18 padding when present.
|
|
134
134
|
*/
|
|
135
|
-
private async zstdDecompressSync(data: Buffer): Promise<Buffer> {
|
|
135
|
+
private async zstdDecompressSync(data: Buffer, entry?: ZipEntry): Promise<Buffer> {
|
|
136
136
|
this.log(`zstdDecompressSync() called with ${data.length} bytes`);
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
let decompressed = await ZstdNode.decompress(data);
|
|
140
|
+
if (entry && entry.uncompressedSize > 0) {
|
|
141
|
+
const { repaired, wasLegacy } = repairLegacyWasmZstdPlaintext(
|
|
142
|
+
decompressed,
|
|
143
|
+
entry.uncompressedSize,
|
|
144
|
+
entry.crc
|
|
145
|
+
);
|
|
146
|
+
if (wasLegacy) {
|
|
147
|
+
this.log(
|
|
148
|
+
`Legacy WASM zstd padding repaired: ${decompressed.length} -> ${repaired.length} bytes`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
decompressed = repaired;
|
|
152
|
+
}
|
|
141
153
|
this.log(`ZSTD decompression successful: ${data.length} bytes -> ${decompressed.length} bytes`);
|
|
142
|
-
return
|
|
154
|
+
return decompressed;
|
|
143
155
|
} catch (error) {
|
|
144
156
|
this.log(`ZSTD decompression failed: ${error}`);
|
|
145
157
|
throw new Error(`ZSTD decompression failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -173,23 +185,28 @@ class ZipDecompress {
|
|
|
173
185
|
} else if (method === CMP_METHOD.DEFLATED) {
|
|
174
186
|
outBuf = this.inflate(compressedData);
|
|
175
187
|
} else if (method === CMP_METHOD.ZSTD) {
|
|
176
|
-
outBuf = await this.zstdDecompressSync(compressedData);
|
|
188
|
+
outBuf = await this.zstdDecompressSync(compressedData, entry);
|
|
177
189
|
} else {
|
|
178
190
|
throw new Error(`Unsupported compression method: ${method}`);
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
//
|
|
193
|
+
// One pass: CRC + bare SHA-256 (0x014E) + v1 Merkle leaf — no second payload read
|
|
194
|
+
const hashCalc = new HashCalculator({ useSHA256: true });
|
|
195
|
+
hashCalc.update(outBuf);
|
|
196
|
+
const calculatedCRC = hashCalc.finalizeCRC32();
|
|
197
|
+
const calculatedHash = hashCalc.finalizeSHA256();
|
|
198
|
+
const leaf = hashCalc.finalizeMerkleLeafV1();
|
|
199
|
+
if (leaf) {
|
|
200
|
+
entry.merkleLeafV1 = leaf;
|
|
201
|
+
}
|
|
202
|
+
|
|
182
203
|
if (!skipHashCheck) {
|
|
183
204
|
if (entry.sha256) {
|
|
184
|
-
|
|
185
|
-
if (!isValid) {
|
|
205
|
+
if (calculatedHash !== entry.sha256) {
|
|
186
206
|
throw new Error(Errors.INVALID_SHA256);
|
|
187
207
|
}
|
|
188
|
-
} else {
|
|
189
|
-
|
|
190
|
-
if (!isValid) {
|
|
191
|
-
throw new Error(Errors.INVALID_CRC);
|
|
192
|
-
}
|
|
208
|
+
} else if (calculatedCRC !== entry.crc) {
|
|
209
|
+
throw new Error(Errors.INVALID_CRC);
|
|
193
210
|
}
|
|
194
211
|
}
|
|
195
212
|
|
package/src/core/ZipEntry.ts
CHANGED
|
@@ -82,7 +82,14 @@ export default class ZipEntry implements ZipFileEntry {
|
|
|
82
82
|
universalTime: number | null = null; // Universal Time
|
|
83
83
|
uid: number | null = null; // User ID
|
|
84
84
|
gid: number | null = null; // Group ID
|
|
85
|
-
sha256: string | null = null; // SHA-256 hash of the file
|
|
85
|
+
sha256: string | null = null; // SHA-256 hash of the file (Extra Field 0x014E, bare content digest)
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* APPNOTE §6.3 v1 Merkle leaf: SHA-256(0x00 ‖ uncompressed payload).
|
|
89
|
+
* In-memory only (not written as a ZIP Extra today). Filled in the same streaming hash
|
|
90
|
+
* pass as `sha256` during create/extract — never reconstruct by re-reading payload when set.
|
|
91
|
+
*/
|
|
92
|
+
merkleLeafV1: string | null = null;
|
|
86
93
|
|
|
87
94
|
// Symbolic link data
|
|
88
95
|
isSymlink: boolean = false; // Entry is a symbolic link
|
package/src/core/Zipkit.ts
CHANGED
|
@@ -10,7 +10,14 @@ import { NEOZIPKIT_INFO } from '../types';
|
|
|
10
10
|
import { ZipCompress, CompressOptions } from './ZipCompress';
|
|
11
11
|
import ZipDecompress from './ZipDecompress';
|
|
12
12
|
import { sha256, crc32 } from './encryption/ZipCrypto';
|
|
13
|
-
import
|
|
13
|
+
import {
|
|
14
|
+
computeArchiveMerkleRoot,
|
|
15
|
+
computeMerkleRootV0FromDigests,
|
|
16
|
+
computeMerkleRootV1FromContents,
|
|
17
|
+
computeMerkleRootV1FromLeaves,
|
|
18
|
+
leafHashV1,
|
|
19
|
+
type MerkleAlgorithm,
|
|
20
|
+
} from './merkle/MerkleRoot';
|
|
14
21
|
import {
|
|
15
22
|
LOCAL_HDR,
|
|
16
23
|
ENCRYPT_HDR_SIZE,
|
|
@@ -28,6 +35,11 @@ import {
|
|
|
28
35
|
CENTRAL_DIRECTORY_END,
|
|
29
36
|
HDR_ID
|
|
30
37
|
} from './constants/Headers';
|
|
38
|
+
import {
|
|
39
|
+
findReservedMetaEntry,
|
|
40
|
+
isMetaInfPath,
|
|
41
|
+
isReservedMetaPath,
|
|
42
|
+
} from './constants/MetaPaths';
|
|
31
43
|
|
|
32
44
|
/**
|
|
33
45
|
* Streaming file handle interface
|
|
@@ -371,15 +383,22 @@ export default class Zipkit {
|
|
|
371
383
|
|
|
372
384
|
|
|
373
385
|
/**
|
|
374
|
-
* Get specific ZIP entry by filename from zipEntries[] array
|
|
375
|
-
*
|
|
386
|
+
* Get specific ZIP entry by filename from zipEntries[] array.
|
|
387
|
+
* Reserved META-INF paths (APPNOTE §2.3) also match ASCII case-insensitively
|
|
388
|
+
* when an exact spelling is not found.
|
|
389
|
+
*
|
|
376
390
|
* @param filename - The name/path of the file/directory to find
|
|
377
391
|
* @returns ZipEntry object if found, null if not found
|
|
378
392
|
*/
|
|
379
393
|
getZipEntry(filename: string): ZipEntry | null {
|
|
380
394
|
try {
|
|
381
395
|
const centralDir = this.getDirectory();
|
|
382
|
-
|
|
396
|
+
const exact = centralDir.find((entry: ZipEntry) => entry.filename === filename);
|
|
397
|
+
if (exact) return exact;
|
|
398
|
+
if (isReservedMetaPath(filename)) {
|
|
399
|
+
return findReservedMetaEntry(centralDir, filename);
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
383
402
|
} catch (error) {
|
|
384
403
|
return null;
|
|
385
404
|
}
|
|
@@ -686,112 +705,166 @@ export default class Zipkit {
|
|
|
686
705
|
}
|
|
687
706
|
|
|
688
707
|
/**
|
|
689
|
-
* Calculate Merkle
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
708
|
+
* Calculate Merkle root of archive content (non-META-INF entries).
|
|
709
|
+
*
|
|
710
|
+
* Per NEOZIP_APPNOTE.md §6:
|
|
711
|
+
* - Default **v1**: domain-separated leaves SHA-256(0x00 ‖ uncompressed bytes) and
|
|
712
|
+
* SHA-256(0x01 ‖ left ‖ right) parents; paths NFC-normalized before sort.
|
|
713
|
+
* - Legacy **v0**: bare 0x014E digests as leaves, duplicate-odd pairing, path as stored.
|
|
714
|
+
*
|
|
715
|
+
* When only Extra Field digests are available (typical after open), **v1 requires
|
|
716
|
+
* payload bytes**. This sync method therefore returns null for v1; use
|
|
717
|
+
* {@link getMerkleRootAsync} (extracts content) or `{ algorithm: 'v0' }`.
|
|
718
|
+
* When you already hold payloads, use {@link merkleRootFromEntries}.
|
|
719
|
+
*
|
|
720
|
+
* @param options.algorithm - `v1` (default) or legacy `v0`
|
|
721
|
+
* @returns Lowercase hex Merkle root, or null if calculation fails
|
|
695
722
|
*/
|
|
696
|
-
getMerkleRoot(): string | null {
|
|
697
|
-
|
|
723
|
+
getMerkleRoot(options?: { algorithm?: MerkleAlgorithm }): string | null {
|
|
724
|
+
const algorithm: MerkleAlgorithm = options?.algorithm ?? 'v1';
|
|
725
|
+
|
|
698
726
|
let zipEntries: ZipEntry[];
|
|
699
727
|
try {
|
|
700
728
|
zipEntries = this.getDirectory();
|
|
701
729
|
} catch (error) {
|
|
702
|
-
// Catch any errors from getDirectory() - this should never happen
|
|
703
|
-
// but if it does, log it and return null
|
|
704
730
|
Logger.error(`getMerkleRoot(): Error calling getDirectory(): ${error instanceof Error ? error.message : String(error)}`);
|
|
705
731
|
Logger.error(`getMerkleRoot(): Stack trace: ${error instanceof Error ? error.stack : 'No stack trace'}`);
|
|
706
732
|
return null;
|
|
707
733
|
}
|
|
708
|
-
|
|
734
|
+
|
|
709
735
|
if (!zipEntries || zipEntries.length === 0) {
|
|
710
736
|
return null;
|
|
711
737
|
}
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
return
|
|
720
|
-
filename !== TIMESTAMP_METADATA &&
|
|
721
|
-
filename !== TOKENIZED_METADATA;
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
for (const entry of contentEntries) {
|
|
725
|
-
if (entry.sha256) {
|
|
726
|
-
// Convert hex string to Buffer
|
|
727
|
-
const hashBuffer = Buffer.from(entry.sha256, 'hex');
|
|
728
|
-
hashAccumulator.addHash(hashBuffer);
|
|
729
|
-
}
|
|
738
|
+
|
|
739
|
+
const contentEntries = zipEntries.filter((entry) => !isMetaInfPath(entry.filename || ''));
|
|
740
|
+
|
|
741
|
+
if (algorithm === 'v0') {
|
|
742
|
+
const digests = contentEntries
|
|
743
|
+
.filter((e) => e.sha256)
|
|
744
|
+
.map((e) => ({ path: e.filename || '', sha256: e.sha256 as string }));
|
|
745
|
+
return computeMerkleRootV0FromDigests(digests);
|
|
730
746
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (
|
|
734
|
-
|
|
747
|
+
|
|
748
|
+
// v1: prefer leaves captured in the same create/extract hash stream (no re-hash)
|
|
749
|
+
if (
|
|
750
|
+
contentEntries.length > 0 &&
|
|
751
|
+
contentEntries.every((e) => !!e.merkleLeafV1)
|
|
752
|
+
) {
|
|
753
|
+
return computeMerkleRootV1FromLeaves(
|
|
754
|
+
contentEntries.map((e) => ({
|
|
755
|
+
path: e.filename || '',
|
|
756
|
+
merkleLeafV1: e.merkleLeafV1 as string,
|
|
757
|
+
}))
|
|
758
|
+
);
|
|
735
759
|
}
|
|
736
760
|
|
|
737
|
-
|
|
761
|
+
// v1: bare digests are not leaves — need getMerkleRootAsync() or stream leaves
|
|
762
|
+
return null;
|
|
738
763
|
}
|
|
739
764
|
|
|
740
765
|
/**
|
|
741
|
-
* Calculate Merkle
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
766
|
+
* Calculate Merkle root (preferred for v1 / new archives).
|
|
767
|
+
*
|
|
768
|
+
* **v1 (default):** uses precomputed stream leaves when available; otherwise extracts
|
|
769
|
+
* each content entry once and folds APPNOTE §6.3 root (no second content hash pass
|
|
770
|
+
* when extract captures merkleLeafV1).
|
|
771
|
+
* **v0:** uses Extra Field digests only (no extract), APPNOTE §6.2.
|
|
772
|
+
*
|
|
773
|
+
* META-INF/** is excluded. File-streaming mode: use ZipkitNode override (testEntry
|
|
774
|
+
* pipeline) rather than buffering the whole archive.
|
|
775
|
+
*
|
|
776
|
+
* @param options.algorithm - `v1` (default) or legacy `v0`
|
|
777
|
+
* @returns Promise resolving to lowercase hex root, or null
|
|
747
778
|
*/
|
|
748
|
-
async getMerkleRootAsync(): Promise<string | null> {
|
|
779
|
+
async getMerkleRootAsync(options?: { algorithm?: MerkleAlgorithm }): Promise<string | null> {
|
|
780
|
+
const algorithm: MerkleAlgorithm = options?.algorithm ?? 'v1';
|
|
781
|
+
|
|
749
782
|
let zipEntries: ZipEntry[] = [];
|
|
750
|
-
|
|
783
|
+
|
|
751
784
|
try {
|
|
752
|
-
|
|
753
|
-
if (this.inBuffer) {
|
|
754
|
-
zipEntries = this.getDirectory();
|
|
755
|
-
} else {
|
|
756
|
-
// File-based: cannot get entries synchronously - return null
|
|
757
|
-
// Caller should use ZipkitServer.getMerkleRootAsync() for file-based ZIPs
|
|
758
|
-
Logger.error('getMerkleRootAsync() called on file-based ZIP. Use ZipkitServer.getMerkleRootAsync() instead.');
|
|
759
|
-
return null;
|
|
760
|
-
}
|
|
785
|
+
zipEntries = this.getDirectory();
|
|
761
786
|
} catch (error) {
|
|
762
787
|
Logger.error(`Failed to get directory for merkle root calculation: ${error instanceof Error ? error.message : String(error)}`);
|
|
763
788
|
return null;
|
|
764
789
|
}
|
|
765
|
-
|
|
790
|
+
|
|
766
791
|
if (!zipEntries || zipEntries.length === 0) {
|
|
767
792
|
return null;
|
|
768
793
|
}
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
return
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
794
|
+
|
|
795
|
+
const contentEntries = zipEntries.filter((entry) => !isMetaInfPath(entry.filename || ''));
|
|
796
|
+
|
|
797
|
+
if (algorithm === 'v0') {
|
|
798
|
+
const digests = contentEntries
|
|
799
|
+
.filter((e) => e.sha256)
|
|
800
|
+
.map((e) => ({ path: e.filename || '', sha256: e.sha256 as string }));
|
|
801
|
+
return computeMerkleRootV0FromDigests(digests);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Reuse leaves already produced during write/extract (one stream hash pass)
|
|
805
|
+
if (
|
|
806
|
+
contentEntries.length > 0 &&
|
|
807
|
+
contentEntries.every((e) => !!e.merkleLeafV1)
|
|
808
|
+
) {
|
|
809
|
+
return computeMerkleRootV1FromLeaves(
|
|
810
|
+
contentEntries.map((e) => ({
|
|
811
|
+
path: e.filename || '',
|
|
812
|
+
merkleLeafV1: e.merkleLeafV1 as string,
|
|
813
|
+
}))
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Buffer path: one decompress + dual hash inside extract (ZipDecompress)
|
|
818
|
+
if (!this.inBuffer) {
|
|
819
|
+
Logger.error(
|
|
820
|
+
'getMerkleRootAsync() called on file-based ZIP without precomputed merkle leaves. ' +
|
|
821
|
+
'Use ZipkitNode (streaming testEntry) or ensure create captured merkleLeafV1.'
|
|
822
|
+
);
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
781
826
|
for (const entry of contentEntries) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
827
|
+
try {
|
|
828
|
+
const data = await this.extract(entry, false);
|
|
829
|
+
if (entry.merkleLeafV1) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
// Legacy decompress without dual leaf: hash payload once, cache leaf
|
|
833
|
+
if (data === null || data.length === 0) {
|
|
834
|
+
entry.merkleLeafV1 = leafHashV1(Buffer.alloc(0)).toString('hex');
|
|
835
|
+
} else {
|
|
836
|
+
entry.merkleLeafV1 = leafHashV1(data).toString('hex');
|
|
837
|
+
}
|
|
838
|
+
} catch (error) {
|
|
839
|
+
Logger.error(
|
|
840
|
+
`getMerkleRootAsync(): failed to extract ${entry.filename}: ${error instanceof Error ? error.message : String(error)}`
|
|
841
|
+
);
|
|
842
|
+
return null;
|
|
786
843
|
}
|
|
787
844
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (!merkleRoot) {
|
|
845
|
+
|
|
846
|
+
if (!contentEntries.every((e) => !!e.merkleLeafV1)) {
|
|
791
847
|
return null;
|
|
792
848
|
}
|
|
793
849
|
|
|
794
|
-
return
|
|
850
|
+
return computeMerkleRootV1FromLeaves(
|
|
851
|
+
contentEntries.map((e) => ({
|
|
852
|
+
path: e.filename || '',
|
|
853
|
+
merkleLeafV1: e.merkleLeafV1 as string,
|
|
854
|
+
}))
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Compute Merkle root from known digests and/or payloads (APPNOTE §6).
|
|
860
|
+
* Prefer this during create when uncompressed bytes are still in hand, or when
|
|
861
|
+
* stream leaves (`merkleLeafV1`) were already produced.
|
|
862
|
+
*/
|
|
863
|
+
static merkleRootFromEntries(
|
|
864
|
+
entries: Array<{ path: string; content?: Buffer; contentSha256?: string; merkleLeafV1?: string }>,
|
|
865
|
+
algorithm: MerkleAlgorithm = 'v1'
|
|
866
|
+
): string | null {
|
|
867
|
+
return computeArchiveMerkleRoot(entries, algorithm);
|
|
795
868
|
}
|
|
796
869
|
|
|
797
870
|
}
|