blob: b41939fc93968a5dd4e2c7276c4cb8d7f4879061 [file]
import { access, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
assertMissing,
assertPackagedResources,
isolatedUserEnv,
makePtyProbe,
runCommand,
sha256File,
smokePackagedRenderer,
} from './verify-packaged-app.mjs';
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const desktopRoot = join(repoRoot, 'apps', 'desktop');
const executableName = 'Maka.exe';
const amd64Machine = 0x8664;
// conpty echoes the command and terminates lines with CRLF, so the probe keeps
// matching on a substring rather than the whole output.
const ptyProbe = makePtyProbe(process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok']);
function runCommandFromRepo(command, args, options = {}) {
return runCommand(command, args, { cwd: repoRoot, ...options });
}
// The release workflow shows only this script's output, so each stage announces
// itself: an unfinished stage is the one that hung.
function step(message) {
console.log(`[verify-windows] ${message}`);
}
function runPowerShell(run, script) {
return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]);
}
// A single-quoted PowerShell string is literal — a double-quoted one would
// expand `$` in a path we did not choose.
export function powerShellLiteral(value) {
return `'${value.replace(/'/g, "''")}'`;
}
// electron-builder writes the Windows product version resource in the four-part
// form Windows wants (app-builder-lib `AppInfo.getVersionInWeirdWindowsForm`),
// so 0.1.5 ships as 0.1.5.0 and the release version is its first three parts.
// The fourth part is a build number, which is 0 unless one is configured.
export function assertWindowsProductVersion(productVersion, expectedVersion) {
const [expected] = expectedVersion.split('-');
const parts = productVersion.trim().split('.');
if (parts.length !== 4 || parts.slice(0, 3).join('.') !== expected || !/^\d+$/.test(parts[3])) {
throw new Error(
`Expected app version ${expected}.<build>, found ${productVersion.trim() || '<none>'}.`,
);
}
}
// The Windows build is unsigned, so the only architecture evidence in the
// artifact is the PE header of the executable itself.
export async function readPeMachine(path) {
const file = await open(path, 'r');
try {
const header = Buffer.alloc(4);
const { bytesRead } = await file.read(header, 0, 4, 0x3c);
if (bytesRead !== 4) {
throw new Error(`${path} is too small to be a PE image.`);
}
const peOffset = header.readUInt32LE(0);
const signature = Buffer.alloc(6);
const peRead = await file.read(signature, 0, 6, peOffset);
if (peRead.bytesRead !== 6 || signature.toString('latin1', 0, 4) !== 'PE\0\0') {
throw new Error(`${path} is not a PE image.`);
}
return signature.readUInt16LE(4);
} finally {
await file.close();
}
}
export async function verifyPackagedWindowsApp(
appDirectory,
{
run = runCommandFromRepo,
requirePath = access,
forbidPath = assertMissing,
readMachine = readPeMachine,
smokeRenderer = smokePackagedRenderer,
workingDirectory = appDirectory,
} = {},
) {
const desktopManifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8'));
const resources = join(appDirectory, 'resources');
const executable = join(appDirectory, executableName);
const appAsar = join(resources, 'app.asar');
step('checking packaged resources');
await requirePath(executable);
await assertPackagedResources(resources, { requirePath, forbidPath });
await requirePath(join(resources, 'git', 'cmd', 'git.exe'));
step('reading the executable architecture');
const machine = await readMachine(executable);
if (machine !== amd64Machine) {
throw new Error(`${executableName} must be x64, found PE machine 0x${machine.toString(16)}.`);
}
step('reading the product version resource');
const { stdout } = await runPowerShell(
run,
`(Get-Item -LiteralPath ${powerShellLiteral(executable)}).VersionInfo.ProductVersion`,
);
assertWindowsProductVersion(stdout, desktopManifest.version);
step('smoking node-pty through conpty');
await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], {
env: {
ELECTRON_RUN_AS_NODE: '1',
...isolatedUserEnv(join(workingDirectory, 'pty-home')),
},
timeoutMs: 60_000,
});
// No filesystem worker smoke here, unlike macOS. The worker exists to enforce
// a sandbox profile, and `isBuiltinFilesystemWorkerSandboxAvailable` is false
// on Windows (packages/runtime/src/sandbox/default-sandbox-manager.ts), so the
// app never launches it — file tools run through the workspace executor
// instead. Driving the worker by hand would only prove that a POSIX-only
// boundary check rejects Windows paths, which no Windows user can reach.
step('smoking the packaged renderer');
await smokeRenderer(executable, { workingDirectory });
step('packaged app verified');
}
// Neither release artifact is inspected as an artifact: the NSIS installer has
// no readable app structure, and the ZIP is an archive of the win-unpacked
// directory electron-builder just produced. That directory is the app, so it is
// what gets verified — unpacking the ZIP would only rebuild a copy of it. The
// artifacts themselves are pinned by checksum, and installing the .exe is a
// checklist step. (macOS mounts its DMG instead because notarizing and stapling
// rewrite the DMG after packaging, so only the final artifact can be trusted.)
export async function verifyWindowsX64Release(
inputPath,
{ platform = process.platform, verifyApp = verifyPackagedWindowsApp, checksum = sha256File } = {},
) {
if (platform !== 'win32') {
throw new Error('Windows release verification requires Windows.');
}
if (!inputPath) {
throw new Error('Usage: npm run verify:windows-x64 -- <path-to-exe>');
}
const exePath = resolve(inputPath);
if (!exePath.endsWith('.exe')) {
throw new Error(`Expected the NSIS installer .exe, found ${basename(exePath)}.`);
}
const zipPath = `${exePath.slice(0, -'.exe'.length)}.zip`;
const unpackedDirectory = join(dirname(exePath), 'win-unpacked');
await access(exePath);
await access(zipPath);
await access(unpackedDirectory);
// The smokes write into their working directory, which therefore must not be
// the release directory the artifacts live in.
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'maka-release-verify-'));
try {
await verifyApp(unpackedDirectory, { workingDirectory: temporaryDirectory });
step('checksumming the release artifacts');
const checksums = [];
for (const path of [exePath, zipPath]) {
const sha256 = await checksum(path);
const checksumPath = `${path}.sha256`;
await writeFile(checksumPath, `${sha256} ${basename(path)}\n`, 'utf8');
checksums.push({ path, checksumPath, sha256 });
}
return { exePath, zipPath, unpackedDirectory, checksums };
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await verifyWindowsX64Release(process.argv[2]);
console.log(`Verified ${result.exePath}`);
for (const { path, sha256 } of result.checksums) {
console.log(`SHA-256 ${sha256} ${basename(path)}`);
}
}