@noy-db/as-zip 0.1.0-pre.3
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/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/index.cjs +648 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +349 -0
- package/dist/index.d.ts +349 -0
- package/dist/index.js +603 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// src/sha1.ts
|
|
2
|
+
async function hmacSha1(key, bytes) {
|
|
3
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
4
|
+
"raw",
|
|
5
|
+
key,
|
|
6
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
7
|
+
false,
|
|
8
|
+
["sign"]
|
|
9
|
+
);
|
|
10
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, bytes);
|
|
11
|
+
return new Uint8Array(sig);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/aes.ts
|
|
15
|
+
var WZAES_STRENGTH_256 = 3;
|
|
16
|
+
var WZAES_SALT_LEN = 16;
|
|
17
|
+
var WZAES_VERIFIER_LEN = 2;
|
|
18
|
+
var WZAES_AUTH_LEN = 10;
|
|
19
|
+
var WZAES_PBKDF2_ITERATIONS = 1e3;
|
|
20
|
+
var WZAES_METHOD_MARKER = 99;
|
|
21
|
+
var WZAES_VENDOR_ID = 17729;
|
|
22
|
+
var WZAES_VENDOR_VERSION_AE2 = 2;
|
|
23
|
+
var WZAES_REAL_METHOD = 0;
|
|
24
|
+
var WZAES_EXTRA_TAG = 39169;
|
|
25
|
+
var ZipCipherError = class extends Error {
|
|
26
|
+
code = "ZIP_CIPHER_ERROR";
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ZipCipherError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
async function encryptEntryWzAes(plaintext, password) {
|
|
33
|
+
if (password.length === 0) {
|
|
34
|
+
throw new ZipCipherError("encryptEntryWzAes: password must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
const salt = crypto.getRandomValues(new Uint8Array(WZAES_SALT_LEN));
|
|
37
|
+
const { encryptKey, authKey, verifier } = await deriveKeys(password, salt);
|
|
38
|
+
const ciphertext = await aesCtrXor(plaintext, encryptKey);
|
|
39
|
+
const authCode = await hmacSha1(authKey, ciphertext);
|
|
40
|
+
const auth = authCode.slice(0, WZAES_AUTH_LEN);
|
|
41
|
+
const data = new Uint8Array(salt.length + verifier.length + ciphertext.length + auth.length);
|
|
42
|
+
let pos = 0;
|
|
43
|
+
data.set(salt, pos);
|
|
44
|
+
pos += salt.length;
|
|
45
|
+
data.set(verifier, pos);
|
|
46
|
+
pos += verifier.length;
|
|
47
|
+
data.set(ciphertext, pos);
|
|
48
|
+
pos += ciphertext.length;
|
|
49
|
+
data.set(auth, pos);
|
|
50
|
+
return {
|
|
51
|
+
extraField: buildExtraField(),
|
|
52
|
+
dataRegion: data
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function decryptEntryWzAes(data, password) {
|
|
56
|
+
if (data.length < WZAES_SALT_LEN + WZAES_VERIFIER_LEN + WZAES_AUTH_LEN) {
|
|
57
|
+
throw new ZipCipherError("decryptEntryWzAes: data region is shorter than the WinZip-AES envelope");
|
|
58
|
+
}
|
|
59
|
+
const salt = data.slice(0, WZAES_SALT_LEN);
|
|
60
|
+
const verifier = data.slice(WZAES_SALT_LEN, WZAES_SALT_LEN + WZAES_VERIFIER_LEN);
|
|
61
|
+
const ciphertext = data.slice(
|
|
62
|
+
WZAES_SALT_LEN + WZAES_VERIFIER_LEN,
|
|
63
|
+
data.length - WZAES_AUTH_LEN
|
|
64
|
+
);
|
|
65
|
+
const auth = data.slice(data.length - WZAES_AUTH_LEN);
|
|
66
|
+
const { encryptKey, authKey, verifier: expectedVerifier } = await deriveKeys(password, salt);
|
|
67
|
+
if (!constantTimeEqual(verifier, expectedVerifier)) {
|
|
68
|
+
throw new ZipCipherError("decryptEntryWzAes: wrong password (verifier mismatch)");
|
|
69
|
+
}
|
|
70
|
+
const expectedAuth = (await hmacSha1(authKey, ciphertext)).slice(0, WZAES_AUTH_LEN);
|
|
71
|
+
if (!constantTimeEqual(auth, expectedAuth)) {
|
|
72
|
+
throw new ZipCipherError("decryptEntryWzAes: authentication code mismatch (tampered ciphertext or wrong password)");
|
|
73
|
+
}
|
|
74
|
+
return aesCtrXor(ciphertext, encryptKey);
|
|
75
|
+
}
|
|
76
|
+
async function deriveKeys(password, salt) {
|
|
77
|
+
const passBytes = new TextEncoder().encode(password);
|
|
78
|
+
const baseKey = await crypto.subtle.importKey(
|
|
79
|
+
"raw",
|
|
80
|
+
passBytes,
|
|
81
|
+
{ name: "PBKDF2" },
|
|
82
|
+
false,
|
|
83
|
+
["deriveBits"]
|
|
84
|
+
);
|
|
85
|
+
const totalBits = (32 + 32 + 2) * 8;
|
|
86
|
+
const bits = await crypto.subtle.deriveBits(
|
|
87
|
+
{
|
|
88
|
+
name: "PBKDF2",
|
|
89
|
+
hash: "SHA-1",
|
|
90
|
+
salt,
|
|
91
|
+
iterations: WZAES_PBKDF2_ITERATIONS
|
|
92
|
+
},
|
|
93
|
+
baseKey,
|
|
94
|
+
totalBits
|
|
95
|
+
);
|
|
96
|
+
const out = new Uint8Array(bits);
|
|
97
|
+
const encryptRaw = out.slice(0, 32);
|
|
98
|
+
const authKey = out.slice(32, 64);
|
|
99
|
+
const verifier = out.slice(64, 66);
|
|
100
|
+
const encryptKey = await crypto.subtle.importKey(
|
|
101
|
+
"raw",
|
|
102
|
+
encryptRaw,
|
|
103
|
+
{ name: "AES-CTR" },
|
|
104
|
+
false,
|
|
105
|
+
["encrypt", "decrypt"]
|
|
106
|
+
);
|
|
107
|
+
return { encryptKey, authKey, verifier };
|
|
108
|
+
}
|
|
109
|
+
async function aesCtrXor(data, key) {
|
|
110
|
+
const out = new Uint8Array(data.length);
|
|
111
|
+
let blockNum = 1n;
|
|
112
|
+
let pos = 0;
|
|
113
|
+
const zeroBlock = new Uint8Array(16);
|
|
114
|
+
while (pos < data.length) {
|
|
115
|
+
const counterBlock = new Uint8Array(16);
|
|
116
|
+
let n = blockNum;
|
|
117
|
+
for (let i = 0; i < 8; i++) {
|
|
118
|
+
counterBlock[i] = Number(n & 0xffn);
|
|
119
|
+
n >>= 8n;
|
|
120
|
+
}
|
|
121
|
+
const block = await crypto.subtle.encrypt(
|
|
122
|
+
{ name: "AES-CTR", counter: counterBlock, length: 128 },
|
|
123
|
+
key,
|
|
124
|
+
zeroBlock
|
|
125
|
+
);
|
|
126
|
+
const keystream = new Uint8Array(block);
|
|
127
|
+
const remain = Math.min(16, data.length - pos);
|
|
128
|
+
for (let i = 0; i < remain; i++) {
|
|
129
|
+
out[pos + i] = data[pos + i] ^ keystream[i];
|
|
130
|
+
}
|
|
131
|
+
pos += remain;
|
|
132
|
+
blockNum += 1n;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function buildExtraField() {
|
|
137
|
+
const ef = new Uint8Array(11);
|
|
138
|
+
const view = new DataView(ef.buffer);
|
|
139
|
+
view.setUint16(0, WZAES_EXTRA_TAG, true);
|
|
140
|
+
view.setUint16(2, 7, true);
|
|
141
|
+
view.setUint16(4, WZAES_VENDOR_VERSION_AE2, true);
|
|
142
|
+
view.setUint16(6, WZAES_VENDOR_ID, true);
|
|
143
|
+
ef[8] = WZAES_STRENGTH_256;
|
|
144
|
+
view.setUint16(9, WZAES_REAL_METHOD, true);
|
|
145
|
+
return ef;
|
|
146
|
+
}
|
|
147
|
+
function parseWzAesExtraField(extra) {
|
|
148
|
+
let pos = 0;
|
|
149
|
+
while (pos + 4 <= extra.length) {
|
|
150
|
+
const tag = readU16(extra, pos);
|
|
151
|
+
const size = readU16(extra, pos + 2);
|
|
152
|
+
if (tag === WZAES_EXTRA_TAG) {
|
|
153
|
+
if (size !== 7) {
|
|
154
|
+
throw new ZipCipherError(
|
|
155
|
+
`WinZip-AES extra field has size ${size}, expected 7`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const vendorVersion = readU16(extra, pos + 4);
|
|
159
|
+
const vendorId = readU16(extra, pos + 6);
|
|
160
|
+
const strength = extra[pos + 8];
|
|
161
|
+
if (vendorId !== WZAES_VENDOR_ID) {
|
|
162
|
+
throw new ZipCipherError(
|
|
163
|
+
`WinZip-AES extra field vendor id 0x${vendorId.toString(16)}, expected 0x${WZAES_VENDOR_ID.toString(16)}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (strength !== WZAES_STRENGTH_256) {
|
|
167
|
+
throw new ZipCipherError(
|
|
168
|
+
`WinZip-AES strength ${strength} not supported \u2014 AES-256 only`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return { vendorVersion };
|
|
172
|
+
}
|
|
173
|
+
pos += 4 + size;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function constantTimeEqual(a, b) {
|
|
178
|
+
if (a.length !== b.length) return false;
|
|
179
|
+
let diff = 0;
|
|
180
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
181
|
+
return diff === 0;
|
|
182
|
+
}
|
|
183
|
+
function readU16(bytes, offset) {
|
|
184
|
+
return bytes[offset] | bytes[offset + 1] << 8;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/zip.ts
|
|
188
|
+
var TEXT_ENCODER = new TextEncoder();
|
|
189
|
+
async function writeZip(entries, options = {}) {
|
|
190
|
+
const localParts = [];
|
|
191
|
+
const cdParts = [];
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const nameBytes = TEXT_ENCODER.encode(entry.path);
|
|
195
|
+
const dosTime = toDosTime(entry.mtime ?? /* @__PURE__ */ new Date());
|
|
196
|
+
let dataBytes;
|
|
197
|
+
let extraField;
|
|
198
|
+
let methodField;
|
|
199
|
+
let crcField;
|
|
200
|
+
let flagsField = 2048;
|
|
201
|
+
if (options.password !== void 0) {
|
|
202
|
+
const enc = await encryptEntryWzAes(entry.bytes, options.password);
|
|
203
|
+
dataBytes = enc.dataRegion;
|
|
204
|
+
extraField = enc.extraField;
|
|
205
|
+
methodField = WZAES_METHOD_MARKER;
|
|
206
|
+
crcField = 0;
|
|
207
|
+
flagsField |= 1;
|
|
208
|
+
void WZAES_REAL_METHOD;
|
|
209
|
+
} else {
|
|
210
|
+
dataBytes = entry.bytes;
|
|
211
|
+
extraField = new Uint8Array(0);
|
|
212
|
+
methodField = 0;
|
|
213
|
+
crcField = crc32(entry.bytes);
|
|
214
|
+
}
|
|
215
|
+
const size = dataBytes.length;
|
|
216
|
+
const uncompressedSize = options.password !== void 0 ? entry.bytes.length : size;
|
|
217
|
+
const lfh = new Uint8Array(30 + nameBytes.length + extraField.length);
|
|
218
|
+
const lfhView = new DataView(lfh.buffer);
|
|
219
|
+
lfhView.setUint32(0, 67324752, true);
|
|
220
|
+
lfhView.setUint16(4, 20, true);
|
|
221
|
+
lfhView.setUint16(6, flagsField, true);
|
|
222
|
+
lfhView.setUint16(8, methodField, true);
|
|
223
|
+
lfhView.setUint16(10, dosTime.time, true);
|
|
224
|
+
lfhView.setUint16(12, dosTime.date, true);
|
|
225
|
+
lfhView.setUint32(14, crcField, true);
|
|
226
|
+
lfhView.setUint32(18, size, true);
|
|
227
|
+
lfhView.setUint32(22, uncompressedSize, true);
|
|
228
|
+
lfhView.setUint16(26, nameBytes.length, true);
|
|
229
|
+
lfhView.setUint16(28, extraField.length, true);
|
|
230
|
+
lfh.set(nameBytes, 30);
|
|
231
|
+
if (extraField.length > 0) lfh.set(extraField, 30 + nameBytes.length);
|
|
232
|
+
localParts.push(lfh, dataBytes);
|
|
233
|
+
const cdh = new Uint8Array(46 + nameBytes.length + extraField.length);
|
|
234
|
+
const cdhView = new DataView(cdh.buffer);
|
|
235
|
+
cdhView.setUint32(0, 33639248, true);
|
|
236
|
+
cdhView.setUint16(4, 20, true);
|
|
237
|
+
cdhView.setUint16(6, 20, true);
|
|
238
|
+
cdhView.setUint16(8, flagsField, true);
|
|
239
|
+
cdhView.setUint16(10, methodField, true);
|
|
240
|
+
cdhView.setUint16(12, dosTime.time, true);
|
|
241
|
+
cdhView.setUint16(14, dosTime.date, true);
|
|
242
|
+
cdhView.setUint32(16, crcField, true);
|
|
243
|
+
cdhView.setUint32(20, size, true);
|
|
244
|
+
cdhView.setUint32(24, uncompressedSize, true);
|
|
245
|
+
cdhView.setUint16(28, nameBytes.length, true);
|
|
246
|
+
cdhView.setUint16(30, extraField.length, true);
|
|
247
|
+
cdhView.setUint16(32, 0, true);
|
|
248
|
+
cdhView.setUint16(34, 0, true);
|
|
249
|
+
cdhView.setUint16(36, 0, true);
|
|
250
|
+
cdhView.setUint32(38, 0, true);
|
|
251
|
+
cdhView.setUint32(42, offset, true);
|
|
252
|
+
cdh.set(nameBytes, 46);
|
|
253
|
+
if (extraField.length > 0) cdh.set(extraField, 46 + nameBytes.length);
|
|
254
|
+
cdParts.push(cdh);
|
|
255
|
+
offset += lfh.length + dataBytes.length;
|
|
256
|
+
}
|
|
257
|
+
const localTotal = offset;
|
|
258
|
+
const cdSize = cdParts.reduce((n, p) => n + p.length, 0);
|
|
259
|
+
const eocd = new Uint8Array(22);
|
|
260
|
+
const eocdView = new DataView(eocd.buffer);
|
|
261
|
+
eocdView.setUint32(0, 101010256, true);
|
|
262
|
+
eocdView.setUint16(4, 0, true);
|
|
263
|
+
eocdView.setUint16(6, 0, true);
|
|
264
|
+
eocdView.setUint16(8, entries.length, true);
|
|
265
|
+
eocdView.setUint16(10, entries.length, true);
|
|
266
|
+
eocdView.setUint32(12, cdSize, true);
|
|
267
|
+
eocdView.setUint32(16, localTotal, true);
|
|
268
|
+
eocdView.setUint16(20, 0, true);
|
|
269
|
+
const out = new Uint8Array(localTotal + cdSize + eocd.length);
|
|
270
|
+
let pos = 0;
|
|
271
|
+
for (const part of localParts) {
|
|
272
|
+
out.set(part, pos);
|
|
273
|
+
pos += part.length;
|
|
274
|
+
}
|
|
275
|
+
for (const part of cdParts) {
|
|
276
|
+
out.set(part, pos);
|
|
277
|
+
pos += part.length;
|
|
278
|
+
}
|
|
279
|
+
out.set(eocd, pos);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
var CRC_TABLE = null;
|
|
283
|
+
function ensureCrcTable() {
|
|
284
|
+
if (CRC_TABLE) return CRC_TABLE;
|
|
285
|
+
const t = new Uint32Array(256);
|
|
286
|
+
for (let n = 0; n < 256; n++) {
|
|
287
|
+
let c = n;
|
|
288
|
+
for (let k = 0; k < 8; k++) {
|
|
289
|
+
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
290
|
+
}
|
|
291
|
+
t[n] = c >>> 0;
|
|
292
|
+
}
|
|
293
|
+
CRC_TABLE = t;
|
|
294
|
+
return t;
|
|
295
|
+
}
|
|
296
|
+
function crc32(bytes) {
|
|
297
|
+
const t = ensureCrcTable();
|
|
298
|
+
let c = 4294967295;
|
|
299
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
300
|
+
c = t[(c ^ bytes[i]) & 255] ^ c >>> 8;
|
|
301
|
+
}
|
|
302
|
+
return (c ^ 4294967295) >>> 0;
|
|
303
|
+
}
|
|
304
|
+
function toDosTime(date) {
|
|
305
|
+
const y = Math.max(1980, date.getUTCFullYear()) - 1980;
|
|
306
|
+
const m = date.getUTCMonth() + 1;
|
|
307
|
+
const d = date.getUTCDate();
|
|
308
|
+
const hh = date.getUTCHours();
|
|
309
|
+
const mm = date.getUTCMinutes();
|
|
310
|
+
const ss = Math.floor(date.getUTCSeconds() / 2);
|
|
311
|
+
return {
|
|
312
|
+
date: y << 9 | m << 5 | d,
|
|
313
|
+
time: hh << 11 | mm << 5 | ss
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/read.ts
|
|
318
|
+
var TEXT_DECODER = new TextDecoder();
|
|
319
|
+
var ZipReadError = class extends Error {
|
|
320
|
+
code = "ZIP_READ_ERROR";
|
|
321
|
+
constructor(message) {
|
|
322
|
+
super(message);
|
|
323
|
+
this.name = "ZipReadError";
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
async function readZip(bytes, options = {}) {
|
|
327
|
+
if (bytes.length < 22) {
|
|
328
|
+
throw new ZipReadError("readZip: archive shorter than the EOCD record (22 bytes)");
|
|
329
|
+
}
|
|
330
|
+
const eocdOffset = locateEOCD(bytes);
|
|
331
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
332
|
+
const cdSize = view.getUint32(eocdOffset + 12, true);
|
|
333
|
+
const cdOffset = view.getUint32(eocdOffset + 16, true);
|
|
334
|
+
const recordCount = view.getUint16(eocdOffset + 10, true);
|
|
335
|
+
if (cdOffset + cdSize > bytes.length) {
|
|
336
|
+
throw new ZipReadError("readZip: central directory extends past end of file");
|
|
337
|
+
}
|
|
338
|
+
const out = [];
|
|
339
|
+
let pos = cdOffset;
|
|
340
|
+
for (let i = 0; i < recordCount; i++) {
|
|
341
|
+
if (view.getUint32(pos, true) !== 33639248) {
|
|
342
|
+
throw new ZipReadError(`readZip: missing central directory file header signature at offset ${pos}`);
|
|
343
|
+
}
|
|
344
|
+
const flags = view.getUint16(pos + 8, true);
|
|
345
|
+
const method = view.getUint16(pos + 10, true);
|
|
346
|
+
const compressedSize = view.getUint32(pos + 20, true);
|
|
347
|
+
const uncompressedSize = view.getUint32(pos + 24, true);
|
|
348
|
+
const nameLen = view.getUint16(pos + 28, true);
|
|
349
|
+
const extraLen = view.getUint16(pos + 30, true);
|
|
350
|
+
const commentLen = view.getUint16(pos + 32, true);
|
|
351
|
+
const lfhOffset = view.getUint32(pos + 42, true);
|
|
352
|
+
const path = TEXT_DECODER.decode(bytes.subarray(pos + 46, pos + 46 + nameLen));
|
|
353
|
+
const extra = bytes.subarray(pos + 46 + nameLen, pos + 46 + nameLen + extraLen);
|
|
354
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
355
|
+
if (lfhOffset + 30 > bytes.length) {
|
|
356
|
+
throw new ZipReadError(`readZip: local header offset ${lfhOffset} past end of file`);
|
|
357
|
+
}
|
|
358
|
+
if (view.getUint32(lfhOffset, true) !== 67324752) {
|
|
359
|
+
throw new ZipReadError(`readZip: missing local file header signature for "${path}"`);
|
|
360
|
+
}
|
|
361
|
+
const lfhNameLen = view.getUint16(lfhOffset + 26, true);
|
|
362
|
+
const lfhExtraLen = view.getUint16(lfhOffset + 28, true);
|
|
363
|
+
const dataStart = lfhOffset + 30 + lfhNameLen + lfhExtraLen;
|
|
364
|
+
if (dataStart + compressedSize > bytes.length) {
|
|
365
|
+
throw new ZipReadError(`readZip: data region for "${path}" extends past end of file`);
|
|
366
|
+
}
|
|
367
|
+
const dataRegion = bytes.subarray(dataStart, dataStart + compressedSize);
|
|
368
|
+
const encryptedFlag = (flags & 1) !== 0;
|
|
369
|
+
const isWzAes = method === WZAES_METHOD_MARKER;
|
|
370
|
+
if (isWzAes) {
|
|
371
|
+
if (!encryptedFlag) {
|
|
372
|
+
throw new ZipReadError(
|
|
373
|
+
`readZip: entry "${path}" carries the AES marker but the encryption flag is unset`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const wz = parseWzAesExtraField(extra);
|
|
377
|
+
if (!wz) {
|
|
378
|
+
throw new ZipReadError(
|
|
379
|
+
`readZip: entry "${path}" uses method 99 but is missing the 0x9901 extra field`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (options.password === void 0) {
|
|
383
|
+
throw new ZipReadError(
|
|
384
|
+
`readZip: entry "${path}" is AES-encrypted but no password was supplied`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const plaintext = await decryptEntryWzAes(dataRegion, options.password);
|
|
388
|
+
if (uncompressedSize !== 0 && plaintext.length !== uncompressedSize) {
|
|
389
|
+
throw new ZipReadError(
|
|
390
|
+
`readZip: entry "${path}" plaintext length ${plaintext.length} \u2260 declared ${uncompressedSize}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
out.push({ path, bytes: plaintext, encrypted: true });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (encryptedFlag) {
|
|
397
|
+
throw new ZipCipherError(
|
|
398
|
+
`readZip: entry "${path}" uses an encryption method other than WinZip-AES-256 \u2014 refusing to decrypt`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (method !== 0) {
|
|
402
|
+
throw new ZipReadError(
|
|
403
|
+
`readZip: entry "${path}" uses compression method ${method} \u2014 only STORE (0) is supported`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
out.push({ path, bytes: new Uint8Array(dataRegion), encrypted: false });
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function locateEOCD(bytes) {
|
|
411
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
412
|
+
const minStart = Math.max(0, bytes.length - 22 - 65535);
|
|
413
|
+
for (let pos = bytes.length - 22; pos >= minStart; pos--) {
|
|
414
|
+
if (view.getUint32(pos, true) === 101010256) return pos;
|
|
415
|
+
}
|
|
416
|
+
throw new ZipReadError("readZip: EOCD signature not found \u2014 input is not a valid ZIP");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/index.ts
|
|
420
|
+
import { diffVault } from "@noy-db/hub";
|
|
421
|
+
async function toBytes(vault, options) {
|
|
422
|
+
vault.assertCanExport("plaintext", "zip");
|
|
423
|
+
const collectionName = options.records.collection;
|
|
424
|
+
const collection = vault.collection(collectionName);
|
|
425
|
+
const ids = await collection.list().then((rs) => extractIds(rs));
|
|
426
|
+
const records = [];
|
|
427
|
+
for (const id of ids) {
|
|
428
|
+
const r = await collection.get(id);
|
|
429
|
+
if (r === null) continue;
|
|
430
|
+
if (options.records.filter && !options.records.filter(r)) continue;
|
|
431
|
+
records.push({ id, record: r });
|
|
432
|
+
}
|
|
433
|
+
const slotsSelector = options.attachments?.slots ?? "*";
|
|
434
|
+
const attachmentEntries = [];
|
|
435
|
+
const includeAll = slotsSelector === "*";
|
|
436
|
+
const includeSet = includeAll ? null : new Set(slotsSelector);
|
|
437
|
+
for (const { id } of records) {
|
|
438
|
+
const blobSet = collection.blob(id);
|
|
439
|
+
const slotsList = await blobSet.list();
|
|
440
|
+
for (const slot of slotsList) {
|
|
441
|
+
if (!includeAll && !includeSet.has(slot.name)) continue;
|
|
442
|
+
const bytes = await blobSet.get(slot.name);
|
|
443
|
+
if (!bytes) continue;
|
|
444
|
+
const safeId = sanitiseFsSegment(id);
|
|
445
|
+
const safeSlot = sanitiseFsSegment(slot.name);
|
|
446
|
+
const entry = {
|
|
447
|
+
path: `attachments/${safeId}/${safeSlot}`,
|
|
448
|
+
bytes,
|
|
449
|
+
size: bytes.length,
|
|
450
|
+
slot: slot.name,
|
|
451
|
+
recordId: id,
|
|
452
|
+
...slot.mimeType !== void 0 && { mimeType: slot.mimeType }
|
|
453
|
+
};
|
|
454
|
+
attachmentEntries.push(entry);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const recordIndex = records.map(({ id }) => {
|
|
458
|
+
const attach = attachmentEntries.filter((a) => a.recordId === id).map(({ slot, path, size, mimeType }) => ({
|
|
459
|
+
slot,
|
|
460
|
+
path,
|
|
461
|
+
size,
|
|
462
|
+
...mimeType !== void 0 && { mimeType }
|
|
463
|
+
}));
|
|
464
|
+
return { id, attachments: attach };
|
|
465
|
+
});
|
|
466
|
+
const manifest = {
|
|
467
|
+
_noydb_archive: 1,
|
|
468
|
+
collection: collectionName,
|
|
469
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
470
|
+
recordCount: records.length,
|
|
471
|
+
attachmentCount: attachmentEntries.length,
|
|
472
|
+
records: recordIndex
|
|
473
|
+
};
|
|
474
|
+
const encoder = new TextEncoder();
|
|
475
|
+
const entries = [
|
|
476
|
+
{ path: "manifest.json", bytes: encoder.encode(JSON.stringify(manifest, null, 2)) },
|
|
477
|
+
{
|
|
478
|
+
path: "records.json",
|
|
479
|
+
bytes: encoder.encode(
|
|
480
|
+
JSON.stringify(
|
|
481
|
+
records.map((r) => {
|
|
482
|
+
const pojo = toPojo(r.record);
|
|
483
|
+
const base = pojo && typeof pojo === "object" && !Array.isArray(pojo) ? pojo : { value: pojo };
|
|
484
|
+
return { _id: r.id, ...base };
|
|
485
|
+
}),
|
|
486
|
+
null,
|
|
487
|
+
2
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
];
|
|
492
|
+
for (const a of attachmentEntries) {
|
|
493
|
+
entries.push({ path: a.path, bytes: a.bytes });
|
|
494
|
+
}
|
|
495
|
+
return writeZip(entries, options.password !== void 0 ? { password: options.password } : {});
|
|
496
|
+
}
|
|
497
|
+
async function download(vault, options) {
|
|
498
|
+
const bytes = await toBytes(vault, options);
|
|
499
|
+
const filename = options.filename ?? `${options.records.collection}.zip`;
|
|
500
|
+
const blob = new Blob([bytes], { type: "application/zip" });
|
|
501
|
+
const url = URL.createObjectURL(blob);
|
|
502
|
+
const a = document.createElement("a");
|
|
503
|
+
a.href = url;
|
|
504
|
+
a.download = filename;
|
|
505
|
+
a.click();
|
|
506
|
+
URL.revokeObjectURL(url);
|
|
507
|
+
}
|
|
508
|
+
async function write(vault, path, options) {
|
|
509
|
+
if (options.acknowledgeRisks !== true) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`as-zip.write: acknowledgeRisks: true is required for on-disk plaintext output. This call creates a persistent plaintext archive outside noy-db's encrypted storage \u2014 see docs/patterns/as-exports.md \xA7"The three tiers of \\"plaintext out\\""`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const bytes = await toBytes(vault, options);
|
|
515
|
+
const { writeFile } = await import("fs/promises");
|
|
516
|
+
await writeFile(path, bytes);
|
|
517
|
+
}
|
|
518
|
+
async function fromBytes(vault, bytes, options) {
|
|
519
|
+
vault.assertCanImport("plaintext", "zip");
|
|
520
|
+
const policy = options.policy ?? "merge";
|
|
521
|
+
const idKey = options.idKey ?? "id";
|
|
522
|
+
const entries = await readZip(bytes, options.password !== void 0 ? { password: options.password } : {});
|
|
523
|
+
const recordsEntry = entries.find((e) => e.path === "records.json");
|
|
524
|
+
if (!recordsEntry) {
|
|
525
|
+
throw new Error("as-zip.fromBytes: archive is missing records.json");
|
|
526
|
+
}
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = JSON.parse(new TextDecoder().decode(recordsEntry.bytes));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
throw new Error(`as-zip.fromBytes: records.json is not valid JSON (${err.message})`);
|
|
532
|
+
}
|
|
533
|
+
if (!Array.isArray(parsed)) {
|
|
534
|
+
throw new Error("as-zip.fromBytes: records.json must be a JSON array of records");
|
|
535
|
+
}
|
|
536
|
+
const plan = await diffVault(vault, { [options.collection]: parsed }, {
|
|
537
|
+
collections: [options.collection],
|
|
538
|
+
idKey
|
|
539
|
+
});
|
|
540
|
+
return {
|
|
541
|
+
plan,
|
|
542
|
+
policy,
|
|
543
|
+
async apply() {
|
|
544
|
+
await vault.noydb.transaction((tx) => {
|
|
545
|
+
const txVault = tx.vault(vault.name);
|
|
546
|
+
for (const entry of plan.added) {
|
|
547
|
+
txVault.collection(entry.collection).put(entry.id, entry.record);
|
|
548
|
+
}
|
|
549
|
+
if (policy !== "insert-only") {
|
|
550
|
+
for (const entry of plan.modified) {
|
|
551
|
+
txVault.collection(entry.collection).put(entry.id, entry.record);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (policy === "replace") {
|
|
555
|
+
for (const entry of plan.deleted) {
|
|
556
|
+
txVault.collection(entry.collection).delete(entry.id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function extractIds(list) {
|
|
564
|
+
const out = [];
|
|
565
|
+
for (const item of list) {
|
|
566
|
+
if (typeof item === "string") {
|
|
567
|
+
out.push(item);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (item && typeof item === "object") {
|
|
571
|
+
const rec = item;
|
|
572
|
+
const id = rec.id ?? rec._id;
|
|
573
|
+
if (typeof id === "string") out.push(id);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
function toPojo(value) {
|
|
579
|
+
if (value === null || value === void 0) return value;
|
|
580
|
+
if (value instanceof Date) return value.toISOString();
|
|
581
|
+
if (Array.isArray(value)) return value.map(toPojo);
|
|
582
|
+
if (typeof value === "object") {
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const [k, v] of Object.entries(value)) out[k] = toPojo(v);
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
return value;
|
|
588
|
+
}
|
|
589
|
+
function sanitiseFsSegment(s) {
|
|
590
|
+
return s.replace(/[/\\:*?"<>|\x00-\x1f]/g, "_") || "_";
|
|
591
|
+
}
|
|
592
|
+
export {
|
|
593
|
+
ZipCipherError,
|
|
594
|
+
ZipReadError,
|
|
595
|
+
crc32,
|
|
596
|
+
download,
|
|
597
|
+
fromBytes,
|
|
598
|
+
readZip,
|
|
599
|
+
toBytes,
|
|
600
|
+
write,
|
|
601
|
+
writeZip
|
|
602
|
+
};
|
|
603
|
+
//# sourceMappingURL=index.js.map
|