Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { access, mkdtemp, open, 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 { readProductManifestIdentity } from './product-release-identity.mjs';
|
|
import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs';
|
|
import { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } from './desktop-nightly.mjs';
|
|
import {
|
|
assertMissing,
|
|
assertPackagedDependencyClosure,
|
|
assertPackagedResources,
|
|
isolatedUserEnv,
|
|
makePtyProbe,
|
|
runCommand,
|
|
sha256File,
|
|
smokePackagedRenderer,
|
|
} from './verify-packaged-app.mjs';
|
|
|
|
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const executableName = 'Maka.exe';
|
|
const amd64Machine = 0x8664;
|
|
const temporaryCleanupRetries = 20;
|
|
const temporaryCleanupRetryDelayMs = 250;
|
|
// conpty echoes the command and terminates lines with CRLF, so the probe keeps
|
|
// matching on a substring rather than the whole output.
|
|
|
|
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>'}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function verifyPackagedWindowsSandboxLifecycle(
|
|
sandboxExecutable,
|
|
{ run = runCommandFromRepo } = {},
|
|
) {
|
|
for (const script of ['appcontainer-smoke.ps1', 'acl-recovery-smoke.ps1']) {
|
|
await run('pwsh', [
|
|
'-NoProfile',
|
|
'-File',
|
|
join(repoRoot, 'experiments', 'windows-sandbox', script),
|
|
'-LauncherPath',
|
|
sandboxExecutable,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
expectedVersion,
|
|
artifactContract = 'current',
|
|
environment = process.env,
|
|
} = {},
|
|
) {
|
|
if (artifactContract !== 'current' && artifactContract !== 'upgrade-baseline') {
|
|
throw new Error(`Unknown packaged Windows artifact contract: ${artifactContract}`);
|
|
}
|
|
const requiresCurrentContract = artifactContract === 'current';
|
|
const product = await readProductManifestIdentity();
|
|
const resources = join(appDirectory, 'resources');
|
|
const executable = join(appDirectory, executableName);
|
|
const appAsar = join(resources, 'app.asar');
|
|
const sandboxExecutable = join(resources, 'windows-sandbox', 'maka-windows-sandbox.exe');
|
|
|
|
step('checking packaged resources');
|
|
await requirePath(executable);
|
|
await assertPackagedResources(resources, {
|
|
requirePath,
|
|
forbidPath,
|
|
requireWindowsSandbox: requiresCurrentContract,
|
|
requireDisclaimer: requiresCurrentContract,
|
|
requireCanonicalIcon: requiresCurrentContract,
|
|
requireAppIconCatalog: requiresCurrentContract,
|
|
requireDirectPeerArtifact: requiresCurrentContract,
|
|
});
|
|
// The upgrade baseline is a build that shipped on its own channel, from its
|
|
// own commit: its update feed and dependency closure are the ones that were
|
|
// right for it, not the ones this checkout expects.
|
|
if (requiresCurrentContract) {
|
|
await assertPackagedUpdateConfiguration(resources, {
|
|
channel: environment.MAKA_DESKTOP_NIGHTLY_VERSION ? 'nightly' : 'release',
|
|
});
|
|
await assertPackagedDependencyClosure(resources);
|
|
}
|
|
|
|
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)}.`);
|
|
}
|
|
|
|
if (requiresCurrentContract) {
|
|
step('smoking the packaged Windows sandbox');
|
|
const sandboxMachine = await readMachine(sandboxExecutable);
|
|
if (sandboxMachine !== amd64Machine) {
|
|
throw new Error(
|
|
`maka-windows-sandbox.exe must be x64, found PE machine 0x${sandboxMachine.toString(16)}.`,
|
|
);
|
|
}
|
|
const sandboxLaunch = {
|
|
version: 1,
|
|
requestId: 'packaged-sandbox-launch',
|
|
executable: sandboxExecutable,
|
|
arguments: ['--self-probe'],
|
|
cwd: dirname(sandboxExecutable),
|
|
readRoots: [],
|
|
writeRoots: [],
|
|
exactReadRoots: [],
|
|
exactWriteRoots: [],
|
|
network: 'restricted',
|
|
environment: {},
|
|
};
|
|
const sandboxManifest = join(workingDirectory, 'packaged-sandbox-manifest.json');
|
|
await writeFile(
|
|
sandboxManifest,
|
|
JSON.stringify({
|
|
version: 1,
|
|
requestId: 'packaged-sandbox',
|
|
clientPid: 0,
|
|
clientNonce: '0123456789abcdef0123456789abcdef',
|
|
profileDigest: createHash('sha256').update(JSON.stringify(sandboxLaunch)).digest('hex'),
|
|
launch: sandboxLaunch,
|
|
}),
|
|
'utf8',
|
|
);
|
|
const sandboxProbe = await run(sandboxExecutable, ['--broker-local', sandboxManifest]);
|
|
// The broker relays the child's stdout as the worker response channel and
|
|
// keeps its own boundary diagnostics on stderr, so the channel split itself
|
|
// is part of what this probe verifies: the child's --self-probe JSON must
|
|
// arrive on stdout and the broker's atomic-job diagnostic on stderr.
|
|
if (
|
|
!sandboxProbe.stdout.includes('"appContainer":true') ||
|
|
!sandboxProbe.stdout.includes('"inJob":true')
|
|
) {
|
|
throw new Error(
|
|
`Packaged Windows sandbox child probe did not reach stdout: ${sandboxProbe.stdout}`,
|
|
);
|
|
}
|
|
if (!sandboxProbe.stderr.includes('"atomicJob":true')) {
|
|
throw new Error(
|
|
`Packaged Windows sandbox broker diagnostic did not reach stderr: ${sandboxProbe.stderr}`,
|
|
);
|
|
}
|
|
await assertMissing(sandboxManifest);
|
|
|
|
step('relaying command stdio through the packaged sandbox');
|
|
await run('pwsh', [
|
|
'-NoProfile',
|
|
'-File',
|
|
join(repoRoot, 'experiments', 'windows-sandbox', 'stdio-relay-smoke.ps1'),
|
|
'-LauncherPath',
|
|
sandboxExecutable,
|
|
]);
|
|
|
|
step('verifying packaged sandbox lifecycle recovery');
|
|
await verifyPackagedWindowsSandboxLifecycle(sandboxExecutable, { run });
|
|
|
|
step('running real filesystem-worker operations through the packaged app');
|
|
// The evidence executes the packaged artifacts themselves: the packaged
|
|
// broker, the packaged Electron executable as the worker runtime and the
|
|
// packaged worker bundle under resources — not the repository dist worker
|
|
// on a copied node.exe.
|
|
const { verifyWindowsSandboxWorkerE2E } = await import('./verify-windows-sandbox-e2e.mjs');
|
|
await verifyWindowsSandboxWorkerE2E(appDirectory);
|
|
}
|
|
|
|
step('reading the product version resource');
|
|
const { stdout } = await runPowerShell(
|
|
run,
|
|
`(Get-Item -LiteralPath ${powerShellLiteral(executable)}).VersionInfo.ProductVersion`,
|
|
);
|
|
assertWindowsProductVersion(
|
|
stdout,
|
|
expectedVersion ?? resolveDesktopBuildVersion(product.version, environment),
|
|
);
|
|
|
|
step('smoking node-pty through conpty');
|
|
const ptyProbe = makePtyProbe(
|
|
process.env.ComSpec || 'cmd.exe',
|
|
['/c', 'echo', 'maka-node-pty-ok'],
|
|
requiresCurrentContract
|
|
? resolveRuntimeHostSetupPackage(product.version, environment)
|
|
: undefined,
|
|
);
|
|
await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], {
|
|
env: {
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
...isolatedUserEnv(join(workingDirectory, 'pty-home')),
|
|
},
|
|
timeoutMs: 60_000,
|
|
});
|
|
|
|
step('smoking the packaged renderer');
|
|
await smokeRenderer(executable, {
|
|
workingDirectory,
|
|
verifyMaximizeRestore: requiresCurrentContract,
|
|
});
|
|
|
|
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 {
|
|
// The Runtime Host intentionally outlives its last Desktop client for its
|
|
// idle grace period. On Windows, SQLite keeps the temporary workspace files
|
|
// locked until that Host exits. fs.rm retries only the transient filesystem
|
|
// errors for recursive removal, so cleanup follows the actual lock lifetime
|
|
// without changing the production continuity policy.
|
|
await rm(temporaryDirectory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: temporaryCleanupRetries,
|
|
retryDelay: temporaryCleanupRetryDelayMs,
|
|
});
|
|
}
|
|
}
|
|
|
|
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)}`);
|
|
}
|
|
}
|