Files
makelore/scripts/verify-packaged-electron-runtime.mjs
2026-07-29 17:22:35 +08:00

260 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
createReadStream,
existsSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const args = process.argv.slice(2);
const valueOptions = new Set([
'--app-exe',
'--installer',
'--expected-electron',
'--expected-node',
'--expected-commit',
'--expected-build-id',
'--manifest',
]);
const flagOptions = new Set(['--allow-dirty']);
const seenOptions = new Set();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--') continue;
if (!valueOptions.has(arg) && !flagOptions.has(arg)) {
throw new Error(`Unknown option: ${arg}`);
}
if (seenOptions.has(arg)) {
throw new Error(`Duplicate option: ${arg}`);
}
seenOptions.add(arg);
if (valueOptions.has(arg)) {
const value = args[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
index += 1;
}
}
function option(name, fallback) {
const index = args.indexOf(name);
if (index === -1) return fallback;
if (!args[index + 1]) throw new Error(`${name} requires a value`);
return args[index + 1];
}
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, {
cwd: root,
encoding: 'utf8',
...options,
});
if (result.error) throw result.error;
if (result.signal) throw new Error(`${command} ended by signal ${result.signal}`);
if (result.status !== 0) {
throw new Error(`${command} exited ${result.status}: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
}
function sha256(filePath) {
return new Promise((resolveHash, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolveHash(hash.digest('hex').toUpperCase()));
});
}
function assertEqual(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function isPathInside(parentPath, candidatePath) {
const relativePath = relative(resolve(parentPath), resolve(candidatePath));
return relativePath !== ''
&& relativePath !== '..'
&& !relativePath.startsWith(`..${sep}`)
&& !isAbsolute(relativePath);
}
if (process.platform !== 'win32') {
throw new Error('Packaged Electron verification must run on Windows');
}
const appExecutable = resolve(option(
'--app-exe',
join(root, 'release', 'win-unpacked', 'Makelore.exe'),
));
const installerPath = resolve(option(
'--installer',
join(root, 'release', `Makelore-${packageJson.version}-win-x64.exe`),
));
const expectedElectron = option('--expected-electron', '40.10.6');
const expectedNode = option('--expected-node', '24.15.0');
const expectedCommit = option('--expected-commit', null);
const expectedBuildId = option('--expected-build-id', null);
const manifestPath = option('--manifest', null);
const appAsarPath = join(dirname(appExecutable), 'resources', 'app.asar');
const appAsarUnpackedPath = join(dirname(appExecutable), 'resources', 'app.asar.unpacked');
for (const filePath of [appExecutable, installerPath]) {
if (!existsSync(filePath)) throw new Error(`Missing artifact: ${filePath}`);
}
const verificationHead = run('git', ['rev-parse', 'HEAD']);
const gitStatus = run('git', ['status', '--porcelain']);
if (gitStatus && !args.includes('--allow-dirty')) {
throw new Error(`Tracked worktree is not clean:\n${gitStatus}`);
}
const probeCode = String.raw`
const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } = require('node:fs');
const { createRequire } = require('node:module');
const { tmpdir } = require('node:os');
const { dirname, join, sep } = require('node:path');
const appRequire = createRequire(join(dirname(process.execPath), 'resources', 'app.asar', 'package.json'));
const packagedPackage = appRequire('./package.json');
const msgpackResolved = appRequire.resolve('msgpackr');
const canvasResolved = appRequire.resolve('@napi-rs/canvas');
const { pack, unpack } = appRequire('msgpackr');
const { createCanvas } = appRequire('@napi-rs/canvas');
const packedValue = unpack(pack({ ok: true, text: '\u5362\u6b22' }));
const canvas = createCanvas(1, 1);
const nativeModules = Object.keys(require.cache)
.filter((modulePath) => modulePath.endsWith('.node'))
.map((modulePath) => {
const physicalPath = modulePath.replace(
sep + 'app.asar' + sep,
sep + 'app.asar.unpacked' + sep,
);
return { modulePath, physicalPath, exists: existsSync(physicalPath) };
});
const copyRoot = mkdtempSync(join(tmpdir(), 'niancode-packaged-copy-'));
let copyEvidence;
try {
const source = join(copyRoot, 'source');
const destination = join(copyRoot, '\u5362\u6b22', 'AppData', 'Roaming', 'niancode', 'nested');
mkdirSync(join(source, 'child'), { recursive: true });
writeFileSync(join(source, 'child', 'proof.txt'), 'unicode-copy-proof', 'utf8');
cpSync(source, destination, { recursive: true, force: true, filter: () => true });
copyEvidence = {
content: readFileSync(join(destination, 'child', 'proof.txt'), 'utf8'),
rootEntries: readdirSync(copyRoot).sort(),
};
} finally {
rmSync(copyRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
}
console.log('NIANCODE_ARTIFACT_PROBE=' + JSON.stringify({
electron: process.versions.electron,
node: process.versions.node,
packagedMetadata: {
buildCommit: packagedPackage.buildCommit,
buildId: packagedPackage.buildId,
},
msgpackResolved,
canvasResolved,
nativeModules,
packedValue,
canvas: [canvas.width, canvas.height],
copyEvidence,
}));
`;
const probeResult = spawnSync(appExecutable, ['-e', probeCode], {
cwd: dirname(appExecutable),
encoding: 'utf8',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
if (probeResult.error) throw probeResult.error;
if (probeResult.signal) throw new Error(`Makelore.exe ended by signal ${probeResult.signal}`);
if (probeResult.status !== 0) {
throw new Error(`Makelore.exe exited ${probeResult.status}: ${probeResult.stderr || probeResult.stdout}`);
}
const probeLine = probeResult.stdout
.split(/\r?\n/)
.find((line) => line.startsWith('NIANCODE_ARTIFACT_PROBE='));
if (!probeLine) throw new Error(`Missing packaged probe output: ${probeResult.stdout}`);
const probe = JSON.parse(probeLine.slice('NIANCODE_ARTIFACT_PROBE='.length));
assertEqual(probe.electron, expectedElectron, 'Electron version');
assertEqual(probe.node, expectedNode, 'Node.js version');
if (expectedCommit) {
assertEqual(probe.packagedMetadata.buildCommit, expectedCommit, 'packaged build commit');
}
if (expectedBuildId) {
assertEqual(probe.packagedMetadata.buildId, expectedBuildId, 'packaged build id');
}
assertEqual(probe.packedValue, { ok: true, text: '\u5362\u6b22' }, 'msgpackr round trip');
assertEqual(probe.canvas, [1, 1], 'canvas dimensions');
assertEqual(probe.copyEvidence, {
content: 'unicode-copy-proof',
rootEntries: ['source', '\u5362\u6b22'],
}, 'packaged Unicode copy');
if (!isPathInside(appAsarPath, probe.msgpackResolved)) {
throw new Error(`msgpackr was not resolved from app.asar: ${probe.msgpackResolved}`);
}
if (!isPathInside(appAsarPath, probe.canvasResolved)) {
throw new Error(`@napi-rs/canvas was not resolved from app.asar: ${probe.canvasResolved}`);
}
const msgpackNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => (
exists
&& isPathInside(appAsarPath, modulePath)
&& isPathInside(appAsarUnpackedPath, physicalPath)
&& modulePath.includes('@msgpackr-extract')
&& modulePath.endsWith('node.napi.node')
));
if (!msgpackNative) {
throw new Error(`Packaged msgpackr N-API addon was not loaded: ${JSON.stringify(probe.nativeModules)}`);
}
const canvasNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => (
exists
&& isPathInside(appAsarPath, modulePath)
&& isPathInside(appAsarUnpackedPath, physicalPath)
&& modulePath.includes('@napi-rs')
&& modulePath.includes('canvas-win32-x64-msvc')
&& modulePath.endsWith('skia.win32-x64-msvc.node')
));
if (!canvasNative) {
throw new Error(`Packaged x64 canvas addon was not loaded: ${JSON.stringify(probe.nativeModules)}`);
}
const installerBytes = statSync(installerPath).size;
if (installerBytes <= 0) throw new Error(`Installer is empty: ${installerPath}`);
const installerSha256 = await sha256(installerPath);
if (!/^[A-F0-9]{64}$/.test(installerSha256)) {
throw new Error(`Invalid installer SHA-256: ${installerSha256}`);
}
const evidence = {
gitCommit: probe.packagedMetadata.buildCommit ?? verificationHead,
verificationHead,
packagedMetadata: probe.packagedMetadata,
appExecutable,
installerPath,
installerBytes,
installerSha256,
runtime: { electron: probe.electron, node: probe.node },
nativeModules: {
msgpackr: probe.msgpackResolved,
canvas: probe.canvasResolved,
msgpackAddon: msgpackNative,
canvasAddon: canvasNative,
},
unicodeCopy: probe.copyEvidence,
};
if (manifestPath) {
writeFileSync(resolve(manifestPath), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(evidence, null, 2));