to-userscript
Version:
Converts simple browser extensions to userscripts
275 lines (227 loc) • 7.92 kB
JavaScript
const fs = require("fs").promises;
const path = require("path");
const yauzl = require("yauzl");
const debug = require("debug")("to-userscript:unpacker");
async function detectArchiveType(archivePath) {
debug("Detecting archive type for: %s", archivePath);
const ext = path.extname(archivePath).toLowerCase();
if ([".zip", ".xpi"].includes(ext)) {
return "zip";
}
if (ext === ".crx") {
return "crx";
}
// Try to detect by reading file header
try {
const handle = await fs.open(archivePath, "r");
const buffer = Buffer.alloc(8);
await handle.read(buffer, 0, 8, 0);
await handle.close();
// Check for ZIP signature
if (buffer[0] === 0x50 && buffer[1] === 0x4b) {
debug("Detected ZIP signature");
return "zip";
}
// Check for CRX signature
if (
buffer[0] === 0x43 &&
buffer[1] === 0x72 &&
buffer[2] === 0x32 &&
buffer[3] === 0x34
) {
debug("Detected CRX signature");
return "crx";
}
} catch (error) {
debug("Error reading file header: %s", error.message);
}
// Default to zip if we can't determine
debug("Defaulting to ZIP format");
return "zip";
}
async function extractZip(archivePath, destinationDir) {
debug("Extracting ZIP archive: %s -> %s", archivePath, destinationDir);
return new Promise((resolve, reject) => {
yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
reject(new Error(`Failed to open ZIP file: ${err.message}`));
return;
}
let extractedFiles = 0;
zipfile.readEntry();
zipfile.on("entry", async (entry) => {
const entryPath = path.join(destinationDir, entry.fileName);
// Security check: ensure the entry doesn't escape the destination directory
const normalizedPath = path.normalize(entryPath);
if (!normalizedPath.startsWith(path.normalize(destinationDir))) {
debug("Skipping potentially dangerous path: %s", entry.fileName);
zipfile.readEntry();
return;
}
if (/\/$/.test(entry.fileName)) {
// Directory entry
try {
await fs.mkdir(entryPath, { recursive: true });
debug("Created directory: %s", entry.fileName);
} catch (mkdirError) {
debug(
"Error creating directory %s: %s",
entry.fileName,
mkdirError.message
);
}
zipfile.readEntry();
} else {
// File entry
zipfile.openReadStream(entry, async (streamErr, readStream) => {
if (streamErr) {
debug(
"Error opening read stream for %s: %s",
entry.fileName,
streamErr.message
);
zipfile.readEntry();
return;
}
try {
// Ensure parent directory exists
await fs.mkdir(path.dirname(entryPath), { recursive: true });
const writeStream = await fs.open(entryPath, "w");
for await (const chunk of readStream) {
await writeStream.write(chunk);
}
await writeStream.close();
extractedFiles++;
debug(
"Extracted file: %s (%d bytes)",
entry.fileName,
entry.uncompressedSize
);
} catch (fileError) {
debug(
"Error extracting file %s: %s",
entry.fileName,
fileError.message
);
}
zipfile.readEntry();
});
}
});
zipfile.on("end", () => {
debug("ZIP extraction complete: %d files extracted", extractedFiles);
resolve(destinationDir);
});
zipfile.on("error", (zipError) => {
reject(new Error(`ZIP extraction failed: ${zipError.message}`));
});
});
});
}
async function extractCrx(archivePath, destinationDir) {
debug("Extracting CRX archive: %s -> %s", archivePath, destinationDir);
// CRX files are ZIP files with a header
// We need to skip the header and extract the ZIP portion
const handle = await fs.open(archivePath, "r");
try {
// Read CRX header to determine ZIP offset
const headerBuffer = Buffer.alloc(16);
await handle.read(headerBuffer, 0, 16, 0);
// CRX3 format: "Cr24" + version (4 bytes) + header_length (4 bytes) + header_content
if (headerBuffer.toString("ascii", 0, 4) !== "Cr24") {
throw new Error("Invalid CRX file: missing Cr24 signature");
}
const version = headerBuffer.readUInt32LE(4);
debug("CRX version: %d", version);
let zipOffset;
if (version === 2) {
// CRX2 format
const publicKeyLength = headerBuffer.readUInt32LE(8);
const signatureLength = headerBuffer.readUInt32LE(12);
zipOffset = 16 + publicKeyLength + signatureLength;
} else if (version === 3) {
// CRX3 format
const headerLength = headerBuffer.readUInt32LE(8);
zipOffset = 12 + headerLength;
} else {
throw new Error(`Unsupported CRX version: ${version}`);
}
debug("ZIP data starts at offset: %d", zipOffset);
// Create a temporary ZIP file with just the ZIP portion
const tempZipPath = archivePath + ".zip";
const stats = await fs.stat(archivePath);
const zipSize = stats.size - zipOffset;
debug("Creating temporary ZIP file: %s (%d bytes)", tempZipPath, zipSize);
const tempZipHandle = await fs.open(tempZipPath, "w");
try {
// Copy ZIP portion to temporary file
const buffer = Buffer.alloc(64 * 1024); // 64KB buffer
let position = zipOffset;
let remaining = zipSize;
while (remaining > 0) {
const chunkSize = Math.min(buffer.length, remaining);
const { bytesRead } = await handle.read(buffer, 0, chunkSize, position);
if (bytesRead === 0) break;
await tempZipHandle.write(buffer, 0, bytesRead);
position += bytesRead;
remaining -= bytesRead;
}
} finally {
await tempZipHandle.close();
}
// Extract the temporary ZIP file
await extractZip(tempZipPath, destinationDir);
// Clean up temporary file
try {
await fs.unlink(tempZipPath);
debug("Cleaned up temporary ZIP file");
} catch (cleanupError) {
debug(
"Warning: failed to clean up temporary file: %s",
cleanupError.message
);
}
return destinationDir;
} finally {
await handle.close();
}
}
async function unpack(archivePath, destinationDir) {
debug("Starting unpack: %s -> %s", archivePath, destinationDir);
try {
// Ensure destination directory exists
await fs.mkdir(destinationDir, { recursive: true });
// Detect archive type
const archiveType = await detectArchiveType(archivePath);
debug("Archive type detected: %s", archiveType);
// Extract based on type
let result;
if (archiveType === "crx") {
result = await extractCrx(archivePath, destinationDir);
} else {
result = await extractZip(archivePath, destinationDir);
}
// Verify manifest.json exists
const manifestPath = path.join(destinationDir, "manifest.json");
try {
await fs.access(manifestPath);
debug("Manifest verified at: %s", manifestPath);
} catch (manifestError) {
throw new Error(
`No manifest.json found after extraction. This may not be a valid browser extension.`
);
}
debug("Unpack completed successfully: %s", result);
return result;
} catch (error) {
const errorMsg = `Failed to unpack archive ${archivePath}: ${error.message}`;
debug("Unpack error: %s", errorMsg);
throw new Error(errorMsg);
}
}
module.exports = {
unpack,
detectArchiveType,
extractZip,
extractCrx,
};