#!/usr/bin/env node import { createHash } from "node:crypto"; import { cpSync, chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import * as asar from "@electron/asar"; import { addIntegrityFailure, runPatchDescriptors } from "./patches/engine.mjs"; import { corePatchDescriptors } from "./patches/index.mjs"; import { copyTreeContents, relativeFileHashes, sha256File, walkFiles } from "./patches/lib.mjs"; import { verifyLinuxOnboarding } from "./patches/linux-onboarding.mjs"; import { verifyOcrStub } from "./patches/ocr-stub.mjs"; import { verifyNonLinuxHeaderEntries, verifyNonLinuxPayloadsAbsent, } from "./patches/prune-non-linux.mjs"; import { AUTOSTART_MARKER } from "./patches/xdg-autostart.mjs"; import { UPDATER_MARKER } from "./patches/disable-updater.mjs"; import { TRAY_MARKER } from "./patches/linux-tray.mjs"; const USAGE = `Usage: node scripts/patch-asar.mjs \\ --asar PATH \\ --unpacked PATH \\ --report PATH \\ [--native-replacements DIRECTORY] The ASAR and its unpacked directory are replaced in place only after every required patch and post-repack integrity check succeeds. A native replacement directory, when supplied, must mirror paths below the extracted application (for example node_modules/better-sqlite3/build/Release/better_sqlite3.node).`; export const XSEL_RELATIVE_PATH = "node_modules/clipboardy/fallbacks/linux/xsel"; function parseArgs(argv) { const options = {}; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--help" || argument === "-h") return { help: true }; const key = { "--asar": "asarPath", "--unpacked": "unpackedPath", "--report": "reportPath", "--native-replacements": "nativeReplacements", }[argument]; if (!key) throw new Error(`Unknown argument: ${argument}`); const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${argument} requires a path`); options[key] = path.resolve(value); index += 1; } for (const key of ["asarPath", "unpackedPath", "reportPath"]) { if (!options[key]) throw new Error(`Missing required option for ${key}`); } return options; } function atomicJson(filePath, value) { mkdirSync(path.dirname(filePath), { recursive: true }); const temporary = `${filePath}.tmp-${process.pid}`; writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); renameSync(temporary, filePath); } function safeMainPath(extractedDir) { const packagePath = path.join(extractedDir, "package.json"); const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); if (typeof packageJson.main !== "string" || packageJson.main.length === 0) { throw new Error("Extracted package.json has no main entrypoint"); } const resolved = path.resolve(extractedDir, packageJson.main); const relative = path.relative(extractedDir, resolved); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error(`Unsafe main entrypoint in package.json: ${packageJson.main}`); } if (!existsSync(resolved)) throw new Error(`Main bundle does not exist: ${packageJson.main}`); return { mainBundlePath: resolved, packageJson }; } function writeOrderingFile(extractedDir, orderingPath) { const ordering = walkFiles(extractedDir) .map((filePath) => path.relative(extractedDir, filePath).split(path.sep).join("/")) .join("\n"); writeFileSync(orderingPath, `${ordering}\n`); } function sha256Text(value) { return createHash("sha256").update(value).digest("hex"); } function prepareExecutablePayloads(extractedDir) { const relativePath = XSEL_RELATIVE_PATH; const filePath = path.join(extractedDir, relativePath); if (!existsSync(filePath) || !statSync(filePath).isFile()) { throw new Error(`Required Linux executable payload is missing: ${relativePath}`); } const beforeHash = sha256File(filePath); const mode = statSync(filePath).mode & 0o777; chmodSync(filePath, mode | 0o111); if (sha256File(filePath) !== beforeHash) { throw new Error(`Setting executable bits changed payload bytes: ${relativePath}`); } return { [relativePath]: { sha256: beforeHash, mode: statSync(filePath).mode & 0o777, }, }; } function verifyExecutablePayloads(stagedUnpacked, verifyDir, executablePayloads) { for (const [relativePath, expected] of Object.entries(executablePayloads)) { const unpackedPath = path.join(stagedUnpacked, relativePath); const extractedPath = path.join(verifyDir, relativePath); for (const [location, filePath] of [ ["app.asar.unpacked", unpackedPath], ["post-repack extraction", extractedPath], ]) { if (!existsSync(filePath) || !statSync(filePath).isFile()) { throw new Error(`Executable payload is missing from ${location}: ${relativePath}`); } if (sha256File(filePath) !== expected.sha256) { throw new Error(`Executable payload bytes changed in ${location}: ${relativePath}`); } if ((statSync(filePath).mode & 0o111) === 0) { throw new Error(`Executable payload lost its executable bit in ${location}: ${relativePath}`); } } } } function promoteOutputs({ stagedAsar, stagedUnpacked, asarPath, unpackedPath, workspace }) { const previousAsar = path.join(workspace, "previous.asar"); const previousUnpacked = path.join(workspace, "previous.asar.unpacked"); let asarBackedUp = false; let unpackedBackedUp = false; let asarPromoted = false; let unpackedPromoted = false; try { renameSync(asarPath, previousAsar); asarBackedUp = true; if (existsSync(unpackedPath)) { renameSync(unpackedPath, previousUnpacked); unpackedBackedUp = true; } renameSync(stagedAsar, asarPath); asarPromoted = true; if (existsSync(stagedUnpacked)) { renameSync(stagedUnpacked, unpackedPath); unpackedPromoted = true; } } catch (error) { if (unpackedPromoted) rmSync(unpackedPath, { recursive: true, force: true }); if (asarPromoted) rmSync(asarPath, { force: true }); if (unpackedBackedUp) renameSync(previousUnpacked, unpackedPath); if (asarBackedUp) renameSync(previousAsar, asarPath); throw error; } } async function postRepackVerification({ stagedAsar, stagedUnpacked, verifyDir, expectedMainSource, mainRelativePath, nativeHashes, executablePayloads, }) { verifyNonLinuxHeaderEntries(asar.listPackage(stagedAsar)); await asar.extractAll(stagedAsar, verifyDir); if (existsSync(stagedUnpacked)) copyTreeContents(stagedUnpacked, verifyDir); const verifiedMain = readFileSync(path.join(verifyDir, mainRelativePath), "utf8"); if (verifiedMain !== expectedMainSource) { throw new Error("Main bundle bytes changed during ASAR repack"); } for (const marker of [TRAY_MARKER, UPDATER_MARKER]) { if (verifiedMain.split(marker).length !== 2) { throw new Error(`Required patch marker is missing or duplicated: ${marker}`); } } const optionalMarkerCount = verifiedMain.split(AUTOSTART_MARKER).length - 1; if (optionalMarkerCount > 1) { throw new Error(`Optional patch marker is duplicated: ${AUTOSTART_MARKER}`); } verifyOcrStub(verifyDir); verifyLinuxOnboarding(verifyDir); verifyNonLinuxPayloadsAbsent(verifyDir); verifyExecutablePayloads(stagedUnpacked, verifyDir, executablePayloads); for (const [relativePath, expectedHash] of Object.entries(nativeHashes)) { const actualPath = path.join(verifyDir, relativePath); if (!existsSync(actualPath) || sha256File(actualPath) !== expectedHash) { throw new Error(`Native replacement failed integrity check: ${relativePath}`); } } } async function patchAsar(options) { if (!existsSync(options.asarPath)) throw new Error(`ASAR does not exist: ${options.asarPath}`); if (options.nativeReplacements && !existsSync(options.nativeReplacements)) { throw new Error(`Native replacements do not exist: ${options.nativeReplacements}`); } // Keep staging beside the target so final rename operations stay on one filesystem. const workspace = mkdtempSync(path.join(path.dirname(options.asarPath), ".nani-patch-asar-")); const extractedDir = path.join(workspace, "extracted"); const verifyDir = path.join(workspace, "verify"); const orderingPath = path.join(workspace, "app.asar.ordering"); const stagedAsar = path.join(workspace, "app.asar"); const stagedUnpacked = `${stagedAsar}.unpacked`; const report = { schemaVersion: 1, input: { asar: options.asarPath, unpacked: options.unpackedPath, sha256: sha256File(options.asarPath), }, patches: [], success: false, }; try { await asar.extractAll(options.asarPath, extractedDir); if (existsSync(options.unpackedPath)) copyTreeContents(options.unpackedPath, extractedDir); if (options.nativeReplacements) copyTreeContents(options.nativeReplacements, extractedDir); const nativeHashes = options.nativeReplacements ? relativeFileHashes(options.nativeReplacements) : {}; const executablePayloads = prepareExecutablePayloads(extractedDir); const { mainBundlePath, packageJson } = safeMainPath(extractedDir); report.upstream = { name: packageJson.name ?? null, version: packageJson.version ?? null, main: path.relative(extractedDir, mainBundlePath).split(path.sep).join("/"), }; const result = runPatchDescriptors({ extractedDir, mainBundlePath, descriptors: corePatchDescriptors, }); report.patches.push(...result.entries); if (result.hasRequiredFailure || result.hasIntegrityFailure) { throw new Error("One or more required ASAR patches failed"); } writeOrderingFile(extractedDir, orderingPath); await asar.createPackageWithOptions(extractedDir, stagedAsar, { ordering: orderingPath, unpack: "{*.node,*.so,xsel}", }); await postRepackVerification({ stagedAsar, stagedUnpacked, verifyDir, expectedMainSource: result.mainSource, mainRelativePath: report.upstream.main, nativeHashes, executablePayloads, }); promoteOutputs({ stagedAsar, stagedUnpacked, asarPath: options.asarPath, unpackedPath: options.unpackedPath, workspace, }); report.output = { sha256: sha256File(options.asarPath), mainSha256: sha256Text(result.mainSource), nativeReplacements: Object.keys(nativeHashes).length, executablePayloads, }; report.success = true; atomicJson(options.reportPath, report); return report; } catch (error) { const detail = error instanceof Error ? error.message : String(error); if (!report.patches.some((entry) => entry.status === "failed-integrity") && !report.patches.some((entry) => entry.status === "failed-required")) { addIntegrityFailure(report.patches, "patch-asar", detail); } report.error = detail; atomicJson(options.reportPath, report); throw error; } finally { rmSync(workspace, { recursive: true, force: true }); } } async function main() { let options; try { options = parseArgs(process.argv.slice(2)); } catch (error) { console.error(error.message); console.error(USAGE); process.exitCode = 2; return; } if (options.help) { console.log(USAGE); return; } try { const report = await patchAsar(options); const summary = report.patches.map(({ id, status }) => `${id}=${status}`).join(", "); console.log(`ASAR patched: ${summary}`); } catch (error) { console.error(`ASAR patch failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } } export { parseArgs, patchAsar, postRepackVerification, prepareExecutablePayloads, promoteOutputs, safeMainPath, writeOrderingFile, verifyExecutablePayloads, }; if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) await main();