w3wallets 0.10.2 → 1.0.0-beta.10

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.
@@ -1,81 +1,369 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- *
5
- * Downloads and extracts Chrome extensions by alias ("backpack" and "metamask")
4
+ * Downloads and extracts Chrome extensions from the Chrome Web Store.
6
5
  *
7
6
  * Usage:
8
- * npx w3wallets backpack
9
- * npx w3wallets metamask
10
- * npx w3wallets backpack metamask
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
11
  */
12
12
 
13
13
  const fs = require("fs");
14
14
  const https = require("https");
15
15
  const path = require("path");
16
- const url = require("url");
17
16
  const zlib = require("zlib");
18
17
 
19
18
  // ---------------------------------------------------------------------
20
- // 1. Known aliases -> extension IDs
19
+ // 1. Known aliases -> extension IDs (case-insensitive lookup)
21
20
  // ---------------------------------------------------------------------
22
- const ALIASES = {
23
- backpack: "aflkmfhebedbjioipglgcbcmnbpgliof",
21
+ const EXTENSION_REGISTRY = {
22
+ // MetaMask wallet
24
23
  metamask: "nkbihfbeogaeaoehlefnkodbefgpgknn",
25
- polkadotJS: "mopnmbcafieddcagagdcbnhejhlodfdd",
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",
26
35
  };
27
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
+
28
43
  // ---------------------------------------------------------------------
29
- // 2. Read aliases from CLI
44
+ // ZIP format constants (per PKWARE APPNOTE.TXT specification)
30
45
  // ---------------------------------------------------------------------
31
- const inputAliases = process.argv.slice(2);
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
+ };
32
51
 
33
- if (!inputAliases.length) {
34
- console.error("Usage: npx w3wallets <aliases...>");
35
- console.error("Available aliases:", Object.keys(ALIASES).join(", "));
36
- process.exit(1);
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
+ }
37
223
  }
38
224
 
39
- for (const alias of inputAliases) {
40
- if (!ALIASES[alias]) {
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)) {
41
239
  console.error(
42
- `Unknown alias "${alias}". Must be one of: ${Object.keys(ALIASES).join(", ")}`,
240
+ "Error: Cache script not found. Make sure w3wallets is built (run: npx tsup).",
43
241
  );
44
242
  process.exit(1);
45
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);
46
277
  }
47
278
 
48
279
  // ---------------------------------------------------------------------
49
- // 3. Main: download and extract each requested alias
280
+ // 3. Main: download and extract each requested extension
50
281
  // ---------------------------------------------------------------------
51
282
  (async function main() {
52
- for (const alias of inputAliases) {
53
- const extensionId = ALIASES[alias];
54
- console.log(`\n=== Processing alias: "${alias}" (ID: ${extensionId}) ===`);
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
+ }
55
316
 
56
- try {
57
- // 1) Download CRX
58
- const crxBuffer = await downloadCrx(extensionId);
59
- console.log(`Got CRX data for "${alias}"! ${crxBuffer.length} bytes`);
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}) ===`);
60
322
 
61
- // 2) Save raw CRX to disk
62
- const outDir = path.join(".w3wallets", alias);
63
- fs.mkdirSync(outDir, { recursive: true });
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
+ }
64
330
 
65
- const debugPath = path.join(outDir, `debug-${alias}.crx`);
66
- fs.writeFileSync(debugPath, crxBuffer);
67
- console.log(`Saved ${debugPath}`);
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
+ }
68
344
 
69
- // 3) Extract CRX into "wallets/<alias>"
345
+ // 3) Extract CRX
346
+ console.log("Extracting...");
70
347
  extractCrxToFolder(crxBuffer, outDir);
71
- console.log(`Extraction complete! See folder: ${outDir}`);
348
+ console.log(`Done: ${outDir}`);
72
349
  } catch (err) {
73
- console.error(`Failed to process "${alias}":`, err.message);
350
+ console.error(`Failed: ${err.message}`);
74
351
  process.exit(1);
75
352
  }
76
353
  }
354
+
355
+ console.log("\nAll extensions downloaded successfully!");
77
356
  })();
78
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
+
79
367
  // ---------------------------------------------------------------------
80
368
  // downloadCrx: Build CRX URL and fetch it
81
369
  // ---------------------------------------------------------------------
@@ -95,7 +383,7 @@ async function downloadCrx(extensionId) {
95
383
  }
96
384
 
97
385
  // ---------------------------------------------------------------------
98
- // fetchUrl: minimal GET + redirect handling
386
+ // fetchUrl: minimal GET + redirect handling with timeout and progress
99
387
  // ---------------------------------------------------------------------
100
388
  function fetchUrl(
101
389
  targetUrl,
@@ -108,12 +396,13 @@ function fetchUrl(
108
396
  return reject(new Error("Too many redirects"));
109
397
  }
110
398
 
111
- const req = https.get(targetUrl, options, (res) => {
399
+ const reqOptions = { ...options, timeout: REQUEST_TIMEOUT_MS };
400
+ const req = https.get(targetUrl, reqOptions, (res) => {
112
401
  const { statusCode, headers } = res;
113
402
 
114
403
  // Follow redirects
115
404
  if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
116
- const newUrl = url.resolve(targetUrl, headers.location);
405
+ const newUrl = new URL(headers.location, targetUrl).href;
117
406
  res.resume(); // discard body
118
407
  return resolve(
119
408
  fetchUrl(newUrl, options, redirectCount + 1, maxRedirects),
@@ -127,15 +416,60 @@ function fetchUrl(
127
416
  );
128
417
  }
129
418
 
419
+ const contentLength = parseInt(headers["content-length"], 10) || 0;
130
420
  const dataChunks = [];
131
- res.on("data", (chunk) => dataChunks.push(chunk));
132
- res.on("end", () => resolve(Buffer.concat(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
+ );
133
456
  });
134
457
 
135
- req.on("error", (err) => reject(err));
458
+ req.on("error", (err) => {
459
+ reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`));
460
+ });
136
461
  });
137
462
  }
138
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
+
139
473
  // ---------------------------------------------------------------------
140
474
  // extractCrxToFolder
141
475
  // 1) Checks "Cr24" magic
@@ -149,6 +483,7 @@ function extractCrxToFolder(crxBuffer, outFolder) {
149
483
 
150
484
  const version = crxBuffer.readUInt32LE(4);
151
485
  let zipStartOffset = 0;
486
+
152
487
  if (version === 2) {
153
488
  const pkLen = crxBuffer.readUInt32LE(8);
154
489
  const sigLen = crxBuffer.readUInt32LE(12);
@@ -174,16 +509,16 @@ function extractCrxToFolder(crxBuffer, outFolder) {
174
509
 
175
510
  // ---------------------------------------------------------------------
176
511
  // parseZipCentralDirectory(buffer, outFolder)
177
- // 1) Finds End of Central Directory (EOCD) record (0x06054b50).
512
+ // 1) Finds End of Central Directory (EOCD) record
178
513
  // 2) Reads central directory for file metadata
179
514
  // 3) For each file, decompress into outFolder
180
515
  // ---------------------------------------------------------------------
181
516
  function parseZipCentralDirectory(zipBuffer, outFolder) {
182
- const eocdSig = 0x06054b50;
517
+ // Find EOCD by scanning backwards from end of file
183
518
  let eocdPos = -1;
184
- const minPos = Math.max(0, zipBuffer.length - 65557);
519
+ const minPos = Math.max(0, zipBuffer.length - MAX_EOCD_SEARCH);
185
520
  for (let i = zipBuffer.length - 4; i >= minPos; i--) {
186
- if (zipBuffer.readUInt32LE(i) === eocdSig) {
521
+ if (zipBuffer.readUInt32LE(i) === ZIP_SIGNATURES.EOCD) {
187
522
  eocdPos = i;
188
523
  break;
189
524
  }
@@ -196,6 +531,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
196
531
  const cdSize = zipBuffer.readUInt32LE(eocdPos + 12);
197
532
  const cdOffset = zipBuffer.readUInt32LE(eocdPos + 16);
198
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
+
199
539
  if (cdOffset + cdSize > zipBuffer.length) {
200
540
  throw new Error("Central directory offset/size out of range.");
201
541
  }
@@ -204,23 +544,20 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
204
544
  const files = [];
205
545
  for (let i = 0; i < totalCD; i++) {
206
546
  const sig = zipBuffer.readUInt32LE(ptr);
207
- if (sig !== 0x02014b50) {
547
+ if (sig !== ZIP_SIGNATURES.CENTRAL_DIR) {
208
548
  throw new Error(`Central directory signature mismatch at ${ptr}`);
209
549
  }
210
550
  ptr += 4;
211
551
 
212
- /* const verMade = */ zipBuffer.readUInt16LE(ptr);
213
- ptr += 2;
552
+ ptr += 2; // version made by (unused)
214
553
  const verNeed = zipBuffer.readUInt16LE(ptr);
215
554
  ptr += 2;
216
555
  const flags = zipBuffer.readUInt16LE(ptr);
217
556
  ptr += 2;
218
557
  const method = zipBuffer.readUInt16LE(ptr);
219
558
  ptr += 2;
220
- /* const modTime = */ zipBuffer.readUInt16LE(ptr);
221
- ptr += 2;
222
- /* const modDate = */ zipBuffer.readUInt16LE(ptr);
223
- ptr += 2;
559
+ ptr += 2; // mod time (unused)
560
+ ptr += 2; // mod date (unused)
224
561
  const crc32 = zipBuffer.readUInt32LE(ptr);
225
562
  ptr += 4;
226
563
  const compSize = zipBuffer.readUInt32LE(ptr);
@@ -233,18 +570,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
233
570
  ptr += 2;
234
571
  const cLen = zipBuffer.readUInt16LE(ptr);
235
572
  ptr += 2;
236
- /* const diskNo = */ zipBuffer.readUInt16LE(ptr);
237
- ptr += 2;
238
- /* const intAttr = */ zipBuffer.readUInt16LE(ptr);
239
- ptr += 2;
240
- /* const extAttr = */ zipBuffer.readUInt32LE(ptr);
241
- ptr += 4;
573
+ ptr += 2; // disk number (unused)
574
+ ptr += 2; // internal attributes (unused)
575
+ ptr += 4; // external attributes (unused)
242
576
  const localHeaderOffset = zipBuffer.readUInt32LE(ptr);
243
577
  ptr += 4;
244
578
 
245
579
  const filename = zipBuffer.toString("utf8", ptr, ptr + fLen);
246
580
  ptr += fLen + xLen + cLen; // skip the extra + comment
247
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
+
248
598
  files.push({
249
599
  filename,
250
600
  method,
@@ -257,19 +607,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
257
607
  });
258
608
  }
259
609
 
260
- fs.mkdirSync(outFolder, { recursive: true });
610
+ const resolvedOutFolder = path.resolve(outFolder);
611
+ fs.mkdirSync(resolvedOutFolder, { recursive: true });
261
612
 
262
613
  for (const file of files) {
263
614
  const { filename, method, compSize, localHeaderOffset } = file;
264
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
+
265
627
  if (filename.endsWith("/")) {
266
- fs.mkdirSync(path.join(outFolder, filename), { recursive: true });
628
+ fs.mkdirSync(outPath, { recursive: true });
267
629
  continue;
268
630
  }
269
631
 
270
632
  let lhPtr = localHeaderOffset;
271
633
  const localSig = zipBuffer.readUInt32LE(lhPtr);
272
- if (localSig !== 0x04034b50) {
634
+ if (localSig !== ZIP_SIGNATURES.LOCAL_FILE) {
273
635
  throw new Error(`Local file header mismatch at ${lhPtr} for ${filename}`);
274
636
  }
275
637
  lhPtr += 4;
@@ -290,12 +652,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
290
652
  lhPtr += lhFNameLen + lhXLen;
291
653
  const fileData = zipBuffer.slice(lhPtr, lhPtr + compSize);
292
654
 
293
- const outPath = path.join(outFolder, filename);
294
655
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
295
656
 
296
- if (method === 0) {
657
+ if (method === ZIP_METHODS.STORE) {
297
658
  fs.writeFileSync(outPath, fileData);
298
- } else if (method === 8) {
659
+ } else if (method === ZIP_METHODS.DEFLATE) {
299
660
  const unzipped = zlib.inflateRawSync(fileData);
300
661
  fs.writeFileSync(outPath, unzipped);
301
662
  } else {