260 lines
9.4 KiB
JavaScript
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));
|