neozipkit 1.0.2 → 1.5.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.
Files changed (128) hide show
  1. package/NEOZIP_APPNOTE.md +625 -0
  2. package/README.md +44 -11
  3. package/WHATS_NEW.md +153 -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/zstd-browser-shim.d.ts +17 -9
  8. package/dist/browser/zstd-browser-shim.d.ts.map +1 -1
  9. package/dist/browser/zstd-browser-shim.js +99 -21
  10. package/dist/browser/zstd-browser-shim.js.map +1 -1
  11. package/dist/browser-esm/index.js +6534 -1275
  12. package/dist/browser-esm/index.js.map +4 -4
  13. package/dist/browser-umd/index.js +6534 -1275
  14. package/dist/browser-umd/index.js.map +4 -4
  15. package/dist/browser-umd/index.min.js +6 -3
  16. package/dist/core/ZipCompress.d.ts +6 -0
  17. package/dist/core/ZipCompress.d.ts.map +1 -1
  18. package/dist/core/ZipCompress.js +12 -11
  19. package/dist/core/ZipCompress.js.map +1 -1
  20. package/dist/core/ZipCopy.d.ts.map +1 -1
  21. package/dist/core/ZipCopy.js +33 -25
  22. package/dist/core/ZipCopy.js.map +1 -1
  23. package/dist/core/ZipDecompress.d.ts.map +1 -1
  24. package/dist/core/ZipDecompress.js +13 -8
  25. package/dist/core/ZipDecompress.js.map +1 -1
  26. package/dist/core/ZipEntry.d.ts +24 -0
  27. package/dist/core/ZipEntry.d.ts.map +1 -1
  28. package/dist/core/ZipEntry.js +146 -14
  29. package/dist/core/ZipEntry.js.map +1 -1
  30. package/dist/core/Zipkit.d.ts +43 -11
  31. package/dist/core/Zipkit.d.ts.map +1 -1
  32. package/dist/core/Zipkit.js +132 -82
  33. package/dist/core/Zipkit.js.map +1 -1
  34. package/dist/core/components/HashCalculator.d.ts +22 -1
  35. package/dist/core/components/HashCalculator.d.ts.map +1 -1
  36. package/dist/core/components/HashCalculator.js +49 -6
  37. package/dist/core/components/HashCalculator.js.map +1 -1
  38. package/dist/core/components/Util.d.ts +3 -4
  39. package/dist/core/components/Util.d.ts.map +1 -1
  40. package/dist/core/components/Util.js +10 -8
  41. package/dist/core/components/Util.js.map +1 -1
  42. package/dist/core/constants/Headers.d.ts +2 -3
  43. package/dist/core/constants/Headers.d.ts.map +1 -1
  44. package/dist/core/constants/Headers.js +15 -5
  45. package/dist/core/constants/Headers.js.map +1 -1
  46. package/dist/core/constants/MetaPaths.d.ts +38 -0
  47. package/dist/core/constants/MetaPaths.d.ts.map +1 -0
  48. package/dist/core/constants/MetaPaths.js +79 -0
  49. package/dist/core/constants/MetaPaths.js.map +1 -0
  50. package/dist/core/index.d.ts +5 -0
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +18 -1
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/inflateZipPayload.d.ts +6 -0
  55. package/dist/core/inflateZipPayload.d.ts.map +1 -0
  56. package/dist/core/inflateZipPayload.js +28 -0
  57. package/dist/core/inflateZipPayload.js.map +1 -0
  58. package/dist/core/merkle/MerkleRoot.d.ts +109 -0
  59. package/dist/core/merkle/MerkleRoot.d.ts.map +1 -0
  60. package/dist/core/merkle/MerkleRoot.js +275 -0
  61. package/dist/core/merkle/MerkleRoot.js.map +1 -0
  62. package/dist/core/origin/OriginExtra.d.ts +43 -0
  63. package/dist/core/origin/OriginExtra.d.ts.map +1 -0
  64. package/dist/core/origin/OriginExtra.js +187 -0
  65. package/dist/core/origin/OriginExtra.js.map +1 -0
  66. package/dist/core/version.js +1 -1
  67. package/dist/core/zip64/Zip64.d.ts +138 -0
  68. package/dist/core/zip64/Zip64.d.ts.map +1 -0
  69. package/dist/core/zip64/Zip64.js +353 -0
  70. package/dist/core/zip64/Zip64.js.map +1 -0
  71. package/dist/node/ZipCompressNode.d.ts.map +1 -1
  72. package/dist/node/ZipCompressNode.js +40 -49
  73. package/dist/node/ZipCompressNode.js.map +1 -1
  74. package/dist/node/ZipCopyNode.d.ts.map +1 -1
  75. package/dist/node/ZipCopyNode.js +26 -26
  76. package/dist/node/ZipCopyNode.js.map +1 -1
  77. package/dist/node/ZipDecompressNode.d.ts.map +1 -1
  78. package/dist/node/ZipDecompressNode.js +21 -3
  79. package/dist/node/ZipDecompressNode.js.map +1 -1
  80. package/dist/node/ZipkitNode.d.ts +23 -1
  81. package/dist/node/ZipkitNode.d.ts.map +1 -1
  82. package/dist/node/ZipkitNode.js +158 -57
  83. package/dist/node/ZipkitNode.js.map +1 -1
  84. package/dist/node/ZstdNode.d.ts +14 -4
  85. package/dist/node/ZstdNode.d.ts.map +1 -1
  86. package/dist/node/ZstdNode.js +28 -4
  87. package/dist/node/ZstdNode.js.map +1 -1
  88. package/dist/node/buildZipBuffer.d.ts +49 -0
  89. package/dist/node/buildZipBuffer.d.ts.map +1 -0
  90. package/dist/node/buildZipBuffer.js +146 -0
  91. package/dist/node/buildZipBuffer.js.map +1 -0
  92. package/dist/node/index.d.ts +3 -0
  93. package/dist/node/index.d.ts.map +1 -1
  94. package/dist/node/index.js +6 -1
  95. package/dist/node/index.js.map +1 -1
  96. package/dist/node/inflateZipPayload.d.ts +5 -0
  97. package/dist/node/inflateZipPayload.d.ts.map +1 -0
  98. package/dist/node/inflateZipPayload.js +21 -0
  99. package/dist/node/inflateZipPayload.js.map +1 -0
  100. package/dist/types/index.js +2 -2
  101. package/node-esm.mjs +32 -0
  102. package/package.json +13 -6
  103. package/src/browser/ZipkitBrowser.ts +4 -6
  104. package/src/browser/zstd-browser-shim.ts +110 -28
  105. package/src/core/ZipCompress.ts +20 -13
  106. package/src/core/ZipCopy.ts +56 -27
  107. package/src/core/ZipDecompress.ts +14 -8
  108. package/src/core/ZipEntry.ts +200 -14
  109. package/src/core/Zipkit.ts +189 -98
  110. package/src/core/components/HashCalculator.ts +58 -6
  111. package/src/core/components/Util.ts +16 -7
  112. package/src/core/constants/Headers.ts +15 -4
  113. package/src/core/constants/MetaPaths.ts +86 -0
  114. package/src/core/index.ts +17 -0
  115. package/src/core/inflateZipPayload.ts +27 -0
  116. package/src/core/merkle/MerkleRoot.ts +312 -0
  117. package/src/core/origin/OriginExtra.ts +209 -0
  118. package/src/core/version.ts +1 -1
  119. package/src/core/zip64/Zip64.ts +473 -0
  120. package/src/node/ZipCompressNode.ts +40 -49
  121. package/src/node/ZipCopyNode.ts +31 -29
  122. package/src/node/ZipDecompressNode.ts +16 -3
  123. package/src/node/ZipkitNode.ts +203 -72
  124. package/src/node/ZstdNode.ts +33 -8
  125. package/src/node/buildZipBuffer.ts +221 -0
  126. package/src/node/index.ts +7 -0
  127. package/src/node/inflateZipPayload.ts +19 -0
  128. package/src/types/index.ts +2 -2
@@ -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,
@@ -18,7 +25,7 @@ import {
18
25
  CENTRAL_END,
19
26
  CENTRAL_DIR,
20
27
  ZIP64_CENTRAL_END,
21
- ZIP64_CENTRAL_DIR ,
28
+ ZIP64_CENTRAL_DIR,
22
29
  GP_FLAG,
23
30
  TIMESTAMP_SUBMITTED,
24
31
  TIMESTAMP_METADATA,
@@ -28,6 +35,19 @@ import {
28
35
  CENTRAL_DIRECTORY_END,
29
36
  HDR_ID
30
37
  } from './constants/Headers';
38
+ import {
39
+ buildEndRecords,
40
+ classicEocdNeedsZip64,
41
+ parseZip64Eocd,
42
+ parseZip64Locator,
43
+ ZIP64_EOCD_RECORD_SIZE,
44
+ ZIP64_LOCATOR_SIZE,
45
+ } from './zip64/Zip64';
46
+ import {
47
+ findReservedMetaEntry,
48
+ isMetaInfPath,
49
+ isReservedMetaPath,
50
+ } from './constants/MetaPaths';
31
51
 
32
52
  /**
33
53
  * Streaming file handle interface
@@ -371,15 +391,22 @@ export default class Zipkit {
371
391
 
372
392
 
373
393
  /**
374
- * Get specific ZIP entry by filename from zipEntries[] array
375
- *
394
+ * Get specific ZIP entry by filename from zipEntries[] array.
395
+ * Reserved META-INF paths (APPNOTE §2.3) also match ASCII case-insensitively
396
+ * when an exact spelling is not found.
397
+ *
376
398
  * @param filename - The name/path of the file/directory to find
377
399
  * @returns ZipEntry object if found, null if not found
378
400
  */
379
401
  getZipEntry(filename: string): ZipEntry | null {
380
402
  try {
381
403
  const centralDir = this.getDirectory();
382
- return centralDir.find((entry: ZipEntry) => entry.filename === filename) || null;
404
+ const exact = centralDir.find((entry: ZipEntry) => entry.filename === filename);
405
+ if (exact) return exact;
406
+ if (isReservedMetaPath(filename)) {
407
+ return findReservedMetaEntry(centralDir, filename);
408
+ }
409
+ return null;
383
410
  } catch (error) {
384
411
  return null;
385
412
  }
@@ -586,17 +613,30 @@ export default class Zipkit {
586
613
  if (eocdBuffer.readUInt32LE(0) === CENTRAL_END.SIGNATURE) {
587
614
  this.centralDirSize = eocdBuffer.readUInt32LE(CENTRAL_END.CENTRAL_DIR_SIZE);
588
615
  this.centralDirOffset = eocdBuffer.readUInt32LE(CENTRAL_END.CENTRAL_DIR_OFFSET);
589
-
590
- if (this.centralDirOffset === 0xFFFFFFFF) {
591
- // ZIP64: locate locator (20 bytes before EOCD) and then ZIP64 EOCD (56 bytes)
592
- const locatorOffset = eocdOffset - 20;
593
- const locatorBuffer = buffer.subarray(locatorOffset, locatorOffset + 20);
594
- if (locatorBuffer.readUInt32LE(0) === ZIP64_CENTRAL_END.SIGNATURE) {
595
- const zip64Offset = Number(locatorBuffer.readBigUInt64LE(8));
596
- const zip64Buffer = buffer.subarray(zip64Offset, zip64Offset + 56);
597
- this.centralDirSize = Number(zip64Buffer.readBigUInt64LE(ZIP64_CENTRAL_DIR.CENTRAL_DIR_SIZE));
598
- this.centralDirOffset = Number(zip64Buffer.readBigUInt64LE(ZIP64_CENTRAL_DIR.CENTRAL_DIR_OFFSET));
616
+ const volEntries = eocdBuffer.readUInt16LE(CENTRAL_END.VOL_ENTRIES);
617
+ const totalEntries = eocdBuffer.readUInt16LE(CENTRAL_END.TOTAL_ENTRIES);
618
+
619
+ if (
620
+ classicEocdNeedsZip64({
621
+ volEntries,
622
+ totalEntries,
623
+ centralDirSize: this.centralDirSize,
624
+ centralDirOffset: this.centralDirOffset,
625
+ })
626
+ ) {
627
+ const locatorOffset = eocdOffset - ZIP64_LOCATOR_SIZE;
628
+ if (locatorOffset < 0) {
629
+ throw new Error(Errors.INVALID_FORMAT);
599
630
  }
631
+ const locatorBuffer = buffer.subarray(locatorOffset, locatorOffset + ZIP64_LOCATOR_SIZE);
632
+ const zip64Offset = parseZip64Locator(locatorBuffer);
633
+ const zip64Buffer = buffer.subarray(
634
+ zip64Offset,
635
+ zip64Offset + ZIP64_EOCD_RECORD_SIZE
636
+ );
637
+ const z64 = parseZip64Eocd(zip64Buffer);
638
+ this.centralDirSize = z64.centralDirSize;
639
+ this.centralDirOffset = z64.centralDirOffset;
600
640
  }
601
641
  } else {
602
642
  throw new Error(Errors.INVALID_FORMAT);
@@ -672,126 +712,177 @@ export default class Zipkit {
672
712
  * Note: totalEntries must be passed as parameter since zipEntries[] in Zipkit is the cache
673
713
  */
674
714
  private centralEndHdrMethod(centralDirSize: number, centralDirOffset: number, totalEntries: number): Buffer {
675
- const ceBuf = Buffer.alloc(CENTRAL_END.SIZE);
676
- ceBuf.writeUInt32LE(CENTRAL_END.SIGNATURE, 0);
677
- ceBuf.writeUInt16LE(0, 4); // Number of this disk
678
- ceBuf.writeUInt16LE(0, 6); // Number of the disk with the start of the central directory
679
- ceBuf.writeUInt16LE(totalEntries, 8); // Total number of entries
680
- ceBuf.writeUInt16LE(totalEntries, 10); // Total number of entries on this disk
681
- ceBuf.writeUInt32LE(centralDirSize, 12); // Size of central directory
682
- ceBuf.writeUInt32LE(centralDirOffset, 16); // Offset of start of central directory
683
- ceBuf.writeUInt16LE(0, 20); // ZIP file comment length
684
-
685
- return ceBuf;
715
+ // Buffer path: allow entry-count Zip64; refuse size/offset Zip64 (multi-GiB in memory).
716
+ return buildEndRecords({
717
+ totalEntries,
718
+ centralDirSize,
719
+ centralDirOffset,
720
+ zip64EocdOffset: centralDirOffset + centralDirSize,
721
+ allowSizeOffsetZip64: false,
722
+ });
686
723
  }
687
724
 
688
725
  /**
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
726
+ * Calculate Merkle root of archive content (non-META-INF entries).
727
+ *
728
+ * Per NEOZIP_APPNOTE.md §6:
729
+ * - Default **v1**: domain-separated leaves SHA-256(0x00 ‖ uncompressed bytes) and
730
+ * SHA-256(0x01 ‖ left ‖ right) parents; paths NFC-normalized before sort.
731
+ * - Legacy **v0**: bare 0x014E digests as leaves, duplicate-odd pairing, path as stored.
732
+ *
733
+ * When only Extra Field digests are available (typical after open), **v1 requires
734
+ * payload bytes**. This sync method therefore returns null for v1; use
735
+ * {@link getMerkleRootAsync} (extracts content) or `{ algorithm: 'v0' }`.
736
+ * When you already hold payloads, use {@link merkleRootFromEntries}.
737
+ *
738
+ * @param options.algorithm - `v1` (default) or legacy `v0`
739
+ * @returns Lowercase hex Merkle root, or null if calculation fails
695
740
  */
696
- getMerkleRoot(): string | null {
697
- // Simply get directory - no loading logic
741
+ getMerkleRoot(options?: { algorithm?: MerkleAlgorithm }): string | null {
742
+ const algorithm: MerkleAlgorithm = options?.algorithm ?? 'v1';
743
+
698
744
  let zipEntries: ZipEntry[];
699
745
  try {
700
746
  zipEntries = this.getDirectory();
701
747
  } catch (error) {
702
- // Catch any errors from getDirectory() - this should never happen
703
- // but if it does, log it and return null
704
748
  Logger.error(`getMerkleRoot(): Error calling getDirectory(): ${error instanceof Error ? error.message : String(error)}`);
705
749
  Logger.error(`getMerkleRoot(): Stack trace: ${error instanceof Error ? error.stack : 'No stack trace'}`);
706
750
  return null;
707
751
  }
708
-
752
+
709
753
  if (!zipEntries || zipEntries.length === 0) {
710
754
  return null;
711
755
  }
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
- }
756
+
757
+ const contentEntries = zipEntries.filter((entry) => !isMetaInfPath(entry.filename || ''));
758
+
759
+ if (algorithm === 'v0') {
760
+ const digests = contentEntries
761
+ .filter((e) => e.sha256)
762
+ .map((e) => ({ path: e.filename || '', sha256: e.sha256 as string }));
763
+ return computeMerkleRootV0FromDigests(digests);
730
764
  }
731
-
732
- const merkleRoot = hashAccumulator.merkleRoot();
733
- if (!merkleRoot) {
734
- return null;
765
+
766
+ // v1: prefer leaves captured in the same create/extract hash stream (no re-hash)
767
+ if (
768
+ contentEntries.length > 0 &&
769
+ contentEntries.every((e) => !!e.merkleLeafV1)
770
+ ) {
771
+ return computeMerkleRootV1FromLeaves(
772
+ contentEntries.map((e) => ({
773
+ path: e.filename || '',
774
+ merkleLeafV1: e.merkleLeafV1 as string,
775
+ }))
776
+ );
735
777
  }
736
778
 
737
- return merkleRoot;
779
+ // v1: bare digests are not leaves — need getMerkleRootAsync() or stream leaves
780
+ return null;
738
781
  }
739
782
 
740
783
  /**
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
784
+ * Calculate Merkle root (preferred for v1 / new archives).
785
+ *
786
+ * **v1 (default):** uses precomputed stream leaves when available; otherwise extracts
787
+ * each content entry once and folds APPNOTE §6.3 root (no second content hash pass
788
+ * when extract captures merkleLeafV1).
789
+ * **v0:** uses Extra Field digests only (no extract), APPNOTE §6.2.
790
+ *
791
+ * META-INF/** is excluded. File-streaming mode: use ZipkitNode override (testEntry
792
+ * pipeline) rather than buffering the whole archive.
793
+ *
794
+ * @param options.algorithm - `v1` (default) or legacy `v0`
795
+ * @returns Promise resolving to lowercase hex root, or null
747
796
  */
748
- async getMerkleRootAsync(): Promise<string | null> {
797
+ async getMerkleRootAsync(options?: { algorithm?: MerkleAlgorithm }): Promise<string | null> {
798
+ const algorithm: MerkleAlgorithm = options?.algorithm ?? 'v1';
799
+
749
800
  let zipEntries: ZipEntry[] = [];
750
-
801
+
751
802
  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
- }
803
+ zipEntries = this.getDirectory();
761
804
  } catch (error) {
762
805
  Logger.error(`Failed to get directory for merkle root calculation: ${error instanceof Error ? error.message : String(error)}`);
763
806
  return null;
764
807
  }
765
-
808
+
766
809
  if (!zipEntries || zipEntries.length === 0) {
767
810
  return null;
768
811
  }
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
-
812
+
813
+ const contentEntries = zipEntries.filter((entry) => !isMetaInfPath(entry.filename || ''));
814
+
815
+ if (algorithm === 'v0') {
816
+ const digests = contentEntries
817
+ .filter((e) => e.sha256)
818
+ .map((e) => ({ path: e.filename || '', sha256: e.sha256 as string }));
819
+ return computeMerkleRootV0FromDigests(digests);
820
+ }
821
+
822
+ // Reuse leaves already produced during write/extract (one stream hash pass)
823
+ if (
824
+ contentEntries.length > 0 &&
825
+ contentEntries.every((e) => !!e.merkleLeafV1)
826
+ ) {
827
+ return computeMerkleRootV1FromLeaves(
828
+ contentEntries.map((e) => ({
829
+ path: e.filename || '',
830
+ merkleLeafV1: e.merkleLeafV1 as string,
831
+ }))
832
+ );
833
+ }
834
+
835
+ // Buffer path: one decompress + dual hash inside extract (ZipDecompress)
836
+ if (!this.inBuffer) {
837
+ Logger.error(
838
+ 'getMerkleRootAsync() called on file-based ZIP without precomputed merkle leaves. ' +
839
+ 'Use ZipkitNode (streaming testEntry) or ensure create captured merkleLeafV1.'
840
+ );
841
+ return null;
842
+ }
843
+
781
844
  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);
845
+ try {
846
+ const data = await this.extract(entry, false);
847
+ if (entry.merkleLeafV1) {
848
+ continue;
849
+ }
850
+ // Legacy decompress without dual leaf: hash payload once, cache leaf
851
+ if (data === null || data.length === 0) {
852
+ entry.merkleLeafV1 = leafHashV1(Buffer.alloc(0)).toString('hex');
853
+ } else {
854
+ entry.merkleLeafV1 = leafHashV1(data).toString('hex');
855
+ }
856
+ } catch (error) {
857
+ Logger.error(
858
+ `getMerkleRootAsync(): failed to extract ${entry.filename}: ${error instanceof Error ? error.message : String(error)}`
859
+ );
860
+ return null;
786
861
  }
787
862
  }
788
-
789
- const merkleRoot = hashAccumulator.merkleRoot();
790
- if (!merkleRoot) {
863
+
864
+ if (!contentEntries.every((e) => !!e.merkleLeafV1)) {
791
865
  return null;
792
866
  }
793
867
 
794
- return merkleRoot;
868
+ return computeMerkleRootV1FromLeaves(
869
+ contentEntries.map((e) => ({
870
+ path: e.filename || '',
871
+ merkleLeafV1: e.merkleLeafV1 as string,
872
+ }))
873
+ );
874
+ }
875
+
876
+ /**
877
+ * Compute Merkle root from known digests and/or payloads (APPNOTE §6).
878
+ * Prefer this during create when uncompressed bytes are still in hand, or when
879
+ * stream leaves (`merkleLeafV1`) were already produced.
880
+ */
881
+ static merkleRootFromEntries(
882
+ entries: Array<{ path: string; content?: Buffer; contentSha256?: string; merkleLeafV1?: string }>,
883
+ algorithm: MerkleAlgorithm = 'v1'
884
+ ): string | null {
885
+ return computeArchiveMerkleRoot(entries, algorithm);
795
886
  }
796
887
 
797
888
  }
@@ -45,6 +45,8 @@ export class HashCalculator {
45
45
  // Incremental hash calculation state (from StreamHashCalculator)
46
46
  private crc32State: number = ~0;
47
47
  private sha256Hash: crypto.Hash | null = null;
48
+ /** Domain-separated leaf hasher (APPNOTE §6.3): SHA-256(0x00 ‖ payload). */
49
+ private merkleLeafHash: crypto.Hash | null = null;
48
50
  private useSHA256: boolean = false;
49
51
 
50
52
  // Hash accumulation state (from HashAccumulator)
@@ -52,13 +54,15 @@ export class HashCalculator {
52
54
  private xorResult: Buffer = Buffer.alloc(32, 0);
53
55
  private enableAccumulation: boolean = false;
54
56
 
55
- // Merkle tree state (from MerkleTree)
57
+ // Merkle tree state (from MerkleTree) — generic digest folding; archive roots use merkle/MerkleRoot.ts
56
58
  private merkleLeaves: Buffer[] = [];
57
59
  private merkleLayers: Buffer[][] = [];
58
60
  private merkleOptions: MerkleTreeOptions = {
59
61
  hashLeaves: false,
60
- sortLeaves: true,
61
- sortPairs: true,
62
+ // Archive content roots sort by path in MerkleRoot; generic accumulator keeps insertion order.
63
+ sortLeaves: false,
64
+ sortPairs: false,
65
+ // match APPNOTE v0 odd-leaf rule when used as a generic Bitcoin-style tree
62
66
  duplicateOdd: true
63
67
  };
64
68
 
@@ -77,6 +81,8 @@ export class HashCalculator {
77
81
 
78
82
  if (this.useSHA256) {
79
83
  this.sha256Hash = crypto.createHash('sha256');
84
+ this.merkleLeafHash = crypto.createHash('sha256');
85
+ this.merkleLeafHash.update(Buffer.from([0x00]));
80
86
  }
81
87
  }
82
88
 
@@ -95,10 +101,13 @@ export class HashCalculator {
95
101
  this.crc32State = crc32update(this.crc32State, chunk[i]);
96
102
  }
97
103
 
98
- // Update SHA-256 incrementally
104
+ // Update SHA-256 and v1 Merkle leaf hash incrementally
99
105
  if (this.sha256Hash) {
100
106
  this.sha256Hash.update(chunk);
101
107
  }
108
+ if (this.merkleLeafHash) {
109
+ this.merkleLeafHash.update(chunk);
110
+ }
102
111
  }
103
112
 
104
113
  /**
@@ -110,7 +119,7 @@ export class HashCalculator {
110
119
  }
111
120
 
112
121
  /**
113
- * Get final SHA-256 hash as hex string
122
+ * Get final SHA-256 hash as hex string (Extra Field 0x014E — bare content digest)
114
123
  * @returns SHA-256 hash as hex string, or null if SHA-256 not enabled
115
124
  */
116
125
  finalizeSHA256(): string | null {
@@ -120,13 +129,55 @@ export class HashCalculator {
120
129
  return null;
121
130
  }
122
131
 
132
+ /**
133
+ * Get APPNOTE §6.3 v1 Merkle leaf: SHA-256(0x00 ‖ uncompressed payload) as hex.
134
+ * Consumes the leaf hasher; call after or instead of finalizeSHA256 for dual streams.
135
+ */
136
+ finalizeMerkleLeafV1(): string | null {
137
+ if (this.merkleLeafHash) {
138
+ return this.merkleLeafHash.digest('hex');
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Apply stream digests to a ZIP entry in one place.
145
+ *
146
+ * When SHA-256 was enabled, both Extra Field 0x014E (`sha256`) and the v1 Merkle leaf
147
+ * (`merkleLeafV1`) are finalized from the **same** chunk stream — no second payload pass.
148
+ */
149
+ applyToEntry(
150
+ entry: {
151
+ crc: number;
152
+ sha256: string | null;
153
+ merkleLeafV1?: string | null;
154
+ },
155
+ options: { setCrc?: boolean; setSha256?: boolean }
156
+ ): void {
157
+ if (options.setCrc) {
158
+ entry.crc = this.finalizeCRC32();
159
+ }
160
+ if (options.setSha256) {
161
+ const digest = this.finalizeSHA256();
162
+ const leaf = this.finalizeMerkleLeafV1();
163
+ if (digest) {
164
+ entry.sha256 = digest;
165
+ }
166
+ if (leaf) {
167
+ entry.merkleLeafV1 = leaf;
168
+ }
169
+ }
170
+ }
171
+
123
172
  /**
124
173
  * Reset the incremental hash calculation state
125
174
  */
126
175
  reset(): void {
127
176
  this.crc32State = ~0;
128
- if (this.sha256Hash) {
177
+ if (this.useSHA256) {
129
178
  this.sha256Hash = crypto.createHash('sha256');
179
+ this.merkleLeafHash = crypto.createHash('sha256');
180
+ this.merkleLeafHash.update(Buffer.from([0x00]));
130
181
  }
131
182
  }
132
183
 
@@ -264,6 +315,7 @@ export class HashCalculator {
264
315
  if (this.merkleOptions.sortPairs && Buffer.compare(left, right) > 0) {
265
316
  [left, right] = [right, left];
266
317
  }
318
+ // Generic accumulator tree (not APPNOTE archive root — see merkle/MerkleRoot.ts)
267
319
  return this.hash(Buffer.concat([left, right]));
268
320
  }
269
321
 
@@ -6,7 +6,19 @@
6
6
 
7
7
  import Support from "./Support";
8
8
  import Errors from "../constants/Errors";
9
- import { TIMESTAMP_SUBMITTED, TIMESTAMP_METADATA, TOKENIZED_METADATA } from "../constants/Headers";
9
+ import {
10
+ isMetaInfPath,
11
+ findReservedMetaEntry,
12
+ isReservedMetaPath,
13
+ asciiPathEqualsIgnoreCase,
14
+ } from "../constants/MetaPaths";
15
+
16
+ export {
17
+ findReservedMetaEntry,
18
+ isReservedMetaPath,
19
+ isMetaInfPath,
20
+ asciiPathEqualsIgnoreCase,
21
+ } from "../constants/MetaPaths";
10
22
 
11
23
  export const DATATYPE = {
12
24
  STRING: "string",
@@ -80,12 +92,9 @@ export function getTypeOf(input: string|Buffer|Blob|Array<any>|ArrayBuffer|Uint8
80
92
  };
81
93
 
82
94
  /**
83
- * Check if a filename is a metadata file
84
- * @param filename The filename to check
85
- * @returns True if the file is a metadata file (META-INF)
95
+ * Check if a filename is NeoZip metadata (any META-INF/** path).
96
+ * Case-insensitive on the META-INF/ prefix (NEOZIP_APPNOTE.md §2.3 / §6.2).
86
97
  */
87
98
  export const isMetadataFile = (filename: string): boolean => {
88
- return filename === TIMESTAMP_SUBMITTED ||
89
- filename === TIMESTAMP_METADATA ||
90
- filename === TOKENIZED_METADATA;
99
+ return isMetaInfPath(filename);
91
100
  };
@@ -11,9 +11,19 @@ export const ZIP64_CENTRAL_DIRECTORY_LOCATOR = 'PK\x06\x07';
11
11
  export const ZIP64_CENTRAL_DIRECTORY_END = 'PK\x06\x06';
12
12
  export const DATA_DESCRIPTOR = 'PK\x07\x08';
13
13
 
14
- export const TIMESTAMP_SUBMITTED = 'META-INF/TS-SUBMIT.OTS';
15
- export const TIMESTAMP_METADATA = 'META-INF/TIMESTAMP.OTS';
16
- export const TOKENIZED_METADATA = 'META-INF/NZIP.TOKEN';
14
+ // Reserved META-INF paths — see constants/MetaPaths.ts and NEOZIP_APPNOTE.md §2
15
+ export {
16
+ META_TOKEN_NZIP,
17
+ META_TOKEN_NZIP_LEGACY,
18
+ META_TS_SUBMIT_NZIP,
19
+ META_TIMESTAMP_NZIP,
20
+ META_TS_SUBMIT_OTS,
21
+ META_TIMESTAMP_OTS,
22
+ META_MANIFEST_JSON,
23
+ TIMESTAMP_SUBMITTED,
24
+ TIMESTAMP_METADATA,
25
+ TOKENIZED_METADATA,
26
+ } from './MetaPaths';
17
27
 
18
28
  // Local file header
19
29
  export const LOCAL_HDR = {
@@ -176,7 +186,8 @@ export const HDR_ID = {
176
186
  x509PKCS7RL: 0x0019, // X.509 Certificate Revocation List (PKCS#7)
177
187
  IBM1: 0x0065, // IBM S/390 (Z390) - Attribute
178
188
  IBM2: 0x0066, // IBM S/390 (Z390) - Attribute
179
- SHA256: 0x014E, // SHA256 "N\01" with 64 bytes (256 bit) of SHA256 hash data
189
+ SHA256: 0x014E, // NeoZip Extra Field: 32-byte SHA-256 of uncompressed payload (APPNOTE §5)
190
+ ORIGIN: 0x014F, // ZipWiki origin locator (URI, size, mtime, CRC-32 or SHA-256)
180
191
  NEO_CRYPTO: 0x024E, // NeoEncrypt metadata (see docs/NEO_CRYPTO_FORMAT.md); LE on wire: 4E 02
181
192
  POSZIP: 0x4690, // POSZIP 4690
182
193
  UNV_TIME: 0x5455, // Universal Time (UT) (32-bit) 5 data bytes
@@ -0,0 +1,86 @@
1
+ /**
2
+ * NeoZip reserved META-INF paths and ASCII case-insensitive discovery.
3
+ * Matches NEOZIP_APPNOTE.md §2.3: writers emit canonical spellings; readers fold case.
4
+ */
5
+
6
+ /** Canonical writers MUST emit these spellings. */
7
+ export const META_TOKEN_NZIP = 'META-INF/TOKEN.NZIP';
8
+ export const META_TOKEN_NZIP_LEGACY = 'META-INF/NZIP.TOKEN';
9
+ export const META_TS_SUBMIT_NZIP = 'META-INF/TS-SUBMIT.NZIP';
10
+ export const META_TIMESTAMP_NZIP = 'META-INF/TIMESTAMP.NZIP';
11
+ export const META_TS_SUBMIT_OTS = 'META-INF/TS-SUBMIT.OTS';
12
+ export const META_TIMESTAMP_OTS = 'META-INF/TIMESTAMP.OTS';
13
+ export const META_MANIFEST_JSON = 'META-INF/manifest.json';
14
+
15
+ /** @deprecated Prefer META_TS_SUBMIT_OTS — kept for existing imports. */
16
+ export const TIMESTAMP_SUBMITTED = META_TS_SUBMIT_OTS;
17
+ /** @deprecated Prefer META_TIMESTAMP_OTS — kept for existing imports. */
18
+ export const TIMESTAMP_METADATA = META_TIMESTAMP_OTS;
19
+ /** @deprecated Prefer META_TOKEN_NZIP — kept for existing imports (canonical TOKEN.NZIP). */
20
+ export const TOKENIZED_METADATA = META_TOKEN_NZIP;
21
+
22
+ const RESERVED_CANONICAL = [
23
+ META_TOKEN_NZIP,
24
+ META_TOKEN_NZIP_LEGACY,
25
+ META_TS_SUBMIT_NZIP,
26
+ META_TIMESTAMP_NZIP,
27
+ META_TS_SUBMIT_OTS,
28
+ META_TIMESTAMP_OTS,
29
+ META_MANIFEST_JSON,
30
+ ] as const;
31
+
32
+ export type ZipEntryLikeFilename = { filename?: string };
33
+
34
+ /** ASCII case-insensitive path compare (APPNOTE §2.3). */
35
+ export function asciiPathEqualsIgnoreCase(a: string, b: string): boolean {
36
+ if (a.length !== b.length) {
37
+ // Still allow length mismatch only via toLowerCase for UTF-8 safety on ASCII paths
38
+ }
39
+ return a.toLowerCase() === b.toLowerCase();
40
+ }
41
+
42
+ /** True when path is under META-INF/ (ASCII case-insensitive). */
43
+ export function isMetaInfPath(filename: string): boolean {
44
+ return filename.toLowerCase().startsWith('meta-inf/');
45
+ }
46
+
47
+ /** True when filename is a reserved NeoZip meta path (any casing). */
48
+ export function isReservedMetaPath(filename: string): boolean {
49
+ const lower = filename.toLowerCase();
50
+ return RESERVED_CANONICAL.some((c) => c.toLowerCase() === lower);
51
+ }
52
+
53
+ /**
54
+ * True for any META-INF/** entry (integrity merkle leaves exclude these per APPNOTE §6.2).
55
+ */
56
+ export function isMetadataFile(filename: string): boolean {
57
+ return isMetaInfPath(filename);
58
+ }
59
+
60
+ /**
61
+ * Find a reserved meta entry among ZIP directory entries.
62
+ * Prefers an exact canonical match, then ASCII case-insensitive match.
63
+ * When multiple preferred names are given, earlier names win.
64
+ */
65
+ export function findReservedMetaEntry<T extends ZipEntryLikeFilename>(
66
+ entries: T[],
67
+ preferredCanonicalNames: string | readonly string[]
68
+ ): T | null {
69
+ const preferred = Array.isArray(preferredCanonicalNames)
70
+ ? preferredCanonicalNames
71
+ : [preferredCanonicalNames];
72
+
73
+ for (const name of preferred) {
74
+ const exact = entries.find((e) => e.filename === name);
75
+ if (exact) return exact;
76
+ }
77
+
78
+ for (const name of preferred) {
79
+ const folded = entries.find(
80
+ (e) => typeof e.filename === 'string' && asciiPathEqualsIgnoreCase(e.filename, name)
81
+ );
82
+ if (folded) return folded;
83
+ }
84
+
85
+ return null;
86
+ }