From e1c1579f5b4d704b6264e0ff2da52516200a6605 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 20 Aug 2026 21:13:40 +0800 Subject: [PATCH 1/3] feat(win): Abort-path installer rollback with backup retention Reworked from review (#3265): the restore is now a two-step same-volume swap ordered so every intermediate state keeps at least one complete installation on disk - the extracted new tree is moved aside, never deleted, before the backup is moved in, and is only discarded after the restore and registry write-back completed. Success is judged by the filesystem, not the error flag; a blocked-but-empty $INSTDIR shell is distinguished from real residue by enumeration and recovered through the copy path. Fail closed (101) when the upgrade has no uninstall registration and no adoptable backup: with nothing to restore, a failed upgrade would leave files without an uninstall entry and the template would silently merge trees on the next attempt. The backup is verified (executable witness) before anything destructive runs and marked complete only then; the registry snapshot is also persisted outside the keys the upgrade deletes, so an attempt that dies on a hookless template Quit path leaves a state the next run adopts and completes. Every kept-backup path writes RECOVERY-README.txt and shows a message when interactive. 102 is reported only after the filesystem witness and the write-back. The gate now pins the boundaries instead of assuming them: the Abort failpoint proves byte-identical restore (102); a Quit failpoint at the same moment proves no hook fires and the backup survives; a rerun proves adoption completes the upgrade; a deleted registration proves the 101 refusal leaves files untouched; the no-failpoint control run proves normal upgrades are unaffected. Title and docs now say what is covered - Abort-path rollback with backup retention - and spell out the Quit gap, the supported rerun recovery, and manual recovery steps. ASCII-only so the POSIX makensis toolchain can compile the include. Co-Authored-By: Claude Fable 5 Generated-by: Claude Fable 5 Generated-by: Codex --- .github/workflows/release-windows-check.yml | 12 +- .github/workflows/release.yml | 9 +- apps/desktop/build/installer.nsh | 630 ++++++++++++++++++ apps/desktop/electron-builder.config.mjs | 7 + docs/windows-support.md | 66 +- package.json | 1 + scripts/asf-license-headers.mjs | 1 + scripts/asf-license-headers.test.mjs | 1 + scripts/product-release.test.mjs | 9 +- scripts/verify-windows-installer-rollback.mjs | 572 ++++++++++++++++ 10 files changed, 1294 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/build/installer.nsh create mode 100644 scripts/verify-windows-installer-rollback.mjs diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index aed957f8a9..d4b58c0b81 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -38,7 +38,10 @@ on: - 'scripts/verify-windows-sandbox-e2e.mjs' - 'scripts/verify-windows-installer-lifecycle.mjs' - 'scripts/verify-windows-autoupdate.mjs' + - 'scripts/verify-windows-installer-rollback.mjs' - 'scripts/package-windows-autoupdate-next.mjs' + # The Abort-path rollback hook ships inside the installer itself. + - 'apps/desktop/build/installer.nsh' # The packaged updater's feed behavior — and the boot wiring that hands # MAKA_UPDATE_TEST_FEED to it — is only observable on this path. - 'apps/desktop/src/main/app-update-service.ts' @@ -70,7 +73,7 @@ concurrency: jobs: package: runs-on: windows-2025 - timeout-minutes: 75 + timeout-minutes: 90 defaults: run: shell: bash @@ -122,3 +125,10 @@ jobs: npm run verify:windows-autoupdate -- \ "apps/desktop/release/Maka-${version}-win-x64.exe" \ apps/desktop/release-autoupdate-next + + - name: Prove deterministic mid-install failure rollback + run: | + version="$(node -p "require('./apps/desktop/package.json').version")" + npm run verify:windows-installer-rollback -- \ + "apps/desktop/release/Maka-${version}-win-x64.exe" \ + apps/desktop/release-autoupdate-next diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7e626aac6..32d10e1806 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: runner: windows-2025 runs-on: ${{ matrix.runner }} environment: release - timeout-minutes: 60 + timeout-minutes: 75 defaults: run: # Windows runners default to pwsh; the release steps are written once, @@ -222,6 +222,13 @@ jobs: "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ apps/desktop/release-autoupdate-next + - name: Prove deterministic mid-install failure rollback + if: matrix.platform == 'windows' + run: | + npm run verify:windows-installer-rollback -- \ + "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ + apps/desktop/release-autoupdate-next + - name: Stage the exact product artifact group run: >- node scripts/product-release-artifacts.mjs stage diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh new file mode 100644 index 0000000000..95dc6d4a0a --- /dev/null +++ b/apps/desktop/build/installer.nsh @@ -0,0 +1,630 @@ +# 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. + +# Abort-path upgrade backup/rollback for the one-click NSIS installer. +# +# Why this exists: electron-builder's default upgrade sequence runs the OLD +# uninstaller (which removes the whole $INSTDIR and the uninstall registry +# keys) before extracting the new files. A failure after that point strands +# the machine with no working install and no rollback source. This include +# adds a verified pre-upgrade backup and an Abort-path restore: +# +# customInit (before anything destructive) back up $INSTDIR to a +# same-volume sibling, verify the copy, mark it complete, +# and snapshot the registry values the template deletes. +# A backup failure or an empty registry snapshot fails the +# upgrade CLOSED (exit 101) with the old install intact. +# customFiles_* (after extraction, worst case: old files gone, new +# uninstaller/registry not yet written) test-only +# deterministic failpoints, read at run time: +# MAKA_INSTALLER_TEST_FAILPOINT=after-extract aborts here +# (the covered path); =after-extract-quit quits here, +# pinning the uncovered template Quit shape. +# .onInstFailed (any script Abort) two-step same-volume swap: move the +# partial new tree aside, move the backup into place, and +# only then discard the aside copy -- every intermediate +# state keeps at least one complete installation on disk. +# Exit 102 when the restore and registry write-back are +# verified, 103 otherwise. Every 103 keeps a launchable +# tree and recovery metadata; a sibling backup and note +# are guaranteed only when that copy completed. +# customInstall (success tail) delete the backup and any aside residue. +# +# Recovery story for hookless Quit, and for 103 when the sibling copy exists: +# ".pre-upgrade-backup" holds a complete, verified copy plus +# RECOVERY-README.txt. A later installer adopts it only when its version marker +# matches the current or persisted registry snapshot. Every 103 keeps at least +# one launchable tree and recovery metadata, but rebuilding the sibling backup +# after a registry mismatch is best effort; without it, the next installer +# fails closed and manual repair/reinstall is required. +# +# What this does NOT cover (documented in docs/windows-support.md): failures +# that bypass NSIS's Abort path -- the template's own failure branches exit +# via Quit (old uninstaller failure, extraction retry exhaustion), which NSIS +# gives no hook for; a hard kill of the installer process; power loss. On +# those paths the backup and its recovery note are retained but no automatic +# restore runs. The backup copy is not forced through volatile device caches, +# in line with the repository's Windows durability boundary. +# +# Security posture of the failpoint (same narrative as MAKA_UPDATE_TEST_FEED): +# setting an environment variable on the installer's process already requires +# code execution as the same user, which under the per-user install model can +# rewrite the installation directory directly. The failpoint can only make an +# upgrade fail and roll back -- self-denial-of-service -- and it is read at +# run time (ReadEnvStr), never baked in at compile time. With the variable +# unset the only added behavior is one ReadEnvStr, the pre-upgrade backup, +# and the post-success backup removal. +# +# Compile-order note: this file is prepended to the generated script, before +# common.nsh/multiUser.nsh. Product defines arrive as makensis -D flags and +# are safe everywhere; template defines (INSTALL_REGISTRY_KEY, +# APP_EXECUTABLE_FILENAME, ...) are only safe inside !macro bodies, which +# expand at their insertion points. Functions therefore read exclusively from +# the $maka* variables assigned in customInit. + +!ifndef BUILD_UNINSTALLER + +!include LogicLib.nsh + +!define MAKA_EXIT_BACKUP_FAILED 101 +!define MAKA_EXIT_ROLLBACK_OK 102 +!define MAKA_EXIT_ROLLBACK_FAILED 103 +!define MAKA_BACKUP_MARKER ".maka-backup-complete" +!define MAKA_RECOVERY_README "RECOVERY-README.txt" +# The registry snapshot is ALSO persisted here, because the template's +# upgrade path deletes the real registration mid-upgrade: if the +# installer then dies on a Quit path (no Abort hook), the next run finds the +# real keys empty. This sibling key is not touched by the uninstaller, so an +# adopted backup can still restore registration across attempts. Deleted on +# every successful install and after every completed restore. +!define MAKA_SNAPSHOT_REG_KEY "Software\Maka\PreUpgradeSnapshot" + +Var makaBackupDir +Var makaAsideDir +Var makaAppExe +Var makaBackupArmed +Var makaFailpoint +Var makaInstallRegKey +Var makaUninstallRegKey +Var makaPrevInstallLocation +Var makaPrevKeepShortcuts +Var makaPrevShortcutName +Var makaPrevMenuDirectory +Var makaPrevDisplayName +Var makaPrevUninstallString +Var makaPrevQuietUninstallString +Var makaPrevDisplayVersion +Var makaPrevDisplayIcon +Var makaPrevPublisher +Var makaPrevComments + +# Sets $R7 to "1" when the directory in $R5 contains any entry besides +# "." and "..", else "0". IfFileExists "dir\*.*" cannot make this +# distinction (it is true for an empty directory), and the difference +# decides whether a blocked-but-empty $INSTDIR shell is recoverable. +Function makaDirHasEntries + StrCpy $R7 "0" + ClearErrors + FindFirst $R6 $R4 "$R5\*.*" + ${If} ${Errors} + ClearErrors + Return + ${EndIf} + makaDirHasEntriesLoop: + StrCmp $R4 "" makaDirHasEntriesDone + StrCmp $R4 "." makaDirHasEntriesNext + StrCmp $R4 ".." makaDirHasEntriesNext + StrCpy $R7 "1" + Goto makaDirHasEntriesDone + makaDirHasEntriesNext: + FindNext $R6 $R4 + Goto makaDirHasEntriesLoop + makaDirHasEntriesDone: + FindClose $R6 + ClearErrors +FunctionEnd + +# Writes RECOVERY-README.txt into the backup directory (best effort). Called +# at backup-creation time -- not only on failure -- because the paths that +# most need it are the hookless template Quit exits, where no code of ours +# runs after the failure: the note must already be there. +Function makaWriteRecoveryReadme + ClearErrors + FileOpen $R6 "$makaBackupDir\${MAKA_RECOVERY_README}" w + ${IfNot} ${Errors} + FileWrite $R6 "This directory holds a complete copy of a Maka installation$\r$\n" + FileWrite $R6 "(version $makaPrevDisplayVersion), taken before an upgrade. If you can read$\r$\n" + FileWrite $R6 "this, that upgrade did not finish.$\r$\n$\r$\n" + FileWrite $R6 "The safest recovery is to run the Maka installer again. It adopts this$\r$\n" + FileWrite $R6 "backup only when it matches the interrupted upgrade snapshot; otherwise$\r$\n" + FileWrite $R6 "the current installation is left unchanged.$\r$\n$\r$\n" + FileWrite $R6 "To restore manually instead:$\r$\n" + FileWrite $R6 " 1. Close Maka if it is running.$\r$\n" + FileWrite $R6 " 2. Delete the installation directory: $INSTDIR$\r$\n" + FileWrite $R6 " 3. Rename this directory to: $INSTDIR$\r$\n" + FileWrite $R6 " 4. Delete the ${MAKA_BACKUP_MARKER} and ${MAKA_RECOVERY_README} files inside it.$\r$\n" + FileClose $R6 + ${EndIf} + ClearErrors +FunctionEnd + +# Failure-path variant: refresh the note and tell an interactive user where +# the backup is. Silent installs rely on the note alone. +Function makaWriteRecoveryNote + Call makaWriteRecoveryReadme + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "The Maka upgrade failed. A complete copy of the previous installation was kept at:$\r$\n$makaBackupDir$\r$\n$\r$\nSee ${MAKA_RECOVERY_README} inside it for recovery steps." + ${EndIf} +FunctionEnd + +# Sets $R7 to "1" only when the complete-backup marker belongs to the exact +# source version represented by the registry snapshot this run would restore. +Function makaBackupMatchesSnapshot + StrCpy $R7 "0" + ${IfNot} ${FileExists} "$makaBackupDir\$makaAppExe" + Return + ${EndIf} + ClearErrors + FileOpen $R6 "$makaBackupDir\${MAKA_BACKUP_MARKER}" r + ${IfNot} ${Errors} + FileRead $R6 $R4 + FileClose $R6 + ${If} $R4 S== "version=$makaPrevDisplayVersion" + StrCpy $R7 "1" + ${EndIf} + ${EndIf} + ClearErrors +FunctionEnd + +# Sets $R7 to "1" only when the required uninstall registration was written +# back exactly. Optional snapshot values remain best effort, but 102 must never +# be reported without the identity and uninstall command the template needs. +Function makaRegistryMatchesSnapshot + StrCpy $R7 "0" + ClearErrors + ReadRegStr $R8 SHELL_CONTEXT "$makaUninstallRegKey" DisplayName + ${If} ${Errors} + ClearErrors + Return + ${EndIf} + StrCmp $R8 "$makaPrevDisplayName" 0 makaRegistryMatchesSnapshotDone + ClearErrors + ReadRegStr $R8 SHELL_CONTEXT "$makaUninstallRegKey" UninstallString + ${If} ${Errors} + ClearErrors + Return + ${EndIf} + StrCmp $R8 "$makaPrevUninstallString" 0 makaRegistryMatchesSnapshotDone + ClearErrors + ReadRegDWORD $R8 SHELL_CONTEXT "$makaUninstallRegKey" NoModify + ${If} ${Errors} + ClearErrors + Return + ${EndIf} + StrCmp $R8 "1" 0 makaRegistryMatchesSnapshotDone + ClearErrors + ReadRegDWORD $R8 SHELL_CONTEXT "$makaUninstallRegKey" NoRepair + ${If} ${Errors} + ClearErrors + Return + ${EndIf} + StrCmp $R8 "1" 0 makaRegistryMatchesSnapshotDone + StrCpy $R7 "1" + makaRegistryMatchesSnapshotDone: + ClearErrors +FunctionEnd + +# Restores the pre-upgrade state captured by customInit, ordered so that at +# every intermediate point at least one complete installation exists on disk: +# +# 1. move the (complete, launchable) new tree aside -- never delete it +# before the old one is back; +# 2. move the verified backup into place; +# 3. write the registry snapshot back; +# 4. only then discard the aside copy. +# +# Guarded by $makaBackupArmed, which only this run's customInit sets after +# verifying the backup: an Abort during a fresh install, or with a stale +# backup from some earlier attempt, must behave exactly as upstream does +# today rather than resurrect files with an empty registry snapshot. +Function makaRestoreFromBackup + ${If} $makaBackupArmed != "1" + Return + ${EndIf} + DetailPrint "Restoring the previous installation from $makaBackupDir" + # Move the working directory out of $INSTDIR so directory operations cannot + # fail on our own open handle (same precaution as the template uninstaller). + SetOutPath $PLUGINSDIR + + # Step 1: move the extracted new tree aside. If $INSTDIR does not exist + # (nothing was extracted), there is nothing to move. The rename is retried + # briefly first: a scanner or indexer transiently holding one of the + # hundreds of freshly extracted files is most likely at exactly this + # moment, and the template's own destructive loops use the same 5x1s + # cadence. + ${If} ${FileExists} "$INSTDIR\*.*" + StrCpy $R3 0 + ${Do} + ClearErrors + Rename "$INSTDIR" "$makaAsideDir" + ${IfNot} ${Errors} + ${Break} + ${EndIf} + IntOp $R3 $R3 + 1 + ${If} $R3 >= 5 + ${Break} + ${EndIf} + Sleep 1000 + ${Loop} + ${If} ${Errors} + # The rename failed -- typically a handle held on the directory itself. + # An empty shell is still recoverable: the backup can be copied into + # it. A populated tree that cannot be moved is not: removing it first + # would create a window with no installation at all, so keep the new + # (launchable) tree, keep the backup, and report the restore failure. + StrCpy $R5 "$INSTDIR" + Call makaDirHasEntries + ${If} $R7 == "1" + DetailPrint "The new-version tree cannot be moved aside; keeping it and the backup" + Call makaWriteRecoveryNote + SetErrorLevel ${MAKA_EXIT_ROLLBACK_FAILED} + Return + ${EndIf} + # Empty shell: try to clear it (non-recursive: only succeeds on empty), + # then fall through -- if the shell persists, the copy path below fills + # it in place. + ClearErrors + RMDir "$INSTDIR" + ClearErrors + ${EndIf} + ${EndIf} + + # Step 2: bring the backup into place. Prefer the instant same-volume + # rename (retried on the same cadence as step 1); fall back to copying + # into a persisting empty shell. Success is judged by the filesystem, not + # by the error flag (the flag cannot say which of two prior operations set + # it). + StrCpy $R3 0 + ${Do} + ClearErrors + Rename "$makaBackupDir" "$INSTDIR" + ${IfNot} ${Errors} + ${Break} + ${EndIf} + IntOp $R3 $R3 + 1 + ${If} $R3 >= 5 + ${Break} + ${EndIf} + Sleep 1000 + ${Loop} + ClearErrors + ${IfNot} ${FileExists} "$INSTDIR\$makaAppExe" + ${If} ${FileExists} "$makaBackupDir\$makaAppExe" + # The rename did not take (e.g. the empty $INSTDIR shell persists). + # Copy the backup into the target instead. + CreateDirectory "$INSTDIR" + ClearErrors + CopyFiles /SILENT "$makaBackupDir\*.*" "$INSTDIR" + ClearErrors + ${EndIf} + ${EndIf} + ${IfNot} ${FileExists} "$INSTDIR\$makaAppExe" + # The old tree is not back. Undo step 1 so the machine keeps the + # launchable new tree rather than an empty directory, keep the backup, + # and report the failure. + ${IfNot} ${FileExists} "$INSTDIR\*.*" + ClearErrors + Rename "$makaAsideDir" "$INSTDIR" + ClearErrors + ${EndIf} + DetailPrint "Restore failed; keeping $makaBackupDir for manual recovery" + Call makaWriteRecoveryNote + SetErrorLevel ${MAKA_EXIT_ROLLBACK_FAILED} + Return + ${EndIf} + + # Step 3: re-create the registry state the old uninstaller removed. + # customInit failed closed on an empty snapshot, so UninstallString and + # DisplayName are known non-empty here; other values are written back only + # when they existed. NoModify/NoRepair are template constants in every + # shipped version. EstimatedSize is cosmetic and intentionally not + # restored. + ${If} $makaPrevInstallLocation != "" + WriteRegStr SHELL_CONTEXT "$makaInstallRegKey" InstallLocation "$makaPrevInstallLocation" + ${EndIf} + ${If} $makaPrevKeepShortcuts != "" + WriteRegStr SHELL_CONTEXT "$makaInstallRegKey" KeepShortcuts "$makaPrevKeepShortcuts" + ${EndIf} + ${If} $makaPrevShortcutName != "" + WriteRegStr SHELL_CONTEXT "$makaInstallRegKey" ShortcutName "$makaPrevShortcutName" + ${EndIf} + ${If} $makaPrevMenuDirectory != "" + WriteRegStr SHELL_CONTEXT "$makaInstallRegKey" MenuDirectory "$makaPrevMenuDirectory" + ${EndIf} + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" DisplayName "$makaPrevDisplayName" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" UninstallString "$makaPrevUninstallString" + ${If} $makaPrevQuietUninstallString != "" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" QuietUninstallString "$makaPrevQuietUninstallString" + ${EndIf} + ${If} $makaPrevDisplayVersion != "" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" DisplayVersion "$makaPrevDisplayVersion" + ${EndIf} + ${If} $makaPrevDisplayIcon != "" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" DisplayIcon "$makaPrevDisplayIcon" + ${EndIf} + ${If} $makaPrevPublisher != "" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" Publisher "$makaPrevPublisher" + ${EndIf} + ${If} $makaPrevComments != "" + WriteRegStr SHELL_CONTEXT "$makaUninstallRegKey" Comments "$makaPrevComments" + ${EndIf} + WriteRegDWORD SHELL_CONTEXT "$makaUninstallRegKey" NoModify 1 + WriteRegDWORD SHELL_CONTEXT "$makaUninstallRegKey" NoRepair 1 + + # Test-only corruption of one required value proves the read-back gate. + ${If} $makaFailpoint == "abort-registry-mismatch" + DeleteRegValue SHELL_CONTEXT "$makaUninstallRegKey" UninstallString + ${EndIf} + Call makaRegistryMatchesSnapshot + ${If} $R7 != "1" + DetailPrint "Registry write-back could not be verified; keeping recovery evidence" + # A successful rename consumed the sibling backup into $INSTDIR. Recreate + # the sibling copy best-effort while keeping the restored tree live; the + # marker, snapshot key, and aside tree are deliberately not cleaned up. + ${IfNot} ${FileExists} "$makaBackupDir\$makaAppExe" + CreateDirectory "$makaBackupDir" + ClearErrors + CopyFiles /SILENT "$INSTDIR\*.*" "$makaBackupDir" + ClearErrors + ${EndIf} + ${If} ${FileExists} "$makaBackupDir\$makaAppExe" + Call makaWriteRecoveryNote + ${Else} + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "The previous Maka files were restored, but the uninstall registration could not be verified. Recovery metadata and the extracted replacement were retained. A later installer will refuse this state; inspect the retained files, then repair the registration or remove the installation directory and reinstall Maka." + ${EndIf} + ${EndIf} + SetErrorLevel ${MAKA_EXIT_ROLLBACK_FAILED} + Return + ${EndIf} + + # Step 4: the restore is complete; the markers must not ship inside the + # restored tree, and the aside copy -- plus the backup directory itself + # when the copy fallback (rather than the consuming rename) restored it -- + # is now redundant. All removals are best effort: a scanner holding any of + # them must not demote a completed restore. + ClearErrors + Delete "$INSTDIR\${MAKA_BACKUP_MARKER}" + Delete "$INSTDIR\${MAKA_RECOVERY_README}" + RMDir /r "$makaAsideDir" + RMDir /r "$makaBackupDir" + DeleteRegKey SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" + ClearErrors + DetailPrint "Previous installation restored" + SetErrorLevel ${MAKA_EXIT_ROLLBACK_OK} +FunctionEnd + +Function .onInstFailed + Call makaRestoreFromBackup +FunctionEnd + +!macro customInit + StrCpy $makaBackupDir "$INSTDIR.pre-upgrade-backup" + StrCpy $makaAsideDir "$INSTDIR.failed-upgrade" + StrCpy $makaAppExe "${APP_EXECUTABLE_FILENAME}" + StrCpy $makaInstallRegKey "${INSTALL_REGISTRY_KEY}" + StrCpy $makaUninstallRegKey "${UNINSTALL_REGISTRY_KEY}" + StrCpy $makaBackupArmed "0" + StrCpy $makaFailpoint "" + ReadEnvStr $R9 MAKA_INSTALLER_TEST_FAILPOINT + # S== is case-sensitive: only the exact tokens are accepted. + ${If} $R9 S== "after-extract" + StrCpy $makaFailpoint "abort" + ${ElseIf} $R9 S== "after-extract-quit" + StrCpy $makaFailpoint "quit" + ${ElseIf} $R9 S== "after-extract-registry-mismatch" + StrCpy $makaFailpoint "abort-registry-mismatch" + ${EndIf} + ${If} ${FileExists} "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + # Snapshot the registry values the template's upgrade path deletes, while + # they still exist (the old uninstaller runs inside the install section, + # after this hook). The set mirrors registryAddInstallInfo, including the + # conditional MenuDirectory (reading a missing value yields "" and the + # write-back skips empty values). + ReadRegStr $makaPrevInstallLocation SHELL_CONTEXT "$makaInstallRegKey" InstallLocation + ReadRegStr $makaPrevKeepShortcuts SHELL_CONTEXT "$makaInstallRegKey" KeepShortcuts + ReadRegStr $makaPrevShortcutName SHELL_CONTEXT "$makaInstallRegKey" ShortcutName + ReadRegStr $makaPrevMenuDirectory SHELL_CONTEXT "$makaInstallRegKey" MenuDirectory + ReadRegStr $makaPrevDisplayName SHELL_CONTEXT "$makaUninstallRegKey" DisplayName + ReadRegStr $makaPrevUninstallString SHELL_CONTEXT "$makaUninstallRegKey" UninstallString + ReadRegStr $makaPrevQuietUninstallString SHELL_CONTEXT "$makaUninstallRegKey" QuietUninstallString + ReadRegStr $makaPrevDisplayVersion SHELL_CONTEXT "$makaUninstallRegKey" DisplayVersion + ReadRegStr $makaPrevDisplayIcon SHELL_CONTEXT "$makaUninstallRegKey" DisplayIcon + ReadRegStr $makaPrevPublisher SHELL_CONTEXT "$makaUninstallRegKey" Publisher + ReadRegStr $makaPrevComments SHELL_CONTEXT "$makaUninstallRegKey" Comments + ClearErrors + ${If} $makaPrevUninstallString == "" + ${OrIf} $makaPrevDisplayName == "" + ${OrIf} $makaPrevDisplayVersion == "" + # The real registration is missing. One legitimate way here: a previous + # upgrade attempt died on a template Quit path after the old + # uninstaller deleted the keys. That attempt left a verified backup and + # persisted its snapshot in MAKA_SNAPSHOT_REG_KEY -- adopt the snapshot + # so the upgrade can still complete (or restore) across attempts. + ${If} ${FileExists} "$makaBackupDir\${MAKA_BACKUP_MARKER}" + ReadRegStr $makaPrevInstallLocation SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" InstallLocation + ReadRegStr $makaPrevKeepShortcuts SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" KeepShortcuts + ReadRegStr $makaPrevShortcutName SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" ShortcutName + ReadRegStr $makaPrevMenuDirectory SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" MenuDirectory + ReadRegStr $makaPrevDisplayName SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayName + ReadRegStr $makaPrevUninstallString SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" UninstallString + ReadRegStr $makaPrevQuietUninstallString SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" QuietUninstallString + ReadRegStr $makaPrevDisplayVersion SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayVersion + ReadRegStr $makaPrevDisplayIcon SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayIcon + ReadRegStr $makaPrevPublisher SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" Publisher + ReadRegStr $makaPrevComments SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" Comments + ClearErrors + ${EndIf} + ${If} $makaPrevUninstallString == "" + ${OrIf} $makaPrevDisplayName == "" + ${OrIf} $makaPrevDisplayVersion == "" + # Fail closed: with neither a registration nor an adoptable snapshot + # there is nothing a rollback could restore, so a failed upgrade + # would leave files without an uninstall entry -- and the template's + # own upgrade path would silently merge trees rather than upgrade. + # Nothing destructive has happened yet, so refusing here leaves the + # existing installation fully intact. + DetailPrint "The existing installation has no uninstall registration; refusing to upgrade" + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "Maka cannot upgrade safely because the existing installation has no uninstall registration. Delete the installation directory ($INSTDIR), then run this installer again." + ${EndIf} + SetErrorLevel ${MAKA_EXIT_BACKUP_FAILED} + Quit + ${EndIf} + ${EndIf} + ${If} ${FileExists} "$makaBackupDir\${MAKA_BACKUP_MARKER}" + Call makaBackupMatchesSnapshot + ${If} $R7 != "1" + DetailPrint "The pre-upgrade backup does not match the current installation; refusing to upgrade" + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "Maka found a pre-upgrade backup from a different version. The current installation was left unchanged. Move or remove $makaBackupDir after inspecting it, then run this installer again." + ${EndIf} + SetErrorLevel ${MAKA_EXIT_BACKUP_FAILED} + Quit + ${EndIf} + # A previous upgrade attempt failed after completing its backup: the + # current $INSTDIR may hold that attempt's partial residue, so the + # existing verified backup is the trustworthy recovery source. Adopt + # it; never overwrite it with a possibly-torn tree. + DetailPrint "Adopting the complete pre-upgrade backup left by an earlier attempt" + ${Else} + ClearErrors + RMDir /r "$makaBackupDir" + CreateDirectory "$makaBackupDir" + CopyFiles /SILENT "$INSTDIR\*.*" "$makaBackupDir" + ${If} ${Errors} + # Fail closed: nothing destructive has happened yet (the app kill + # and the old uninstaller both run later, inside the install + # section), so refusing the upgrade leaves the existing installation + # fully intact. + DetailPrint "Pre-upgrade backup failed; refusing to upgrade" + RMDir /r "$makaBackupDir" + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "Maka could not back up the existing installation, so the upgrade was not started. The current version is unchanged." + ${EndIf} + SetErrorLevel ${MAKA_EXIT_BACKUP_FAILED} + Quit + ${EndIf} + # Verify before crossing the destructive boundary: the error flag alone + # does not establish a usable backup (a partial SHFileOperation can + # pass it). The application executable is the minimal witness; a + # mismatch here means the backup cannot be trusted, so fail closed + # while the old install is still intact. + ${IfNot} ${FileExists} "$makaBackupDir\${APP_EXECUTABLE_FILENAME}" + DetailPrint "Pre-upgrade backup is incomplete; refusing to upgrade" + RMDir /r "$makaBackupDir" + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "Maka could not back up the existing installation, so the upgrade was not started. The current version is unchanged." + ${EndIf} + SetErrorLevel ${MAKA_EXIT_BACKUP_FAILED} + Quit + ${EndIf} + # Mark the backup complete only after verification; the marker is what + # a later run trusts when deciding to adopt it. + ClearErrors + FileOpen $R8 "$makaBackupDir\${MAKA_BACKUP_MARKER}" w + ${IfNot} ${Errors} + FileWrite $R8 "version=$makaPrevDisplayVersion" + FileClose $R8 + ${EndIf} + ClearErrors + # The recovery note is written NOW, not on failure: the hookless + # template Quit exits run none of our code after the failure, and they + # are the main producers of a leftover backup. + Call makaWriteRecoveryReadme + ${EndIf} + # Persist the snapshot outside the keys the upgrade deletes, so a later + # attempt can adopt it if this one dies on a hookless Quit path. Written + # on the adopt path too (idempotent re-write of the adopted values). + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" InstallLocation "$makaPrevInstallLocation" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" KeepShortcuts "$makaPrevKeepShortcuts" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" ShortcutName "$makaPrevShortcutName" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" MenuDirectory "$makaPrevMenuDirectory" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayName "$makaPrevDisplayName" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" UninstallString "$makaPrevUninstallString" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" QuietUninstallString "$makaPrevQuietUninstallString" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayVersion "$makaPrevDisplayVersion" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" DisplayIcon "$makaPrevDisplayIcon" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" Publisher "$makaPrevPublisher" + WriteRegStr SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" Comments "$makaPrevComments" + ClearErrors + # Clear any aside residue from an interrupted earlier restore, then arm + # the rollback for this run only. + RMDir /r "$makaAsideDir" + ClearErrors + StrCpy $makaBackupArmed "1" + ${EndIf} +!macroend + +# Deterministic test-only failures at the worst possible moment: the new +# files are in place, the old install is gone, and the new uninstaller and +# registry entries have not been written yet. "abort" exercises the covered +# rollback path (.onInstFailed runs); "abort-registry-mismatch" proves that a +# failed registry write-back returns 103 without discarding recovery evidence; +# "quit" pins the uncovered template Quit shape -- no hook fires, the backup +# and its marker survive, and the next installer run adopts them. +!macro makaFailpointAfterExtract + ${If} $makaFailpoint == "abort" + DetailPrint "MAKA_INSTALLER_TEST_FAILPOINT=after-extract hit: aborting deliberately" + Abort + ${ElseIf} $makaFailpoint == "abort-registry-mismatch" + DetailPrint "MAKA_INSTALLER_TEST_FAILPOINT=after-extract-registry-mismatch hit: aborting deliberately" + Abort + ${ElseIf} $makaFailpoint == "quit" + DetailPrint "MAKA_INSTALLER_TEST_FAILPOINT=after-extract-quit hit: quitting deliberately" + Quit + ${EndIf} +!macroend + +!macro customFiles_x64 + !insertmacro makaFailpointAfterExtract +!macroend + +!macro customFiles_arm64 + !insertmacro makaFailpointAfterExtract +!macroend + +!macro customFiles_ia32 + !insertmacro makaFailpointAfterExtract +!macroend + +!macro customInstall + # Success tail: the upgrade completed, so the backup and any aside residue + # are redundant. Best effort -- a held handle must not fail a completed + # install. + ClearErrors + ${If} ${FileExists} "$makaBackupDir\*.*" + RMDir /r "$makaBackupDir" + ${EndIf} + ${If} ${FileExists} "$makaAsideDir\*.*" + RMDir /r "$makaAsideDir" + ${EndIf} + DeleteRegKey SHELL_CONTEXT "${MAKA_SNAPSHOT_REG_KEY}" + ClearErrors +!macroend + +!endif diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index a39a10d8c4..321216a77a 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -207,6 +207,13 @@ export default { // electron-updater skips the check when there is none. Adding a certificate // is then the whole change — the verification follows it. }, + nsis: { + // Everything stays at the one-click per-user defaults; the include only + // adds the Abort-path pre-upgrade backup/rollback (and its test-only + // deterministic failpoint) — see build/installer.nsh for the mechanism + // and its exit-code contract with verify-windows-installer-rollback.mjs. + include: 'build/installer.nsh', + }, publish: [ { provider: 'github', diff --git a/docs/windows-support.md b/docs/windows-support.md index f0d33f4c4e..7924e5a14f 100644 --- a/docs/windows-support.md +++ b/docs/windows-support.md @@ -49,13 +49,42 @@ running, discovers a newer build through its packaged electron-updater against a downloads it in the background, hands off to the NSIS installer, relaunches as the new version, and passes the full packaged smoke — with the feed requests (including the differential-download probe), the `downloaded` state and its exact version pair, and the final installed version asserted -individually; transient states such as `checking` and `downloading` are not individually asserted. What is still not proven: update signature verification (no Authenticode -certificate yet — the feed configuration for the production GitHub channel is pinned by unit tests -and exercised routinely on real releases instead), persisted business-data migration, and rollback -after a mid-install failure. +individually; transient states such as `checking` and `downloading` are not individually asserted. + +A third gate exercises **Abort-path rollback with backup retention**. Before anything destructive +runs, an upgrade backs up the existing installation to a same-volume sibling +(`.pre-upgrade-backup`), verifies the copy, and persists the registry snapshot; a +backup failure, missing uninstall registration, backup marker that does not match the current +snapshot, or backup without an executable witness refuses the upgrade with the old install untouched +(exit 101). On the NSIS **Abort path** +the installer restores the backup with a two-step same-volume swap — at every intermediate point at +least one complete installation exists on disk — and writes and verifies the required registration +values (exit 102). A registry read-back mismatch keeps recovery evidence and returns 103. The gate +arms a deterministic test-only failpoint at +the worst moment — new files extracted, old files gone, the new uninstaller and registry not yet +written — and asserts the previous installation comes back byte-identical (per-file SHA-256 over +the whole tree; file contents, not ACLs or timestamps), registered, and launchable at its old +version, forces and recovers from a registry read-back mismatch, refuses stale-version and incomplete +backups without changing the current install, then asserts a normal upgrade succeeds with no backup +residue. + +Be precise about the boundary: the template's own failure branches (the old uninstaller failing, +extraction retry exhaustion or cancellation) exit via `Quit`, which NSIS gives no hook for — **no +automatic rollback runs on those paths**. The gate pins that gap deliberately: after a `Quit` at +the worst moment, the verified backup and a `RECOVERY-README.txt` inside it are retained. Rerunning +the installer adopts the backup only when its version marker matches the persisted snapshot and +then completes the upgrade — that rerun is the supported recovery, and the gate proves it. Manual +recovery, if ever needed: close Maka, delete the +installation directory, rename `.pre-upgrade-backup` to the installation directory, +and remove the two marker files inside it. Also not covered: a hard kill of the installer process +or power loss (the backup copy is not forced through volatile device caches, in line with the +durability boundary below). What is still not proven overall: update signature verification (no +Authenticode certificate yet — the feed configuration for the production GitHub channel is pinned +by unit tests and exercised routinely on real releases instead) and persisted business-data +migration. To uninstall, use **Settings → Apps → Installed apps → Maka → Uninstall**. Back up any important -workspace data first; the preview does not yet claim installer rollback or migration guarantees. +workspace data first; the preview does not yet claim business-data migration guarantees. ## 安装 Windows x64 预览版 @@ -80,11 +109,28 @@ workspace data first; the preview does not yet claim installer rollback or migra 进程退出,并运行真实卸载器。另一个门禁证明**运行中的自动更新路径**:已安装且正在运行的候选版本通过打包的 electron-updater 从 loopback 测试 feed 发现新版本、后台下载、交接给 NSIS 安装器、以新版本自动重启并通过 完整打包 smoke——feed 请求(含差量下载探测)、`downloaded` 状态及其精确版本对、最终安装版本均逐项断言; -`checking`/`downloading` 等瞬态不逐项断言。仍未证明的是:更新签名校验(尚无 -Authenticode 证书;生产 GitHub 通道的 feed 配置由单测钉死,并在每次真实 release 中例行使用)、业务数据 -迁移,以及安装中途失败后的 rollback。 - -卸载入口为 **设置 → 应用 → 已安装的应用 → Maka → 卸载**。预览版尚未承诺安装器 rollback 或数据迁移, +`checking`/`downloading` 等瞬态不逐项断言。 + +第三个门禁验证 **Abort 路径 rollback 与备份保留**。升级在任何破坏性步骤之前先把现有安装备份到同卷同级 +目录(`<安装目录>.pre-upgrade-backup`),校验副本并持久化注册表快照;备份失败、卸载注册信息缺失、备份 +版本标记与当前快照不匹配,或备份缺少可执行文件见证时直接拒绝升级,旧安装分毫未动(退出码 101)。在 +NSIS **Abort 路径**上,安装器 +用同卷两步换名恢复备份——任意中间时刻磁盘上都至少存在一份完整安装——并回写、回读校验必需的注册信息 +(退出码 102);注册表回读不一致时保留恢复证据并返回 103。门禁在最坏时点——新文件已抽取、 +旧文件已删、新卸载器与注册表尚未写入——注入确定性的测试专用失败点,断言先前安装**逐文件 SHA-256 字节 +一致**地恢复(文件内容字节,不含 ACL/时间戳)、注册信息恢复、且能以旧版本启动;还会强制制造并恢复 +注册表回读不一致,验证旧版本或不完整备份不会被当前安装误接管,并断言正常升级成功且无备份残留。 + +边界必须说清楚:模板自身的失败分支(旧卸载器失败、抽取重试耗尽或被取消)经 `Quit` 退出,NSIS 对此没有 +任何回调——**这些路径上不会发生自动 rollback**。门禁刻意钉住这一缺口:在最坏时点 `Quit` 之后,已校验的 +备份和其中的 `RECOVERY-README.txt` 会被保留。仅当备份版本标记与持久化快照一致时,重新运行安装器才会 +接管该备份并完成升级——这次重跑就是受支持的恢复方式,门禁对其有断言。如需手工恢复:关闭 Maka、删除 +安装目录、把 `<安装目录>.pre-upgrade-backup` +重命名为安装目录、删除其中的两个标记文件。同样不覆盖:安装器进程被硬杀或断电(备份拷贝不经强制介质 +刷盘,与下方耐久性边界同一口径)。整体仍未证明的是:更新签名校验(尚无 Authenticode 证书;生产 GitHub +通道的 feed 配置由单测钉死,并在每次真实 release 中例行使用)与业务数据迁移。 + +卸载入口为 **设置 → 应用 → 已安装的应用 → Maka → 卸载**。预览版尚未承诺业务数据迁移, 请先备份重要 workspace 数据。 ## Phase 0 development target diff --git a/package.json b/package.json index f85b5ec41c..7b9647b882 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs", "package:windows-autoupdate-next": "node scripts/package-windows-autoupdate-next.mjs", "verify:windows-autoupdate": "node scripts/verify-windows-autoupdate.mjs", + "verify:windows-installer-rollback": "node scripts/verify-windows-installer-rollback.mjs", "astryx:theme": "node scripts/build-astryx-theme.mjs", "astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs", "astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs", diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index b5e2e0906d..2e481992a5 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -114,6 +114,7 @@ const coveredExtensions = new Map([ ['.md', 'html'], ['.mjs', 'block'], ['.mts', 'block'], + ['.nsh', 'hash'], ['.ps1', 'hash'], ['.py', 'hash'], ['.rs', 'block'], diff --git a/scripts/asf-license-headers.test.mjs b/scripts/asf-license-headers.test.mjs index 0a98191a14..5efa159ae8 100644 --- a/scripts/asf-license-headers.test.mjs +++ b/scripts/asf-license-headers.test.mjs @@ -209,6 +209,7 @@ describe('ASF header classification', () => { 'packages/core/src/settings.ts', 'apps/desktop/src/renderer/app-shell.tsx', 'scripts/asf-source-release.mjs', + 'apps/desktop/build/installer.nsh', 'experiments/windows-sandbox/launcher/src/main.rs', 'packages/eval/harbor/egress-proxy/Dockerfile', 'packages/eval/harbor/egress-proxy/network-policy', diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 47317c082d..72dcb45f75 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -620,7 +620,11 @@ test('one product workflow gates one draft release on every required artifact', const desktopStepNames = jobs.desktop.steps.map((step) => step.name); const uploadIndex = desktopStepNames.indexOf('Upload the verified release assets'); assert.ok(uploadIndex >= 0); - for (const verifier of ['Verify the final DMG', 'Verify the Windows release']) { + for (const verifier of [ + 'Verify the final DMG', + 'Verify the Windows release', + 'Prove deterministic mid-install failure rollback', + ]) { const verifierIndex = desktopStepNames.indexOf(verifier); assert.ok(verifierIndex >= 0 && verifierIndex < uploadIndex); } @@ -649,9 +653,10 @@ test('one product workflow gates one draft release on every required artifact', .filter((run) => typeof run === 'string') .join('\n'); assert.equal((commands.match(/gh release create/gu) ?? []).length, 1); - assert.equal(jobs.desktop['timeout-minutes'], 60); + assert.equal(jobs.desktop['timeout-minutes'], 75); assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); + assert.match(commands, /npm run verify:windows-installer-rollback/u); assert.match(commands, /product-release-tag\.mjs ensure/u); assert.doesNotMatch(commands, /RECOVERY_SOURCE|inputs\.source_commit/u); assert.match(commands, /if gh release view "\$TAG"/u); diff --git a/scripts/verify-windows-installer-rollback.mjs b/scripts/verify-windows-installer-rollback.mjs new file mode 100644 index 0000000000..25adf1991e --- /dev/null +++ b/scripts/verify-windows-installer-rollback.mjs @@ -0,0 +1,572 @@ +/* + * 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, + findRendererTarget, + isolatedUserEnv, + waitForDevToolsPort, + waitForUsableRenderer, + runCommand, + stopChild, +} from './verify-packaged-app.mjs'; +import { + installerVersion, + readUninstallDisplayVersions, + 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; +} + +/** + * Deletes the Maka 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. + */ +async function deleteUninstallRegistration({ run = runCommand } = {}) { + const script = String.raw` +Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' | + Where-Object { + $name = ($_ | Get-ItemProperty).DisplayName + $name -eq 'Maka' -or $name -like 'Maka *' + } | + Remove-Item -Recurse -Force +`; + await run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { + timeoutMs: 30_000, + }); +} + +async function assertLaunchable(installedExecutable, workingDirectory, expectedVersion) { + const home = join(workingDirectory, 'home'); + const userData = join(workingDirectory, 'user-data'); + const userEnv = isolatedUserEnv(home); + await mkdir(home, { recursive: true }); + await mkdir(userData, { recursive: true }); + await mkdir(userEnv.APPDATA, { recursive: true }); + await mkdir(userEnv.LOCALAPPDATA, { recursive: true }); + const child = spawn( + installedExecutable, + ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], + { + cwd: workingDirectory, + env: { ...process.env, MAKA_SKIP_SHELL_ENV: '1', ...userEnv }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-16_384); + }); + try { + const cdpPort = await waitForDevToolsPort(child); + const target = await findRendererTarget(cdpPort, child); + await waitForUsableRenderer(target.webSocketDebuggerUrl, child, { + description: 'Restored app renderer', + }).catch((error) => { + throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`, { + cause: error, + }); + }); + const productVersion = await readInstalledProductVersion(installedExecutable); + assertWindowsProductVersion(productVersion, expectedVersion); + } finally { + await stopChild(child); + } +} + +/** + * 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, + } = {}, +) { + 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 -- ', + ); + } + + 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 readUninstallDisplayVersions({ 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 readUninstallDisplayVersions({ 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 assertLaunchable( + installedExecutable, + join(temporaryDirectory, 'restored-smoke'), + candidateVersion, + ); + 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 readUninstallDisplayVersions({ 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 readUninstallDisplayVersions({ 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 readUninstallDisplayVersions({ 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, + }); + if (quit.code !== 0) { + throw new Error( + `Quit-failpoint upgrade exited with ${quit.code}, expected 0 (a bare template-style ` + + `Quit reports success): the pinned no-hook behavior changed — re-derive the gap.` + + `${quit.stderr.trim() ? `\nstderr: ${quit.stderr.trim()}` : ''}`, + ); + } + 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 readUninstallDisplayVersions({ 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)}.`, + ); + } + + 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 readUninstallDisplayVersions({ 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 deleteUninstallRegistration({ 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}`, + ); +} From 1b6b6afe741074ce1dff1fe9fcbf66d8d5255ae3 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 20 Aug 2026 23:35:46 +0800 Subject: [PATCH 2/3] test(rollback): pin the hookless Quit exit code at its measured value The gate's first full execution (run 32384536035) proved the covered path - Abort at the worst moment, 607 files restored with 0 diffs, registration back, launchable, control upgrade clean - and then failed its own scenario-3 pin: a bare template-style Quit measured exit 2 (the silent installer's generic failure code), not the 0 the NSIS source reading suggested. Pin the measured value, and assert the state (extracted files, cleared registration, retained backup with marker and recovery note) before the exit code so a future drift reports what actually happened rather than just a number. Co-Authored-By: Claude Fable 5 Generated-by: Claude Fable 5 --- scripts/verify-windows-installer-rollback.mjs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/verify-windows-installer-rollback.mjs b/scripts/verify-windows-installer-rollback.mjs index 25adf1991e..20d08d53c4 100644 --- a/scripts/verify-windows-installer-rollback.mjs +++ b/scripts/verify-windows-installer-rollback.mjs @@ -453,13 +453,10 @@ export async function verifyWindowsInstallerRollback( env: quitEnv, timeoutMs: 300_000, }); - if (quit.code !== 0) { - throw new Error( - `Quit-failpoint upgrade exited with ${quit.code}, expected 0 (a bare template-style ` + - `Quit reports success): the pinned no-hook behavior changed — re-derive the gap.` + - `${quit.stderr.trim() ? `\nstderr: ${quit.stderr.trim()}` : ''}`, - ); - } + // 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. @@ -473,6 +470,18 @@ export async function verifyWindowsInstallerRollback( `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}`], { From 8f003ad59c9d802e49718bbcfb410370d4bf7848 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 23 Aug 2026 15:22:10 +0800 Subject: [PATCH 3/3] fix(test): scope rollback registry cleanup to fixture Generated-by: Codex --- scripts/verify-windows-harness.test.mjs | 29 ++++++ scripts/verify-windows-installer-rollback.mjs | 98 +++++++++++++++---- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index cc0d7bb2aa..2307728c83 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -35,6 +35,10 @@ import { waitForUsableRenderer, } from './verify-packaged-app.mjs'; import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; +import { + deleteUninstallRegistrationForInstall, + readUninstallDisplayVersionsForInstall, +} from './verify-windows-installer-rollback.mjs'; import { completeInstalledApplicationUninstall, installerVersion, @@ -53,6 +57,31 @@ const temporaryRoots = []; const delay = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +it('scopes rollback registration reads and deletion to the fixture uninstaller', async () => { + const calls = []; + const uninstaller = 'C:\\fixture\\installed\\Uninstall Maka.exe'; + const run = async (command, args, options) => { + calls.push({ command, args, options }); + return { stdout: '0.1.11', stderr: '' }; + }; + + assert.equal(await readUninstallDisplayVersionsForInstall(uninstaller, { run }), '0.1.11'); + await deleteUninstallRegistrationForInstall(uninstaller, { run }); + + assert.equal(calls.length, 2); + for (const { command, args, options } of calls) { + assert.equal(command, 'powershell'); + assert.equal(options.timeoutMs, 30_000); + const script = args.at(-1); + assert.match(script, /UninstallString/u); + assert.ok(script.includes('[StringComparison]::OrdinalIgnoreCase')); + assert.match(script, /C:\\fixture\\installed\\Uninstall Maka\.exe/u); + assert.doesNotMatch(script, /DisplayName/u); + } + assert.match(calls[0].args.at(-1), /\.DisplayVersion/u); + assert.match(calls[1].args.at(-1), /Remove-Item -LiteralPath \$_\.Path/u); +}); + it('uses the product SemVer contract throughout Windows release verification', () => { assert.equal(installerVersion('Maka-1.2.3-beta.2-win-x64.exe'), '1.2.3-beta.2'); assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); diff --git a/scripts/verify-windows-installer-rollback.mjs b/scripts/verify-windows-installer-rollback.mjs index 20d08d53c4..8f711ca900 100644 --- a/scripts/verify-windows-installer-rollback.mjs +++ b/scripts/verify-windows-installer-rollback.mjs @@ -34,7 +34,6 @@ import { } from './verify-packaged-app.mjs'; import { installerVersion, - readUninstallDisplayVersions, waitForInstalledProcessesToExit, waitForUninstallRegistrationToClear, waitUntilMissing, @@ -108,20 +107,66 @@ async function readInstalledProductVersion(executablePath, { run = runCommand } 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 the Maka 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. + * 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. */ -async function deleteUninstallRegistration({ run = runCommand } = {}) { - const script = String.raw` -Get-ChildItem 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' | - Where-Object { - $name = ($_ | Get-ItemProperty).DisplayName - $name -eq 'Maka' -or $name -like 'Maka *' - } | - Remove-Item -Recurse -Force +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, @@ -251,7 +296,9 @@ export async function verifyWindowsInstallerRollback( await access(uninstaller); step('asserting the candidate registered its uninstall entry'); - const preUpgradeRegistration = await readUninstallDisplayVersions({ run }); + const preUpgradeRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, { + run, + }); if (!preUpgradeRegistration.split(',').includes(candidateVersion)) { throw new Error( `Precondition failed: candidate install registered DisplayVersion ` + @@ -309,7 +356,7 @@ export async function verifyWindowsInstallerRollback( } step('asserting the uninstall registration was restored'); - const displayVersion = await readUninstallDisplayVersions({ run }); + 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}.`, @@ -373,7 +420,9 @@ export async function verifyWindowsInstallerRollback( run, }); assertWindowsProductVersion(recoveredFromRegistryMismatch, nextVersion); - const mismatchRecoveryRegistration = await readUninstallDisplayVersions({ run }); + const mismatchRecoveryRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, { + run, + }); if (!mismatchRecoveryRegistration.split(',').includes(nextVersion)) { throw new Error( `Registry-mismatch recovery did not restore the uninstall registration: ` + @@ -407,7 +456,9 @@ export async function verifyWindowsInstallerRollback( run, }); assertWindowsProductVersion(unchangedAfterStaleBackup, nextVersion); - const registrationAfterStaleBackup = await readUninstallDisplayVersions({ run }); + const registrationAfterStaleBackup = await readUninstallDisplayVersionsForInstall(uninstaller, { + run, + }); if (!registrationAfterStaleBackup.split(',').includes(nextVersion)) { throw new Error( `Stale backup refusal changed the uninstall registration: ` + @@ -433,7 +484,10 @@ export async function verifyWindowsInstallerRollback( run, }); assertWindowsProductVersion(unchangedAfterIncompleteBackup, nextVersion); - const registrationAfterIncompleteBackup = await readUninstallDisplayVersions({ run }); + const registrationAfterIncompleteBackup = await readUninstallDisplayVersionsForInstall( + uninstaller, + { run }, + ); if (!registrationAfterIncompleteBackup.split(',').includes(nextVersion)) { throw new Error( `Incomplete backup refusal changed the uninstall registration: ` + @@ -463,7 +517,7 @@ export async function verifyWindowsInstallerRollback( await access(join(backupDirectory, 'RECOVERY-README.txt')); const orphanVersion = await readInstalledProductVersion(installedExecutable, { run }); assertWindowsProductVersion(orphanVersion, nextVersion); - const orphanRegistration = await readUninstallDisplayVersions({ run }); + const orphanRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, { run }); if (orphanRegistration !== '') { throw new Error( `After the Quit failpoint the uninstall registration should be gone (the old ` + @@ -491,7 +545,9 @@ export async function verifyWindowsInstallerRollback( await access(uninstaller); const recoveredVersion = await readInstalledProductVersion(installedExecutable, { run }); assertWindowsProductVersion(recoveredVersion, nextVersion); - const recoveredRegistration = await readUninstallDisplayVersions({ run }); + const recoveredRegistration = await readUninstallDisplayVersionsForInstall(uninstaller, { + run, + }); if (!recoveredRegistration.split(',').includes(nextVersion)) { throw new Error( `Recovery rerun did not restore the uninstall registration: ` + @@ -507,7 +563,7 @@ export async function verifyWindowsInstallerRollback( await waitForInstalledProcessesToExit(installDirectory); step('fail closed: an upgrade with no registration and no backup is refused'); - await deleteUninstallRegistration({ run }); + await deleteUninstallRegistrationForInstall(uninstaller, { run }); const refused = await runExpectingExit(nextInstaller, ['/S', `/D=${installDirectory}`], { env: controlEnv, timeoutMs: 300_000,