neozipkit 1.0.2 → 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 (76) hide show
  1. package/NEOZIP_APPNOTE.md +527 -0
  2. package/README.md +8 -5
  3. package/WHATS_NEW.md +42 -10
  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-esm/index.js +424 -82
  8. package/dist/browser-esm/index.js.map +4 -4
  9. package/dist/browser-umd/index.js +424 -82
  10. package/dist/browser-umd/index.js.map +4 -4
  11. package/dist/browser-umd/index.min.js +3 -3
  12. package/dist/core/ZipCompress.d.ts.map +1 -1
  13. package/dist/core/ZipCompress.js +12 -11
  14. package/dist/core/ZipCompress.js.map +1 -1
  15. package/dist/core/ZipDecompress.d.ts.map +1 -1
  16. package/dist/core/ZipDecompress.js +13 -8
  17. package/dist/core/ZipDecompress.js.map +1 -1
  18. package/dist/core/ZipEntry.d.ts +6 -0
  19. package/dist/core/ZipEntry.d.ts.map +1 -1
  20. package/dist/core/ZipEntry.js +7 -1
  21. package/dist/core/ZipEntry.js.map +1 -1
  22. package/dist/core/Zipkit.d.ts +43 -11
  23. package/dist/core/Zipkit.d.ts.map +1 -1
  24. package/dist/core/Zipkit.js +106 -63
  25. package/dist/core/Zipkit.js.map +1 -1
  26. package/dist/core/components/HashCalculator.d.ts +22 -1
  27. package/dist/core/components/HashCalculator.d.ts.map +1 -1
  28. package/dist/core/components/HashCalculator.js +49 -6
  29. package/dist/core/components/HashCalculator.js.map +1 -1
  30. package/dist/core/components/Util.d.ts +3 -4
  31. package/dist/core/components/Util.d.ts.map +1 -1
  32. package/dist/core/components/Util.js +10 -8
  33. package/dist/core/components/Util.js.map +1 -1
  34. package/dist/core/constants/Headers.d.ts +1 -3
  35. package/dist/core/constants/Headers.d.ts.map +1 -1
  36. package/dist/core/constants/Headers.js +14 -5
  37. package/dist/core/constants/Headers.js.map +1 -1
  38. package/dist/core/constants/MetaPaths.d.ts +38 -0
  39. package/dist/core/constants/MetaPaths.d.ts.map +1 -0
  40. package/dist/core/constants/MetaPaths.js +79 -0
  41. package/dist/core/constants/MetaPaths.js.map +1 -0
  42. package/dist/core/index.d.ts +2 -0
  43. package/dist/core/index.d.ts.map +1 -1
  44. package/dist/core/index.js +14 -1
  45. package/dist/core/index.js.map +1 -1
  46. package/dist/core/merkle/MerkleRoot.d.ts +109 -0
  47. package/dist/core/merkle/MerkleRoot.d.ts.map +1 -0
  48. package/dist/core/merkle/MerkleRoot.js +275 -0
  49. package/dist/core/merkle/MerkleRoot.js.map +1 -0
  50. package/dist/core/version.js +1 -1
  51. package/dist/node/ZipCompressNode.d.ts.map +1 -1
  52. package/dist/node/ZipCompressNode.js +40 -49
  53. package/dist/node/ZipCompressNode.js.map +1 -1
  54. package/dist/node/ZipDecompressNode.d.ts.map +1 -1
  55. package/dist/node/ZipDecompressNode.js +17 -1
  56. package/dist/node/ZipDecompressNode.js.map +1 -1
  57. package/dist/node/ZipkitNode.d.ts +9 -0
  58. package/dist/node/ZipkitNode.d.ts.map +1 -1
  59. package/dist/node/ZipkitNode.js +67 -4
  60. package/dist/node/ZipkitNode.js.map +1 -1
  61. package/package.json +3 -2
  62. package/src/browser/ZipkitBrowser.ts +4 -6
  63. package/src/core/ZipCompress.ts +14 -13
  64. package/src/core/ZipDecompress.ts +14 -8
  65. package/src/core/ZipEntry.ts +8 -1
  66. package/src/core/Zipkit.ts +149 -76
  67. package/src/core/components/HashCalculator.ts +58 -6
  68. package/src/core/components/Util.ts +16 -7
  69. package/src/core/constants/Headers.ts +14 -4
  70. package/src/core/constants/MetaPaths.ts +86 -0
  71. package/src/core/index.ts +14 -0
  72. package/src/core/merkle/MerkleRoot.ts +312 -0
  73. package/src/core/version.ts +1 -1
  74. package/src/node/ZipCompressNode.ts +40 -49
  75. package/src/node/ZipDecompressNode.ts +13 -1
  76. package/src/node/ZipkitNode.ts +82 -4
@@ -0,0 +1,312 @@
1
+ // ======================================
2
+ // MerkleRoot.ts — NeoZip APPNOTE §6
3
+ // v0 (legacy Bitcoin-style) and v1 (RFC 6962–style domain separation)
4
+ // ======================================
5
+
6
+ import * as crypto from 'crypto';
7
+ import { isMetaInfPath } from '../constants/MetaPaths';
8
+
9
+ /** Merkle algorithms defined in NEOZIP_APPNOTE.md §6. */
10
+ export type MerkleAlgorithm = 'v0' | 'v1';
11
+
12
+ export interface MerkleContentEntry {
13
+ /** Central-directory path (UTF-8 string as stored or as known to the writer). */
14
+ path: string;
15
+ /**
16
+ * Uncompressed payload bytes (same bytes hashed into Extra Field 0x014E).
17
+ * Required for **v1** leaves when `merkleLeafV1` is absent. Optional for **v0** when `contentSha256` is set.
18
+ */
19
+ content?: Buffer | Uint8Array;
20
+ /**
21
+ * Bare SHA-256 of uncompressed payload (= Extra Field 0x014E), hex or 32-byte Buffer.
22
+ * Used as v0 leaf; for v1 this alone is **not** a leaf (see APPNOTE §6.3).
23
+ */
24
+ contentSha256?: string | Buffer;
25
+ /**
26
+ * Precomputed APPNOTE §6.3 v1 leaf: SHA-256(0x00 ‖ uncompressed_bytes), hex or 32-byte Buffer.
27
+ * Produced in the same streaming pass as `contentSha256` (create/extract via HashCalculator).
28
+ * Preferred for v1 when present — no second content read.
29
+ */
30
+ merkleLeafV1?: string | Buffer;
31
+ }
32
+
33
+ const LEAF_DOMAIN = Buffer.from([0x00]);
34
+ const NODE_DOMAIN = Buffer.from([0x01]);
35
+
36
+ function sha256(data: Buffer | Uint8Array | Buffer[]): Buffer {
37
+ const h = crypto.createHash('sha256');
38
+ if (Array.isArray(data)) {
39
+ for (const part of data) {
40
+ h.update(part);
41
+ }
42
+ } else {
43
+ h.update(data);
44
+ }
45
+ return h.digest();
46
+ }
47
+
48
+ function asBuffer(data: Buffer | Uint8Array | string, encoding?: BufferEncoding): Buffer {
49
+ if (typeof data === 'string') {
50
+ return Buffer.from(data, encoding ?? 'hex');
51
+ }
52
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
53
+ }
54
+
55
+ /**
56
+ * Normalize a ZIP entry path for v1 leaf ordering (APPNOTE §6.3):
57
+ * POSIX separators, strip leading `./` and `/`, Unicode NFC, UTF-8 byte order when compared.
58
+ */
59
+ export function normalizeMerklePath(path: string): string {
60
+ let p = path.replace(/\\/g, '/');
61
+ if (p.startsWith('./')) {
62
+ p = p.slice(2);
63
+ }
64
+ while (p.startsWith('/')) {
65
+ p = p.slice(1);
66
+ }
67
+ if (typeof p.normalize === 'function') {
68
+ p = p.normalize('NFC');
69
+ }
70
+ return p;
71
+ }
72
+
73
+ /** Sort key for path comparison (UTF-8 byte order). */
74
+ function pathSortKey(path: string, algorithm: MerkleAlgorithm): Buffer {
75
+ const normalized = algorithm === 'v1' ? normalizeMerklePath(path) : path;
76
+ return Buffer.from(normalized, 'utf8');
77
+ }
78
+
79
+ /** Bare content digest (= 0x014E value). */
80
+ export function contentDigest(content: Buffer | Uint8Array): Buffer {
81
+ return sha256(asBuffer(content));
82
+ }
83
+
84
+ /**
85
+ * v0 leaf: SHA-256(uncompressed bytes) — same as Extra Field 0x014E.
86
+ */
87
+ export function leafHashV0(contentOrDigest: Buffer | Uint8Array, isDigest = false): Buffer {
88
+ const buf = asBuffer(contentOrDigest);
89
+ return isDigest ? buf : sha256(buf);
90
+ }
91
+
92
+ /**
93
+ * v1 leaf: SHA-256(0x00 ‖ uncompressed_bytes).
94
+ * Must not be confused with the bare 0x014E digest (APPNOTE §6.3).
95
+ */
96
+ export function leafHashV1(content: Buffer | Uint8Array): Buffer {
97
+ return sha256([LEAF_DOMAIN, asBuffer(content)]);
98
+ }
99
+
100
+ /**
101
+ * Parent node hashes.
102
+ * v0: SHA-256(H_left ‖ H_right) — order preserved, no pair sorting.
103
+ * v1: SHA-256(0x01 ‖ H_left ‖ H_right).
104
+ */
105
+ export function parentHash(left: Buffer, right: Buffer, algorithm: MerkleAlgorithm): Buffer {
106
+ if (algorithm === 'v1') {
107
+ return sha256([NODE_DOMAIN, left, right]);
108
+ }
109
+ return sha256([left, right]);
110
+ }
111
+
112
+ /**
113
+ * Fold an ordered list of leaf hashes into a single root (hex lowercase).
114
+ * Odd-count rule:
115
+ * - v0: duplicate last leaf (Bitcoin-style)
116
+ * - v1: promote last node unhashed (RFC 6962–style)
117
+ */
118
+ export function merkleRootFromLeaves(leaves: Buffer[], algorithm: MerkleAlgorithm): string | null {
119
+ if (leaves.length === 0) {
120
+ return null;
121
+ }
122
+
123
+ let level: Buffer[] = leaves.map((l) => Buffer.from(l));
124
+
125
+ while (level.length > 1) {
126
+ const next: Buffer[] = [];
127
+ for (let i = 0; i + 1 < level.length; i += 2) {
128
+ next.push(parentHash(level[i], level[i + 1], algorithm));
129
+ }
130
+ if (level.length % 2 === 1) {
131
+ const last = level[level.length - 1];
132
+ if (algorithm === 'v0') {
133
+ // Duplicate last leaf, then hash the pair
134
+ next.push(parentHash(last, last, algorithm));
135
+ } else {
136
+ // Promote unhashed
137
+ next.push(last);
138
+ }
139
+ }
140
+ level = next;
141
+ }
142
+
143
+ return level[0].toString('hex');
144
+ }
145
+
146
+ function resolveLeaf(
147
+ entry: MerkleContentEntry,
148
+ algorithm: MerkleAlgorithm
149
+ ): Buffer | null {
150
+ if (algorithm === 'v0') {
151
+ if (entry.contentSha256) {
152
+ const d = asBuffer(entry.contentSha256, typeof entry.contentSha256 === 'string' ? 'hex' : undefined);
153
+ if (d.length !== 32) {
154
+ return null;
155
+ }
156
+ return d;
157
+ }
158
+ if (entry.content) {
159
+ return contentDigest(entry.content);
160
+ }
161
+ return null;
162
+ }
163
+
164
+ // v1: prefer precomputed domain-separated leaf from the same hashing pass as 0x014E
165
+ if (entry.merkleLeafV1) {
166
+ const d = asBuffer(
167
+ entry.merkleLeafV1,
168
+ typeof entry.merkleLeafV1 === 'string' ? 'hex' : undefined
169
+ );
170
+ if (d.length !== 32) {
171
+ return null;
172
+ }
173
+ return d;
174
+ }
175
+ // Domain-separated hash of uncompressed payload (re-hash only if leaf not available)
176
+ if (entry.content) {
177
+ return leafHashV1(entry.content);
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Compute archive Merkle root per APPNOTE §6 from content entries
184
+ * (caller already excluded META-INF/** if desired).
185
+ *
186
+ * @returns lowercase hex root, or null if no usable leaves
187
+ */
188
+ export function computeArchiveMerkleRoot(
189
+ entries: MerkleContentEntry[],
190
+ algorithm: MerkleAlgorithm = 'v1'
191
+ ): string | null {
192
+ const prepared: { key: Buffer; leaf: Buffer }[] = [];
193
+
194
+ for (const entry of entries) {
195
+ if (isMetaInfPath(entry.path || '')) {
196
+ continue;
197
+ }
198
+ const leaf = resolveLeaf(entry, algorithm);
199
+ if (!leaf) {
200
+ continue;
201
+ }
202
+ prepared.push({
203
+ key: pathSortKey(entry.path || '', algorithm),
204
+ leaf,
205
+ });
206
+ }
207
+
208
+ if (prepared.length === 0) {
209
+ return null;
210
+ }
211
+
212
+ prepared.sort((a, b) => Buffer.compare(a.key, b.key));
213
+ return merkleRootFromLeaves(
214
+ prepared.map((p) => p.leaf),
215
+ algorithm
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Build v0 root from path + bare content digests only (0x014E extras).
221
+ * Path order: central-directory bytes as stored (no NFC / strip).
222
+ */
223
+ export function computeMerkleRootV0FromDigests(
224
+ entries: Array<{ path: string; sha256: string | Buffer }>
225
+ ): string | null {
226
+ return computeArchiveMerkleRoot(
227
+ entries.map((e) => ({ path: e.path, contentSha256: e.sha256 })),
228
+ 'v0'
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Build v1 root from path + uncompressed payloads (APPNOTE §6.3).
234
+ * Paths are normalized before sorting.
235
+ */
236
+ export function computeMerkleRootV1FromContents(
237
+ entries: Array<{ path: string; content: Buffer | Uint8Array }>
238
+ ): string | null {
239
+ return computeArchiveMerkleRoot(
240
+ entries.map((e) => ({ path: e.path, content: e.content })),
241
+ 'v1'
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Build v1 root from path + precomputed domain-separated leaves (APPNOTE §6.3).
247
+ * Prefer this after create/extract when HashCalculator produced merkleLeafV1 in one stream pass.
248
+ */
249
+ export function computeMerkleRootV1FromLeaves(
250
+ entries: Array<{ path: string; merkleLeafV1: string | Buffer }>
251
+ ): string | null {
252
+ return computeArchiveMerkleRoot(
253
+ entries.map((e) => ({ path: e.path, merkleLeafV1: e.merkleLeafV1 })),
254
+ 'v1'
255
+ );
256
+ }
257
+
258
+ /**
259
+ * Verify a declared root (APPNOTE §6.4): try v1 first (when content or leaves available),
260
+ * then v0 digests. Returns which algorithm matched, or null on failure.
261
+ *
262
+ * When only bare 0x014E digests are available, only v0 can match APPNOTE correctly.
263
+ * Pass `merkleLeafV1` or content for full v1 verification without re-hashing from scratch
264
+ * when leaves were computed in the decompress stream.
265
+ */
266
+ export function matchMerkleRoot(
267
+ entries: MerkleContentEntry[],
268
+ declaredRoot: string
269
+ ): { algorithm: MerkleAlgorithm; security: 'high' | 'legacy' } | null {
270
+ const expected = declaredRoot.toLowerCase().replace(/^0x/, '');
271
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
272
+ return null;
273
+ }
274
+
275
+ const hasV1Material = entries.some(
276
+ (e) =>
277
+ !isMetaInfPath(e.path || '') &&
278
+ (!!e.merkleLeafV1 || !!e.content)
279
+ );
280
+ if (hasV1Material) {
281
+ const v1 = computeArchiveMerkleRoot(entries, 'v1');
282
+ if (v1 && v1 === expected) {
283
+ return { algorithm: 'v1', security: 'high' };
284
+ }
285
+ }
286
+
287
+ const v0 = computeArchiveMerkleRoot(entries, 'v0');
288
+ if (v0 && v0 === expected) {
289
+ return { algorithm: 'v0', security: 'legacy' };
290
+ }
291
+
292
+ // If v1 was not tried (no leaves/content), v0 was the only attempt.
293
+ if (hasV1Material) {
294
+ return null;
295
+ }
296
+
297
+ return null;
298
+ }
299
+
300
+ export default {
301
+ normalizeMerklePath,
302
+ contentDigest,
303
+ leafHashV0,
304
+ leafHashV1,
305
+ parentHash,
306
+ merkleRootFromLeaves,
307
+ computeArchiveMerkleRoot,
308
+ computeMerkleRootV0FromDigests,
309
+ computeMerkleRootV1FromContents,
310
+ computeMerkleRootV1FromLeaves,
311
+ matchMerkleRoot,
312
+ };
@@ -28,6 +28,6 @@ const currentDate = new Date();
28
28
  const releaseDate = `${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${currentDate.getFullYear()}`;
29
29
 
30
30
  export const VERSION = {
31
- number: '1.0.2', // packageJson.version
31
+ number: '1.0.5', // packageJson.version
32
32
  date: releaseDate
33
33
  };
@@ -119,12 +119,10 @@ export class ZipCompressNode {
119
119
 
120
120
  if (hashCalculator) {
121
121
  hashCalculator.update(data);
122
- if (!entry.crc || entry.crc === 0) {
123
- entry.crc = hashCalculator.finalizeCRC32();
124
- }
125
- if (options?.useSHA256 && !entry.sha256) {
126
- entry.sha256 = hashCalculator.finalizeSHA256();
127
- }
122
+ hashCalculator.applyToEntry(entry, {
123
+ setCrc: !entry.crc || entry.crc === 0,
124
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
125
+ });
128
126
  }
129
127
 
130
128
  // Compress based on method
@@ -148,10 +146,11 @@ export class ZipCompressNode {
148
146
  const enc = options.encryptionMethod;
149
147
  if (enc === 'zipcrypto') {
150
148
  buffer = this.encryptCompressedData(buffer, entry, options.password);
151
- } else if (enc === 'neo-aes256') {
152
- buffer = this.encryptCompressedDataNeo(buffer, entry, options.password);
153
- } else {
149
+ } else if (enc === 'aes256') {
154
150
  buffer = this.encryptCompressedDataAes(buffer, entry, options.password);
151
+ } else {
152
+ // Default / 'neo-aes256': NeoEncrypt (0x024E)
153
+ buffer = this.encryptCompressedDataNeo(buffer, entry, options.password);
155
154
  }
156
155
  }
157
156
 
@@ -187,12 +186,10 @@ export class ZipCompressNode {
187
186
  // For buffer, calculate hashes if needed
188
187
  if (hashCalculator && entry) {
189
188
  hashCalculator.update(data);
190
- if (!entry.crc || entry.crc === 0) {
191
- entry.crc = hashCalculator.finalizeCRC32();
192
- }
193
- if (options?.useSHA256 && !entry.sha256) {
194
- entry.sha256 = hashCalculator.finalizeSHA256();
195
- }
189
+ hashCalculator.applyToEntry(entry, {
190
+ setCrc: !entry.crc || entry.crc === 0,
191
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
192
+ });
196
193
  }
197
194
  return data;
198
195
  } else {
@@ -219,12 +216,10 @@ export class ZipCompressNode {
219
216
 
220
217
  // Finalize hashes
221
218
  if (hashCalculator && entry) {
222
- if (!entry.crc || entry.crc === 0) {
223
- entry.crc = hashCalculator.finalizeCRC32();
224
- }
225
- if (options?.useSHA256 && !entry.sha256) {
226
- entry.sha256 = hashCalculator.finalizeSHA256();
227
- }
219
+ hashCalculator.applyToEntry(entry, {
220
+ setCrc: !entry.crc || entry.crc === 0,
221
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
222
+ });
228
223
  }
229
224
 
230
225
  return Buffer.alloc(0);
@@ -245,10 +240,10 @@ export class ZipCompressNode {
245
240
  if (hashCalculator && entry) {
246
241
  hashCalculator.update(data);
247
242
  // Always set CRC if hash calculator was used (it calculated the correct CRC)
248
- entry.crc = hashCalculator.finalizeCRC32();
249
- if (options?.useSHA256 && !entry.sha256) {
250
- entry.sha256 = hashCalculator.finalizeSHA256();
251
- }
243
+ hashCalculator.applyToEntry(entry, {
244
+ setCrc: true,
245
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
246
+ });
252
247
  }
253
248
 
254
249
  const result = pako.deflateRaw(data, { level: level ?? 6 });
@@ -298,10 +293,10 @@ export class ZipCompressNode {
298
293
  // Finalize hashes after all chunks processed and compressed
299
294
  if (hashCalculator && entry) {
300
295
  // Always set CRC if hash calculator was used (it calculated the correct CRC)
301
- entry.crc = hashCalculator.finalizeCRC32();
302
- if (options?.useSHA256 && !entry.sha256) {
303
- entry.sha256 = hashCalculator.finalizeSHA256();
304
- }
296
+ hashCalculator.applyToEntry(entry, {
297
+ setCrc: true,
298
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
299
+ });
305
300
  }
306
301
 
307
302
  // For chunked processing, return empty buffer (data already written via onOutputBuffer)
@@ -375,12 +370,10 @@ export class ZipCompressNode {
375
370
  if (isBuffer) {
376
371
  if (hashCalculator && entry) {
377
372
  hashCalculator.update(input);
378
- if (!entry.crc || entry.crc === 0) {
379
- entry.crc = hashCalculator.finalizeCRC32();
380
- }
381
- if (options?.useSHA256 && !entry.sha256) {
382
- entry.sha256 = hashCalculator.finalizeSHA256();
383
- }
373
+ hashCalculator.applyToEntry(entry, {
374
+ setCrc: !entry.crc || entry.crc === 0,
375
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
376
+ });
384
377
  }
385
378
  return input;
386
379
  }
@@ -405,12 +398,10 @@ export class ZipCompressNode {
405
398
  position += chunk.length;
406
399
  }
407
400
  if (hashCalculator && entry) {
408
- if (!entry.crc || entry.crc === 0) {
409
- entry.crc = hashCalculator.finalizeCRC32();
410
- }
411
- if (options?.useSHA256 && !entry.sha256) {
412
- entry.sha256 = hashCalculator.finalizeSHA256();
413
- }
401
+ hashCalculator.applyToEntry(entry, {
402
+ setCrc: !entry.crc || entry.crc === 0,
403
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
404
+ });
414
405
  }
415
406
  return onOutputBuffer ? Buffer.alloc(0) : Buffer.concat(chunks);
416
407
  }
@@ -430,10 +421,10 @@ export class ZipCompressNode {
430
421
 
431
422
  if (isBuffer && hashCalculator && entry) {
432
423
  hashCalculator.update(input);
433
- entry.crc = hashCalculator.finalizeCRC32();
434
- if (options?.useSHA256 && !entry.sha256) {
435
- entry.sha256 = hashCalculator.finalizeSHA256();
436
- }
424
+ hashCalculator.applyToEntry(entry, {
425
+ setCrc: true,
426
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
427
+ });
437
428
  }
438
429
 
439
430
  const collected: Buffer[] = [];
@@ -457,10 +448,10 @@ export class ZipCompressNode {
457
448
  );
458
449
 
459
450
  if (!isBuffer && hashCalculator && entry) {
460
- entry.crc = hashCalculator.finalizeCRC32();
461
- if (options?.useSHA256 && !entry.sha256) {
462
- entry.sha256 = hashCalculator.finalizeSHA256();
463
- }
451
+ hashCalculator.applyToEntry(entry, {
452
+ setCrc: true,
453
+ setSha256: !!(options?.useSHA256 && !entry.sha256),
454
+ });
464
455
  }
465
456
  if (entry) {
466
457
  entry.compressedSize = compressedSize;
@@ -451,6 +451,8 @@ export class ZipDecompressNode {
451
451
  if (!options?.skipHashCheck) {
452
452
  if (entry.sha256) {
453
453
  const calculatedHash = hashCalc.finalizeSHA256();
454
+ const leaf = hashCalc.finalizeMerkleLeafV1();
455
+ if (leaf) entry.merkleLeafV1 = leaf;
454
456
  this.log(`SHA-256 comparison: calculated=${calculatedHash}, stored=${entry.sha256}`);
455
457
  if (calculatedHash !== entry.sha256) {
456
458
  if (options?.outputPath && fs) {
@@ -540,6 +542,8 @@ export class ZipDecompressNode {
540
542
  if (!options?.skipHashCheck) {
541
543
  if (entry.sha256) {
542
544
  const calculatedHash = hashCalc.finalizeSHA256();
545
+ const leaf = hashCalc.finalizeMerkleLeafV1();
546
+ if (leaf) entry.merkleLeafV1 = leaf;
543
547
  this.log(`SHA-256 comparison: calculated=${calculatedHash}, stored=${entry.sha256}`);
544
548
  if (calculatedHash !== entry.sha256) {
545
549
  throw new Error(Errors.INVALID_SHA256);
@@ -607,6 +611,8 @@ export class ZipDecompressNode {
607
611
  if (!options?.skipHashCheck) {
608
612
  if (entry.sha256) {
609
613
  const calculatedHash = hashCalc.finalizeSHA256();
614
+ const leaf = hashCalc.finalizeMerkleLeafV1();
615
+ if (leaf) entry.merkleLeafV1 = leaf;
610
616
  this.log(`SHA-256 comparison: calculated=${calculatedHash}, stored=${entry.sha256}`);
611
617
  if (calculatedHash !== entry.sha256) {
612
618
  throw new Error(Errors.INVALID_SHA256);
@@ -625,7 +631,13 @@ export class ZipDecompressNode {
625
631
  return { verifiedHash: undefined };
626
632
  }
627
633
  } else {
628
- // Hash check skipped - return undefined
634
+ // Hash check skipped — still finalize leaf/digest once from this stream when SHA was enabled
635
+ if (entry.sha256 && hashCalc) {
636
+ const calculatedHash = hashCalc.finalizeSHA256();
637
+ const leaf = hashCalc.finalizeMerkleLeafV1();
638
+ if (leaf) entry.merkleLeafV1 = leaf;
639
+ return { verifiedHash: calculatedHash || undefined };
640
+ }
629
641
  return { verifiedHash: undefined };
630
642
  }
631
643
  } catch (error) {
@@ -27,6 +27,12 @@ import {
27
27
  import * as fs from 'fs';
28
28
  import * as path from 'path';
29
29
  import { minimatch } from 'minimatch';
30
+ import {
31
+ computeMerkleRootV0FromDigests,
32
+ computeMerkleRootV1FromLeaves,
33
+ type MerkleAlgorithm,
34
+ } from '../core/merkle/MerkleRoot';
35
+ import { isMetaInfPath } from '../core/constants/MetaPaths';
30
36
 
31
37
  // Re-export everything from core Zipkit
32
38
  export * from '../core';
@@ -111,6 +117,79 @@ export default class ZipkitNode extends Zipkit {
111
117
  return this._zipkitDeCmpNode;
112
118
  }
113
119
 
120
+ /**
121
+ * Streaming Merkle root for file-based archives.
122
+ * V1 leaves come from the same decompress→hash pass as integrity (testEntry / extract),
123
+ * chunked — never re-reads uncompressed sources or materializes whole entries for the root alone.
124
+ */
125
+ async getMerkleRootAsync(options?: { algorithm?: MerkleAlgorithm }): Promise<string | null> {
126
+ const algorithm: MerkleAlgorithm = options?.algorithm ?? 'v1';
127
+ let zipEntries: ZipEntry[];
128
+ try {
129
+ zipEntries = this.getDirectory();
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (!zipEntries?.length) {
134
+ return null;
135
+ }
136
+
137
+ const contentEntries = zipEntries.filter((e) => !isMetaInfPath(e.filename || ''));
138
+ if (contentEntries.length === 0) {
139
+ return null;
140
+ }
141
+
142
+ if (algorithm === 'v0') {
143
+ const digests = contentEntries
144
+ .filter((e) => e.sha256)
145
+ .map((e) => ({ path: e.filename || '', sha256: e.sha256 as string }));
146
+ return computeMerkleRootV0FromDigests(digests);
147
+ }
148
+
149
+ if (contentEntries.every((e) => !!e.merkleLeafV1)) {
150
+ return computeMerkleRootV1FromLeaves(
151
+ contentEntries.map((e) => ({
152
+ path: e.filename || '',
153
+ merkleLeafV1: e.merkleLeafV1 as string,
154
+ }))
155
+ );
156
+ }
157
+
158
+ // Buffer-loaded: base class extract path
159
+ if ((this as any).inBuffer) {
160
+ return super.getMerkleRootAsync(options);
161
+ }
162
+
163
+ // File-based: stream hash each content entry once via testEntry (no write, dual hash)
164
+ if (!this.fileHandle && !this.filePath) {
165
+ // Create-session only: may only have live ZipEntry[] with leaves from write
166
+ return super.getMerkleRootAsync(options);
167
+ }
168
+
169
+ try {
170
+ for (const entry of contentEntries) {
171
+ if (entry.merkleLeafV1) {
172
+ continue;
173
+ }
174
+ await this.getZipDecompressNode().testEntry(entry, { skipHashCheck: false });
175
+ }
176
+ } catch (err) {
177
+ console.error('[ZipkitNode.getMerkleRootAsync] stream hash failed:', err);
178
+ return null;
179
+ }
180
+
181
+ if (!contentEntries.every((e) => !!e.merkleLeafV1)) {
182
+ return null;
183
+ }
184
+
185
+ return computeMerkleRootV1FromLeaves(
186
+ contentEntries.map((e) => ({
187
+ path: e.filename || '',
188
+ merkleLeafV1: e.merkleLeafV1 as string,
189
+ }))
190
+ );
191
+ }
192
+
114
193
  // ============================================================================
115
194
  // File Loading Methods
116
195
  // ============================================================================
@@ -650,11 +729,10 @@ export default class ZipkitNode extends Zipkit {
650
729
  }
651
730
 
652
731
  // For AES encryption, pre-configure the entry so the initial local header
653
- // includes the 0x9901 extra field and method 99. The actual compression
654
- // method is preserved in realCmpMethod for the AES extra field.
655
- // Default password encryption is WinZip AES-256 (matches compressData).
732
+ // matches the chosen scheme (WinZip method 99 + 0x9901, or NeoEncrypt + 0x024E).
733
+ // Default password encryption is NeoEncrypt (matches NeoZip product AES-256).
656
734
  const encMethod = options?.password
657
- ? (options.encryptionMethod || 'aes256')
735
+ ? (options.encryptionMethod || 'neo-aes256')
658
736
  : undefined;
659
737
  const isAesRequested = encMethod === 'aes256';
660
738
  const isNeoRequested = encMethod === 'neo-aes256';