Files
maka/scripts/verify-windows-installer-rollback.mjs
agent-radar 7be535059e
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

614 lines
25 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 { spawn } from 'node:child_process';
import { access, cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
diffTreeManifests,
directoryTreeManifest,
runCommand,
smokePackagedRenderer,
} from './verify-packaged-app.mjs';
import {
installerVersion,
waitForInstalledProcessesToExit,
waitForUninstallRegistrationToClear,
waitUntilMissing,
} from './verify-windows-installer-lifecycle.mjs';
import { assertWindowsProductVersion, powerShellLiteral } from './verify-windows-x64.mjs';
const uninstallExecutableName = 'Uninstall Maka.exe';
const executableName = 'Maka.exe';
// Exit-code and naming contract with apps/desktop/build/installer.nsh — keep
// in sync.
const exitBackupFailed = 101;
const exitRollbackCompleted = 102;
const exitRollbackFailed = 103;
const backupSuffix = '.pre-upgrade-backup';
const backupMarkerName = '.maka-backup-complete';
function delay(milliseconds) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
}
function step(label) {
console.log(`[verify-windows-installer-rollback] ${label}`);
}
/**
* Runs a command whose exit code is the assertion, so unlike the shared
* `runCommand` a non-zero exit resolves instead of rejecting.
*/
function runExpectingExit(command, args, { env, timeoutMs } = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
env: env ?? process.env,
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
stderr = `${stderr}${chunk}`.slice(-8_192);
});
const deadline =
timeoutMs === undefined
? null
: setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`${command} did not finish within ${timeoutMs}ms`));
}, timeoutMs);
child.once('error', (error) => {
if (deadline) clearTimeout(deadline);
reject(error);
});
child.once('exit', (code, signal) => {
if (deadline) clearTimeout(deadline);
if (signal) {
reject(new Error(`${command} was terminated by signal ${signal}`));
return;
}
resolvePromise({ code, stderr });
});
});
}
async function readInstalledProductVersion(executablePath, { run = runCommand } = {}) {
const script = `(Get-Item ${powerShellLiteral(executablePath)}).VersionInfo.ProductVersion`;
const { stdout } = await run(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{
timeoutMs: 30_000,
},
);
return stdout;
}
function scopedUninstallRegistrationScript(uninstallerPath) {
return String.raw`
$expectedUninstaller = [IO.Path]::GetFullPath(${powerShellLiteral(uninstallerPath)})
$entries = @(
Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' |
ForEach-Object {
$entry = $_
$properties = $entry | Get-ItemProperty
$command = [string]$properties.UninstallString
$match = [regex]::Match($command, '^"([^"]+)"(?:\s|$)')
if ($match.Success) {
try {
$actualUninstaller = [IO.Path]::GetFullPath($match.Groups[1].Value)
if ([String]::Equals(
$actualUninstaller,
$expectedUninstaller,
[StringComparison]::OrdinalIgnoreCase
)) {
[PSCustomObject]@{
Path = $entry.PSPath
DisplayVersion = [string]$properties.DisplayVersion
}
}
} catch {
# A malformed foreign registration is not this fixture's authority.
}
}
}
)
`;
}
export async function readUninstallDisplayVersionsForInstall(
uninstallerPath,
{ run = runCommand } = {},
) {
const script = `${scopedUninstallRegistrationScript(uninstallerPath)}
@($entries | ForEach-Object { $_.DisplayVersion }) -join ','
`;
const { stdout } = await run(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeoutMs: 30_000 },
);
return stdout.trim();
}
/**
* Deletes only this fixture installation's uninstall registration, emulating
* the state the fail-closed branch exists for (a user- or tool-cleaned
* registry). The installer's own pre-upgrade snapshot key is left alone on
* purpose: without a complete backup the snapshot must not be sufficient to
* proceed.
*/
export async function deleteUninstallRegistrationForInstall(
uninstallerPath,
{ run = runCommand } = {},
) {
const script = `${scopedUninstallRegistrationScript(uninstallerPath)}
$entries | ForEach-Object { Remove-Item -LiteralPath $_.Path -Recurse -Force }
`;
await run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
timeoutMs: 30_000,
});
}
export async function verifyRestoredWindowsInstallation(
installDirectory,
workingDirectory,
expectedVersion,
{ smokeRenderer = smokePackagedRenderer, run } = {},
) {
await mkdir(workingDirectory, { recursive: true });
const installedExecutable = join(installDirectory, executableName);
// The caller has already proved the restored tree byte-identical. Keep this
// gate focused on rollback-specific evidence: the restored candidate still
// launches and reports the expected version, while the package step owns the
// full artifact/sandbox contract.
await smokeRenderer(installedExecutable, { workingDirectory });
const actualVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(actualVersion, expectedVersion);
}
/**
* Exercises the Abort-path rollback contract of
* apps/desktop/build/installer.nsh, scenario by scenario:
*
* 1. A deterministic Abort after extraction (the worst moment) leaves the
* previous installation byte-identical (per-file SHA-256 over the whole
* tree), registered, and launchable (exit 102).
* 2. A forced registry read-back mismatch returns 103, retains recovery
* evidence, and a no-failpoint rerun recovers cleanly.
* 3. The very same installer succeeds without the failpoint, leaving no
* backup residue (the hook does not break normal upgrades).
* 4. A leftover backup with either a stale version marker or no executable
* witness fails closed before anything destructive runs.
* 5. Gap pin: a Quit at the same moment runs no hook — extracted files stay,
* the registration stays gone, and the verified backup is retained.
* 6. Recovery: rerunning the installer adopts that backup and its persisted
* registry snapshot and completes the upgrade cleanly.
* 7. Fail closed: an upgrade over an installation with no uninstall
* registration and no adoptable backup is refused (exit 101) with the
* files untouched.
*/
export async function verifyWindowsInstallerRollback(
candidateInputPath,
nextDirectoryInput,
{
platform = process.platform,
makeTemporaryDirectory = () => mkdtemp(join(tmpdir(), 'maka-rollback-')),
run = runCommand,
smokeRenderer = smokePackagedRenderer,
} = {},
) {
if (platform !== 'win32') {
throw new Error('Windows installer rollback verification requires Windows.');
}
if (!candidateInputPath || !nextDirectoryInput) {
throw new Error(
'Usage: npm run verify:windows-installer-rollback -- <candidate-exe> <next-release-directory>',
);
}
const candidateInstaller = resolve(candidateInputPath);
const nextDirectory = resolve(nextDirectoryInput);
const candidateVersion = installerVersion(candidateInstaller);
await access(candidateInstaller);
// The next directory is the autoupdate-next output; find its installer by
// the release naming contract rather than trusting directory listing order.
const nextInstallerName = (await readdir(nextDirectory)).find((name) =>
/^Maka-\d+\.\d+\.\d+-win-x64\.exe$/u.test(name),
);
if (!nextInstallerName) {
throw new Error(`No Maka installer found in ${nextDirectory}.`);
}
const nextInstaller = join(nextDirectory, nextInstallerName);
const nextVersion = installerVersion(nextInstaller);
if (nextVersion === candidateVersion) {
throw new Error('The rollback check needs a next installer with a different version.');
}
const temporaryDirectory = await makeTemporaryDirectory();
const installDirectory = join(temporaryDirectory, 'installed');
const backupDirectory = `${installDirectory}${backupSuffix}`;
const staleBackupFixture = join(temporaryDirectory, 'candidate-backup-fixture');
const uninstaller = join(installDirectory, uninstallExecutableName);
const installedExecutable = join(installDirectory, executableName);
let installationStarted = false;
let uninstallCompleted = false;
let primaryError;
try {
// An earlier verify step in the same job ends with a detached NSIS
// uninstall whose LAST action deletes the uninstall registry keys, well
// after its files are gone (see waitForUninstallRegistrationToClear).
// Installing inside that window lets the stale uninstaller delete the
// candidate's fresh registration, which then makes the upgrade's registry
// snapshot legitimately empty — the failure surfaces two steps later as a
// missing restored registration. Barrier first, then install.
step('waiting for any earlier uninstall to finish clearing its registration');
await waitForUninstallRegistrationToClear({ run });
step(`installing candidate ${candidateVersion} into ${installDirectory}`);
installationStarted = true;
await run(candidateInstaller, ['/S', `/D=${installDirectory}`], { timeoutMs: 120_000 });
await access(uninstaller);
step('asserting the candidate registered its uninstall entry');
const preUpgradeRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, {
run,
});
if (!preUpgradeRegistration.split(',').includes(candidateVersion)) {
throw new Error(
`Precondition failed: candidate install registered DisplayVersion ` +
`${JSON.stringify(preUpgradeRegistration)}, expected to include ${candidateVersion}. ` +
`The registry snapshot the rollback must restore does not exist yet.`,
);
}
step('recording the pre-upgrade tree manifest');
const manifestBefore = await directoryTreeManifest(installDirectory);
step(`manifest covers ${manifestBefore.length} files`);
await cp(installDirectory, staleBackupFixture, { recursive: true });
step(`upgrading to ${nextVersion} with the after-extract failpoint armed`);
const failpointEnv = { ...process.env, MAKA_INSTALLER_TEST_FAILPOINT: 'after-extract' };
const failed = await runExpectingExit(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: failpointEnv,
timeoutMs: 300_000,
});
if (failed.code !== exitRollbackCompleted) {
const diagnosis =
failed.code === exitRollbackFailed
? 'the rollback itself failed (backup kept for inspection)'
: failed.code === exitBackupFailed
? 'the pre-upgrade backup failed before anything destructive ran'
: failed.code === 0
? 'the failpoint never fired and the upgrade succeeded'
: 'the installer failed outside the Abort-path rollback contract';
throw new Error(
`Failpoint upgrade exited with ${failed.code}, expected ${exitRollbackCompleted}: ${diagnosis}` +
`${failed.stderr.trim() ? `\nstderr: ${failed.stderr.trim()}` : ''}`,
);
}
step('asserting the previous installation is byte-identical');
const manifestAfter = await directoryTreeManifest(installDirectory);
const difference = diffTreeManifests(manifestBefore, manifestAfter);
if (
difference.missing.length > 0 ||
difference.extra.length > 0 ||
difference.changed.length > 0
) {
throw new Error(
`Restored installation differs from the pre-upgrade tree: ${JSON.stringify(difference)}`,
);
}
step(`0 differing files across ${manifestAfter.length} entries`);
for (const residue of [backupDirectory, `${installDirectory}.failed-upgrade`]) {
try {
await access(residue);
throw new Error(`Residue left behind after rollback: ${residue}`);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
step('asserting the uninstall registration was restored');
const displayVersion = await readUninstallDisplayVersionsForInstall(uninstaller, { run });
if (!displayVersion.split(',').includes(candidateVersion)) {
throw new Error(
`Uninstall registry DisplayVersion is ${JSON.stringify(displayVersion)}, expected to include ${candidateVersion}.`,
);
}
step('asserting the restored installation launches');
await verifyRestoredWindowsInstallation(
installDirectory,
join(temporaryDirectory, 'restored-smoke'),
candidateVersion,
{ run, smokeRenderer },
);
await waitForInstalledProcessesToExit(installDirectory);
step('control run: the same installer must succeed without the failpoint');
const controlEnv = { ...process.env };
delete controlEnv.MAKA_INSTALLER_TEST_FAILPOINT;
await run(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: controlEnv,
timeoutMs: 300_000,
});
await access(uninstaller);
const upgradedVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(upgradedVersion, nextVersion);
try {
await access(backupDirectory);
throw new Error(`Backup residue left behind after a successful upgrade: ${backupDirectory}`);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
await waitForInstalledProcessesToExit(installDirectory);
step('registry mismatch: return 103 and retain evidence for a recovery rerun');
const registryMismatchEnv = {
...process.env,
MAKA_INSTALLER_TEST_FAILPOINT: 'after-extract-registry-mismatch',
};
const registryMismatch = await runExpectingExit(
nextInstaller,
['/S', `/D=${installDirectory}`],
{ env: registryMismatchEnv, timeoutMs: 300_000 },
);
if (registryMismatch.code !== exitRollbackFailed) {
throw new Error(
`Registry-mismatch rollback exited with ${registryMismatch.code}, expected ${exitRollbackFailed}.` +
`${registryMismatch.stderr.trim() ? `\nstderr: ${registryMismatch.stderr.trim()}` : ''}`,
);
}
const mismatchVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(mismatchVersion, nextVersion);
await access(join(backupDirectory, backupMarkerName));
await access(join(backupDirectory, 'RECOVERY-README.txt'));
await access(join(`${installDirectory}.failed-upgrade`, executableName));
step('registry mismatch recovery: rerun without the failpoint');
await run(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: controlEnv,
timeoutMs: 300_000,
});
const recoveredFromRegistryMismatch = await readInstalledProductVersion(installedExecutable, {
run,
});
assertWindowsProductVersion(recoveredFromRegistryMismatch, nextVersion);
const mismatchRecoveryRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, {
run,
});
if (!mismatchRecoveryRegistration.split(',').includes(nextVersion)) {
throw new Error(
`Registry-mismatch recovery did not restore the uninstall registration: ` +
`${JSON.stringify(mismatchRecoveryRegistration)}, expected ${nextVersion}.`,
);
}
for (const residue of [backupDirectory, `${installDirectory}.failed-upgrade`]) {
try {
await access(residue);
throw new Error(`Residue left behind after registry-mismatch recovery: ${residue}`);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
await waitForInstalledProcessesToExit(installDirectory);
step('stale backup identity: refuse before changing the current installation');
await cp(staleBackupFixture, backupDirectory, { recursive: true });
await writeFile(join(backupDirectory, backupMarkerName), `version=${candidateVersion}`, 'utf8');
const staleBackup = await runExpectingExit(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: controlEnv,
timeoutMs: 300_000,
});
if (staleBackup.code !== exitBackupFailed) {
throw new Error(
`Stale-backup upgrade exited with ${staleBackup.code}, expected ${exitBackupFailed}.` +
`${staleBackup.stderr.trim() ? `\nstderr: ${staleBackup.stderr.trim()}` : ''}`,
);
}
const unchangedAfterStaleBackup = await readInstalledProductVersion(installedExecutable, {
run,
});
assertWindowsProductVersion(unchangedAfterStaleBackup, nextVersion);
const registrationAfterStaleBackup = await readUninstallDisplayVersionsForInstall(uninstaller, {
run,
});
if (!registrationAfterStaleBackup.split(',').includes(nextVersion)) {
throw new Error(
`Stale backup refusal changed the uninstall registration: ` +
`${JSON.stringify(registrationAfterStaleBackup)}, expected ${nextVersion}.`,
);
}
step('incomplete backup identity: a matching marker without Maka.exe is refused');
await writeFile(join(backupDirectory, backupMarkerName), `version=${nextVersion}`, 'utf8');
await rm(join(backupDirectory, executableName), { force: true });
const incompleteBackup = await runExpectingExit(
nextInstaller,
['/S', `/D=${installDirectory}`],
{ env: controlEnv, timeoutMs: 300_000 },
);
if (incompleteBackup.code !== exitBackupFailed) {
throw new Error(
`Incomplete-backup upgrade exited with ${incompleteBackup.code}, expected ${exitBackupFailed}.` +
`${incompleteBackup.stderr.trim() ? `\nstderr: ${incompleteBackup.stderr.trim()}` : ''}`,
);
}
const unchangedAfterIncompleteBackup = await readInstalledProductVersion(installedExecutable, {
run,
});
assertWindowsProductVersion(unchangedAfterIncompleteBackup, nextVersion);
const registrationAfterIncompleteBackup = await readUninstallDisplayVersionsForInstall(
uninstaller,
{ run },
);
if (!registrationAfterIncompleteBackup.split(',').includes(nextVersion)) {
throw new Error(
`Incomplete backup refusal changed the uninstall registration: ` +
`${JSON.stringify(registrationAfterIncompleteBackup)}, expected ${nextVersion}.`,
);
}
await rm(backupDirectory, { recursive: true, force: true });
// The template's real failure branches exit via Quit, which NSIS gives
// no hook for: no rollback runs there, by design and by documentation.
// Pin that gap precisely instead of assuming it, and then prove the
// documented recovery story: running the installer again adopts the
// retained backup and completes the upgrade.
step('gap pin: a Quit at the worst moment leaves no hook and keeps the backup');
const quitEnv = { ...process.env, MAKA_INSTALLER_TEST_FAILPOINT: 'after-extract-quit' };
const quit = await runExpectingExit(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: quitEnv,
timeoutMs: 300_000,
});
// State first, exit code last: the state assertions carry the semantic
// pin (extracted files present, registration gone, backup retained), and
// on an exit-code drift they are the evidence that says what actually
// happened.
await access(join(backupDirectory, backupMarkerName));
// The recovery note is written at backup-creation time precisely so the
// hookless Quit paths leave it behind; assert that here.
await access(join(backupDirectory, 'RECOVERY-README.txt'));
const orphanVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(orphanVersion, nextVersion);
const orphanRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, { run });
if (orphanRegistration !== '') {
throw new Error(
`After the Quit failpoint the uninstall registration should be gone (the old ` +
`uninstaller removed it and nothing rewrote it), found ${JSON.stringify(orphanRegistration)}.`,
);
}
// Executed evidence, not inference: run 32384536035 measured a bare Quit
// at this point exiting 2 (the silent installer's generic failure code,
// shared with a hookless Abort default) — not 0 as NSIS source reading
// suggested. The state assertions above establish that the Quit branch
// (extraction completed, nothing registered) is the one that ran.
if (quit.code !== 2) {
throw new Error(
`Quit-failpoint upgrade exited with ${quit.code}, expected 2 (measured template-style ` +
`hookless Quit, run 32384536035): the pinned no-hook behavior changed — re-derive the gap.` +
`${quit.stderr.trim() ? `\nstderr: ${quit.stderr.trim()}` : ''}`,
);
}
step('recovery: rerunning the installer adopts the backup and completes the upgrade');
await run(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: controlEnv,
timeoutMs: 300_000,
});
await access(uninstaller);
const recoveredVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(recoveredVersion, nextVersion);
const recoveredRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, {
run,
});
if (!recoveredRegistration.split(',').includes(nextVersion)) {
throw new Error(
`Recovery rerun did not restore the uninstall registration: ` +
`${JSON.stringify(recoveredRegistration)}, expected to include ${nextVersion}.`,
);
}
try {
await access(backupDirectory);
throw new Error(`Backup residue left behind after the recovery rerun: ${backupDirectory}`);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
await waitForInstalledProcessesToExit(installDirectory);
step('fail closed: an upgrade with no registration and no backup is refused');
await deleteUninstallRegistrationForInstall(uninstaller, { run });
const refused = await runExpectingExit(nextInstaller, ['/S', `/D=${installDirectory}`], {
env: controlEnv,
timeoutMs: 300_000,
});
if (refused.code !== exitBackupFailed) {
throw new Error(
`Registration-less upgrade exited with ${refused.code}, expected ${exitBackupFailed} ` +
`(fail closed before anything destructive).` +
`${refused.stderr.trim() ? `\nstderr: ${refused.stderr.trim()}` : ''}`,
);
}
const untouchedVersion = await readInstalledProductVersion(installedExecutable, { run });
assertWindowsProductVersion(untouchedVersion, nextVersion);
try {
await access(backupDirectory);
throw new Error(`The refused upgrade must not leave a backup: ${backupDirectory}`);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
step('uninstalling');
await run(uninstaller, ['/S'], { timeoutMs: 120_000 });
await waitUntilMissing(installDirectory);
// The detached uninstaller deletes the registry keys after the files; do
// not leave that race armed for whatever runs after this step.
await waitForUninstallRegistrationToClear({ run });
uninstallCompleted = true;
step(
`verified Abort-path rollback and its boundaries for ${candidateVersion} -> ${nextVersion}`,
);
return { candidateVersion, nextVersion, installDirectory };
} catch (error) {
primaryError = error;
throw error;
} finally {
const cleanupErrors = [];
if (installationStarted && !uninstallCompleted) {
try {
await waitForInstalledProcessesToExit(installDirectory);
await access(uninstaller);
await run(uninstaller, ['/S'], { timeoutMs: 120_000 });
} catch (error) {
cleanupErrors.push(error);
}
}
try {
await rm(temporaryDirectory, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 250,
});
} catch (error) {
cleanupErrors.push(error);
}
if (cleanupErrors.length > 0) {
const cleanupFailure = new AggregateError(cleanupErrors, 'Rollback verifier cleanup failed.');
if (!primaryError) throw cleanupFailure;
if (primaryError instanceof Error && primaryError.cause === undefined) {
primaryError.cause = cleanupFailure;
}
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await verifyWindowsInstallerRollback(process.argv[2], process.argv[3]);
console.log(
`Verified Abort-path installer rollback for ${result.candidateVersion} -> ${result.nextVersion}`,
);
}