w3wallets 0.10.2 → 1.0.0-beta.1
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/README.md +51 -19
- package/dist/index.d.mts +125 -42
- package/dist/index.d.ts +125 -42
- package/dist/index.js +277 -241
- package/dist/index.mjs +266 -237
- package/package.json +2 -2
- package/src/scripts/download.js +366 -67
package/src/scripts/download.js
CHANGED
|
@@ -1,81 +1,307 @@
|
|
|
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
|
|
10
|
-
* npx w3wallets
|
|
7
|
+
* npx w3wallets metamask backpack # Download by alias
|
|
8
|
+
* npx w3wallets mm bp 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
|
|
21
|
+
const EXTENSION_REGISTRY = {
|
|
22
|
+
// Backpack wallet
|
|
23
23
|
backpack: "aflkmfhebedbjioipglgcbcmnbpgliof",
|
|
24
|
+
bp: "aflkmfhebedbjioipglgcbcmnbpgliof",
|
|
25
|
+
|
|
26
|
+
// MetaMask wallet
|
|
24
27
|
metamask: "nkbihfbeogaeaoehlefnkodbefgpgknn",
|
|
25
|
-
|
|
28
|
+
mm: "nkbihfbeogaeaoehlefnkodbefgpgknn",
|
|
29
|
+
|
|
30
|
+
// Polkadot.js wallet
|
|
31
|
+
polkadotjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
|
|
32
|
+
pjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
|
|
26
33
|
};
|
|
27
34
|
|
|
35
|
+
// Human-readable names for display
|
|
36
|
+
const EXTENSION_NAMES = {
|
|
37
|
+
aflkmfhebedbjioipglgcbcmnbpgliof: "Backpack",
|
|
38
|
+
nkbihfbeogaeaoehlefnkodbefgpgknn: "MetaMask",
|
|
39
|
+
mopnmbcafieddcagagdcbnhejhlodfdd: "Polkadot.js",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Canonical aliases for listing
|
|
43
|
+
const CANONICAL_ALIASES = [
|
|
44
|
+
{ name: "backpack", short: "bp", id: "aflkmfhebedbjioipglgcbcmnbpgliof" },
|
|
45
|
+
{ name: "metamask", short: "mm", id: "nkbihfbeogaeaoehlefnkodbefgpgknn" },
|
|
46
|
+
{ name: "polkadotjs", short: "pjs", id: "mopnmbcafieddcagagdcbnhejhlodfdd" },
|
|
47
|
+
];
|
|
48
|
+
|
|
28
49
|
// ---------------------------------------------------------------------
|
|
29
|
-
//
|
|
50
|
+
// ZIP format constants (per PKWARE APPNOTE.TXT specification)
|
|
30
51
|
// ---------------------------------------------------------------------
|
|
31
|
-
const
|
|
52
|
+
const ZIP_SIGNATURES = {
|
|
53
|
+
EOCD: 0x06054b50, // End of Central Directory
|
|
54
|
+
CENTRAL_DIR: 0x02014b50, // Central Directory file header
|
|
55
|
+
LOCAL_FILE: 0x04034b50, // Local file header
|
|
56
|
+
};
|
|
32
57
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
const ZIP_FLAGS = {
|
|
59
|
+
ENCRYPTED: 0x0001, // File is encrypted
|
|
60
|
+
DATA_DESCRIPTOR: 0x0008, // Sizes in data descriptor after file data
|
|
61
|
+
UTF8_FILENAME: 0x0800, // Filename is UTF-8 encoded
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ZIP_METHODS = {
|
|
65
|
+
STORE: 0, // No compression
|
|
66
|
+
DEFLATE: 8, // Deflate compression
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Marker value indicating ZIP64 format is required
|
|
70
|
+
const ZIP64_MARKER = 0xffffffff;
|
|
71
|
+
|
|
72
|
+
// Maximum size of EOCD record (22 bytes + max 65535 comment)
|
|
73
|
+
const MAX_EOCD_SEARCH = 65557;
|
|
74
|
+
|
|
75
|
+
// HTTP request timeout in milliseconds
|
|
76
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------
|
|
79
|
+
// 2. CLI Argument Parser
|
|
80
|
+
// ---------------------------------------------------------------------
|
|
81
|
+
const CLI_OPTIONS = {
|
|
82
|
+
help: false,
|
|
83
|
+
list: false,
|
|
84
|
+
output: ".w3wallets",
|
|
85
|
+
force: false,
|
|
86
|
+
debug: false,
|
|
87
|
+
targets: [], // aliases or extension IDs
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function printHelp() {
|
|
91
|
+
console.log(`
|
|
92
|
+
w3wallets - Download Chrome extensions from the Chrome Web Store
|
|
93
|
+
|
|
94
|
+
USAGE:
|
|
95
|
+
npx w3wallets [OPTIONS] <targets...>
|
|
96
|
+
|
|
97
|
+
TARGETS:
|
|
98
|
+
Alias name Known wallet alias (e.g., metamask, backpack)
|
|
99
|
+
Short alias Short form (e.g., mm, bp, pjs)
|
|
100
|
+
Extension ID 32-character Chrome extension ID
|
|
101
|
+
URL Chrome Web Store URL
|
|
102
|
+
|
|
103
|
+
OPTIONS:
|
|
104
|
+
-h, --help Show this help message
|
|
105
|
+
-l, --list List available wallet aliases
|
|
106
|
+
-o, --output Output directory (default: .w3wallets)
|
|
107
|
+
-f, --force Force re-download even if already exists
|
|
108
|
+
--debug Save raw .crx file for debugging
|
|
109
|
+
|
|
110
|
+
EXAMPLES:
|
|
111
|
+
npx w3wallets metamask # Download MetaMask
|
|
112
|
+
npx w3wallets mm bp # Download using short aliases
|
|
113
|
+
npx w3wallets --list # List available aliases
|
|
114
|
+
npx w3wallets -o ./extensions metamask # Custom output directory
|
|
115
|
+
npx w3wallets --force mm # Force re-download
|
|
116
|
+
npx w3wallets nkbihfbeogaeaoehlefnkodbefgpgknn # Download by extension ID
|
|
117
|
+
npx w3wallets "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
118
|
+
`);
|
|
37
119
|
}
|
|
38
120
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
121
|
+
function printList() {
|
|
122
|
+
console.log("\nAvailable wallet aliases:\n");
|
|
123
|
+
console.log(" ALIAS SHORT EXTENSION ID");
|
|
124
|
+
console.log(" " + "-".repeat(50));
|
|
125
|
+
for (const { name, short, id } of CANONICAL_ALIASES) {
|
|
126
|
+
console.log(` ${name.padEnd(12)} ${short.padEnd(7)} ${id}`);
|
|
45
127
|
}
|
|
128
|
+
console.log(
|
|
129
|
+
"\nYou can also download any extension by ID or Chrome Web Store URL.\n",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse extension ID from various input formats:
|
|
135
|
+
* - Known alias (case-insensitive): "metamask", "MetaMask", "MM"
|
|
136
|
+
* - Direct extension ID: "nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
137
|
+
* - Chrome Web Store URL: "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
|
|
138
|
+
*/
|
|
139
|
+
function parseExtensionTarget(input) {
|
|
140
|
+
// Check for known alias (case-insensitive)
|
|
141
|
+
const normalizedInput = input.toLowerCase();
|
|
142
|
+
if (EXTENSION_REGISTRY[normalizedInput]) {
|
|
143
|
+
const id = EXTENSION_REGISTRY[normalizedInput];
|
|
144
|
+
// Find canonical alias name for directory
|
|
145
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
name: EXTENSION_NAMES[id] || normalizedInput,
|
|
149
|
+
dirName: alias ? alias.name : id,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check if it's a Chrome Web Store URL
|
|
154
|
+
const urlPatterns = [
|
|
155
|
+
/chromewebstore\.google\.com\/detail\/[^/]+\/([a-z]{32})/i,
|
|
156
|
+
/chrome\.google\.com\/webstore\/detail\/[^/]+\/([a-z]{32})/i,
|
|
157
|
+
];
|
|
158
|
+
for (const pattern of urlPatterns) {
|
|
159
|
+
const match = input.match(pattern);
|
|
160
|
+
if (match) {
|
|
161
|
+
const id = match[1].toLowerCase();
|
|
162
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
name: EXTENSION_NAMES[id] || id,
|
|
166
|
+
dirName: alias ? alias.name : id,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if it's a direct extension ID (32 lowercase letters)
|
|
172
|
+
if (/^[a-z]{32}$/i.test(input)) {
|
|
173
|
+
const id = input.toLowerCase();
|
|
174
|
+
const alias = CANONICAL_ALIASES.find((a) => a.id === id);
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
name: EXTENSION_NAMES[id] || id,
|
|
178
|
+
dirName: alias ? alias.name : id,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseArgs(args) {
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < args.length) {
|
|
188
|
+
const arg = args[i];
|
|
189
|
+
|
|
190
|
+
if (arg === "-h" || arg === "--help") {
|
|
191
|
+
CLI_OPTIONS.help = true;
|
|
192
|
+
} else if (arg === "-l" || arg === "--list") {
|
|
193
|
+
CLI_OPTIONS.list = true;
|
|
194
|
+
} else if (arg === "-o" || arg === "--output") {
|
|
195
|
+
i++;
|
|
196
|
+
if (i >= args.length) {
|
|
197
|
+
console.error("Error: --output requires a directory path");
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
CLI_OPTIONS.output = args[i];
|
|
201
|
+
} else if (arg === "-f" || arg === "--force") {
|
|
202
|
+
CLI_OPTIONS.force = true;
|
|
203
|
+
} else if (arg === "--debug") {
|
|
204
|
+
CLI_OPTIONS.debug = true;
|
|
205
|
+
} else if (arg.startsWith("-")) {
|
|
206
|
+
console.error(`Error: Unknown option "${arg}"`);
|
|
207
|
+
console.error("Use --help for usage information");
|
|
208
|
+
process.exit(1);
|
|
209
|
+
} else {
|
|
210
|
+
// It's a target (alias, ID, or URL)
|
|
211
|
+
const parsed = parseExtensionTarget(arg);
|
|
212
|
+
if (!parsed) {
|
|
213
|
+
console.error(
|
|
214
|
+
`Error: "${arg}" is not a valid alias, extension ID, or URL`,
|
|
215
|
+
);
|
|
216
|
+
console.error(
|
|
217
|
+
"Use --list to see available aliases, or provide a 32-character extension ID",
|
|
218
|
+
);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
CLI_OPTIONS.targets.push(parsed);
|
|
222
|
+
}
|
|
223
|
+
i++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Parse command line arguments
|
|
228
|
+
parseArgs(process.argv.slice(2));
|
|
229
|
+
|
|
230
|
+
// Handle --help
|
|
231
|
+
if (CLI_OPTIONS.help) {
|
|
232
|
+
printHelp();
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle --list
|
|
237
|
+
if (CLI_OPTIONS.list) {
|
|
238
|
+
printList();
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Validate we have targets
|
|
243
|
+
if (CLI_OPTIONS.targets.length === 0) {
|
|
244
|
+
console.error("Error: No extension targets specified");
|
|
245
|
+
console.error(
|
|
246
|
+
"Use --help for usage information or --list to see available aliases",
|
|
247
|
+
);
|
|
248
|
+
process.exit(1);
|
|
46
249
|
}
|
|
47
250
|
|
|
48
251
|
// ---------------------------------------------------------------------
|
|
49
|
-
// 3. Main: download and extract each requested
|
|
252
|
+
// 3. Main: download and extract each requested extension
|
|
50
253
|
// ---------------------------------------------------------------------
|
|
51
254
|
(async function main() {
|
|
52
|
-
for (const
|
|
53
|
-
const
|
|
54
|
-
|
|
255
|
+
for (const target of CLI_OPTIONS.targets) {
|
|
256
|
+
const { id, name, dirName } = target;
|
|
257
|
+
const outDir = path.join(CLI_OPTIONS.output, dirName);
|
|
55
258
|
|
|
56
|
-
|
|
57
|
-
// 1) Download CRX
|
|
58
|
-
const crxBuffer = await downloadCrx(extensionId);
|
|
59
|
-
console.log(`Got CRX data for "${alias}"! ${crxBuffer.length} bytes`);
|
|
259
|
+
console.log(`\n=== ${name} (${id}) ===`);
|
|
60
260
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
261
|
+
// Check if already exists (skip unless --force)
|
|
262
|
+
const manifestPath = path.join(outDir, "manifest.json");
|
|
263
|
+
if (!CLI_OPTIONS.force && fs.existsSync(manifestPath)) {
|
|
264
|
+
console.log(`Already exists: ${outDir}`);
|
|
265
|
+
console.log("Use --force to re-download");
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
64
268
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
console.log(
|
|
269
|
+
try {
|
|
270
|
+
// 1) Download CRX with progress
|
|
271
|
+
console.log("Downloading...");
|
|
272
|
+
const crxBuffer = await downloadCrx(id);
|
|
273
|
+
console.log(`Downloaded ${formatBytes(crxBuffer.length)}`);
|
|
274
|
+
|
|
275
|
+
// 2) Optionally save raw CRX for debugging
|
|
276
|
+
if (CLI_OPTIONS.debug) {
|
|
277
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
278
|
+
const debugPath = path.join(outDir, `debug-${dirName}.crx`);
|
|
279
|
+
fs.writeFileSync(debugPath, crxBuffer);
|
|
280
|
+
console.log(`Debug CRX saved: ${debugPath}`);
|
|
281
|
+
}
|
|
68
282
|
|
|
69
|
-
// 3) Extract CRX
|
|
283
|
+
// 3) Extract CRX
|
|
284
|
+
console.log("Extracting...");
|
|
70
285
|
extractCrxToFolder(crxBuffer, outDir);
|
|
71
|
-
console.log(`
|
|
286
|
+
console.log(`Done: ${outDir}`);
|
|
72
287
|
} catch (err) {
|
|
73
|
-
console.error(`Failed
|
|
288
|
+
console.error(`Failed: ${err.message}`);
|
|
74
289
|
process.exit(1);
|
|
75
290
|
}
|
|
76
291
|
}
|
|
292
|
+
|
|
293
|
+
console.log("\nAll extensions downloaded successfully!");
|
|
77
294
|
})();
|
|
78
295
|
|
|
296
|
+
// ---------------------------------------------------------------------
|
|
297
|
+
// Utility: format bytes for human display
|
|
298
|
+
// ---------------------------------------------------------------------
|
|
299
|
+
function formatBytes(bytes) {
|
|
300
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
301
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
302
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
303
|
+
}
|
|
304
|
+
|
|
79
305
|
// ---------------------------------------------------------------------
|
|
80
306
|
// downloadCrx: Build CRX URL and fetch it
|
|
81
307
|
// ---------------------------------------------------------------------
|
|
@@ -95,7 +321,7 @@ async function downloadCrx(extensionId) {
|
|
|
95
321
|
}
|
|
96
322
|
|
|
97
323
|
// ---------------------------------------------------------------------
|
|
98
|
-
// fetchUrl: minimal GET + redirect handling
|
|
324
|
+
// fetchUrl: minimal GET + redirect handling with timeout and progress
|
|
99
325
|
// ---------------------------------------------------------------------
|
|
100
326
|
function fetchUrl(
|
|
101
327
|
targetUrl,
|
|
@@ -108,12 +334,13 @@ function fetchUrl(
|
|
|
108
334
|
return reject(new Error("Too many redirects"));
|
|
109
335
|
}
|
|
110
336
|
|
|
111
|
-
const
|
|
337
|
+
const reqOptions = { ...options, timeout: REQUEST_TIMEOUT_MS };
|
|
338
|
+
const req = https.get(targetUrl, reqOptions, (res) => {
|
|
112
339
|
const { statusCode, headers } = res;
|
|
113
340
|
|
|
114
341
|
// Follow redirects
|
|
115
342
|
if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
|
|
116
|
-
const newUrl =
|
|
343
|
+
const newUrl = new URL(headers.location, targetUrl).href;
|
|
117
344
|
res.resume(); // discard body
|
|
118
345
|
return resolve(
|
|
119
346
|
fetchUrl(newUrl, options, redirectCount + 1, maxRedirects),
|
|
@@ -127,15 +354,60 @@ function fetchUrl(
|
|
|
127
354
|
);
|
|
128
355
|
}
|
|
129
356
|
|
|
357
|
+
const contentLength = parseInt(headers["content-length"], 10) || 0;
|
|
130
358
|
const dataChunks = [];
|
|
131
|
-
|
|
132
|
-
|
|
359
|
+
let downloadedBytes = 0;
|
|
360
|
+
let lastProgressUpdate = 0;
|
|
361
|
+
|
|
362
|
+
res.on("data", (chunk) => {
|
|
363
|
+
dataChunks.push(chunk);
|
|
364
|
+
downloadedBytes += chunk.length;
|
|
365
|
+
|
|
366
|
+
// Update progress at most every 100ms to avoid flickering
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
if (contentLength > 0 && now - lastProgressUpdate > 100) {
|
|
369
|
+
lastProgressUpdate = now;
|
|
370
|
+
const percent = Math.round((downloadedBytes / contentLength) * 100);
|
|
371
|
+
const progressBar = createProgressBar(percent);
|
|
372
|
+
process.stdout.write(
|
|
373
|
+
`\r ${progressBar} ${percent}% (${formatBytes(downloadedBytes)})`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
res.on("end", () => {
|
|
379
|
+
// Clear the progress line
|
|
380
|
+
if (contentLength > 0) {
|
|
381
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
382
|
+
}
|
|
383
|
+
resolve(Buffer.concat(dataChunks));
|
|
384
|
+
});
|
|
133
385
|
});
|
|
134
386
|
|
|
135
|
-
req.on("
|
|
387
|
+
req.on("timeout", () => {
|
|
388
|
+
req.destroy();
|
|
389
|
+
reject(
|
|
390
|
+
new Error(
|
|
391
|
+
`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${targetUrl}`,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
req.on("error", (err) => {
|
|
397
|
+
reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`));
|
|
398
|
+
});
|
|
136
399
|
});
|
|
137
400
|
}
|
|
138
401
|
|
|
402
|
+
// ---------------------------------------------------------------------
|
|
403
|
+
// createProgressBar: Generate ASCII progress bar
|
|
404
|
+
// ---------------------------------------------------------------------
|
|
405
|
+
function createProgressBar(percent, width = 20) {
|
|
406
|
+
const filled = Math.round((percent / 100) * width);
|
|
407
|
+
const empty = width - filled;
|
|
408
|
+
return "[" + "=".repeat(filled) + " ".repeat(empty) + "]";
|
|
409
|
+
}
|
|
410
|
+
|
|
139
411
|
// ---------------------------------------------------------------------
|
|
140
412
|
// extractCrxToFolder
|
|
141
413
|
// 1) Checks "Cr24" magic
|
|
@@ -149,6 +421,7 @@ function extractCrxToFolder(crxBuffer, outFolder) {
|
|
|
149
421
|
|
|
150
422
|
const version = crxBuffer.readUInt32LE(4);
|
|
151
423
|
let zipStartOffset = 0;
|
|
424
|
+
|
|
152
425
|
if (version === 2) {
|
|
153
426
|
const pkLen = crxBuffer.readUInt32LE(8);
|
|
154
427
|
const sigLen = crxBuffer.readUInt32LE(12);
|
|
@@ -174,16 +447,16 @@ function extractCrxToFolder(crxBuffer, outFolder) {
|
|
|
174
447
|
|
|
175
448
|
// ---------------------------------------------------------------------
|
|
176
449
|
// parseZipCentralDirectory(buffer, outFolder)
|
|
177
|
-
// 1) Finds End of Central Directory (EOCD) record
|
|
450
|
+
// 1) Finds End of Central Directory (EOCD) record
|
|
178
451
|
// 2) Reads central directory for file metadata
|
|
179
452
|
// 3) For each file, decompress into outFolder
|
|
180
453
|
// ---------------------------------------------------------------------
|
|
181
454
|
function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
182
|
-
|
|
455
|
+
// Find EOCD by scanning backwards from end of file
|
|
183
456
|
let eocdPos = -1;
|
|
184
|
-
const minPos = Math.max(0, zipBuffer.length -
|
|
457
|
+
const minPos = Math.max(0, zipBuffer.length - MAX_EOCD_SEARCH);
|
|
185
458
|
for (let i = zipBuffer.length - 4; i >= minPos; i--) {
|
|
186
|
-
if (zipBuffer.readUInt32LE(i) ===
|
|
459
|
+
if (zipBuffer.readUInt32LE(i) === ZIP_SIGNATURES.EOCD) {
|
|
187
460
|
eocdPos = i;
|
|
188
461
|
break;
|
|
189
462
|
}
|
|
@@ -196,6 +469,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
|
196
469
|
const cdSize = zipBuffer.readUInt32LE(eocdPos + 12);
|
|
197
470
|
const cdOffset = zipBuffer.readUInt32LE(eocdPos + 16);
|
|
198
471
|
|
|
472
|
+
// ZIP64 check: marker values indicate ZIP64 format is required
|
|
473
|
+
if (cdOffset === ZIP64_MARKER || cdSize === ZIP64_MARKER) {
|
|
474
|
+
throw new Error("ZIP64 format is not supported.");
|
|
475
|
+
}
|
|
476
|
+
|
|
199
477
|
if (cdOffset + cdSize > zipBuffer.length) {
|
|
200
478
|
throw new Error("Central directory offset/size out of range.");
|
|
201
479
|
}
|
|
@@ -204,23 +482,20 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
|
204
482
|
const files = [];
|
|
205
483
|
for (let i = 0; i < totalCD; i++) {
|
|
206
484
|
const sig = zipBuffer.readUInt32LE(ptr);
|
|
207
|
-
if (sig !==
|
|
485
|
+
if (sig !== ZIP_SIGNATURES.CENTRAL_DIR) {
|
|
208
486
|
throw new Error(`Central directory signature mismatch at ${ptr}`);
|
|
209
487
|
}
|
|
210
488
|
ptr += 4;
|
|
211
489
|
|
|
212
|
-
|
|
213
|
-
ptr += 2;
|
|
490
|
+
ptr += 2; // version made by (unused)
|
|
214
491
|
const verNeed = zipBuffer.readUInt16LE(ptr);
|
|
215
492
|
ptr += 2;
|
|
216
493
|
const flags = zipBuffer.readUInt16LE(ptr);
|
|
217
494
|
ptr += 2;
|
|
218
495
|
const method = zipBuffer.readUInt16LE(ptr);
|
|
219
496
|
ptr += 2;
|
|
220
|
-
|
|
221
|
-
ptr += 2;
|
|
222
|
-
/* const modDate = */ zipBuffer.readUInt16LE(ptr);
|
|
223
|
-
ptr += 2;
|
|
497
|
+
ptr += 2; // mod time (unused)
|
|
498
|
+
ptr += 2; // mod date (unused)
|
|
224
499
|
const crc32 = zipBuffer.readUInt32LE(ptr);
|
|
225
500
|
ptr += 4;
|
|
226
501
|
const compSize = zipBuffer.readUInt32LE(ptr);
|
|
@@ -233,18 +508,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
|
233
508
|
ptr += 2;
|
|
234
509
|
const cLen = zipBuffer.readUInt16LE(ptr);
|
|
235
510
|
ptr += 2;
|
|
236
|
-
|
|
237
|
-
ptr += 2;
|
|
238
|
-
|
|
239
|
-
ptr += 2;
|
|
240
|
-
/* const extAttr = */ zipBuffer.readUInt32LE(ptr);
|
|
241
|
-
ptr += 4;
|
|
511
|
+
ptr += 2; // disk number (unused)
|
|
512
|
+
ptr += 2; // internal attributes (unused)
|
|
513
|
+
ptr += 4; // external attributes (unused)
|
|
242
514
|
const localHeaderOffset = zipBuffer.readUInt32LE(ptr);
|
|
243
515
|
ptr += 4;
|
|
244
516
|
|
|
245
517
|
const filename = zipBuffer.toString("utf8", ptr, ptr + fLen);
|
|
246
518
|
ptr += fLen + xLen + cLen; // skip the extra + comment
|
|
247
519
|
|
|
520
|
+
// Validate: encrypted files not supported
|
|
521
|
+
if (flags & ZIP_FLAGS.ENCRYPTED) {
|
|
522
|
+
throw new Error(`Encrypted files are not supported: ${filename}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Validate: ZIP64 extended sizes not supported
|
|
526
|
+
if (
|
|
527
|
+
compSize === ZIP64_MARKER ||
|
|
528
|
+
unCompSize === ZIP64_MARKER ||
|
|
529
|
+
localHeaderOffset === ZIP64_MARKER
|
|
530
|
+
) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`ZIP64 extended information not supported for file: ${filename}`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
248
536
|
files.push({
|
|
249
537
|
filename,
|
|
250
538
|
method,
|
|
@@ -257,19 +545,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
|
257
545
|
});
|
|
258
546
|
}
|
|
259
547
|
|
|
260
|
-
|
|
548
|
+
const resolvedOutFolder = path.resolve(outFolder);
|
|
549
|
+
fs.mkdirSync(resolvedOutFolder, { recursive: true });
|
|
261
550
|
|
|
262
551
|
for (const file of files) {
|
|
263
552
|
const { filename, method, compSize, localHeaderOffset } = file;
|
|
264
553
|
|
|
554
|
+
// Security: validate path to prevent directory traversal attacks
|
|
555
|
+
const outPath = path.join(resolvedOutFolder, filename);
|
|
556
|
+
if (
|
|
557
|
+
!outPath.startsWith(resolvedOutFolder + path.sep) &&
|
|
558
|
+
outPath !== resolvedOutFolder
|
|
559
|
+
) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`Path traversal detected, refusing to extract: ${filename}`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
265
565
|
if (filename.endsWith("/")) {
|
|
266
|
-
fs.mkdirSync(
|
|
566
|
+
fs.mkdirSync(outPath, { recursive: true });
|
|
267
567
|
continue;
|
|
268
568
|
}
|
|
269
569
|
|
|
270
570
|
let lhPtr = localHeaderOffset;
|
|
271
571
|
const localSig = zipBuffer.readUInt32LE(lhPtr);
|
|
272
|
-
if (localSig !==
|
|
572
|
+
if (localSig !== ZIP_SIGNATURES.LOCAL_FILE) {
|
|
273
573
|
throw new Error(`Local file header mismatch at ${lhPtr} for ${filename}`);
|
|
274
574
|
}
|
|
275
575
|
lhPtr += 4;
|
|
@@ -290,12 +590,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
|
|
|
290
590
|
lhPtr += lhFNameLen + lhXLen;
|
|
291
591
|
const fileData = zipBuffer.slice(lhPtr, lhPtr + compSize);
|
|
292
592
|
|
|
293
|
-
const outPath = path.join(outFolder, filename);
|
|
294
593
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
295
594
|
|
|
296
|
-
if (method ===
|
|
595
|
+
if (method === ZIP_METHODS.STORE) {
|
|
297
596
|
fs.writeFileSync(outPath, fileData);
|
|
298
|
-
} else if (method ===
|
|
597
|
+
} else if (method === ZIP_METHODS.DEFLATE) {
|
|
299
598
|
const unzipped = zlib.inflateRawSync(fileData);
|
|
300
599
|
fs.writeFileSync(outPath, unzipped);
|
|
301
600
|
} else {
|