neozipkit 0.8.0 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NEOZIP_APPNOTE.md +527 -0
- package/README.md +57 -21
- package/WHATS_NEW.md +146 -0
- package/dist/browser/ZipkitBrowser.d.ts.map +1 -1
- package/dist/browser/ZipkitBrowser.js +4 -6
- package/dist/browser/ZipkitBrowser.js.map +1 -1
- package/dist/browser/zstd-browser-shim.d.ts +28 -0
- package/dist/browser/zstd-browser-shim.d.ts.map +1 -0
- package/dist/browser/zstd-browser-shim.js +66 -0
- package/dist/browser/zstd-browser-shim.js.map +1 -0
- package/dist/browser-esm/index.js +2110 -41755
- package/dist/browser-esm/index.js.map +4 -4
- package/dist/browser-umd/index.js +2110 -41755
- package/dist/browser-umd/index.js.map +4 -4
- package/dist/browser-umd/index.min.js +3 -21
- package/dist/core/ZipCompress.d.ts +2 -7
- package/dist/core/ZipCompress.d.ts.map +1 -1
- package/dist/core/ZipCompress.js +57 -33
- package/dist/core/ZipCompress.js.map +1 -1
- package/dist/core/ZipDecompress.d.ts +1 -3
- package/dist/core/ZipDecompress.d.ts.map +1 -1
- package/dist/core/ZipDecompress.js +27 -17
- package/dist/core/ZipDecompress.js.map +1 -1
- package/dist/core/ZipEntry.d.ts +6 -0
- package/dist/core/ZipEntry.d.ts.map +1 -1
- package/dist/core/ZipEntry.js +7 -1
- package/dist/core/ZipEntry.js.map +1 -1
- package/dist/core/Zipkit.d.ts +43 -11
- package/dist/core/Zipkit.d.ts.map +1 -1
- package/dist/core/Zipkit.js +106 -63
- package/dist/core/Zipkit.js.map +1 -1
- package/dist/core/components/HashCalculator.d.ts +22 -1
- package/dist/core/components/HashCalculator.d.ts.map +1 -1
- package/dist/core/components/HashCalculator.js +49 -6
- package/dist/core/components/HashCalculator.js.map +1 -1
- package/dist/core/components/Util.d.ts +3 -4
- package/dist/core/components/Util.d.ts.map +1 -1
- package/dist/core/components/Util.js +10 -8
- package/dist/core/components/Util.js.map +1 -1
- package/dist/core/constants/Headers.d.ts +1 -3
- package/dist/core/constants/Headers.d.ts.map +1 -1
- package/dist/core/constants/Headers.js +14 -5
- package/dist/core/constants/Headers.js.map +1 -1
- package/dist/core/constants/MetaPaths.d.ts +38 -0
- package/dist/core/constants/MetaPaths.d.ts.map +1 -0
- package/dist/core/constants/MetaPaths.js +79 -0
- package/dist/core/constants/MetaPaths.js.map +1 -0
- package/dist/core/encryption/AesCrypto.d.ts +55 -11
- package/dist/core/encryption/AesCrypto.d.ts.map +1 -1
- package/dist/core/encryption/AesCrypto.js +223 -68
- package/dist/core/encryption/AesCrypto.js.map +1 -1
- package/dist/core/encryption/index.d.ts +1 -1
- package/dist/core/encryption/index.d.ts.map +1 -1
- package/dist/core/encryption/index.js +3 -1
- package/dist/core/encryption/index.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +14 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/merkle/MerkleRoot.d.ts +109 -0
- package/dist/core/merkle/MerkleRoot.d.ts.map +1 -0
- package/dist/core/merkle/MerkleRoot.js +275 -0
- package/dist/core/merkle/MerkleRoot.js.map +1 -0
- package/dist/core/version.js +1 -1
- package/dist/node/LegacyZstd.d.ts +67 -0
- package/dist/node/LegacyZstd.d.ts.map +1 -0
- package/dist/node/LegacyZstd.js +209 -0
- package/dist/node/LegacyZstd.js.map +1 -0
- package/dist/node/ZipCompressNode.d.ts +9 -7
- package/dist/node/ZipCompressNode.d.ts.map +1 -1
- package/dist/node/ZipCompressNode.js +105 -75
- package/dist/node/ZipCompressNode.js.map +1 -1
- package/dist/node/ZipDecompressNode.d.ts +7 -3
- package/dist/node/ZipDecompressNode.d.ts.map +1 -1
- package/dist/node/ZipDecompressNode.js +68 -56
- package/dist/node/ZipDecompressNode.js.map +1 -1
- package/dist/node/ZipkitNode.d.ts +23 -0
- package/dist/node/ZipkitNode.d.ts.map +1 -1
- package/dist/node/ZipkitNode.js +195 -41
- package/dist/node/ZipkitNode.js.map +1 -1
- package/dist/node/ZstdNode.d.ts +39 -0
- package/dist/node/ZstdNode.d.ts.map +1 -0
- package/dist/node/ZstdNode.js +171 -0
- package/dist/node/ZstdNode.js.map +1 -0
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +11 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +4 -3
- package/src/browser/ZipkitBrowser.ts +4 -6
- package/src/browser/zstd-browser-shim.ts +76 -0
- package/src/core/ZipCompress.ts +71 -42
- package/src/core/ZipDecompress.ts +35 -18
- package/src/core/ZipEntry.ts +8 -1
- package/src/core/Zipkit.ts +149 -76
- package/src/core/components/HashCalculator.ts +58 -6
- package/src/core/components/Util.ts +16 -7
- package/src/core/constants/Headers.ts +14 -4
- package/src/core/constants/MetaPaths.ts +86 -0
- package/src/core/encryption/AesCrypto.ts +265 -86
- package/src/core/encryption/index.ts +1 -1
- package/src/core/index.ts +14 -0
- package/src/core/merkle/MerkleRoot.ts +312 -0
- package/src/core/version.ts +1 -1
- package/src/node/LegacyZstd.ts +270 -0
- package/src/node/ZipCompressNode.ts +127 -84
- package/src/node/ZipDecompressNode.ts +93 -67
- package/src/node/ZipkitNode.ts +230 -42
- package/src/node/ZstdNode.ts +155 -0
- package/src/node/index.ts +10 -0
- package/dist/core/ZstdManager.d.ts +0 -56
- package/dist/core/ZstdManager.d.ts.map +0 -1
- package/dist/core/ZstdManager.js +0 -144
- package/dist/core/ZstdManager.js.map +0 -1
- package/src/core/ZstdManager.ts +0 -165
|
@@ -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
|
+
};
|
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: '0.
|
|
31
|
+
number: '1.0.5', // packageJson.version
|
|
32
32
|
date: releaseDate
|
|
33
33
|
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect legacy NeoZipKit WASM zstd (`@oneidentity/zstd-js`).
|
|
3
|
+
*
|
|
4
|
+
* That codec wrote valid-looking zstd frames but declared Frame_Content_Size as
|
|
5
|
+
* `uncompressed_size + 18`. Standard libzstd / Node zlib decode to the real
|
|
6
|
+
* payload plus 18 trailing zero bytes. CRC of the truncated payload matches
|
|
7
|
+
* the ZIP entry CRC.
|
|
8
|
+
*
|
|
9
|
+
* Header-only checks do not require decompressing. Verified checks decompress
|
|
10
|
+
* with native zlib and confirm trailing zeros + optional CRC.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ZstdNode } from './ZstdNode';
|
|
14
|
+
import { HashCalculator } from '../core/components/HashCalculator';
|
|
15
|
+
|
|
16
|
+
/** Fixed extra bytes declared/emitted by the broken WASM codec. */
|
|
17
|
+
export const LEGACY_WASM_ZSTD_PADDING = 18;
|
|
18
|
+
|
|
19
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
20
|
+
|
|
21
|
+
export type LegacyZstdConfidence = 'none' | 'header' | 'verified';
|
|
22
|
+
|
|
23
|
+
export interface LegacyZstdDetectResult {
|
|
24
|
+
/** True when this looks like (or verifies as) legacy WASM zstd. */
|
|
25
|
+
isLegacyWasm: boolean;
|
|
26
|
+
/** How the decision was made. */
|
|
27
|
+
confidence: LegacyZstdConfidence;
|
|
28
|
+
/** Parsed Frame_Content_Size, if present in the frame header. */
|
|
29
|
+
frameContentSize: number | null;
|
|
30
|
+
/** Expected plaintext size from the ZIP entry (or caller). */
|
|
31
|
+
expectedUncompressedSize: number;
|
|
32
|
+
/** Declared/observed padding past expected size (typically 18). */
|
|
33
|
+
paddingBytes: number;
|
|
34
|
+
/** Human-readable reason. */
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DetectLegacyZstdOptions {
|
|
39
|
+
/**
|
|
40
|
+
* When true (default), decompress with native zlib and confirm
|
|
41
|
+
* trailing zeros (+ optional CRC) instead of trusting the header alone.
|
|
42
|
+
*/
|
|
43
|
+
verify?: boolean;
|
|
44
|
+
/** ZIP CRC-32 of the original uncompressed data (strengthens verified check). */
|
|
45
|
+
crc?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read Frame_Content_Size from a zstd frame header, or null if absent / unparseable.
|
|
50
|
+
* @see https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md
|
|
51
|
+
*/
|
|
52
|
+
export function parseZstdFrameContentSize(compressed: Buffer): number | null {
|
|
53
|
+
if (!compressed || compressed.length < 6) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
compressed[0] !== ZSTD_MAGIC[0] ||
|
|
58
|
+
compressed[1] !== ZSTD_MAGIC[1] ||
|
|
59
|
+
compressed[2] !== ZSTD_MAGIC[2] ||
|
|
60
|
+
compressed[3] !== ZSTD_MAGIC[3]
|
|
61
|
+
) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const descriptor = compressed[4];
|
|
66
|
+
const fcsFlag = (descriptor >> 6) & 0x3;
|
|
67
|
+
const singleSegment = ((descriptor >> 5) & 0x1) === 1;
|
|
68
|
+
const dictIdFlag = descriptor & 0x3;
|
|
69
|
+
const dictIdSizes = [0, 1, 2, 4];
|
|
70
|
+
let pos = 5 + dictIdSizes[dictIdFlag];
|
|
71
|
+
|
|
72
|
+
if (fcsFlag === 0) {
|
|
73
|
+
if (!singleSegment) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (pos >= compressed.length) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return compressed[pos];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const fcsSizes = [0, 2, 4, 8];
|
|
83
|
+
const n = fcsSizes[fcsFlag];
|
|
84
|
+
if (pos + n > compressed.length) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let fcs: number;
|
|
89
|
+
if (n === 2) {
|
|
90
|
+
fcs = compressed.readUInt16LE(pos);
|
|
91
|
+
} else if (n === 4) {
|
|
92
|
+
fcs = compressed.readUInt32LE(pos);
|
|
93
|
+
} else {
|
|
94
|
+
const lo = compressed.readUInt32LE(pos);
|
|
95
|
+
const hi = compressed.readUInt32LE(pos + 4);
|
|
96
|
+
// Content sizes that do not fit in JS safe integers are not our legacy case.
|
|
97
|
+
if (hi !== 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
fcs = lo;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Spec: when FCS_Flag == 1, stored value is (size - 256).
|
|
104
|
+
if (fcsFlag === 1) {
|
|
105
|
+
fcs += 256;
|
|
106
|
+
}
|
|
107
|
+
return fcs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fast header heuristic: FCS === expectedUncompressedSize + 18.
|
|
112
|
+
*/
|
|
113
|
+
export function looksLikeLegacyWasmZstd(
|
|
114
|
+
compressed: Buffer,
|
|
115
|
+
expectedUncompressedSize: number
|
|
116
|
+
): boolean {
|
|
117
|
+
if (expectedUncompressedSize < 0 || !Number.isFinite(expectedUncompressedSize)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const fcs = parseZstdFrameContentSize(compressed);
|
|
121
|
+
if (fcs === null) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return fcs === expectedUncompressedSize + LEGACY_WASM_ZSTD_PADDING;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function crc32Of(data: Buffer): number {
|
|
128
|
+
const h = new HashCalculator();
|
|
129
|
+
h.update(data);
|
|
130
|
+
return h.finalizeCRC32();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detect legacy WASM zstd frames for a single compressed payload.
|
|
135
|
+
*/
|
|
136
|
+
export async function detectLegacyWasmZstd(
|
|
137
|
+
compressed: Buffer,
|
|
138
|
+
expectedUncompressedSize: number,
|
|
139
|
+
options?: DetectLegacyZstdOptions
|
|
140
|
+
): Promise<LegacyZstdDetectResult> {
|
|
141
|
+
const expected = expectedUncompressedSize >>> 0;
|
|
142
|
+
const fcs = parseZstdFrameContentSize(compressed);
|
|
143
|
+
const headerMatch =
|
|
144
|
+
fcs !== null && fcs === expected + LEGACY_WASM_ZSTD_PADDING;
|
|
145
|
+
|
|
146
|
+
const base = {
|
|
147
|
+
frameContentSize: fcs,
|
|
148
|
+
expectedUncompressedSize: expected,
|
|
149
|
+
paddingBytes: LEGACY_WASM_ZSTD_PADDING,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (!headerMatch && options?.verify === false) {
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
isLegacyWasm: false,
|
|
156
|
+
confidence: 'none',
|
|
157
|
+
reason:
|
|
158
|
+
fcs === null
|
|
159
|
+
? 'No Frame_Content_Size in zstd header (or not a zstd frame)'
|
|
160
|
+
: `Frame_Content_Size ${fcs} does not equal expected ${expected} + ${LEGACY_WASM_ZSTD_PADDING}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options?.verify === false) {
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
isLegacyWasm: true,
|
|
168
|
+
confidence: 'header',
|
|
169
|
+
reason: `Frame_Content_Size is expected+${LEGACY_WASM_ZSTD_PADDING} (legacy WASM heuristic)`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Verified path (default): decompress and check padding / CRC.
|
|
174
|
+
let decoded: Buffer;
|
|
175
|
+
try {
|
|
176
|
+
decoded = await ZstdNode.decompress(compressed);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
isLegacyWasm: false,
|
|
181
|
+
confidence: 'none',
|
|
182
|
+
reason: `Native decompress failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pad = decoded.length - expected;
|
|
187
|
+
const trailingZeros =
|
|
188
|
+
pad === LEGACY_WASM_ZSTD_PADDING &&
|
|
189
|
+
decoded.subarray(expected).equals(Buffer.alloc(LEGACY_WASM_ZSTD_PADDING));
|
|
190
|
+
const truncated = decoded.subarray(0, Math.min(expected, decoded.length));
|
|
191
|
+
const crcOk =
|
|
192
|
+
options?.crc === undefined ||
|
|
193
|
+
options.crc === 0 ||
|
|
194
|
+
crc32Of(truncated) === (options.crc >>> 0);
|
|
195
|
+
|
|
196
|
+
if (trailingZeros && crcOk && decoded.length === expected + LEGACY_WASM_ZSTD_PADDING) {
|
|
197
|
+
return {
|
|
198
|
+
...base,
|
|
199
|
+
isLegacyWasm: true,
|
|
200
|
+
confidence: 'verified',
|
|
201
|
+
reason:
|
|
202
|
+
options?.crc && options.crc !== 0
|
|
203
|
+
? `Decoded length is +${LEGACY_WASM_ZSTD_PADDING} with trailing zeros; truncated CRC matches`
|
|
204
|
+
: `Decoded length is +${LEGACY_WASM_ZSTD_PADDING} with trailing zeros`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (headerMatch && !trailingZeros) {
|
|
209
|
+
return {
|
|
210
|
+
...base,
|
|
211
|
+
isLegacyWasm: false,
|
|
212
|
+
confidence: 'none',
|
|
213
|
+
reason:
|
|
214
|
+
'Header suggested legacy WASM but decoded payload did not have 18 trailing zeros',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
isLegacyWasm: false,
|
|
221
|
+
confidence: 'none',
|
|
222
|
+
paddingBytes: Math.max(0, pad),
|
|
223
|
+
reason:
|
|
224
|
+
decoded.length === expected
|
|
225
|
+
? 'Standard zstd frame (decoded size matches expected)'
|
|
226
|
+
: `Decoded size ${decoded.length} does not match legacy pattern (expected ${expected} or ${expected + LEGACY_WASM_ZSTD_PADDING})`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Truncate plaintext if it matches the legacy +18 zero padding pattern.
|
|
232
|
+
* Returns the original buffer when not legacy-shaped.
|
|
233
|
+
*/
|
|
234
|
+
export function repairLegacyWasmZstdPlaintext(
|
|
235
|
+
decoded: Buffer,
|
|
236
|
+
expectedUncompressedSize: number,
|
|
237
|
+
crc?: number
|
|
238
|
+
): { repaired: Buffer; wasLegacy: boolean } {
|
|
239
|
+
const expected = expectedUncompressedSize >>> 0;
|
|
240
|
+
if (
|
|
241
|
+
decoded.length === expected + LEGACY_WASM_ZSTD_PADDING &&
|
|
242
|
+
decoded.subarray(expected).equals(Buffer.alloc(LEGACY_WASM_ZSTD_PADDING))
|
|
243
|
+
) {
|
|
244
|
+
const repaired = decoded.subarray(0, expected);
|
|
245
|
+
if (crc !== undefined && crc !== 0) {
|
|
246
|
+
if (crc32Of(repaired) !== (crc >>> 0)) {
|
|
247
|
+
return { repaired: decoded, wasLegacy: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { repaired, wasLegacy: true };
|
|
251
|
+
}
|
|
252
|
+
if (decoded.length > expected && expected > 0) {
|
|
253
|
+
// Match Rust extract behavior: bound to declared uncompressed size for zstd.
|
|
254
|
+
const repaired = decoded.subarray(0, expected);
|
|
255
|
+
if (crc !== undefined && crc !== 0 && crc32Of(repaired) === (crc >>> 0)) {
|
|
256
|
+
return { repaired, wasLegacy: true };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { repaired: decoded, wasLegacy: false };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const LegacyZstd = {
|
|
263
|
+
LEGACY_WASM_ZSTD_PADDING,
|
|
264
|
+
parseZstdFrameContentSize,
|
|
265
|
+
looksLikeLegacyWasmZstd,
|
|
266
|
+
detectLegacyWasmZstd,
|
|
267
|
+
repairLegacyWasmZstdPlaintext,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export default LegacyZstd;
|