w3wallets 1.0.0-beta.7 → 1.0.0-beta.9

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.
@@ -0,0 +1,668 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Downloads and extracts Chrome extensions from the Chrome Web Store.
5
+ *
6
+ * Usage:
7
+ * npx w3wallets metamask polkadotjs # Download by alias
8
+ * npx w3wallets mm pjs # Short aliases
9
+ * npx w3wallets <extension-id> # Download by extension ID
10
+ * npx w3wallets --help # Show help
11
+ */
12
+
13
+ const fs = require("fs");
14
+ const https = require("https");
15
+ const path = require("path");
16
+ const zlib = require("zlib");
17
+
18
+ // ---------------------------------------------------------------------
19
+ // 1. Known aliases -> extension IDs (case-insensitive lookup)
20
+ // ---------------------------------------------------------------------
21
+ const EXTENSION_REGISTRY = {
22
+ // MetaMask wallet
23
+ metamask: "nkbihfbeogaeaoehlefnkodbefgpgknn",
24
+ mm: "nkbihfbeogaeaoehlefnkodbefgpgknn",
25
+
26
+ // Polkadot.js wallet
27
+ polkadotjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
28
+ pjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
29
+ };
30
+
31
+ // Human-readable names for display
32
+ const EXTENSION_NAMES = {
33
+ nkbihfbeogaeaoehlefnkodbefgpgknn: "MetaMask",
34
+ mopnmbcafieddcagagdcbnhejhlodfdd: "Polkadot.js",
35
+ };
36
+
37
+ // Canonical aliases for listing
38
+ const CANONICAL_ALIASES = [
39
+ { name: "metamask", short: "mm", id: "nkbihfbeogaeaoehlefnkodbefgpgknn" },
40
+ { name: "polkadotjs", short: "pjs", id: "mopnmbcafieddcagagdcbnhejhlodfdd" },
41
+ ];
42
+
43
+ // ---------------------------------------------------------------------
44
+ // ZIP format constants (per PKWARE APPNOTE.TXT specification)
45
+ // ---------------------------------------------------------------------
46
+ const ZIP_SIGNATURES = {
47
+ EOCD: 0x06054b50, // End of Central Directory
48
+ CENTRAL_DIR: 0x02014b50, // Central Directory file header
49
+ LOCAL_FILE: 0x04034b50, // Local file header
50
+ };
51
+
52
+ const ZIP_FLAGS = {
53
+ ENCRYPTED: 0x0001, // File is encrypted
54
+ DATA_DESCRIPTOR: 0x0008, // Sizes in data descriptor after file data
55
+ UTF8_FILENAME: 0x0800, // Filename is UTF-8 encoded
56
+ };
57
+
58
+ const ZIP_METHODS = {
59
+ STORE: 0, // No compression
60
+ DEFLATE: 8, // Deflate compression
61
+ };
62
+
63
+ // Marker value indicating ZIP64 format is required
64
+ const ZIP64_MARKER = 0xffffffff;
65
+
66
+ // Maximum size of EOCD record (22 bytes + max 65535 comment)
67
+ const MAX_EOCD_SEARCH = 65557;
68
+
69
+ // HTTP request timeout in milliseconds
70
+ const REQUEST_TIMEOUT_MS = 30000;
71
+
72
+ // ---------------------------------------------------------------------
73
+ // 2. CLI Argument Parser
74
+ // ---------------------------------------------------------------------
75
+ const CLI_OPTIONS = {
76
+ help: false,
77
+ list: false,
78
+ output: ".w3wallets",
79
+ force: false,
80
+ debug: false,
81
+ dryRun: false,
82
+ targets: [], // aliases or extension IDs
83
+ };
84
+
85
+ function printHelp() {
86
+ console.log(`
87
+ w3wallets - Download Chrome extensions from the Chrome Web Store
88
+
89
+ USAGE:
90
+ npx w3wallets [OPTIONS] <targets...>
91
+
92
+ TARGETS:
93
+ Alias name Known wallet alias (e.g., metamask, polkadotjs)
94
+ Short alias Short form (e.g., mm, pjs)
95
+ Extension ID 32-character Chrome extension ID
96
+ URL Chrome Web Store URL
97
+
98
+ OPTIONS:
99
+ -h, --help Show this help message
100
+ -l, --list List available wallet aliases
101
+ -o, --output Output directory (default: .w3wallets)
102
+ -f, --force Force re-download even if already exists
103
+ -n, --dry-run Show what would be done without downloading
104
+ --debug Save raw .crx file for debugging
105
+
106
+ EXAMPLES:
107
+ npx w3wallets metamask # Download MetaMask
108
+ npx w3wallets mm pjs # Download using short aliases
109
+ npx w3wallets --list # List available aliases
110
+ npx w3wallets -o ./extensions metamask # Custom output directory
111
+ npx w3wallets --force mm # Force re-download
112
+ npx w3wallets nkbihfbeogaeaoehlefnkodbefgpgknn # Download by extension ID
113
+ npx w3wallets "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
114
+ `);
115
+ }
116
+
117
+ function printList() {
118
+ console.log("\nAvailable wallet aliases:\n");
119
+ console.log(" ALIAS SHORT EXTENSION ID");
120
+ console.log(" " + "-".repeat(50));
121
+ for (const { name, short, id } of CANONICAL_ALIASES) {
122
+ console.log(` ${name.padEnd(12)} ${short.padEnd(7)} ${id}`);
123
+ }
124
+ console.log(
125
+ "\nYou can also download any extension by ID or Chrome Web Store URL.\n",
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Parse extension ID from various input formats:
131
+ * - Known alias (case-insensitive): "metamask", "MetaMask", "MM"
132
+ * - Direct extension ID: "nkbihfbeogaeaoehlefnkodbefgpgknn"
133
+ * - Chrome Web Store URL: "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
134
+ */
135
+ function parseExtensionTarget(input) {
136
+ // Check for known alias (case-insensitive)
137
+ const normalizedInput = input.toLowerCase();
138
+ if (EXTENSION_REGISTRY[normalizedInput]) {
139
+ const id = EXTENSION_REGISTRY[normalizedInput];
140
+ // Find canonical alias name for directory
141
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
142
+ return {
143
+ id,
144
+ name: EXTENSION_NAMES[id] || normalizedInput,
145
+ dirName: alias ? alias.name : id,
146
+ };
147
+ }
148
+
149
+ // Check if it's a Chrome Web Store URL
150
+ const urlPatterns = [
151
+ /chromewebstore\.google\.com\/detail\/[^/]+\/([a-z]{32})/i,
152
+ /chrome\.google\.com\/webstore\/detail\/[^/]+\/([a-z]{32})/i,
153
+ ];
154
+ for (const pattern of urlPatterns) {
155
+ const match = input.match(pattern);
156
+ if (match) {
157
+ const id = match[1].toLowerCase();
158
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
159
+ return {
160
+ id,
161
+ name: EXTENSION_NAMES[id] || id,
162
+ dirName: alias ? alias.name : id,
163
+ };
164
+ }
165
+ }
166
+
167
+ // Check if it's a direct extension ID (32 lowercase letters)
168
+ if (/^[a-z]{32}$/i.test(input)) {
169
+ const id = input.toLowerCase();
170
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
171
+ return {
172
+ id,
173
+ name: EXTENSION_NAMES[id] || id,
174
+ dirName: alias ? alias.name : id,
175
+ };
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ function parseArgs(args) {
182
+ let i = 0;
183
+ while (i < args.length) {
184
+ const arg = args[i];
185
+
186
+ if (arg === "-h" || arg === "--help") {
187
+ CLI_OPTIONS.help = true;
188
+ } else if (arg === "-l" || arg === "--list") {
189
+ CLI_OPTIONS.list = true;
190
+ } else if (arg === "-o" || arg === "--output") {
191
+ i++;
192
+ if (i >= args.length) {
193
+ console.error("Error: --output requires a directory path");
194
+ process.exit(1);
195
+ }
196
+ CLI_OPTIONS.output = args[i];
197
+ } else if (arg === "-f" || arg === "--force") {
198
+ CLI_OPTIONS.force = true;
199
+ } else if (arg === "-n" || arg === "--dry-run") {
200
+ CLI_OPTIONS.dryRun = true;
201
+ } else if (arg === "--debug") {
202
+ CLI_OPTIONS.debug = true;
203
+ } else if (arg.startsWith("-")) {
204
+ console.error(`Error: Unknown option "${arg}"`);
205
+ console.error("Use --help for usage information");
206
+ process.exit(1);
207
+ } else {
208
+ // It's a target (alias, ID, or URL)
209
+ const parsed = parseExtensionTarget(arg);
210
+ if (!parsed) {
211
+ console.error(
212
+ `Error: "${arg}" is not a valid alias, extension ID, or URL`,
213
+ );
214
+ console.error(
215
+ "Use --list to see available aliases, or provide a 32-character extension ID",
216
+ );
217
+ process.exit(1);
218
+ }
219
+ CLI_OPTIONS.targets.push(parsed);
220
+ }
221
+ i++;
222
+ }
223
+ }
224
+
225
+ // Check if first arg is "cache" — delegate to compiled cache script
226
+ const rawArgs = process.argv.slice(2);
227
+ if (rawArgs[0] === "cache") {
228
+ const { execFileSync } = require("child_process");
229
+ const cacheScript = path.join(
230
+ __dirname,
231
+ "..",
232
+ "..",
233
+ "dist",
234
+ "scripts",
235
+ "cache.js",
236
+ );
237
+
238
+ if (!fs.existsSync(cacheScript)) {
239
+ console.error(
240
+ "Error: Cache script not found. Make sure w3wallets is built (run: npx tsup).",
241
+ );
242
+ process.exit(1);
243
+ }
244
+
245
+ try {
246
+ execFileSync(process.execPath, [cacheScript, ...rawArgs.slice(1)], {
247
+ stdio: "inherit",
248
+ });
249
+ } catch (err) {
250
+ process.exit(err.status || 1);
251
+ }
252
+ process.exit(0);
253
+ }
254
+
255
+ // Parse command line arguments for download mode
256
+ parseArgs(rawArgs);
257
+
258
+ // Handle --help
259
+ if (CLI_OPTIONS.help) {
260
+ printHelp();
261
+ process.exit(0);
262
+ }
263
+
264
+ // Handle --list
265
+ if (CLI_OPTIONS.list) {
266
+ printList();
267
+ process.exit(0);
268
+ }
269
+
270
+ // Validate we have targets
271
+ if (CLI_OPTIONS.targets.length === 0) {
272
+ console.error("Error: No extension targets specified");
273
+ console.error(
274
+ "Use --help for usage information or --list to see available aliases",
275
+ );
276
+ process.exit(1);
277
+ }
278
+
279
+ // ---------------------------------------------------------------------
280
+ // 3. Main: download and extract each requested extension
281
+ // ---------------------------------------------------------------------
282
+ (async function main() {
283
+ // Handle --dry-run mode
284
+ if (CLI_OPTIONS.dryRun) {
285
+ for (const target of CLI_OPTIONS.targets) {
286
+ const { id, name, dirName } = target;
287
+ const outDir = path.join(CLI_OPTIONS.output, dirName);
288
+ const manifestPath = path.join(outDir, "manifest.json");
289
+ const exists = fs.existsSync(manifestPath);
290
+
291
+ const downloadUrl =
292
+ "https://clients2.google.com/service/update2/crx" +
293
+ "?response=redirect" +
294
+ "&prod=chrome" +
295
+ "&prodversion=9999" +
296
+ "&acceptformat=crx2,crx3" +
297
+ `&x=id%3D${id}%26uc`;
298
+
299
+ console.log(`\n=== ${name} (${id}) ===`);
300
+ console.log(` Output: ${outDir}`);
301
+ if (exists && !CLI_OPTIONS.force) {
302
+ console.log(
303
+ ` Status: already exists (would skip, use --force to override)`,
304
+ );
305
+ } else if (exists && CLI_OPTIONS.force) {
306
+ console.log(
307
+ ` Status: already exists (would re-download with --force)`,
308
+ );
309
+ } else {
310
+ console.log(` Status: not downloaded (would download)`);
311
+ }
312
+ console.log(` URL: ${downloadUrl}`);
313
+ }
314
+ return;
315
+ }
316
+
317
+ for (const target of CLI_OPTIONS.targets) {
318
+ const { id, name, dirName } = target;
319
+ const outDir = path.join(CLI_OPTIONS.output, dirName);
320
+
321
+ console.log(`\n=== ${name} (${id}) ===`);
322
+
323
+ // Check if already exists (skip unless --force)
324
+ const manifestPath = path.join(outDir, "manifest.json");
325
+ if (!CLI_OPTIONS.force && fs.existsSync(manifestPath)) {
326
+ console.log(`Already exists: ${outDir}`);
327
+ console.log("Use --force to re-download");
328
+ continue;
329
+ }
330
+
331
+ try {
332
+ // 1) Download CRX with progress
333
+ console.log("Downloading...");
334
+ const crxBuffer = await downloadCrx(id);
335
+ console.log(`Downloaded ${formatBytes(crxBuffer.length)}`);
336
+
337
+ // 2) Optionally save raw CRX for debugging
338
+ if (CLI_OPTIONS.debug) {
339
+ fs.mkdirSync(outDir, { recursive: true });
340
+ const debugPath = path.join(outDir, `debug-${dirName}.crx`);
341
+ fs.writeFileSync(debugPath, crxBuffer);
342
+ console.log(`Debug CRX saved: ${debugPath}`);
343
+ }
344
+
345
+ // 3) Extract CRX
346
+ console.log("Extracting...");
347
+ extractCrxToFolder(crxBuffer, outDir);
348
+ console.log(`Done: ${outDir}`);
349
+ } catch (err) {
350
+ console.error(`Failed: ${err.message}`);
351
+ process.exit(1);
352
+ }
353
+ }
354
+
355
+ console.log("\nAll extensions downloaded successfully!");
356
+ })();
357
+
358
+ // ---------------------------------------------------------------------
359
+ // Utility: format bytes for human display
360
+ // ---------------------------------------------------------------------
361
+ function formatBytes(bytes) {
362
+ if (bytes < 1024) return `${bytes} B`;
363
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
364
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------
368
+ // downloadCrx: Build CRX URL and fetch it
369
+ // ---------------------------------------------------------------------
370
+ async function downloadCrx(extensionId) {
371
+ const downloadUrl =
372
+ "https://clients2.google.com/service/update2/crx" +
373
+ "?response=redirect" +
374
+ "&prod=chrome" +
375
+ "&prodversion=9999" +
376
+ "&acceptformat=crx2,crx3" +
377
+ `&x=id%3D${extensionId}%26uc`;
378
+
379
+ console.log("Requesting:", downloadUrl);
380
+
381
+ const crxBuffer = await fetchUrl(downloadUrl);
382
+ return crxBuffer;
383
+ }
384
+
385
+ // ---------------------------------------------------------------------
386
+ // fetchUrl: minimal GET + redirect handling with timeout and progress
387
+ // ---------------------------------------------------------------------
388
+ function fetchUrl(
389
+ targetUrl,
390
+ options = {},
391
+ redirectCount = 0,
392
+ maxRedirects = 10,
393
+ ) {
394
+ return new Promise((resolve, reject) => {
395
+ if (redirectCount > maxRedirects) {
396
+ return reject(new Error("Too many redirects"));
397
+ }
398
+
399
+ const reqOptions = { ...options, timeout: REQUEST_TIMEOUT_MS };
400
+ const req = https.get(targetUrl, reqOptions, (res) => {
401
+ const { statusCode, headers } = res;
402
+
403
+ // Follow redirects
404
+ if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
405
+ const newUrl = new URL(headers.location, targetUrl).href;
406
+ res.resume(); // discard body
407
+ return resolve(
408
+ fetchUrl(newUrl, options, redirectCount + 1, maxRedirects),
409
+ );
410
+ }
411
+
412
+ if (statusCode !== 200) {
413
+ res.resume();
414
+ return reject(
415
+ new Error(`Request failed with status code ${statusCode}`),
416
+ );
417
+ }
418
+
419
+ const contentLength = parseInt(headers["content-length"], 10) || 0;
420
+ const dataChunks = [];
421
+ let downloadedBytes = 0;
422
+ let lastProgressUpdate = 0;
423
+
424
+ res.on("data", (chunk) => {
425
+ dataChunks.push(chunk);
426
+ downloadedBytes += chunk.length;
427
+
428
+ // Update progress at most every 100ms to avoid flickering
429
+ const now = Date.now();
430
+ if (contentLength > 0 && now - lastProgressUpdate > 100) {
431
+ lastProgressUpdate = now;
432
+ const percent = Math.round((downloadedBytes / contentLength) * 100);
433
+ const progressBar = createProgressBar(percent);
434
+ process.stdout.write(
435
+ `\r ${progressBar} ${percent}% (${formatBytes(downloadedBytes)})`,
436
+ );
437
+ }
438
+ });
439
+
440
+ res.on("end", () => {
441
+ // Clear the progress line
442
+ if (contentLength > 0) {
443
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
444
+ }
445
+ resolve(Buffer.concat(dataChunks));
446
+ });
447
+ });
448
+
449
+ req.on("timeout", () => {
450
+ req.destroy();
451
+ reject(
452
+ new Error(
453
+ `Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${targetUrl}`,
454
+ ),
455
+ );
456
+ });
457
+
458
+ req.on("error", (err) => {
459
+ reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`));
460
+ });
461
+ });
462
+ }
463
+
464
+ // ---------------------------------------------------------------------
465
+ // createProgressBar: Generate ASCII progress bar
466
+ // ---------------------------------------------------------------------
467
+ function createProgressBar(percent, width = 20) {
468
+ const filled = Math.round((percent / 100) * width);
469
+ const empty = width - filled;
470
+ return "[" + "=".repeat(filled) + " ".repeat(empty) + "]";
471
+ }
472
+
473
+ // ---------------------------------------------------------------------
474
+ // extractCrxToFolder
475
+ // 1) Checks "Cr24" magic
476
+ // 2) Reads version (2 or 3/4) to find the ZIP start
477
+ // 3) Uses parseZipCentralDirectory() to extract files properly
478
+ // ---------------------------------------------------------------------
479
+ function extractCrxToFolder(crxBuffer, outFolder) {
480
+ if (crxBuffer.toString("utf8", 0, 4) !== "Cr24") {
481
+ throw new Error("Not a valid CRX file (missing Cr24 magic).");
482
+ }
483
+
484
+ const version = crxBuffer.readUInt32LE(4);
485
+ let zipStartOffset = 0;
486
+
487
+ if (version === 2) {
488
+ const pkLen = crxBuffer.readUInt32LE(8);
489
+ const sigLen = crxBuffer.readUInt32LE(12);
490
+ zipStartOffset = 16 + pkLen + sigLen;
491
+ } else if (version === 3 || version === 4) {
492
+ const headerSize = crxBuffer.readUInt32LE(8);
493
+ zipStartOffset = 12 + headerSize;
494
+ } else {
495
+ throw new Error(
496
+ `Unsupported CRX version (${version}). Only v2, v3, or v4 are supported.`,
497
+ );
498
+ }
499
+
500
+ if (zipStartOffset >= crxBuffer.length) {
501
+ throw new Error("Malformed CRX: header size exceeds file length.");
502
+ }
503
+
504
+ const zipBuffer = crxBuffer.slice(zipStartOffset);
505
+
506
+ // Parse that ZIP via the central directory approach
507
+ parseZipCentralDirectory(zipBuffer, outFolder);
508
+ }
509
+
510
+ // ---------------------------------------------------------------------
511
+ // parseZipCentralDirectory(buffer, outFolder)
512
+ // 1) Finds End of Central Directory (EOCD) record
513
+ // 2) Reads central directory for file metadata
514
+ // 3) For each file, decompress into outFolder
515
+ // ---------------------------------------------------------------------
516
+ function parseZipCentralDirectory(zipBuffer, outFolder) {
517
+ // Find EOCD by scanning backwards from end of file
518
+ let eocdPos = -1;
519
+ const minPos = Math.max(0, zipBuffer.length - MAX_EOCD_SEARCH);
520
+ for (let i = zipBuffer.length - 4; i >= minPos; i--) {
521
+ if (zipBuffer.readUInt32LE(i) === ZIP_SIGNATURES.EOCD) {
522
+ eocdPos = i;
523
+ break;
524
+ }
525
+ }
526
+ if (eocdPos < 0) {
527
+ throw new Error("Could not find End of Central Directory (EOCD) in ZIP.");
528
+ }
529
+
530
+ const totalCD = zipBuffer.readUInt16LE(eocdPos + 10);
531
+ const cdSize = zipBuffer.readUInt32LE(eocdPos + 12);
532
+ const cdOffset = zipBuffer.readUInt32LE(eocdPos + 16);
533
+
534
+ // ZIP64 check: marker values indicate ZIP64 format is required
535
+ if (cdOffset === ZIP64_MARKER || cdSize === ZIP64_MARKER) {
536
+ throw new Error("ZIP64 format is not supported.");
537
+ }
538
+
539
+ if (cdOffset + cdSize > zipBuffer.length) {
540
+ throw new Error("Central directory offset/size out of range.");
541
+ }
542
+
543
+ let ptr = cdOffset;
544
+ const files = [];
545
+ for (let i = 0; i < totalCD; i++) {
546
+ const sig = zipBuffer.readUInt32LE(ptr);
547
+ if (sig !== ZIP_SIGNATURES.CENTRAL_DIR) {
548
+ throw new Error(`Central directory signature mismatch at ${ptr}`);
549
+ }
550
+ ptr += 4;
551
+
552
+ ptr += 2; // version made by (unused)
553
+ const verNeed = zipBuffer.readUInt16LE(ptr);
554
+ ptr += 2;
555
+ const flags = zipBuffer.readUInt16LE(ptr);
556
+ ptr += 2;
557
+ const method = zipBuffer.readUInt16LE(ptr);
558
+ ptr += 2;
559
+ ptr += 2; // mod time (unused)
560
+ ptr += 2; // mod date (unused)
561
+ const crc32 = zipBuffer.readUInt32LE(ptr);
562
+ ptr += 4;
563
+ const compSize = zipBuffer.readUInt32LE(ptr);
564
+ ptr += 4;
565
+ const unCompSize = zipBuffer.readUInt32LE(ptr);
566
+ ptr += 4;
567
+ const fLen = zipBuffer.readUInt16LE(ptr);
568
+ ptr += 2;
569
+ const xLen = zipBuffer.readUInt16LE(ptr);
570
+ ptr += 2;
571
+ const cLen = zipBuffer.readUInt16LE(ptr);
572
+ ptr += 2;
573
+ ptr += 2; // disk number (unused)
574
+ ptr += 2; // internal attributes (unused)
575
+ ptr += 4; // external attributes (unused)
576
+ const localHeaderOffset = zipBuffer.readUInt32LE(ptr);
577
+ ptr += 4;
578
+
579
+ const filename = zipBuffer.toString("utf8", ptr, ptr + fLen);
580
+ ptr += fLen + xLen + cLen; // skip the extra + comment
581
+
582
+ // Validate: encrypted files not supported
583
+ if (flags & ZIP_FLAGS.ENCRYPTED) {
584
+ throw new Error(`Encrypted files are not supported: ${filename}`);
585
+ }
586
+
587
+ // Validate: ZIP64 extended sizes not supported
588
+ if (
589
+ compSize === ZIP64_MARKER ||
590
+ unCompSize === ZIP64_MARKER ||
591
+ localHeaderOffset === ZIP64_MARKER
592
+ ) {
593
+ throw new Error(
594
+ `ZIP64 extended information not supported for file: ${filename}`,
595
+ );
596
+ }
597
+
598
+ files.push({
599
+ filename,
600
+ method,
601
+ compSize,
602
+ unCompSize,
603
+ flags,
604
+ localHeaderOffset,
605
+ crc32,
606
+ verNeed,
607
+ });
608
+ }
609
+
610
+ const resolvedOutFolder = path.resolve(outFolder);
611
+ fs.mkdirSync(resolvedOutFolder, { recursive: true });
612
+
613
+ for (const file of files) {
614
+ const { filename, method, compSize, localHeaderOffset } = file;
615
+
616
+ // Security: validate path to prevent directory traversal attacks
617
+ const outPath = path.join(resolvedOutFolder, filename);
618
+ if (
619
+ !outPath.startsWith(resolvedOutFolder + path.sep) &&
620
+ outPath !== resolvedOutFolder
621
+ ) {
622
+ throw new Error(
623
+ `Path traversal detected, refusing to extract: ${filename}`,
624
+ );
625
+ }
626
+
627
+ if (filename.endsWith("/")) {
628
+ fs.mkdirSync(outPath, { recursive: true });
629
+ continue;
630
+ }
631
+
632
+ let lhPtr = localHeaderOffset;
633
+ const localSig = zipBuffer.readUInt32LE(lhPtr);
634
+ if (localSig !== ZIP_SIGNATURES.LOCAL_FILE) {
635
+ throw new Error(`Local file header mismatch at ${lhPtr} for ${filename}`);
636
+ }
637
+ lhPtr += 4;
638
+
639
+ lhPtr += 2; // version needed
640
+ lhPtr += 2; // flags
641
+ lhPtr += 2; // method
642
+ lhPtr += 2; // mod time
643
+ lhPtr += 2; // mod date
644
+ lhPtr += 4; // crc32
645
+ lhPtr += 4; // comp size
646
+ lhPtr += 4; // uncomp size
647
+ const lhFNameLen = zipBuffer.readUInt16LE(lhPtr);
648
+ lhPtr += 2;
649
+ const lhXLen = zipBuffer.readUInt16LE(lhPtr);
650
+ lhPtr += 2;
651
+
652
+ lhPtr += lhFNameLen + lhXLen;
653
+ const fileData = zipBuffer.slice(lhPtr, lhPtr + compSize);
654
+
655
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
656
+
657
+ if (method === ZIP_METHODS.STORE) {
658
+ fs.writeFileSync(outPath, fileData);
659
+ } else if (method === ZIP_METHODS.DEFLATE) {
660
+ const unzipped = zlib.inflateRawSync(fileData);
661
+ fs.writeFileSync(outPath, unzipped);
662
+ } else {
663
+ throw new Error(
664
+ `Unsupported compression method (${method}) for file ${filename}`,
665
+ );
666
+ }
667
+ }
668
+ }