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.
- package/NEOZIP_APPNOTE.md +625 -0
- package/README.md +44 -11
- package/WHATS_NEW.md +153 -10
- 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 +17 -9
- package/dist/browser/zstd-browser-shim.d.ts.map +1 -1
- package/dist/browser/zstd-browser-shim.js +99 -21
- package/dist/browser/zstd-browser-shim.js.map +1 -1
- package/dist/browser-esm/index.js +6534 -1275
- package/dist/browser-esm/index.js.map +4 -4
- package/dist/browser-umd/index.js +6534 -1275
- package/dist/browser-umd/index.js.map +4 -4
- package/dist/browser-umd/index.min.js +6 -3
- package/dist/core/ZipCompress.d.ts +6 -0
- package/dist/core/ZipCompress.d.ts.map +1 -1
- package/dist/core/ZipCompress.js +12 -11
- package/dist/core/ZipCompress.js.map +1 -1
- package/dist/core/ZipCopy.d.ts.map +1 -1
- package/dist/core/ZipCopy.js +33 -25
- package/dist/core/ZipCopy.js.map +1 -1
- package/dist/core/ZipDecompress.d.ts.map +1 -1
- package/dist/core/ZipDecompress.js +13 -8
- package/dist/core/ZipDecompress.js.map +1 -1
- package/dist/core/ZipEntry.d.ts +24 -0
- package/dist/core/ZipEntry.d.ts.map +1 -1
- package/dist/core/ZipEntry.js +146 -14
- 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 +132 -82
- 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 +2 -3
- package/dist/core/constants/Headers.d.ts.map +1 -1
- package/dist/core/constants/Headers.js +15 -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/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +18 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/inflateZipPayload.d.ts +6 -0
- package/dist/core/inflateZipPayload.d.ts.map +1 -0
- package/dist/core/inflateZipPayload.js +28 -0
- package/dist/core/inflateZipPayload.js.map +1 -0
- 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/origin/OriginExtra.d.ts +43 -0
- package/dist/core/origin/OriginExtra.d.ts.map +1 -0
- package/dist/core/origin/OriginExtra.js +187 -0
- package/dist/core/origin/OriginExtra.js.map +1 -0
- package/dist/core/version.js +1 -1
- package/dist/core/zip64/Zip64.d.ts +138 -0
- package/dist/core/zip64/Zip64.d.ts.map +1 -0
- package/dist/core/zip64/Zip64.js +353 -0
- package/dist/core/zip64/Zip64.js.map +1 -0
- package/dist/node/ZipCompressNode.d.ts.map +1 -1
- package/dist/node/ZipCompressNode.js +40 -49
- package/dist/node/ZipCompressNode.js.map +1 -1
- package/dist/node/ZipCopyNode.d.ts.map +1 -1
- package/dist/node/ZipCopyNode.js +26 -26
- package/dist/node/ZipCopyNode.js.map +1 -1
- package/dist/node/ZipDecompressNode.d.ts.map +1 -1
- package/dist/node/ZipDecompressNode.js +21 -3
- package/dist/node/ZipDecompressNode.js.map +1 -1
- package/dist/node/ZipkitNode.d.ts +23 -1
- package/dist/node/ZipkitNode.d.ts.map +1 -1
- package/dist/node/ZipkitNode.js +158 -57
- package/dist/node/ZipkitNode.js.map +1 -1
- package/dist/node/ZstdNode.d.ts +14 -4
- package/dist/node/ZstdNode.d.ts.map +1 -1
- package/dist/node/ZstdNode.js +28 -4
- package/dist/node/ZstdNode.js.map +1 -1
- package/dist/node/buildZipBuffer.d.ts +49 -0
- package/dist/node/buildZipBuffer.d.ts.map +1 -0
- package/dist/node/buildZipBuffer.js +146 -0
- package/dist/node/buildZipBuffer.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 +6 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/inflateZipPayload.d.ts +5 -0
- package/dist/node/inflateZipPayload.d.ts.map +1 -0
- package/dist/node/inflateZipPayload.js +21 -0
- package/dist/node/inflateZipPayload.js.map +1 -0
- package/dist/types/index.js +2 -2
- package/node-esm.mjs +32 -0
- package/package.json +13 -6
- package/src/browser/ZipkitBrowser.ts +4 -6
- package/src/browser/zstd-browser-shim.ts +110 -28
- package/src/core/ZipCompress.ts +20 -13
- package/src/core/ZipCopy.ts +56 -27
- package/src/core/ZipDecompress.ts +14 -8
- package/src/core/ZipEntry.ts +200 -14
- package/src/core/Zipkit.ts +189 -98
- package/src/core/components/HashCalculator.ts +58 -6
- package/src/core/components/Util.ts +16 -7
- package/src/core/constants/Headers.ts +15 -4
- package/src/core/constants/MetaPaths.ts +86 -0
- package/src/core/index.ts +17 -0
- package/src/core/inflateZipPayload.ts +27 -0
- package/src/core/merkle/MerkleRoot.ts +312 -0
- package/src/core/origin/OriginExtra.ts +209 -0
- package/src/core/version.ts +1 -1
- package/src/core/zip64/Zip64.ts +473 -0
- package/src/node/ZipCompressNode.ts +40 -49
- package/src/node/ZipCopyNode.ts +31 -29
- package/src/node/ZipDecompressNode.ts +16 -3
- package/src/node/ZipkitNode.ts +203 -72
- package/src/node/ZstdNode.ts +33 -8
- package/src/node/buildZipBuffer.ts +221 -0
- package/src/node/index.ts +7 -0
- package/src/node/inflateZipPayload.ts +19 -0
- package/src/types/index.ts +2 -2
package/src/core/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './ZipEntry';
|
|
|
10
10
|
export { ZipCompress } from './ZipCompress';
|
|
11
11
|
export type { CompressOptions, CreateZipOptions } from './ZipCompress';
|
|
12
12
|
export type { DecompressionResult, DecompressionOptions } from './ZipDecompress';
|
|
13
|
+
export { inflateZipPayload } from './inflateZipPayload';
|
|
13
14
|
export { ZipCopy } from './ZipCopy';
|
|
14
15
|
export type {
|
|
15
16
|
CopyOptions,
|
|
@@ -24,6 +25,9 @@ export { default as HashCalculator, HashCalculator as HashCalculatorClass } from
|
|
|
24
25
|
export * from './components/Util';
|
|
25
26
|
export * from './components/Support';
|
|
26
27
|
export { ProgressTracker } from './components/ProgressTracker';
|
|
28
|
+
export * from './merkle/MerkleRoot';
|
|
29
|
+
export * from './zip64/Zip64';
|
|
30
|
+
export * from './origin/OriginExtra';
|
|
27
31
|
|
|
28
32
|
// Encryption functionality
|
|
29
33
|
export { EncryptionManager } from './encryption/Manager';
|
|
@@ -33,6 +37,19 @@ export type { EncryptionMethod, EncryptionOptions, EncryptionResult, DecryptionR
|
|
|
33
37
|
// Types and constants
|
|
34
38
|
export * from '../types';
|
|
35
39
|
export * from './constants/Headers';
|
|
40
|
+
export {
|
|
41
|
+
META_TOKEN_NZIP,
|
|
42
|
+
META_TOKEN_NZIP_LEGACY,
|
|
43
|
+
META_TS_SUBMIT_NZIP,
|
|
44
|
+
META_TIMESTAMP_NZIP,
|
|
45
|
+
META_TS_SUBMIT_OTS,
|
|
46
|
+
META_TIMESTAMP_OTS,
|
|
47
|
+
META_MANIFEST_JSON,
|
|
48
|
+
asciiPathEqualsIgnoreCase,
|
|
49
|
+
isMetaInfPath,
|
|
50
|
+
isReservedMetaPath,
|
|
51
|
+
findReservedMetaEntry,
|
|
52
|
+
} from './constants/MetaPaths';
|
|
36
53
|
export * from './constants/Errors';
|
|
37
54
|
export * from './version';
|
|
38
55
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inflate one ZIP member payload (store, deflate, zstd) from an in-memory buffer.
|
|
3
|
+
* Browser bundles alias ZstdNode to the CompressionStream codec.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import pako from 'pako';
|
|
7
|
+
import { decompress } from '../node/ZstdNode';
|
|
8
|
+
|
|
9
|
+
export async function inflateZipPayload(
|
|
10
|
+
method: number,
|
|
11
|
+
compressed: Uint8Array | ArrayBuffer,
|
|
12
|
+
): Promise<Uint8Array> {
|
|
13
|
+
const bytes =
|
|
14
|
+
compressed instanceof ArrayBuffer ? new Uint8Array(compressed) : compressed;
|
|
15
|
+
if (method === 0) {
|
|
16
|
+
return bytes.slice();
|
|
17
|
+
}
|
|
18
|
+
if (method === 8) {
|
|
19
|
+
const out = pako.inflateRaw(bytes);
|
|
20
|
+
return out instanceof Uint8Array ? out : new Uint8Array(out);
|
|
21
|
+
}
|
|
22
|
+
if (method === 93) {
|
|
23
|
+
const out = await decompress(bytes);
|
|
24
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`unsupported compression method ${method}`);
|
|
27
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// ======================================
|
|
2
|
+
// OriginExtra.ts — NeoZip Extra Field 0x014F (original locator)
|
|
3
|
+
// Copyright (c) 2026 NeoWare, Inc. All rights reserved.
|
|
4
|
+
// ======================================
|
|
5
|
+
// APPNOTE §7.1.1 / ZipWiki §6.7 — v1 TLV (URI, CRC-32, size, mtime, SHA-256).
|
|
6
|
+
// The kit stores opaque caller extras on ZipEntry.additionalExtra; these helpers
|
|
7
|
+
// build and parse the 0x014F wire form.
|
|
8
|
+
|
|
9
|
+
import { HDR_ID } from '../constants/Headers';
|
|
10
|
+
import { crc32, sha256 } from '../encryption/ZipCrypto';
|
|
11
|
+
|
|
12
|
+
/** Payload version byte. Stays 0x01 until first release. */
|
|
13
|
+
export const ORIGIN_EXTRA_VERSION = 0x01;
|
|
14
|
+
|
|
15
|
+
/** Soft cap on URI byte length (well under uint16 extra size). */
|
|
16
|
+
export const ORIGIN_URI_MAX_BYTES = 2048;
|
|
17
|
+
|
|
18
|
+
/** Registered v1 TLV tags. Unknown tags are skipped (forward compatible). */
|
|
19
|
+
export const ORIGIN_TAG = {
|
|
20
|
+
URI: 0x01,
|
|
21
|
+
CRC32: 0x02,
|
|
22
|
+
SIZE: 0x03,
|
|
23
|
+
MTIME: 0x04,
|
|
24
|
+
SHA256: 0x05,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type OriginLocator = {
|
|
28
|
+
/** Absolute RFC 3986 URI (`https:` / `http:` / `file:`). */
|
|
29
|
+
uri?: string;
|
|
30
|
+
/** ZIP/IEEE CRC-32 of original uncompressed bytes. */
|
|
31
|
+
crc32?: number;
|
|
32
|
+
/** Original uncompressed byte length. */
|
|
33
|
+
size?: number;
|
|
34
|
+
/** Original modification time: Unix seconds (UTC, signed). */
|
|
35
|
+
mtime?: number;
|
|
36
|
+
/** SHA-256 of original uncompressed bytes (32 bytes or hex). */
|
|
37
|
+
sha256?: Buffer | string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function originSha256Bytes(value: Buffer | string): Buffer {
|
|
41
|
+
const buf = typeof value === 'string' ? Buffer.from(value, 'hex') : value;
|
|
42
|
+
if (buf.length !== 32) {
|
|
43
|
+
throw new Error('0x014F sha256 must be 32 bytes');
|
|
44
|
+
}
|
|
45
|
+
return buf;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tlv(tag: number, value: Buffer): Buffer {
|
|
49
|
+
const rec = Buffer.alloc(3 + value.length);
|
|
50
|
+
rec.writeUInt8(tag, 0);
|
|
51
|
+
rec.writeUInt16LE(value.length, 1);
|
|
52
|
+
value.copy(rec, 3);
|
|
53
|
+
return rec;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertInteger(name: string, value: number): void {
|
|
57
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
58
|
+
throw new Error(`0x014F ${name} must be an integer`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function originLocatorPresent(
|
|
63
|
+
loc: OriginLocator | null | undefined
|
|
64
|
+
): loc is OriginLocator {
|
|
65
|
+
if (!loc) return false;
|
|
66
|
+
return (
|
|
67
|
+
(typeof loc.uri === 'string' && loc.uri.length > 0) ||
|
|
68
|
+
loc.crc32 !== undefined ||
|
|
69
|
+
loc.size !== undefined ||
|
|
70
|
+
loc.mtime !== undefined ||
|
|
71
|
+
loc.sha256 !== undefined
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** ZIP/IEEE CRC-32 of uncompressed original bytes. */
|
|
76
|
+
export function originCrc32Of(buf: Buffer): number {
|
|
77
|
+
return crc32(buf) >>> 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** SHA-256 of uncompressed original bytes (32-byte Buffer). */
|
|
81
|
+
export function originSha256Of(buf: Buffer): Buffer {
|
|
82
|
+
return Buffer.from(sha256(buf), 'hex');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Truncate a Date to Unix seconds (UTC). */
|
|
86
|
+
export function unixTimeSeconds(date: Date): number {
|
|
87
|
+
return Math.floor(date.getTime() / 1000);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build Extra Field 0x014F (v1 TLV). Throws if no attribute is present,
|
|
92
|
+
* if URI is empty when provided, or if URI exceeds max bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function makeOriginExtra(loc: OriginLocator): Buffer {
|
|
95
|
+
const records: Buffer[] = [];
|
|
96
|
+
|
|
97
|
+
const uri = loc.uri?.trim();
|
|
98
|
+
if (uri !== undefined && uri.length === 0) {
|
|
99
|
+
throw new Error('0x014F URI must be non-empty when present');
|
|
100
|
+
}
|
|
101
|
+
if (uri) {
|
|
102
|
+
const uriBuf = Buffer.from(uri, 'utf8');
|
|
103
|
+
if (uriBuf.length > ORIGIN_URI_MAX_BYTES) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`0x014F URI exceeds ${ORIGIN_URI_MAX_BYTES} bytes (${uriBuf.length})`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
records.push(tlv(ORIGIN_TAG.URI, uriBuf));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (loc.crc32 !== undefined) {
|
|
112
|
+
assertInteger('crc32', loc.crc32);
|
|
113
|
+
const b = Buffer.alloc(4);
|
|
114
|
+
b.writeUInt32LE(loc.crc32 >>> 0, 0);
|
|
115
|
+
records.push(tlv(ORIGIN_TAG.CRC32, b));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (loc.size !== undefined) {
|
|
119
|
+
assertInteger('size', loc.size);
|
|
120
|
+
if (loc.size < 0) {
|
|
121
|
+
throw new Error('0x014F size must be >= 0');
|
|
122
|
+
}
|
|
123
|
+
const b = Buffer.alloc(8);
|
|
124
|
+
b.writeBigUInt64LE(BigInt(loc.size), 0);
|
|
125
|
+
records.push(tlv(ORIGIN_TAG.SIZE, b));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (loc.mtime !== undefined) {
|
|
129
|
+
assertInteger('mtime', loc.mtime);
|
|
130
|
+
const b = Buffer.alloc(8);
|
|
131
|
+
b.writeBigInt64LE(BigInt(loc.mtime), 0);
|
|
132
|
+
records.push(tlv(ORIGIN_TAG.MTIME, b));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (loc.sha256 !== undefined) {
|
|
136
|
+
records.push(tlv(ORIGIN_TAG.SHA256, originSha256Bytes(loc.sha256)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (records.length === 0) {
|
|
140
|
+
throw new Error('0x014F requires at least one attribute');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const dataSize = 1 + records.reduce((n, r) => n + r.length, 0);
|
|
144
|
+
const extra = Buffer.alloc(4 + dataSize);
|
|
145
|
+
extra.writeUInt16LE(HDR_ID.ORIGIN, 0);
|
|
146
|
+
extra.writeUInt16LE(dataSize, 2);
|
|
147
|
+
extra.writeUInt8(ORIGIN_EXTRA_VERSION, 4);
|
|
148
|
+
let off = 5;
|
|
149
|
+
for (const rec of records) {
|
|
150
|
+
rec.copy(extra, off);
|
|
151
|
+
off += rec.length;
|
|
152
|
+
}
|
|
153
|
+
return extra;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseOriginTlv(data: Buffer): OriginLocator | null {
|
|
157
|
+
const loc: OriginLocator = {};
|
|
158
|
+
let i = 0;
|
|
159
|
+
while (i + 3 <= data.length) {
|
|
160
|
+
const tag = data.readUInt8(i);
|
|
161
|
+
const len = data.readUInt16LE(i + 1);
|
|
162
|
+
i += 3;
|
|
163
|
+
if (i + len > data.length) break;
|
|
164
|
+
const value = data.subarray(i, i + len);
|
|
165
|
+
i += len;
|
|
166
|
+
if (tag === ORIGIN_TAG.URI && len > 0) {
|
|
167
|
+
const u = value.toString('utf8');
|
|
168
|
+
if (u.length > 0) loc.uri = u;
|
|
169
|
+
} else if (tag === ORIGIN_TAG.CRC32 && len === 4) {
|
|
170
|
+
loc.crc32 = value.readUInt32LE(0) >>> 0;
|
|
171
|
+
} else if (tag === ORIGIN_TAG.SIZE && len === 8) {
|
|
172
|
+
loc.size = Number(value.readBigUInt64LE(0));
|
|
173
|
+
} else if (tag === ORIGIN_TAG.MTIME && len === 8) {
|
|
174
|
+
loc.mtime = Number(value.readBigInt64LE(0));
|
|
175
|
+
} else if (tag === ORIGIN_TAG.SHA256 && len === 32) {
|
|
176
|
+
loc.sha256 = Buffer.from(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return originLocatorPresent(loc) ? loc : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Walk a ZIP Extra Field blob for NeoZip 0x014F (v1 TLV). */
|
|
183
|
+
export function parseOriginFromExtra(extra: Buffer | null | undefined): OriginLocator | null {
|
|
184
|
+
if (!extra || extra.length < 4) return null;
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (i + 4 <= extra.length) {
|
|
187
|
+
const id = extra.readUInt16LE(i);
|
|
188
|
+
const size = extra.readUInt16LE(i + 2);
|
|
189
|
+
i += 4;
|
|
190
|
+
if (i + size > extra.length) break;
|
|
191
|
+
if (id === HDR_ID.ORIGIN && size >= 1) {
|
|
192
|
+
const version = extra.readUInt8(i);
|
|
193
|
+
const payload = extra.subarray(i + 1, i + size);
|
|
194
|
+
if (version === ORIGIN_EXTRA_VERSION) {
|
|
195
|
+
const loc = parseOriginTlv(payload);
|
|
196
|
+
if (loc) return loc;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
i += size;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Read origin locator from a ZipEntry's preserved caller extras. */
|
|
205
|
+
export function parseOriginFromEntry(entry: {
|
|
206
|
+
additionalExtra?: Buffer | null;
|
|
207
|
+
}): OriginLocator | null {
|
|
208
|
+
return parseOriginFromExtra(entry.additionalExtra ?? null);
|
|
209
|
+
}
|
package/src/core/version.ts
CHANGED
|
@@ -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
|
|
31
|
+
number: '1.5.0', // packageJson.version
|
|
32
32
|
date: releaseDate
|
|
33
33
|
};
|