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.
Files changed (115) hide show
  1. package/NEOZIP_APPNOTE.md +527 -0
  2. package/README.md +57 -21
  3. package/WHATS_NEW.md +146 -0
  4. package/dist/browser/ZipkitBrowser.d.ts.map +1 -1
  5. package/dist/browser/ZipkitBrowser.js +4 -6
  6. package/dist/browser/ZipkitBrowser.js.map +1 -1
  7. package/dist/browser/zstd-browser-shim.d.ts +28 -0
  8. package/dist/browser/zstd-browser-shim.d.ts.map +1 -0
  9. package/dist/browser/zstd-browser-shim.js +66 -0
  10. package/dist/browser/zstd-browser-shim.js.map +1 -0
  11. package/dist/browser-esm/index.js +2110 -41755
  12. package/dist/browser-esm/index.js.map +4 -4
  13. package/dist/browser-umd/index.js +2110 -41755
  14. package/dist/browser-umd/index.js.map +4 -4
  15. package/dist/browser-umd/index.min.js +3 -21
  16. package/dist/core/ZipCompress.d.ts +2 -7
  17. package/dist/core/ZipCompress.d.ts.map +1 -1
  18. package/dist/core/ZipCompress.js +57 -33
  19. package/dist/core/ZipCompress.js.map +1 -1
  20. package/dist/core/ZipDecompress.d.ts +1 -3
  21. package/dist/core/ZipDecompress.d.ts.map +1 -1
  22. package/dist/core/ZipDecompress.js +27 -17
  23. package/dist/core/ZipDecompress.js.map +1 -1
  24. package/dist/core/ZipEntry.d.ts +6 -0
  25. package/dist/core/ZipEntry.d.ts.map +1 -1
  26. package/dist/core/ZipEntry.js +7 -1
  27. package/dist/core/ZipEntry.js.map +1 -1
  28. package/dist/core/Zipkit.d.ts +43 -11
  29. package/dist/core/Zipkit.d.ts.map +1 -1
  30. package/dist/core/Zipkit.js +106 -63
  31. package/dist/core/Zipkit.js.map +1 -1
  32. package/dist/core/components/HashCalculator.d.ts +22 -1
  33. package/dist/core/components/HashCalculator.d.ts.map +1 -1
  34. package/dist/core/components/HashCalculator.js +49 -6
  35. package/dist/core/components/HashCalculator.js.map +1 -1
  36. package/dist/core/components/Util.d.ts +3 -4
  37. package/dist/core/components/Util.d.ts.map +1 -1
  38. package/dist/core/components/Util.js +10 -8
  39. package/dist/core/components/Util.js.map +1 -1
  40. package/dist/core/constants/Headers.d.ts +1 -3
  41. package/dist/core/constants/Headers.d.ts.map +1 -1
  42. package/dist/core/constants/Headers.js +14 -5
  43. package/dist/core/constants/Headers.js.map +1 -1
  44. package/dist/core/constants/MetaPaths.d.ts +38 -0
  45. package/dist/core/constants/MetaPaths.d.ts.map +1 -0
  46. package/dist/core/constants/MetaPaths.js +79 -0
  47. package/dist/core/constants/MetaPaths.js.map +1 -0
  48. package/dist/core/encryption/AesCrypto.d.ts +55 -11
  49. package/dist/core/encryption/AesCrypto.d.ts.map +1 -1
  50. package/dist/core/encryption/AesCrypto.js +223 -68
  51. package/dist/core/encryption/AesCrypto.js.map +1 -1
  52. package/dist/core/encryption/index.d.ts +1 -1
  53. package/dist/core/encryption/index.d.ts.map +1 -1
  54. package/dist/core/encryption/index.js +3 -1
  55. package/dist/core/encryption/index.js.map +1 -1
  56. package/dist/core/index.d.ts +2 -0
  57. package/dist/core/index.d.ts.map +1 -1
  58. package/dist/core/index.js +14 -1
  59. package/dist/core/index.js.map +1 -1
  60. package/dist/core/merkle/MerkleRoot.d.ts +109 -0
  61. package/dist/core/merkle/MerkleRoot.d.ts.map +1 -0
  62. package/dist/core/merkle/MerkleRoot.js +275 -0
  63. package/dist/core/merkle/MerkleRoot.js.map +1 -0
  64. package/dist/core/version.js +1 -1
  65. package/dist/node/LegacyZstd.d.ts +67 -0
  66. package/dist/node/LegacyZstd.d.ts.map +1 -0
  67. package/dist/node/LegacyZstd.js +209 -0
  68. package/dist/node/LegacyZstd.js.map +1 -0
  69. package/dist/node/ZipCompressNode.d.ts +9 -7
  70. package/dist/node/ZipCompressNode.d.ts.map +1 -1
  71. package/dist/node/ZipCompressNode.js +105 -75
  72. package/dist/node/ZipCompressNode.js.map +1 -1
  73. package/dist/node/ZipDecompressNode.d.ts +7 -3
  74. package/dist/node/ZipDecompressNode.d.ts.map +1 -1
  75. package/dist/node/ZipDecompressNode.js +68 -56
  76. package/dist/node/ZipDecompressNode.js.map +1 -1
  77. package/dist/node/ZipkitNode.d.ts +23 -0
  78. package/dist/node/ZipkitNode.d.ts.map +1 -1
  79. package/dist/node/ZipkitNode.js +195 -41
  80. package/dist/node/ZipkitNode.js.map +1 -1
  81. package/dist/node/ZstdNode.d.ts +39 -0
  82. package/dist/node/ZstdNode.d.ts.map +1 -0
  83. package/dist/node/ZstdNode.js +171 -0
  84. package/dist/node/ZstdNode.js.map +1 -0
  85. package/dist/node/index.d.ts +3 -0
  86. package/dist/node/index.d.ts.map +1 -1
  87. package/dist/node/index.js +11 -1
  88. package/dist/node/index.js.map +1 -1
  89. package/package.json +4 -3
  90. package/src/browser/ZipkitBrowser.ts +4 -6
  91. package/src/browser/zstd-browser-shim.ts +76 -0
  92. package/src/core/ZipCompress.ts +71 -42
  93. package/src/core/ZipDecompress.ts +35 -18
  94. package/src/core/ZipEntry.ts +8 -1
  95. package/src/core/Zipkit.ts +149 -76
  96. package/src/core/components/HashCalculator.ts +58 -6
  97. package/src/core/components/Util.ts +16 -7
  98. package/src/core/constants/Headers.ts +14 -4
  99. package/src/core/constants/MetaPaths.ts +86 -0
  100. package/src/core/encryption/AesCrypto.ts +265 -86
  101. package/src/core/encryption/index.ts +1 -1
  102. package/src/core/index.ts +14 -0
  103. package/src/core/merkle/MerkleRoot.ts +312 -0
  104. package/src/core/version.ts +1 -1
  105. package/src/node/LegacyZstd.ts +270 -0
  106. package/src/node/ZipCompressNode.ts +127 -84
  107. package/src/node/ZipDecompressNode.ts +93 -67
  108. package/src/node/ZipkitNode.ts +230 -42
  109. package/src/node/ZstdNode.ts +155 -0
  110. package/src/node/index.ts +10 -0
  111. package/dist/core/ZstdManager.d.ts +0 -56
  112. package/dist/core/ZstdManager.d.ts.map +0 -1
  113. package/dist/core/ZstdManager.js +0 -144
  114. package/dist/core/ZstdManager.js.map +0 -1
  115. package/src/core/ZstdManager.ts +0 -165
@@ -14,7 +14,7 @@
14
14
  //
15
15
 
16
16
  const pako = require('pako');
17
- import { ZstdManager } from './ZstdManager';
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 WinZip AES when password set; 'neo-aes256' = NeoEncrypt extra 0x024E
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; // Whether to use Zstandard compression default is true
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
- if (!entry.crc || entry.crc === 0) {
136
- entry.crc = hashCalculator.finalizeCRC32();
137
- }
138
- if (options?.useSHA256 && !entry.sha256) {
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
- const useNeo = enc === 'neo-aes256';
148
- const useAes = options?.password && !useZipCrypto && !useNeo;
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 (zstd) algorithm
279
- * @param input - Input data to compress (Buffer or chunked reader)
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
- // Handle chunked reader
299
- if (typeof input === 'object' && 'totalSize' in input && 'readChunk' in input) {
300
- // Chunked reader mode - not implemented in simplified version
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
- // Convert Buffer to Uint8Array for WASM module
310
- const inputArray = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
311
-
312
- // Use global ZstdManager for compression (handles queuing and initialization)
313
- const compressed = await ZstdManager.compress(inputArray, level);
314
- const compressedBuffer = Buffer.from(compressed);
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
- await onOutputBuffer(compressedBuffer);
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
- entry.crc = hashCalculator.finalizeCRC32();
401
- if (cmpOptions?.useSHA256) {
402
- entry.sha256 = hashCalculator.finalizeSHA256();
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 { ZstdManager } from './ZstdManager';
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
- * Synchronous zstd decompress method for in-memory mode
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
- // Use global ZstdManager for decompression (handles queuing and initialization)
140
- const decompressed = await ZstdManager.decompress(data);
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 Buffer.from(decompressed);
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
- // Verify hash
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
- const isValid = this.zipkit.testSHA256(entry, outBuf);
185
- if (!isValid) {
205
+ if (calculatedHash !== entry.sha256) {
186
206
  throw new Error(Errors.INVALID_SHA256);
187
207
  }
188
- } else {
189
- const isValid = this.zipkit.testCRC32(entry, outBuf);
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
 
@@ -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
@@ -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 HashCalculator from './components/HashCalculator';
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
- return centralDir.find((entry: ZipEntry) => entry.filename === filename) || null;
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 Root of the ZIP file
690
- *
691
- * Excludes metadata files (META-INF) to ensure consistent calculation.
692
- * Simply calls getDirectory() and returns null if empty.
693
- *
694
- * @returns Merkle root string or null if calculation fails
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
- // Simply get directory - no loading logic
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 hashAccumulator = new HashCalculator({ enableAccumulation: true });
714
-
715
- // Filter out metadata files (META-INF) to ensure consistent Merkle Root calculation
716
- // Inline check to avoid conflict with ZipkitBrowser's isMetadataFile() method
717
- const contentEntries = zipEntries.filter(entry => {
718
- const filename = entry.filename || '';
719
- return filename !== TIMESTAMP_SUBMITTED &&
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
- const merkleRoot = hashAccumulator.merkleRoot();
733
- if (!merkleRoot) {
734
- return null;
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
- return merkleRoot;
761
+ // v1: bare digests are not leaves — need getMerkleRootAsync() or stream leaves
762
+ return null;
738
763
  }
739
764
 
740
765
  /**
741
- * Calculate Merkle Root of the ZIP file asynchronously
742
- *
743
- * Excludes metadata files (META-INF) to ensure consistent calculation.
744
- * Works with both buffer-based and file-based ZIPs.
745
- *
746
- * @returns Promise resolving to Merkle root string or null if calculation fails
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
- // Get entries based on ZIP type
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 hashAccumulator = new HashCalculator({ enableAccumulation: true });
771
-
772
- // Filter out metadata files (META-INF) to ensure consistent Merkle Root calculation
773
- // Inline check to avoid conflict with ZipkitBrowser's isMetadataFile() method
774
- const contentEntries = zipEntries.filter(entry => {
775
- const filename = entry.filename || '';
776
- return filename !== TIMESTAMP_SUBMITTED &&
777
- filename !== TIMESTAMP_METADATA &&
778
- filename !== TOKENIZED_METADATA;
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
- if (entry.sha256) {
783
- // Convert hex string to Buffer
784
- const hashBuffer = Buffer.from(entry.sha256, 'hex');
785
- hashAccumulator.addHash(hashBuffer);
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
- const merkleRoot = hashAccumulator.merkleRoot();
790
- if (!merkleRoot) {
845
+
846
+ if (!contentEntries.every((e) => !!e.merkleLeafV1)) {
791
847
  return null;
792
848
  }
793
849
 
794
- return merkleRoot;
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
  }