Files
2026-08-01 20:21:37 +09:00

347 lines
12 KiB
JavaScript

#!/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();