From 6694713cf97a8bb84bfbf36c68962a942ad23f6e Mon Sep 17 00:00:00 2001 From: yhh Date: Sat, 10 Oct 2026 07:19:29 -0700 Subject: [PATCH] refactor(update): remove the Windows update helper and the code only it reached Studio on Windows runs the downloaded installer directly (Line.RunInstaller); nothing builds or starts reasonix-update-helper.exe there. Delete its Windows sources and tests, the WindowsHandoff staging code, the Windows payload manifest and its sign subcommand, and move the repair/installlayout entry points that only that helper called into test support. --- desktop/README.md | 2 +- desktop/cmd/sign/main.go | 31 +- desktop/cmd/sign/main_test.go | 26 - desktop/cmd/update-helper/args.go | 15 - desktop/cmd/update-helper/args_test.go | 23 - .../cmd/update-helper/authenticode_other.go | 10 - .../cmd/update-helper/authenticode_windows.go | 97 --- desktop/cmd/update-helper/main_other.go | 2 +- desktop/cmd/update-helper/main_windows.go | 581 ------------------ .../cmd/update-helper/main_windows_test.go | 563 ----------------- desktop/cmd/update-helper/release_unit.go | 238 ------- .../cmd/update-helper/release_unit_test.go | 377 ------------ .../cmd/update-helper/versioned_windows.go | 118 ---- .../update-helper/versioned_windows_test.go | 131 ---- internal/platform/installlayout/activate.go | 40 -- internal/platform/installlayout/current.go | 13 - .../installlayout/legacy_support_test.go | 59 ++ internal/platform/repair/apply_failure.go | 138 ----- .../repair/legacy_update_support_test.go | 560 +++++++++++++++++ internal/platform/repair/update.go | 405 ------------ internal/platform/update/apply_other.go | 2 +- internal/platform/update/apply_windows.go | 24 - internal/platform/update/install.go | 12 - internal/platform/update/install_args.go | 52 -- internal/platform/update/install_args_test.go | 105 ---- internal/platform/update/install_other.go | 23 - internal/platform/update/install_windows.go | 327 ---------- .../platform/update/install_windows_test.go | 197 ------ internal/platform/update/windows_payload.go | 128 ---- .../platform/update/windows_payload_test.go | 86 --- tools/repolint/baseline.json | 166 ++--- 31 files changed, 690 insertions(+), 3861 deletions(-) delete mode 100644 desktop/cmd/update-helper/args.go delete mode 100644 desktop/cmd/update-helper/args_test.go delete mode 100644 desktop/cmd/update-helper/authenticode_other.go delete mode 100644 desktop/cmd/update-helper/authenticode_windows.go delete mode 100644 desktop/cmd/update-helper/main_windows.go delete mode 100644 desktop/cmd/update-helper/main_windows_test.go delete mode 100644 desktop/cmd/update-helper/release_unit.go delete mode 100644 desktop/cmd/update-helper/release_unit_test.go delete mode 100644 desktop/cmd/update-helper/versioned_windows.go delete mode 100644 desktop/cmd/update-helper/versioned_windows_test.go create mode 100644 internal/platform/installlayout/legacy_support_test.go create mode 100644 internal/platform/repair/legacy_update_support_test.go delete mode 100644 internal/platform/update/apply_windows.go delete mode 100644 internal/platform/update/install.go delete mode 100644 internal/platform/update/install_args.go delete mode 100644 internal/platform/update/install_args_test.go delete mode 100644 internal/platform/update/install_other.go delete mode 100644 internal/platform/update/install_windows.go delete mode 100644 internal/platform/update/install_windows_test.go delete mode 100644 internal/platform/update/windows_payload.go delete mode 100644 internal/platform/update/windows_payload_test.go diff --git a/desktop/README.md b/desktop/README.md index e1c9f54091..f595371346 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU | `electron/` | the shell: main process, preload bridge, packaging | | `frontend-next/` | the SPA it serves | | `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper | -| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) | +| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) | | `cmd/studio-manifest/`, `cmd/sign/` | release-side tools | | `internal/winuninstall/` | taking a previous Windows install over | diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go index 9f0308de84..5ed024c93f 100644 --- a/desktop/cmd/sign/main.go +++ b/desktop/cmd/sign/main.go @@ -63,11 +63,6 @@ func main() { notesVersion = os.Args[5] } err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion) - case "windows-payload": - if len(os.Args) != 4 { - usage() - } - err = genWindowsPayloadManifest(os.Args[2], os.Args[3]) case "genkey": if len(os.Args) != 3 { usage() @@ -88,34 +83,10 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage:\n sign ...\n manifest [notes-version]\n windows-payload \n genkey \n verify ") + fmt.Fprintln(os.Stderr, "usage:\n sign ...\n manifest [notes-version]\n genkey \n verify ") os.Exit(2) } -func genWindowsPayloadManifest(dir, version string) error { - hashes := make(map[string]string) - for _, name := range update.WindowsPayloadFileNames() { - path := filepath.Join(dir, name) - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("Windows payload %s: %w", name, err) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("Windows payload %s is not a regular file", name) - } - _, sum, err := hashFile(path) - if err != nil { - return err - } - hashes[name] = sum - } - b, err := update.EncodeWindowsPayloadManifest(version, hashes) - if err != nil { - return err - } - return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644) -} - // verifyFile checks against .minisig using the embedded public key — // the same check the updater runs before applying. A self-test that the signing // key matches what's compiled in. Returns an error (nonzero exit) on mismatch. diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go index bd77eb1145..fe720ce405 100644 --- a/desktop/cmd/sign/main_test.go +++ b/desktop/cmd/sign/main_test.go @@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) { t.Fatalf("unexpected native_packages: %+v", m.NativePackages) } } - -func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) { - dir := t.TempDir() - for _, name := range update.WindowsPayloadFileNames() { - if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil { - t.Fatal(err) - } - } - if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil { - t.Fatal(err) - } - b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName)) - if err != nil { - t.Fatal(err) - } - hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4") - if err != nil { - t.Fatal(err) - } - for _, name := range update.WindowsPayloadFileNames() { - want := update.WindowsPayloadSHA256([]byte("payload:" + name)) - if hashes[name] != want { - t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want) - } - } -} diff --git a/desktop/cmd/update-helper/args.go b/desktop/cmd/update-helper/args.go deleted file mode 100644 index 5cc10fd049..0000000000 --- a/desktop/cmd/update-helper/args.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import "fmt" - -func installerCommandLine(installer, dir string) string { - // Auto-updates use a dedicated visible progress mode instead of NSIS /S. - // The current helper also requires staging-only extraction: NSIS never writes - // the live install, and the helper compare-and-publishes the claimed release - // unit after validating every staged member. - line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer) - if dir != "" { - line += " /D=" + dir - } - return line -} diff --git a/desktop/cmd/update-helper/args_test.go b/desktop/cmd/update-helper/args_test.go deleted file mode 100644 index a908b1a29e..0000000000 --- a/desktop/cmd/update-helper/args_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) { - got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`) - want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App` - if got != want { - t.Fatalf("installerCommandLine = %q, want %q", got, want) - } - if strings.Contains(got, " /S") { - t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got) - } - if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) { - t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got) - } - if !strings.Contains(got, " /REASONIXSTAGE=1") { - t.Fatalf("auto-update must extract away from the live install, got %q", got) - } -} diff --git a/desktop/cmd/update-helper/authenticode_other.go b/desktop/cmd/update-helper/authenticode_other.go deleted file mode 100644 index b2c8a0b65e..0000000000 --- a/desktop/cmd/update-helper/authenticode_other.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !windows - -package main - -import "os" - -// Windows verifies each executable with the OS Authenticode policy while the -// same read handle is held. Non-Windows builds keep the release-unit loader -// testable without importing platform-specific trust APIs. -var readVerifiedWindowsStagedPayloadFn = os.ReadFile diff --git a/desktop/cmd/update-helper/authenticode_windows.go b/desktop/cmd/update-helper/authenticode_windows.go deleted file mode 100644 index b8f3fb0b39..0000000000 --- a/desktop/cmd/update-helper/authenticode_windows.go +++ /dev/null @@ -1,97 +0,0 @@ -//go:build windows - -package main - -import ( - "fmt" - "io" - "os" - "unsafe" - - "golang.org/x/sys/windows" -) - -var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload - -// readVerifiedWindowsStagedPayload verifies the Authenticode signature and -// reads the payload through one handle that disallows concurrent writes, -// deletes, and renames. The bytes handed to the publisher therefore cannot -// change between signature verification and publication. -func readVerifiedWindowsStagedPayload(path string) ([]byte, error) { - info, err := os.Lstat(path) - if err != nil { - return nil, err - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("staged payload is not a regular file") - } - path16, err := windows.UTF16PtrFromString(path) - if err != nil { - return nil, err - } - handle, err := windows.CreateFile( - path16, - windows.GENERIC_READ, - windows.FILE_SHARE_READ, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN, - 0, - ) - if err != nil { - return nil, err - } - file := os.NewFile(uintptr(handle), path) - if file == nil { - _ = windows.CloseHandle(handle) - return nil, fmt.Errorf("open staged payload handle") - } - defer file.Close() - - fileInfo, err := file.Stat() - if err != nil { - return nil, err - } - if !fileInfo.Mode().IsRegular() { - return nil, fmt.Errorf("staged payload is not a regular file") - } - - trustFile := &windows.WinTrustFileInfo{ - Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})), - FilePath: path16, - File: handle, - } - trustData := &windows.WinTrustData{ - Size: uint32(unsafe.Sizeof(windows.WinTrustData{})), - UIChoice: windows.WTD_UI_NONE, - RevocationChecks: windows.WTD_REVOKE_NONE, - UnionChoice: windows.WTD_CHOICE_FILE, - FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile), - StateAction: windows.WTD_STATEACTION_VERIFY, - UIContext: windows.WTD_UICONTEXT_EXECUTE, - } - verifyErr := windows.WinVerifyTrustEx( - windows.InvalidHWND, - &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, - trustData, - ) - trustData.StateAction = windows.WTD_STATEACTION_CLOSE - closeErr := windows.WinVerifyTrustEx( - windows.InvalidHWND, - &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, - trustData, - ) - if verifyErr != nil { - if closeErr != nil { - return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr) - } - return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr) - } - if closeErr != nil { - return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr) - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - return nil, err - } - return io.ReadAll(file) -} diff --git a/desktop/cmd/update-helper/main_other.go b/desktop/cmd/update-helper/main_other.go index c41f017f12..dabeec4f86 100644 --- a/desktop/cmd/update-helper/main_other.go +++ b/desktop/cmd/update-helper/main_other.go @@ -8,6 +8,6 @@ import ( ) func main() { - fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds") + fmt.Fprintln(os.Stderr, "reasonix-update-helper is is only used by Linux builds") os.Exit(2) } diff --git a/desktop/cmd/update-helper/main_windows.go b/desktop/cmd/update-helper/main_windows.go deleted file mode 100644 index 4faf9b2de7..0000000000 --- a/desktop/cmd/update-helper/main_windows.go +++ /dev/null @@ -1,581 +0,0 @@ -//go:build windows - -package main - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "flag" - "fmt" - "io" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "golang.org/x/sys/windows" - - "reasonix/desktop/internal/winuninstall" - "reasonix/internal/platform/installlayout" - "reasonix/internal/platform/repair" -) - -const parentExitTimeout = 2 * time.Minute - -var ( - waitForProcessExitFn = waitForProcessExit - runInstallerFn = runInstaller - startRelaunchFn = startRelaunch - claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact - installStagedReleaseUnitFn = installStagedWindowsReleaseUnit - recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled - stageInstallerFn = stageVerifiedInstaller - claimInstallerExecutionFn = claimVerifiedInstallerForExecution - lstatUpdateStagingFn = os.Lstat - reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile -) - -func main() { - os.Exit(run(os.Args[1:])) -} - -func run(args []string) int { - var parentPID uint - var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string - fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing") - fs.StringVar(&installer, "installer", "", "verified NSIS installer path") - fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer") - fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory") - fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds") - fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed") - fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp") - fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity") - fs.StringVar(&installLayout, "install-layout", "", "installation layout contract") - if err := fs.Parse(args); err != nil { - return 2 - } - logger := newLogger() - if installer == "" { - logger.Print("missing --installer") - return 2 - } - if !validSHA256(installerSHA256) { - logger.Print("missing or invalid --installer-sha256") - return 2 - } - if toVersion == "" { - logger.Print("missing --to-version") - return 2 - } - if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 { - logger.Printf("unsupported --install-layout %q", installLayout) - return 2 - } - if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" { - logger.Print("missing --created-at") - return 2 - } - if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" { - logger.Print("missing --transaction-id") - return 2 - } - if installDir == "" { - logger.Print("missing --install-dir") - return 2 - } - if parentPID != 0 { - if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil { - logger.Printf("wait for parent process %d: %v", parentPID, err) - return 1 - } - } - if installLayout == installlayout.InstallLayoutVersionedV1 { - return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion) - } - recoverExisting := func(reason string) int { - if relaunch != "" { - if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil { - logger.Printf("relaunch after %s: %v", reason, relaunchErr) - } - } - return 1 - } - // The detached helper runs from the update cache, so a plain - // repair.ReadPendingUpdate would validate the transaction against the cache - // directory and reject every legitimate flat install. Claim through the - // explicit install-local launcher path instead; the repair package validates - // the complete transaction identity and exact release-unit path set while it - // acquires the pending + target locks. - claimTargets := windowsReleaseUnitPaths(installDir) - claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe") - // The old desktop may acquire the pending lock during its normal shutdown. - // Wait for that exact process first, then claim the transaction and hold both - // pending and target locks across the installer replacement window. - claimed, releaseClaim, err := claimPendingFileUpdateFn( - toVersion, - createdAt, - transactionID, - claimLauncher, - claimTargets, - parentExitTimeout, - ) - if err != nil { - logger.Printf("claim pending update: %v", err) - return recoverExisting("pending update claim failure") - } - defer releaseClaim() - recoverUnstarted := func() int { - releaseClaim() - if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil { - logger.Printf("cancel unstarted update: %v", cancelErr) - } - if relaunch != "" { - if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil { - logger.Printf("relaunch after unstarted update failure: %v", relaunchErr) - } - } - return 1 - } - claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256) - if err != nil { - logger.Printf("bind update installer: %v", err) - return recoverUnstarted() - } - defer func() { - if cleanupErr := cleanupInstaller(); cleanupErr != nil { - logger.Printf("preserving update installer staging: %v", cleanupErr) - } - }() - stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*") - if err != nil { - logger.Printf("create update staging: %v", err) - return recoverUnstarted() - } - stagingOwner, err := lstatUpdateStagingFn(stagingDir) - if err != nil { - logger.Printf("bind update staging: %v", err) - return recoverUnstarted() - } - defer func() { - if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil { - logger.Printf("preserving update staging: %v", cleanupErr) - } - }() - releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256) - if err != nil { - logger.Printf("recheck staged installer: %v", err) - return recoverUnstarted() - } - installerErr := runInstallerFn(claimedInstaller, stagingDir) - releaseInstallerExecution() - if installerErr != nil { - logger.Printf("extract installer payload: %v", installerErr) - return recoverUnstarted() - } - publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir) - if err != nil { - logger.Printf("publish staged release unit: %v", err) - if !publishStarted { - releaseClaim() - if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil { - logger.Printf("cancel unstarted update: %v", cancelErr) - } - } - if relaunch != "" { - if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil { - logger.Printf("relaunch after publish failure: %v", relaunchErr) - } - } - return 1 - } - if receipts == nil { - // Versioned-v1 activation: no flat publish receipts and no health - // probation. Clear the pending transaction and relaunch the launcher. - releaseClaim() - if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil { - logger.Printf("clear pending after versioned activate: %v", cancelErr) - } - if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil { - logger.Printf("clear apply failure after versioned activate: %v", clearErr) - } - } else { - if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil { - logger.Printf("record installed release unit: %v", err) - if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil { - logger.Printf("record install failure: %v", markErr) - } - if relaunch != "" { - if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil { - logger.Printf("relaunch after failed install verification: %v", relaunchErr) - } - } - return 1 - } - if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil { - logger.Printf("clear completed update marker: %v", err) - } - } - if relaunch != "" { - if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil { - logger.Printf("relaunch: %v", err) - return 1 - } - } - return 0 -} - -func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int { - recoverExisting := func() int { - if relaunch != "" { - if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil { - logger.Printf("relaunch after versioned update failure: %v", err) - } - } - return 1 - } - if _, err := installlayout.ReadCurrent(installDir); err != nil { - logger.Printf("read versioned install pointer: %v", err) - return recoverExisting() - } - claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256) - if err != nil { - logger.Printf("bind versioned update installer: %v", err) - return recoverExisting() - } - defer func() { - if cleanupErr := cleanupInstaller(); cleanupErr != nil { - logger.Printf("preserving update installer staging: %v", cleanupErr) - } - }() - stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*") - if err != nil { - logger.Printf("create versioned update staging: %v", err) - return recoverExisting() - } - stagingOwner, err := lstatUpdateStagingFn(stagingDir) - if err != nil { - logger.Printf("bind versioned update staging: %v", err) - return recoverExisting() - } - defer func() { - if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil { - logger.Printf("preserving update staging: %v", cleanupErr) - } - }() - releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256) - if err != nil { - logger.Printf("recheck staged versioned installer: %v", err) - return recoverExisting() - } - installerErr := runInstallerFn(claimedInstaller, stagingDir) - releaseInstallerExecution() - if installerErr != nil { - logger.Printf("extract versioned installer payload: %v", installerErr) - return recoverExisting() - } - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: toVersion, - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - } - if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil { - logger.Printf("activate versioned release: %v", err) - return recoverExisting() - } - if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil { - // The release is already active and must not be rolled back for stale - // Add/Remove Programs metadata. A later update or full installer retries - // this idempotent reconciliation. - logger.Printf("reconcile Windows uninstall registration: %v", err) - } - if relaunch != "" { - if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil { - logger.Printf("relaunch versioned release: %v", err) - return 1 - } - } - return 0 -} - -// preferRelaunchPath chooses the thin launcher when present so post-update -// restarts use the permanent entry point, not a flat desktop binary. -func preferRelaunchPath(relaunch, installDir string) string { - for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} { - path := filepath.Join(installDir, name) - if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() { - return path - } - } - return relaunch -} - -func validSHA256(value string) bool { - value = strings.TrimSpace(value) - if len(value) != sha256.Size*2 { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) { - if !validSHA256(expectedSHA256) { - return "", nil, fmt.Errorf("installer SHA-256 is invalid") - } - sourceInfo, err := os.Lstat(sourcePath) - if err != nil { - return "", nil, err - } - if !sourceInfo.Mode().IsRegular() { - return "", nil, fmt.Errorf("installer is not a regular file") - } - dir, err := os.MkdirTemp("", "reasonix-update-installer-*") - if err != nil { - return "", nil, err - } - owner, err := os.Lstat(dir) - if err != nil { - return "", nil, err - } - cleanup := func() error { - return cleanupOwnedWindowsUpdateDirectory(dir, owner) - } - fail := func(err error) (string, func() error, error) { - _ = cleanup() - return "", nil, err - } - source, err := os.Open(sourcePath) - if err != nil { - return fail(err) - } - defer source.Close() - stagedPath := filepath.Join(dir, "reasonix-installer.exe") - staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) - if err != nil { - return fail(err) - } - hash := sha256.New() - _, copyErr := io.Copy(io.MultiWriter(staged, hash), source) - syncErr := staged.Sync() - closeErr := staged.Close() - switch { - case copyErr != nil: - return fail(copyErr) - case syncErr != nil: - return fail(syncErr) - case closeErr != nil: - return fail(closeErr) - case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)): - return fail(fmt.Errorf("installer SHA-256 changed after desktop verification")) - } - if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil { - return fail(err) - } - return stagedPath, cleanup, nil -} - -func verifyInstallerSHA256(path, expectedSHA256 string) error { - info, err := os.Lstat(path) - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return fmt.Errorf("installer is not a regular file") - } - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - hash := sha256.New() - if _, err := io.Copy(hash, f); err != nil { - return err - } - if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) { - return fmt.Errorf("installer SHA-256 mismatch") - } - return nil -} - -func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) { - if !validSHA256(expectedSHA256) { - return nil, fmt.Errorf("installer SHA-256 is invalid") - } - pathUTF16, err := windows.UTF16PtrFromString(path) - if err != nil { - return nil, err - } - handle, err := windows.CreateFile( - pathUTF16, - windows.GENERIC_READ, - windows.FILE_SHARE_READ, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN, - 0, - ) - if err != nil { - return nil, err - } - file := os.NewFile(uintptr(handle), path) - fail := func(err error) (func(), error) { - _ = file.Close() - return nil, err - } - info, err := file.Stat() - if err != nil { - return fail(err) - } - if !info.Mode().IsRegular() { - return fail(fmt.Errorf("installer is not a regular file")) - } - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return fail(err) - } - if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) { - return fail(fmt.Errorf("installer SHA-256 mismatch")) - } - var once sync.Once - return func() { - once.Do(func() { _ = file.Close() }) - }, nil -} - -func installStagedWindowsReleaseUnit( - claimed *repair.UpdateTransaction, - stagingDir string, -) (bool, []repair.FileUpdateInstallReceipt, error) { - // v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads - // fail closed without mutating the live install or leaving flat pending state. - if !preferVersionedWindowsActivation(stagingDir) { - return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation") - } - if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil { - return false, nil, fmt.Errorf("versioned activate: %w", err) - } - return true, nil, nil -} - -func windowsReleaseUnitPaths(installDir string) []string { - if installDir == "" { - return nil - } - names := []string{ - "reasonix-desktop.exe", - "reasonix-guard.exe", - "reasonix-launcher.exe", - "reasonix-update-helper.exe", - "reasonix-cli.exe", - "Reasonix.exe", - } - paths := make([]string, 0, len(names)) - for _, name := range names { - paths = append(paths, filepath.Join(installDir, name)) - } - return paths -} - -func newLogger() *log.Logger { - dir, err := os.UserCacheDir() - if err == nil { - dir = filepath.Join(dir, "Reasonix", "updates") - if err := os.MkdirAll(dir, 0o700); err == nil { - if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil { - return log.New(f, "", log.LstdFlags) - } - } - } - return log.New(os.Stderr, "", log.LstdFlags) -} - -func waitForProcessExit(pid uint32, timeout time.Duration) error { - h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid) - if err != nil { - if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { - return nil - } - return err - } - defer windows.CloseHandle(h) - waitMS := uint32(timeout / time.Millisecond) - result, err := windows.WaitForSingleObject(h, waitMS) - if err != nil { - return err - } - switch result { - case windows.WAIT_OBJECT_0: - return nil - case uint32(windows.WAIT_TIMEOUT): - return fmt.Errorf("timed out after %s", timeout) - default: - return fmt.Errorf("unexpected wait result %d", result) - } -} - -func runInstaller(installer, installDir string) error { - cmd := exec.Command(installer) - // Keep the helper itself hidden, but let the NSIS update-progress window be - // visible. /REASONIXSTAGE makes the signed installer extract only; the helper - // performs every live replacement through the claimed transaction. - cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)} - return cmd.Run() -} - -func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error { - if path == "" || owner == nil || !owner.IsDir() { - return fmt.Errorf("Windows update cleanup identity is incomplete") - } - for attempt := range 16 { - cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt) - from, err := windows.UTF16PtrFromString(path) - if err != nil { - return err - } - to, err := windows.UTF16PtrFromString(cleanup) - if err != nil { - return err - } - if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil { - if os.IsNotExist(err) { - return nil - } - if os.IsExist(err) { - continue - } - return err - } - actual, err := os.Lstat(cleanup) - if err != nil { - return err - } - if !os.SameFile(owner, actual) { - restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup) - restoreTo, toErr := windows.UTF16PtrFromString(path) - if fromErr != nil || toErr != nil { - return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup) - } - if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil { - return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr) - } - return fmt.Errorf("Windows update staging changed before cleanup") - } - return os.RemoveAll(cleanup) - } - return fmt.Errorf("cannot allocate Windows update cleanup path") -} - -func startRelaunch(relaunch, installDir string) error { - cmd := exec.Command(relaunch) - cmd.Dir = installDir - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - return cmd.Start() -} diff --git a/desktop/cmd/update-helper/main_windows_test.go b/desktop/cmd/update-helper/main_windows_test.go deleted file mode 100644 index f6e2e8d9e7..0000000000 --- a/desktop/cmd/update-helper/main_windows_test.go +++ /dev/null @@ -1,563 +0,0 @@ -//go:build windows - -package main - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "reasonix/internal/platform/installlayout" - "reasonix/internal/platform/repair" - - "reasonix/internal/base/tempdir" -) - -const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - -func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction { - t.Helper() - paths := windowsReleaseUnitPaths(installDir) - for _, path := range paths { - if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil { - t.Fatal(err) - } - } - tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...) - if err != nil { - t.Fatal(err) - } - return tx -} - -func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) { - source := filepath.Join(tempdir.New(t), "installer.exe") - content := []byte("verified-installer") - if err := os.WriteFile(source, content, 0o700); err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(content) - expected := hex.EncodeToString(sum[:]) - staged, cleanup, err := stageVerifiedInstaller(source, expected) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = cleanup() }) - if staged == source { - t.Fatal("installer was not isolated from the mutable cache path") - } - if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil { - t.Fatal(err) - } - if err := verifyInstallerSHA256(staged, expected); err != nil { - t.Fatalf("isolated installer changed with source: %v", err) - } - if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) { - t.Fatalf("staged installer = %q, %v", got, err) - } - if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil { - t.Fatal(err) - } - if err := verifyInstallerSHA256(staged, expected); err == nil { - t.Fatal("tampered staged installer passed final verification") - } -} - -func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) { - source := filepath.Join(tempdir.New(t), "installer.exe") - if err := os.WriteFile(source, []byte("different"), 0o700); err != nil { - t.Fatal(err) - } - if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil { - if cleanup != nil { - _ = cleanup() - } - t.Fatal("installer bytes different from desktop verification were accepted") - } -} - -func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) { - if code := run([]string{ - "--installer", `C:\Temp\Reasonix-installer.exe`, - "--installer-sha256", testInstallerSHA256, - }); code != 2 { - t.Fatalf("run without --to-version = %d, want 2", code) - } -} - -func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) { - installDir := tempdir.New(t) - seed := tempdir.New(t) - for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} { - if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil { - t.Fatal(err) - } - } - if err := installlayout.ActivateVersion(installlayout.ActivationRequest{ - InstallRoot: installDir, - Version: "v1.20.0", - RequestID: "seed-windows-helper", - Members: []installlayout.Member{ - {Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")}, - {Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")}, - {Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")}, - }, - RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"}, - }); err != nil { - t.Fatal(err) - } - - originalInstaller := runInstallerFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - originalStageInstaller := stageInstallerFn - originalClaimInstallerExecution := claimInstallerExecutionFn - originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn - t.Cleanup(func() { - runInstallerFn = originalInstaller - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - stageInstallerFn = originalStageInstaller - claimInstallerExecutionFn = originalClaimInstallerExecution - reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall - }) - legacyClaimed := false - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - legacyClaimed = true - return nil, func() {}, errors.New("legacy claim must not run") - } - stageInstallerFn = func(path, _ string) (string, func() error, error) { - return path, func() error { return nil }, nil - } - claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil } - reconciledVersion := "" - reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) { - if root != installDir { - t.Fatalf("reconcile root = %q, want %q", root, installDir) - } - reconciledVersion = targetVersion - return true, nil - } - runInstallerFn = func(_ string, staging string) error { - for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} { - if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil { - return err - } - } - return nil - } - relaunched := false - startRelaunchFn = func(string, string) error { relaunched = true; return nil } - - if code := run([]string{ - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"), - "--to-version", "v1.20.1", - "--install-layout", "versioned-v1", - }); code != 0 { - t.Fatalf("run exit code = %d", code) - } - if legacyClaimed { - t.Fatal("versioned update claimed legacy pending transaction") - } - if !relaunched { - t.Fatal("versioned update did not relaunch") - } - if reconciledVersion != "v1.20.1" { - t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion) - } - ptr, err := installlayout.ReadCurrent(installDir) - if err != nil || ptr.ActiveVersion != "v1.20.1" { - t.Fatalf("pointer=%+v err=%v", ptr, err) - } -} - -func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) { - t.Setenv("REASONIX_HOME", tempdir.New(t)) - installDir := tempdir.New(t) - pending := prepareLegacyWindowsUpdate(t, installDir, "v2") - var events []string - originalWait := waitForProcessExitFn - originalInstaller := runInstallerFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - originalInstallStaged := installStagedReleaseUnitFn - originalRecord := recordInstalledUpdateFn - originalStageInstaller := stageInstallerFn - originalClaimInstallerExecution := claimInstallerExecutionFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - runInstallerFn = originalInstaller - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - installStagedReleaseUnitFn = originalInstallStaged - recordInstalledUpdateFn = originalRecord - stageInstallerFn = originalStageInstaller - claimInstallerExecutionFn = originalClaimInstallerExecution - }) - waitForProcessExitFn = func(uint32, time.Duration) error { - events = append(events, "wait") - return nil - } - runInstallerFn = func(string, string) error { - events = append(events, "installer") - return nil - } - startRelaunchFn = func(string, string) error { - events = append(events, "relaunch") - return nil - } - claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) { - events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01")) - return pending, func() { events = append(events, "release") }, nil - } - installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) { - events = append(events, "publish") - return true, []repair.FileUpdateInstallReceipt{}, nil - } - recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) { - events = append(events, "record-installed") - return tx, nil - } - stageInstallerFn = func(path, sha256 string) (string, func() error, error) { - events = append(events, "stage-installer:"+sha256) - return path + ".claimed", func() error { return nil }, nil - } - claimInstallerExecutionFn = func(string, string) (func(), error) { - events = append(events, "claim-installer") - return func() { events = append(events, "release-installer") }, nil - } - - if code := run([]string{ - "--parent-pid", "1234", - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"), - "--to-version", pending.ToVersion, - "--created-at", pending.CreatedAt, - "--transaction-id", repair.UpdateTransactionID(pending), - }); code != 0 { - t.Fatalf("run exit code = %d", code) - } - wantClaim := "claim:" + strings.Join([]string{ - pending.ToVersion, - pending.CreatedAt, - repair.UpdateTransactionID(pending), - filepath.Join(installDir, "reasonix-desktop.exe"), - strings.Join(windowsReleaseUnitPaths(installDir), "\x00"), - }, "\x01") - if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim || - events[2] != "stage-installer:"+testInstallerSHA256 || - events[3] != "claim-installer" || events[4] != "installer" || - events[5] != "release-installer" || events[6] != "publish" || - events[7] != "record-installed" || events[8] != "relaunch" || - events[9] != "release" { - t.Fatalf("handoff events = %#v", events) - } -} - -func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) { - installDir := tempdir.New(t) - originalWait := waitForProcessExitFn - originalInstaller := runInstallerFn - originalClaim := claimPendingFileUpdateFn - originalRecord := recordInstalledUpdateFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - runInstallerFn = originalInstaller - claimPendingFileUpdateFn = originalClaim - recordInstalledUpdateFn = originalRecord - }) - waitForProcessExitFn = func(uint32, time.Duration) error { - return errors.New("parent still running") - } - claimed := false - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - claimed = true - return &repair.UpdateTransaction{}, func() {}, nil - } - installerRan := false - runInstallerFn = func(string, string) error { - installerRan = true - return nil - } - - if code := run([]string{ - "--parent-pid", "1234", - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--to-version", "v2", - "--created-at", "2026-07-29T00:00:00Z", - "--transaction-id", "transaction-1", - }); code != 1 { - t.Fatalf("run exit code = %d, want 1", code) - } - if claimed || installerRan { - t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan) - } -} - -func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) { - installDir := tempdir.New(t) - originalWait := waitForProcessExitFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - }) - - waitForProcessExitFn = func(uint32, time.Duration) error { return nil } - relaunched := false - startRelaunchFn = func(path, dir string) error { - relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir - return nil - } - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - return nil, nil, errors.New("pending lock unavailable") - } - - if code := run([]string{ - "--parent-pid", "1234", - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"), - "--to-version", "v2", - "--created-at", "2026-08-02T00:00:00Z", - "--transaction-id", strings.Repeat("a", 64), - }); code != 1 { - t.Fatalf("run exit code = %d, want 1", code) - } - if !relaunched { - t.Fatal("helper did not relaunch the existing desktop") - } -} - -func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) { - t.Setenv("REASONIX_HOME", tempdir.New(t)) - installDir := tempdir.New(t) - originalWait := waitForProcessExitFn - originalInstaller := runInstallerFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - originalInstallStaged := installStagedReleaseUnitFn - originalRecord := recordInstalledUpdateFn - originalStageInstaller := stageInstallerFn - originalClaimInstallerExecution := claimInstallerExecutionFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - runInstallerFn = originalInstaller - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - installStagedReleaseUnitFn = originalInstallStaged - recordInstalledUpdateFn = originalRecord - stageInstallerFn = originalStageInstaller - claimInstallerExecutionFn = originalClaimInstallerExecution - }) - waitForProcessExitFn = func(uint32, time.Duration) error { return nil } - runInstallerFn = func(string, string) error { return errors.New("installer failed") } - startRelaunchFn = func(string, string) error { return nil } - claimed := prepareLegacyWindowsUpdate(t, installDir, "v2") - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - return claimed, func() {}, nil - } - stageInstallerFn = func(path, _ string) (string, func() error, error) { - return path, func() error { return nil }, nil - } - claimInstallerExecutionFn = func(string, string) (func(), error) { - return func() {}, nil - } - transactionID := repair.UpdateTransactionID(claimed) - if code := run([]string{ - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"), - "--to-version", "v2", - "--created-at", claimed.CreatedAt, - "--transaction-id", transactionID, - }); code != 1 { - t.Fatalf("run exit code = %d, want 1", code) - } - if _, ok := repair.ReadUpdateApplyFailure(); ok { - t.Fatal("staging-only installer failure left a recovery marker despite no live publish") - } -} - -func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) { - t.Setenv("REASONIX_HOME", tempdir.New(t)) - installDir := tempdir.New(t) - originalWait := waitForProcessExitFn - originalInstaller := runInstallerFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - originalRecord := recordInstalledUpdateFn - originalStageInstaller := stageInstallerFn - originalClaimInstallerExecution := claimInstallerExecutionFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - runInstallerFn = originalInstaller - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - recordInstalledUpdateFn = originalRecord - stageInstallerFn = originalStageInstaller - claimInstallerExecutionFn = originalClaimInstallerExecution - }) - waitForProcessExitFn = func(uint32, time.Duration) error { return nil } - runInstallerFn = func(string, string) error { return nil } - relaunched := false - startRelaunchFn = func(string, string) error { - relaunched = true - return nil - } - claimed := prepareLegacyWindowsUpdate(t, installDir, "v2") - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - return claimed, func() {}, nil - } - stageInstallerFn = func(path, _ string) (string, func() error, error) { - return path, func() error { return nil }, nil - } - claimInstallerExecutionFn = func(string, string) (func(), error) { - return func() {}, nil - } - installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) { - return true, []repair.FileUpdateInstallReceipt{{ - UpdateTransactionID: repair.UpdateTransactionID(claimed), - TargetPath: claimed.TargetPath, - InstalledStateID: "installed", - }}, nil - } - recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) { - return nil, errors.New("release unit drifted") - } - transactionID := repair.UpdateTransactionID(claimed) - - if code := run([]string{ - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"), - "--to-version", "v2", - "--created-at", claimed.CreatedAt, - "--transaction-id", transactionID, - }); code != 1 { - t.Fatalf("run exit code = %d, want 1", code) - } - if !relaunched { - t.Fatal("helper did not relaunch through recovery after post-install verification failure") - } - failure, ok := repair.ReadUpdateApplyFailure() - if !ok || failure.UpdateTransactionID != transactionID { - t.Fatalf("failure marker = %+v, present=%v", failure, ok) - } -} - -func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) { - t.Setenv("REASONIX_HOME", tempdir.New(t)) - installDir := tempdir.New(t) - originalWait := waitForProcessExitFn - originalInstaller := runInstallerFn - originalRelaunch := startRelaunchFn - originalClaim := claimPendingFileUpdateFn - originalStageInstaller := stageInstallerFn - originalLstatStaging := lstatUpdateStagingFn - t.Cleanup(func() { - waitForProcessExitFn = originalWait - runInstallerFn = originalInstaller - startRelaunchFn = originalRelaunch - claimPendingFileUpdateFn = originalClaim - stageInstallerFn = originalStageInstaller - lstatUpdateStagingFn = originalLstatStaging - }) - waitForProcessExitFn = func(uint32, time.Duration) error { return nil } - installerRan := false - runInstallerFn = func(string, string) error { - installerRan = true - return nil - } - relaunched := false - startRelaunchFn = func(string, string) error { - relaunched = true - return nil - } - claimed := prepareLegacyWindowsUpdate(t, installDir, "v2") - releases := 0 - claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) { - return claimed, func() { releases++ }, nil - } - stageInstallerFn = func(path, _ string) (string, func() error, error) { - return path, func() error { return nil }, nil - } - lstatUpdateStagingFn = func(string) (os.FileInfo, error) { - return nil, errors.New("staging identity unavailable") - } - - if code := run([]string{ - "--installer", filepath.Join(installDir, "installer.exe"), - "--installer-sha256", testInstallerSHA256, - "--install-dir", installDir, - "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"), - "--to-version", claimed.ToVersion, - "--created-at", claimed.CreatedAt, - "--transaction-id", repair.UpdateTransactionID(claimed), - }); code != 1 { - t.Fatalf("run exit code = %d, want 1", code) - } - if installerRan { - t.Fatal("installer ran without a bound staging directory") - } - if !relaunched { - t.Fatal("helper did not relaunch after staging identity failure") - } - if releases == 0 { - t.Fatal("helper did not release the update claim") - } -} - -func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) { - path := filepath.Join(tempdir.New(t), "installer.exe") - content := []byte("verified-installer") - if err := os.WriteFile(path, content, 0o700); err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(content) - release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:])) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil { - release() - t.Fatal("installer remained writable while execution claim was held") - } - if err := os.Rename(path, path+".replaced"); err == nil { - release() - t.Fatal("installer remained renameable while execution claim was held") - } - release() - release() - if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil { - t.Fatalf("installer remained frozen after claim release: %v", err) - } -} - -func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) { - path := filepath.Join(tempdir.New(t), "installer.exe") - if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil { - t.Fatal(err) - } - if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil { - release() - t.Fatal("installer with the wrong SHA-256 received an execution claim") - } -} diff --git a/desktop/cmd/update-helper/release_unit.go b/desktop/cmd/update-helper/release_unit.go deleted file mode 100644 index 186d8c6e4f..0000000000 --- a/desktop/cmd/update-helper/release_unit.go +++ /dev/null @@ -1,238 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "reasonix/internal/platform/repair" - "reasonix/internal/platform/update" -) - -const maxWindowsPayloadMetadataSize = 64 << 10 - -var verifyWindowsPayloadManifestFn = update.Verify - -type stagedFileUpdateMember struct { - targetPath string - content []byte - mode os.FileMode -} - -// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload -// before any live release-unit member is moved. An existing Reasonix.exe is the -// portable alias of reasonix-launcher.exe and reuses those staged bytes; an -// installed package that did not have the alias remains unchanged. -func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) { - if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 { - return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete") - } - if err := validateWindowsClaimedReleaseUnit(claimed); err != nil { - return nil, fmt.Errorf("load staged release unit: %w", err) - } - stagingDir = filepath.Clean(strings.TrimSpace(stagingDir)) - if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) { - return nil, fmt.Errorf("load staged release unit: staging directory is invalid") - } - info, err := os.Lstat(stagingDir) - if err != nil { - return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("load staged release unit: staging path is not a directory") - } - hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion) - if err != nil { - return nil, fmt.Errorf("load staged release unit: %w", err) - } - - contents := make(map[string][]byte) - members := make([]stagedFileUpdateMember, 0, len(claimed.Files)) - seenTargets := make(map[string]struct{}, len(claimed.Files)) - for _, file := range claimed.Files { - targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath)) - targetKey := strings.ToLower(targetPath) - if targetPath == "" || targetPath == "." { - return nil, fmt.Errorf("load staged release unit: target path is invalid") - } - if _, ok := seenTargets[targetKey]; ok { - return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath)) - } - seenTargets[targetKey] = struct{}{} - if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore { - continue - } - - sourceName, err := windowsStagedSourceName(filepath.Base(targetPath)) - if err != nil { - return nil, err - } - sourcePath := filepath.Join(stagingDir, sourceName) - content, ok := contents[sourceName] - if !ok { - sourceInfo, statErr := os.Lstat(sourcePath) - if statErr != nil { - return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr) - } - if !sourceInfo.Mode().IsRegular() { - return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName) - } - content, err = readVerifiedWindowsStagedPayloadFn(sourcePath) - if err != nil { - return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err) - } - if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) { - return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName) - } - contents[sourceName] = content - } - members = append(members, stagedFileUpdateMember{ - targetPath: targetPath, - content: content, - mode: 0o700, - }) - } - - // Publish the running desktop last. If an earlier member fails, the old - // desktop remains the executable entry point that can report/retry recovery. - sort.SliceStable(members, func(i, j int) bool { - iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath) - jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath) - return !iPrimary && jPrimary - }) - return members, nil -} - -func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error { - if claimed == nil || - !strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") { - return fmt.Errorf("claimed release unit primary executable is invalid") - } - required := map[string]bool{ - "reasonix-desktop.exe": false, - "reasonix-guard.exe": false, - "reasonix-launcher.exe": false, - "reasonix-update-helper.exe": false, - "reasonix-cli.exe": false, - "reasonix.exe": false, - } - installDir := filepath.Clean(filepath.Dir(claimed.TargetPath)) - for _, file := range claimed.Files { - target := filepath.Clean(strings.TrimSpace(file.TargetPath)) - if target == "" || target == "." || - !strings.EqualFold(filepath.Dir(target), installDir) { - return fmt.Errorf("claimed release unit target is outside the installation directory") - } - name := strings.ToLower(filepath.Base(target)) - seen, ok := required[name] - if !ok { - return fmt.Errorf("claimed release unit contains an unexpected target") - } - if seen { - return fmt.Errorf("claimed release unit contains a duplicate target") - } - required[name] = true - } - for name, seen := range required { - if !seen { - return fmt.Errorf("claimed release unit omits %s", name) - } - } - if len(claimed.Files) != len(required) { - return fmt.Errorf("claimed release unit contains an unexpected target") - } - return nil -} - -func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) { - manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName)) - if err != nil { - return nil, fmt.Errorf("read signed release manifest: %w", err) - } - signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName)) - if err != nil { - return nil, fmt.Errorf("read signed release manifest signature: %w", err) - } - if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil { - return nil, fmt.Errorf("verify signed release manifest: %w", err) - } - hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion) - if err != nil { - return nil, err - } - return hashes, nil -} - -func readWindowsPayloadMetadata(path string) ([]byte, error) { - pathInfo, err := os.Lstat(path) - if err != nil { - return nil, err - } - if !pathInfo.Mode().IsRegular() { - return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path)) - } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return nil, err - } - if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize { - return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path)) - } - if !os.SameFile(pathInfo, info) { - return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path)) - } - data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1)) - if err != nil { - return nil, err - } - if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize { - return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path)) - } - return data, nil -} - -func windowsStagedSourceName(targetBase string) (string, error) { - switch strings.ToLower(strings.TrimSpace(targetBase)) { - case "reasonix-desktop.exe": - return "reasonix-desktop.exe", nil - case "reasonix-guard.exe": - return "reasonix-guard.exe", nil - case "reasonix-launcher.exe": - return "reasonix-launcher.exe", nil - case "reasonix-update-helper.exe": - return "reasonix-update-helper.exe", nil - case "reasonix-cli.exe": - return "reasonix-cli.exe", nil - case "reasonix.exe": - return "reasonix-launcher.exe", nil - default: - return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase) - } -} - -func publishLoadedFileUpdateReleaseUnit( - claimed *repair.UpdateTransaction, - members []stagedFileUpdateMember, - publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error), -) ([]repair.FileUpdateInstallReceipt, error) { - if publish == nil || len(members) == 0 { - return nil, fmt.Errorf("publish staged release unit: payload is incomplete") - } - receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members)) - for _, member := range members { - receipt, err := publish(claimed, member.targetPath, member.content, member.mode) - if err != nil { - return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err) - } - receipts = append(receipts, receipt) - } - return receipts, nil -} diff --git a/desktop/cmd/update-helper/release_unit_test.go b/desktop/cmd/update-helper/release_unit_test.go deleted file mode 100644 index ad93487be6..0000000000 --- a/desktop/cmd/update-helper/release_unit_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package main - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "reasonix/internal/base/tempdir" - "reasonix/internal/platform/repair" - "reasonix/internal/platform/update" -) - -func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) { - staging := tempdir.New(t) - for name, content := range map[string]string{ - "reasonix-desktop.exe": "desktop-v2", - "reasonix-guard.exe": "guard-v2", - "reasonix-launcher.exe": "launcher-v2", - "reasonix-update-helper.exe": "helper-v2", - "reasonix-cli.exe": "cli-v2", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { - t.Fatal(err) - } - } - useTestWindowsPayloadManifest(t, staging, "v2") - installDir := tempdir.New(t) - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: "v2", - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - Files: []repair.UpdateTransactionFile{ - {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")}, - {TargetPath: filepath.Join(installDir, "Reasonix.exe")}, - }, - } - - members, err := loadWindowsStagedReleaseUnit(claimed, staging) - if err != nil { - t.Fatal(err) - } - if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") { - t.Fatalf("last published member = %q, want desktop", got) - } - var published []string - receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) { - published = append(published, filepath.Base(target)+"="+string(content)) - return repair.FileUpdateInstallReceipt{TargetPath: target}, nil - }) - if err != nil { - t.Fatal(err) - } - if len(receipts) != len(members) { - t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members)) - } - if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") { - t.Fatalf("portable alias did not reuse launcher payload: %s", got) - } - if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") { - t.Fatalf("publish order = %v", published) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) { - staging := tempdir.New(t) - if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil { - t.Fatal(err) - } - useTestWindowsPayloadManifest(t, staging, "v2") - installDir := tempdir.New(t) - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: "v2", - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - Files: []repair.UpdateTransactionFile{ - {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, - }, - } - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil { - t.Fatal("incomplete staged release unit was accepted") - } -} - -func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) { - _, complete := completeWindowsStagedReleaseUnitForTest(t, "v2") - for i, file := range complete.Files { - t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) { - claimed := *complete - claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...) - claimed.Files = append(claimed.Files, complete.Files[i+1:]...) - if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || - !strings.Contains(err.Error(), "omits") { - t.Fatalf("missing target error = %v", err) - } - }) - } - t.Run("extra", func(t *testing.T) { - claimed := *complete - claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), - repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")}) - if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || - !strings.Contains(err.Error(), "unexpected") { - t.Fatalf("extra target error = %v", err) - } - }) - t.Run("duplicate", func(t *testing.T) { - claimed := *complete - claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0]) - if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || - !strings.Contains(err.Error(), "duplicate") { - t.Fatalf("duplicate target error = %v", err) - } - }) - t.Run("outside", func(t *testing.T) { - claimed := *complete - claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...) - claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath)) - if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || - !strings.Contains(err.Error(), "outside") { - t.Fatalf("outside target error = %v", err) - } - }) - t.Run("primary", func(t *testing.T) { - claimed := *complete - claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe") - if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil || - !strings.Contains(err.Error(), "primary") { - t.Fatalf("primary target error = %v", err) - } - }) -} - -func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) { - staging := tempdir.New(t) - for _, name := range update.WindowsPayloadFileNames() { - content := "payload:" + name - if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { - t.Fatal(err) - } - } - useTestWindowsPayloadManifest(t, staging, "v2") - installDir := tempdir.New(t) - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: "v2", - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - Files: []repair.UpdateTransactionFile{ - {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")}, - {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")}, - {TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true}, - }, - } - - members, err := loadWindowsStagedReleaseUnit(claimed, staging) - if err != nil { - t.Fatal(err) - } - for _, member := range members { - if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") { - t.Fatalf("missing portable alias was added to publish set: %+v", members) - } - } -} - -func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) { - claimed := &repair.UpdateTransaction{TargetKind: "file"} - members := []stagedFileUpdateMember{ - {targetPath: "guard.exe", content: []byte("guard"), mode: 0o700}, - {targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700}, - } - var published []string - receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) { - published = append(published, target) - return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation") - }) - if err == nil || !strings.Contains(err.Error(), "concurrent recreation") { - t.Fatalf("publish error = %v", err) - } - if len(published) != 1 { - t.Fatalf("published members = %v, want one attempted member", published) - } - if len(receipts) != 0 { - t.Fatalf("failed first publish returned receipts: %+v", receipts) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) { - staging := tempdir.New(t) - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-guard.exe", - "reasonix-launcher.exe", - "reasonix-update-helper.exe", - "reasonix-cli.exe", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil { - t.Fatal(err) - } - } - writeWindowsPayloadManifestForTest(t, staging, "v2") - acceptWindowsPayloadManifestForTest(t) - _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - original := readVerifiedWindowsStagedPayloadFn - readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) { - return nil, errors.New("payload signature rejected") - } - t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) - - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil { - t.Fatal("unsigned staged payload must be rejected before publication") - } -} - -func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) { - staging := tempdir.New(t) - for name, content := range map[string]string{ - "reasonix-desktop.exe": "desktop-v2", - "reasonix-guard.exe": "guard-v2", - "reasonix-launcher.exe": "launcher-v2", - "reasonix-update-helper.exe": "helper-v2", - "reasonix-cli.exe": "cli-v2", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil { - t.Fatal(err) - } - } - writeWindowsPayloadManifestForTest(t, staging, "v2") - acceptWindowsPayloadManifestForTest(t) - _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - var verified []string - original := readVerifiedWindowsStagedPayloadFn - readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) { - verified = append(verified, filepath.Base(path)) - return os.ReadFile(path) - } - t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) - - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil { - t.Fatal(err) - } - if len(verified) != len(update.WindowsPayloadFileNames()) { - t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) { - staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - useUnverifiedPayloadReaderForTest(t) - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || - !strings.Contains(err.Error(), update.WindowsPayloadManifestName) { - t.Fatalf("missing signed manifest error = %v", err) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) { - staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - writeWindowsPayloadManifestForTest(t, staging, "v2") - useUnverifiedPayloadReaderForTest(t) - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || - !strings.Contains(err.Error(), "verify signed release manifest") { - t.Fatalf("bad signed manifest error = %v", err) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) { - staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - writeWindowsPayloadManifestForTest(t, staging, "v3") - acceptWindowsPayloadManifestForTest(t) - useUnverifiedPayloadReaderForTest(t) - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || - !strings.Contains(err.Error(), "identity does not match") { - t.Fatalf("release version drift error = %v", err) - } -} - -func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) { - staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2") - writeWindowsPayloadManifestForTest(t, staging, "v2") - acceptWindowsPayloadManifestForTest(t) - useUnverifiedPayloadReaderForTest(t) - if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil || - !strings.Contains(err.Error(), "does not match the signed release manifest") { - t.Fatalf("release member drift error = %v", err) - } -} - -func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) { - t.Helper() - staging := tempdir.New(t) - for _, name := range update.WindowsPayloadFileNames() { - if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil { - t.Fatal(err) - } - } - installDir := tempdir.New(t) - files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1) - for _, name := range update.WindowsPayloadFileNames() { - files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)}) - } - files = append(files, repair.UpdateTransactionFile{ - TargetPath: filepath.Join(installDir, "Reasonix.exe"), - MissingBefore: true, - }) - return staging, &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: version, - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - Files: files, - } -} - -func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) { - t.Helper() - hashes := make(map[string]string) - for _, name := range update.WindowsPayloadFileNames() { - content, err := os.ReadFile(filepath.Join(staging, name)) - if os.IsNotExist(err) { - content = []byte("missing:" + name) - } else if err != nil { - t.Fatal(err) - } - hashes[name] = update.WindowsPayloadSHA256(content) - } - manifest, err := update.EncodeWindowsPayloadManifest(version, hashes) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil { - t.Fatal(err) - } -} - -func acceptWindowsPayloadManifestForTest(t *testing.T) { - t.Helper() - original := verifyWindowsPayloadManifestFn - verifyWindowsPayloadManifestFn = func(_, signature []byte) error { - if string(signature) != "test signature" { - return errors.New("unexpected test signature") - } - return nil - } - t.Cleanup(func() { verifyWindowsPayloadManifestFn = original }) -} - -func useTestWindowsPayloadManifest(t *testing.T, staging, version string) { - t.Helper() - writeWindowsPayloadManifestForTest(t, staging, version) - acceptWindowsPayloadManifestForTest(t) - useUnverifiedPayloadReaderForTest(t) -} - -func useUnverifiedPayloadReaderForTest(t *testing.T) { - t.Helper() - original := readVerifiedWindowsStagedPayloadFn - readVerifiedWindowsStagedPayloadFn = os.ReadFile - t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original }) -} diff --git a/desktop/cmd/update-helper/versioned_windows.go b/desktop/cmd/update-helper/versioned_windows.go deleted file mode 100644 index a9d65a4ea0..0000000000 --- a/desktop/cmd/update-helper/versioned_windows.go +++ /dev/null @@ -1,118 +0,0 @@ -//go:build windows - -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "reasonix/internal/platform/installlayout" - "reasonix/internal/platform/repair" -) - -// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a -// staged NSIS payload: -// -// InstallRoot/ -// reasonix-launcher.exe -// Reasonix.exe (launcher alias when present or portable) -// reasonix-cli.exe (CLI entry; full binary for now) -// current.json -// versions// -// reasonix-desktop.exe -// reasonix-cli.exe -// reasonix-update-helper.exe -// -// Any failure before the current.json pointer swap leaves the previous active -// version unchanged. The helper never counts crashes or selects prior versions. -func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error { - if claimed == nil { - return fmt.Errorf("versioned activate: transaction is nil") - } - installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath))) - // When the claimed primary is already under versions//, climb to root. - if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" { - installRoot = root - } - version := strings.TrimSpace(claimed.ToVersion) - if err := installlayout.ValidateVersionName(version); err != nil { - // Accept bare product versions from NSIS (1.20.0 → v1.20.0). - if !strings.HasPrefix(version, "v") { - version = "v" + version - } - if err := installlayout.ValidateVersionName(version); err != nil { - return fmt.Errorf("versioned activate: %w", err) - } - } - stagingDir = filepath.Clean(strings.TrimSpace(stagingDir)) - - desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe") - cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe") - helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe") - launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe") - for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} { - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err) - } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path)) - } - } - - requestID := repair.UpdateTransactionID(claimed) - if requestID == "" { - requestID = "helper-" + version - } - if err := installlayout.ActivateVersion(installlayout.ActivationRequest{ - InstallRoot: installRoot, - Version: version, - RequestID: requestID, - Members: []installlayout.Member{ - {Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700}, - {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700}, - {Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700}, - }, - RootMembers: []installlayout.Member{ - {Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700}, - {Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700}, - {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700}, - }, - RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"}, - }); err != nil { - return err - } - - // Remove flat release-unit leftovers so the install root is the thin layout. - // Do not remove the launcher/CLI/alias we just wrote. - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-guard.exe", - "reasonix-update-helper.exe", // helper lives only under versions/ - } { - _ = os.Remove(filepath.Join(installRoot, name)) - } - // Best-effort retention GC of older version trees. - _ = installlayout.RetainPreviousVersions(installRoot, 0) - _ = installlayout.CleanupStaleStaging(installRoot, 0) - return nil -} - -// preferVersionedWindowsActivation reports whether the staged payload is -// complete enough for versioned-v1 activation. -func preferVersionedWindowsActivation(stagingDir string) bool { - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-cli.exe", - "reasonix-update-helper.exe", - "reasonix-launcher.exe", - } { - info, err := os.Lstat(filepath.Join(stagingDir, name)) - if err != nil || !info.Mode().IsRegular() { - return false - } - } - return true -} diff --git a/desktop/cmd/update-helper/versioned_windows_test.go b/desktop/cmd/update-helper/versioned_windows_test.go deleted file mode 100644 index 70e05639f9..0000000000 --- a/desktop/cmd/update-helper/versioned_windows_test.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build windows - -package main - -import ( - "os" - "path/filepath" - "testing" - - "reasonix/internal/platform/installlayout" - "reasonix/internal/platform/repair" - - "reasonix/internal/base/tempdir" -) - -func TestPreferVersionedWindowsActivation(t *testing.T) { - staging := tempdir.New(t) - if preferVersionedWindowsActivation(staging) { - t.Fatal("empty staging must not prefer versioned") - } - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-cli.exe", - "reasonix-update-helper.exe", - "reasonix-launcher.exe", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil { - t.Fatal(err) - } - } - if !preferVersionedWindowsActivation(staging) { - t.Fatal("complete staging must prefer versioned") - } -} - -func TestActivateVersionedWindowsFromStaging(t *testing.T) { - installDir := tempdir.New(t) - staging := tempdir.New(t) - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-cli.exe", - "reasonix-update-helper.exe", - "reasonix-launcher.exe", - "reasonix-guard.exe", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil { - t.Fatal(err) - } - } - // Seed a flat desktop so cleanup is observable. - if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil { - t.Fatal(err) - } - - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: "v1.20.0", - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - CreatedAt: "2026-01-01T00:00:00Z", - } - if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil { - t.Fatal(err) - } - if !installlayout.HasCurrent(installDir) { - t.Fatal("current.json missing") - } - ptr, err := installlayout.ReadCurrent(installDir) - if err != nil || ptr.ActiveVersion != "v1.20.0" { - t.Fatalf("pointer=%+v err=%v", ptr, err) - } - desktop, err := installlayout.ActiveDesktopPath(installDir) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(desktop) - if err != nil || string(raw) != "payload:reasonix-desktop.exe" { - t.Fatalf("desktop payload = %q err=%v", raw, err) - } - for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} { - if _, err := os.Stat(filepath.Join(installDir, name)); err != nil { - t.Fatalf("root entry %s: %v", name, err) - } - } - if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) { - t.Fatal("flat desktop should be removed") - } - if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) { - t.Fatal("flat guard should be removed") - } - if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" { - t.Fatalf("prefer relaunch = %s", prefer) - } -} - -func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) { - installDir := tempdir.New(t) - staging := tempdir.New(t) - for _, name := range []string{ - "reasonix-desktop.exe", - "reasonix-cli.exe", - "reasonix-update-helper.exe", - "reasonix-launcher.exe", - "reasonix-guard.exe", - } { - if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil { - t.Fatal(err) - } - } - // Minimal claimed unit (versioned path does not need full flat file list). - claimed := &repair.UpdateTransaction{ - SchemaVersion: 1, - ToVersion: "v1.20.0", - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - CreatedAt: "2026-01-01T00:00:00Z", - } - started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging) - if err != nil { - t.Fatal(err) - } - if !started || receipts != nil { - t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts) - } - if !installlayout.HasCurrent(installDir) { - t.Fatal("versioned activation did not write current.json") - } -} diff --git a/internal/platform/installlayout/activate.go b/internal/platform/installlayout/activate.go index 22c6cd3a67..4a3e5446e7 100644 --- a/internal/platform/installlayout/activate.go +++ b/internal/platform/installlayout/activate.go @@ -418,46 +418,6 @@ func stagingNonce(requestID string) (string, error) { return hex.EncodeToString(raw[:]), nil } -// CleanupStaleStaging removes versions/.staging-* directories older than maxAge. -// Safe to call anytime; never touches published version directories or current.json. -func CleanupStaleStaging(installRoot string, maxAge time.Duration) error { - installRoot, err := cleanInstallRoot(installRoot) - if err != nil { - return err - } - if maxAge <= 0 { - maxAge = 24 * time.Hour - } - versionsRoot := filepath.Join(installRoot, VersionsDirName) - entries, err := os.ReadDir(versionsRoot) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - cutoff := time.Now().Add(-maxAge) - for _, e := range entries { - name := e.Name() - if !strings.HasPrefix(name, ".staging-") { - continue - } - path := filepath.Join(versionsRoot, name) - info, err := os.Lstat(path) - if err != nil { - continue - } - if info.Mode()&os.ModeSymlink != 0 { - continue - } - if info.ModTime().After(cutoff) { - continue - } - _ = os.RemoveAll(path) - } - return nil -} - // RetainPreviousVersions keeps the active version plus at most one previous // version directory for signed recovery installers. Older trees are removed. // The launcher never auto-selects a previous version; retention is for manual diff --git a/internal/platform/installlayout/current.go b/internal/platform/installlayout/current.go index db4f3f227b..4e87489c80 100644 --- a/internal/platform/installlayout/current.go +++ b/internal/platform/installlayout/current.go @@ -348,16 +348,3 @@ func ActiveMemberPath(installRoot, name string) (string, error) { } return path, nil } - -// ActiveUpdateHelperPath resolves the active update helper binary. -func ActiveUpdateHelperPath(installRoot string) (string, error) { - return ActiveMemberPath(installRoot, UpdateHelperBinaryName()) -} - -// LauncherBinaryName is the permanent thin launcher at InstallRoot. -func LauncherBinaryName() string { - if runtime.GOOS == "windows" { - return "reasonix-launcher.exe" - } - return "reasonix-launcher" -} diff --git a/internal/platform/installlayout/legacy_support_test.go b/internal/platform/installlayout/legacy_support_test.go new file mode 100644 index 0000000000..ab53e395f4 --- /dev/null +++ b/internal/platform/installlayout/legacy_support_test.go @@ -0,0 +1,59 @@ +package installlayout + +import "runtime" + +import "strings" + +import "os" + +import "path/filepath" + +import "time" + +// CleanupStaleStaging removes versions/.staging-* directories older than maxAge. +// Safe to call anytime; never touches published version directories or current.json. +func CleanupStaleStaging(installRoot string, maxAge time.Duration) error { + installRoot, err := cleanInstallRoot(installRoot) + if err != nil { + return err + } + if maxAge <= 0 { + maxAge = 24 * time.Hour + } + versionsRoot := filepath.Join(installRoot, VersionsDirName) + entries, err := os.ReadDir(versionsRoot) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + cutoff := time.Now().Add(-maxAge) + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, ".staging-") { + continue + } + path := filepath.Join(versionsRoot, name) + info, err := os.Lstat(path) + if err != nil { + continue + } + if info.Mode()&os.ModeSymlink != 0 { + continue + } + if info.ModTime().After(cutoff) { + continue + } + _ = os.RemoveAll(path) + } + return nil +} + +// LauncherBinaryName is the permanent thin launcher at InstallRoot. +func LauncherBinaryName() string { + if runtime.GOOS == "windows" { + return "reasonix-launcher.exe" + } + return "reasonix-launcher" +} diff --git a/internal/platform/repair/apply_failure.go b/internal/platform/repair/apply_failure.go index 5b26d00dba..3d8a45175a 100644 --- a/internal/platform/repair/apply_failure.go +++ b/internal/platform/repair/apply_failure.go @@ -34,35 +34,6 @@ func updateApplyFailurePath() string { return filepath.Join(root, "repair", "update-apply-failed.json") } -// MarkUpdateApplyFailed persists the installer-failure marker. It is written -// by the update helper after the NSIS installer exits non-zero. -func MarkUpdateApplyFailed(toVersion, reason string) error { - tx, err := ReadPendingUpdate() - if err == nil { - if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(toVersion) { - return fmt.Errorf("update apply failure: pending transaction does not match") - } - return markUpdateApplyFailedInvocation(tx, reason) - } - if !os.IsNotExist(err) { - return fmt.Errorf("update apply failure: read pending transaction: %w", err) - } - // Keep accepting a diagnostic marker when no matching transaction exists. - // Recovery treats markers without a complete transaction ID as stale and - // never lets them authorize rollback. - unlock, lockErr := acquirePendingUpdateLock() - if lockErr != nil { - return fmt.Errorf("update apply failure: lock pending transaction: %w", lockErr) - } - defer unlock() - if _, currentErr := ReadPendingUpdate(); currentErr == nil { - return fmt.Errorf("update apply failure: pending transaction appeared while waiting") - } else if !os.IsNotExist(currentErr) { - return fmt.Errorf("update apply failure: read pending transaction: %w", currentErr) - } - return markUpdateApplyFailed(toVersion, "", "", reason) -} - func markUpdateApplyFailedInvocation(invocation *UpdateTransaction, reason string) error { if invocation == nil { return fmt.Errorf("update apply failure: transaction identity is incomplete") @@ -91,20 +62,6 @@ func markUpdateApplyFailedInvocation(invocation *UpdateTransaction, reason strin ) } -// MarkUpdateApplyFailedExact records a failure for the complete transaction -// held by an updater claim. The caller must keep that claim's pending lock -// until this write returns; taking it again here would deadlock the updater. -func MarkUpdateApplyFailedExact(tx *UpdateTransaction, reason string) error { - if tx == nil || strings.TrimSpace(tx.CreatedAt) == "" { - return fmt.Errorf("update apply failure: transaction identity is incomplete") - } - transactionID := UpdateTransactionID(tx) - if transactionID == "" { - return fmt.Errorf("update apply failure: transaction identity is incomplete") - } - return markUpdateApplyFailed(tx.ToVersion, tx.CreatedAt, transactionID, reason) -} - func markUpdateApplyFailed(toVersion, updateCreatedAt, updateTransactionID, reason string) error { path := updateApplyFailurePath() if path == "" { @@ -142,27 +99,6 @@ func ReadUpdateApplyFailure() (*UpdateApplyFailure, bool) { return &failure, true } -// ClearUpdateApplyFailureExact removes only the marker created for tx. Platform -// updaters call this after the installed release-unit state is durable; a marker -// concurrently replaced by another transaction is retained. -func ClearUpdateApplyFailureExact(tx *UpdateTransaction) error { - if tx == nil { - return fmt.Errorf("clear update apply failure: transaction identity is incomplete") - } - expectedID := UpdateTransactionID(tx) - if expectedID == "" { - return fmt.Errorf("clear update apply failure: transaction identity is incomplete") - } - failure, ok := ReadUpdateApplyFailure() - if !ok { - return nil - } - if strings.TrimSpace(failure.UpdateTransactionID) != expectedID { - return fmt.Errorf("clear update apply failure: marker does not match transaction") - } - return clearUpdateApplyFailureExact(failure) -} - func clearUpdateApplyFailureExact(expected *UpdateApplyFailure) error { if expected == nil { return fmt.Errorf("clear update apply failure: marker identity is incomplete") @@ -199,80 +135,6 @@ func clearUpdateApplyFailureExact(expected *UpdateApplyFailure) error { return nil } -// RecoverFailedInstall rolls back the pending update when an update helper -// recorded an installer failure, restoring the previous release unit without -// waiting for a crash loop. The marker is cleared once the rollback succeeded -// (or when nothing was left to roll back); on rollback errors both the marker -// and the pending transaction are kept so the next launch retries. -func RecoverFailedInstall() (UpdateRollbackResult, *UpdateApplyFailure, error) { - invocationFailure, ok := ReadUpdateApplyFailure() - if !ok { - return UpdateRollbackResult{}, nil, nil - } - invocationFailureID := repairPlanStateID(invocationFailure) - invocationTx, invocationTxErr := ReadPendingUpdate() - if invocationTxErr != nil && !os.IsNotExist(invocationTxErr) { - return UpdateRollbackResult{}, invocationFailure, invocationTxErr - } - unlock, lockErr := acquirePendingUpdateLock() - if lockErr != nil { - return UpdateRollbackResult{}, invocationFailure, fmt.Errorf("recover failed install: lock pending transaction: %w", lockErr) - } - defer unlock() - failure, ok := ReadUpdateApplyFailure() - if !ok { - return UpdateRollbackResult{}, nil, nil - } - if repairPlanStateID(failure) != invocationFailureID { - return UpdateRollbackResult{}, failure, fmt.Errorf("recover failed install: failure marker changed while waiting") - } - tx, txErr := ReadPendingUpdate() - if txErr != nil { - if !os.IsNotExist(txErr) { - return UpdateRollbackResult{}, failure, txErr - } - if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { - return UpdateRollbackResult{}, failure, clearErr - } - return UpdateRollbackResult{}, failure, nil - } - if invocationTxErr == nil && UpdateTransactionID(tx) != UpdateTransactionID(invocationTx) { - return UpdateRollbackResult{}, failure, fmt.Errorf("recover failed install: pending transaction changed while waiting") - } - if os.IsNotExist(invocationTxErr) { - if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { - return UpdateRollbackResult{}, failure, clearErr - } - return UpdateRollbackResult{}, failure, nil - } - if !applyFailureMatchesUpdate(failure, tx) { - // A marker can survive when the helper cannot relaunch Guard. Never let - // that stale marker roll back a later, unrelated update transaction. - if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { - return UpdateRollbackResult{}, failure, clearErr - } - return UpdateRollbackResult{}, failure, nil - } - // Keep the exact identity check in the rollback transition as a second - // fail-closed guard even though correlation and recovery share this lock. - stateID, states := pendingUpdateBoundPreview(tx) - result, err := rollbackPendingUpdateMatchingLocked( - tx.ToVersion, - tx.CreatedAt, - stateID, - states, - UpdateTransactionID(tx), - false, - ) - if err != nil { - return result, failure, err - } - if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { - return result, failure, clearErr - } - return result, failure, nil -} - func applyFailureMatchesUpdate(failure *UpdateApplyFailure, tx *UpdateTransaction) bool { if failure == nil || tx == nil { return false diff --git a/internal/platform/repair/legacy_update_support_test.go b/internal/platform/repair/legacy_update_support_test.go new file mode 100644 index 0000000000..8ed45c62f9 --- /dev/null +++ b/internal/platform/repair/legacy_update_support_test.go @@ -0,0 +1,560 @@ +package repair + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "time" + + "reasonix/internal/contract/config" +) + +// RecoverFailedInstall rolls back the pending update when an update helper +// recorded an installer failure, restoring the previous release unit without +// waiting for a crash loop. The marker is cleared once the rollback succeeded +// (or when nothing was left to roll back); on rollback errors both the marker +// and the pending transaction are kept so the next launch retries. +func RecoverFailedInstall() (UpdateRollbackResult, *UpdateApplyFailure, error) { + invocationFailure, ok := ReadUpdateApplyFailure() + if !ok { + return UpdateRollbackResult{}, nil, nil + } + invocationFailureID := repairPlanStateID(invocationFailure) + invocationTx, invocationTxErr := ReadPendingUpdate() + if invocationTxErr != nil && !os.IsNotExist(invocationTxErr) { + return UpdateRollbackResult{}, invocationFailure, invocationTxErr + } + unlock, lockErr := acquirePendingUpdateLock() + if lockErr != nil { + return UpdateRollbackResult{}, invocationFailure, fmt.Errorf("recover failed install: lock pending transaction: %w", lockErr) + } + defer unlock() + failure, ok := ReadUpdateApplyFailure() + if !ok { + return UpdateRollbackResult{}, nil, nil + } + if repairPlanStateID(failure) != invocationFailureID { + return UpdateRollbackResult{}, failure, fmt.Errorf("recover failed install: failure marker changed while waiting") + } + tx, txErr := ReadPendingUpdate() + if txErr != nil { + if !os.IsNotExist(txErr) { + return UpdateRollbackResult{}, failure, txErr + } + if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { + return UpdateRollbackResult{}, failure, clearErr + } + return UpdateRollbackResult{}, failure, nil + } + if invocationTxErr == nil && UpdateTransactionID(tx) != UpdateTransactionID(invocationTx) { + return UpdateRollbackResult{}, failure, fmt.Errorf("recover failed install: pending transaction changed while waiting") + } + if os.IsNotExist(invocationTxErr) { + if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { + return UpdateRollbackResult{}, failure, clearErr + } + return UpdateRollbackResult{}, failure, nil + } + if !applyFailureMatchesUpdate(failure, tx) { + // A marker can survive when the helper cannot relaunch Guard. Never let + // that stale marker roll back a later, unrelated update transaction. + if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { + return UpdateRollbackResult{}, failure, clearErr + } + return UpdateRollbackResult{}, failure, nil + } + // Keep the exact identity check in the rollback transition as a second + // fail-closed guard even though correlation and recovery share this lock. + stateID, states := pendingUpdateBoundPreview(tx) + result, err := rollbackPendingUpdateMatchingLocked( + tx.ToVersion, + tx.CreatedAt, + stateID, + states, + UpdateTransactionID(tx), + false, + ) + if err != nil { + return result, failure, err + } + if clearErr := clearUpdateApplyFailureExact(failure); clearErr != nil { + return result, failure, clearErr + } + return result, failure, nil +} + +// ClearUpdateApplyFailureExact removes only the marker created for tx. Platform +// updaters call this after the installed release-unit state is durable; a marker +// concurrently replaced by another transaction is retained. +func ClearUpdateApplyFailureExact(tx *UpdateTransaction) error { + if tx == nil { + return fmt.Errorf("clear update apply failure: transaction identity is incomplete") + } + expectedID := UpdateTransactionID(tx) + if expectedID == "" { + return fmt.Errorf("clear update apply failure: transaction identity is incomplete") + } + failure, ok := ReadUpdateApplyFailure() + if !ok { + return nil + } + if strings.TrimSpace(failure.UpdateTransactionID) != expectedID { + return fmt.Errorf("clear update apply failure: marker does not match transaction") + } + return clearUpdateApplyFailureExact(failure) +} + +// MarkUpdateApplyFailedExact records a failure for the complete transaction +// held by an updater claim. The caller must keep that claim's pending lock +// until this write returns; taking it again here would deadlock the updater. +func MarkUpdateApplyFailedExact(tx *UpdateTransaction, reason string) error { + if tx == nil || strings.TrimSpace(tx.CreatedAt) == "" { + return fmt.Errorf("update apply failure: transaction identity is incomplete") + } + transactionID := UpdateTransactionID(tx) + if transactionID == "" { + return fmt.Errorf("update apply failure: transaction identity is incomplete") + } + return markUpdateApplyFailed(tx.ToVersion, tx.CreatedAt, transactionID, reason) +} + +// MarkUpdateApplyFailed persists the installer-failure marker. It is written +// by the update helper after the NSIS installer exits non-zero. +func MarkUpdateApplyFailed(toVersion, reason string) error { + tx, err := ReadPendingUpdate() + if err == nil { + if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(toVersion) { + return fmt.Errorf("update apply failure: pending transaction does not match") + } + return markUpdateApplyFailedInvocation(tx, reason) + } + if !os.IsNotExist(err) { + return fmt.Errorf("update apply failure: read pending transaction: %w", err) + } + // Keep accepting a diagnostic marker when no matching transaction exists. + // Recovery treats markers without a complete transaction ID as stale and + // never lets them authorize rollback. + unlock, lockErr := acquirePendingUpdateLock() + if lockErr != nil { + return fmt.Errorf("update apply failure: lock pending transaction: %w", lockErr) + } + defer unlock() + if _, currentErr := ReadPendingUpdate(); currentErr == nil { + return fmt.Errorf("update apply failure: pending transaction appeared while waiting") + } else if !os.IsNotExist(currentErr) { + return fmt.Errorf("update apply failure: read pending transaction: %w", currentErr) + } + return markUpdateApplyFailed(toVersion, "", "", reason) +} + +// RollbackPendingUpdateMatching rolls back only the exact transaction prepared +// by the caller. This is used when an apply attempt fails after another process +// may already have replaced pending-update.json with a same-version retry. +func RollbackPendingUpdateMatching(expectedToVersion, expectedCreatedAt string) (UpdateRollbackResult, error) { + expectedToVersion = strings.TrimSpace(expectedToVersion) + expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) + if expectedToVersion == "" || expectedCreatedAt == "" { + return UpdateRollbackResult{}, fmt.Errorf("rollback update: transaction identity is incomplete") + } + return rollbackPendingUpdateInvocation(expectedToVersion, expectedCreatedAt, "") +} + +// CancelPendingUpdateMatching removes only the exact transaction prepared by +// the caller. It is used by updater failure paths where a same-version retry can +// replace pending-update.json before cleanup runs. +func CancelPendingUpdateMatching(toVersion, expectedCreatedAt string) error { + expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) + if expectedCreatedAt == "" { + return nil + } + return cancelPendingUpdateInvocation(toVersion, expectedCreatedAt, "") +} + +// MarkUpdateHealthyMatching commits only the exact pending transaction observed +// when this desktop process started. The creation identity prevents an older +// process from blessing a later same-version retry. +func MarkUpdateHealthyMatching(runningVersion, expectedCreatedAt string) error { + expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) + if expectedCreatedAt == "" { + return nil + } + return markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, "") +} + +// AbandonPendingUpdate is the user-initiated recovery path for a stuck +// transaction: commit if possible, else reconcile, else force-retire. +func AbandonPendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) { + committed, commitErr := CommitProbationaryPendingUpdate(runningVersion) + if commitErr == nil && committed { + return PendingUpdateReconcileResult{Cleared: true, Healthy: true}, nil + } + if commitErr != nil { + // Keep going: a drifted backup must not block explicit discard. + slog.Debug("repair: probationary commit during abandon failed; continuing", + "err", commitErr) + } + result, reconcileErr := ReconcilePendingUpdate(runningVersion) + if reconcileErr == nil { + return result, nil + } + // Force-retire when still AwaitingHealth with the live target installed. + if errors.Is(reconcileErr, ErrPendingUpdateAwaitingHealth) { + if retired, retireErr := forceRetireProbationaryPendingUpdate(runningVersion); retireErr != nil { + return result, fmt.Errorf("abandon pending update: %w", retireErr) + } else if retired { + result.Pending = false + result.AwaitingHealth = false + result.Healthy = true + result.Cleared = true + return result, nil + } + } + if commitErr != nil && reconcileErr != nil { + return result, fmt.Errorf("abandon pending update: %w", errors.Join(reconcileErr, commitErr)) + } + return result, reconcileErr +} + +func HasPendingUpdate() bool { + _, err := ReadPendingUpdate() + return err == nil +} + +// CancelPendingAppBundleUpdateHandoff abandons an exact handoff only when the +// original installed bundle is still the tree captured during prepare. This is +// the safe recovery path when source verification fails after the desktop has +// exited but before any bundle swap occurred. +func CancelPendingAppBundleUpdateHandoff( + expectedToVersion, expectedCreatedAt string, + timeout time.Duration, +) (*UpdateTransaction, error) { + tx, err := ReadPendingUpdate() + if err != nil { + return nil, fmt.Errorf("cancel update handoff: read pending transaction: %w", err) + } + if tx.TargetKind != "app-bundle" || + strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || + strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { + return nil, fmt.Errorf("cancel update handoff: pending transaction does not match") + } + return cancelPendingAppBundleUpdateHandoff( + expectedToVersion, + expectedCreatedAt, + timeout, + UpdateTransactionID(tx), + ) +} + +// RecordClaimedFileUpdateInstalled binds the complete post-install release unit +// while the platform updater still holds the claim's pending and target locks. +// The binding is a transaction-unique create-only sidecar: pending-update.json +// stays immutable, so a process crash can never strand rollback state in the +// gap between displacing the old pending file and publishing a replacement. +func RecordClaimedFileUpdateInstalled( + claimed *UpdateTransaction, + receipts ...FileUpdateInstallReceipt, +) (*UpdateTransaction, error) { + if claimed == nil || claimed.TargetKind != "file" { + return nil, fmt.Errorf("record installed update: transaction identity is incomplete") + } + current, err := readPendingUpdateForLauncher(claimed.TargetPath) + if err != nil { + return nil, fmt.Errorf("record installed update: read pending transaction: %w", err) + } + if !reflect.DeepEqual(claimed, current) { + return nil, fmt.Errorf("record installed update: pending transaction changed") + } + if len(current.Files) == 0 { + return nil, fmt.Errorf("record installed update: release unit is incomplete") + } + record := &installedFileUpdateState{ + SchemaVersion: 1, + UpdateTransactionID: UpdateTransactionID(current), + InstalledStateIDs: make([]string, len(current.Files)), + } + receiptStates := make(map[string]string, len(receipts)) + for _, receipt := range receipts { + if strings.TrimSpace(receipt.UpdateTransactionID) != record.UpdateTransactionID { + return nil, fmt.Errorf("record installed update: publish receipt belongs to a different transaction") + } + targetKey := canonicalRepairPath(receipt.TargetPath) + if targetKey == "" { + return nil, fmt.Errorf("record installed update: publish receipt target is invalid") + } + stateID := strings.TrimSpace(receipt.InstalledStateID) + if len(stateID) != sha256.Size*2 { + return nil, fmt.Errorf("record installed update: publish receipt state is invalid") + } + if _, err := hex.DecodeString(stateID); err != nil { + return nil, fmt.Errorf("record installed update: publish receipt state is invalid") + } + if _, exists := receiptStates[targetKey]; exists { + return nil, fmt.Errorf("record installed update: duplicate publish receipt") + } + receiptStates[targetKey] = stateID + } + for i := range current.Files { + f := ¤t.Files[i] + targetKey := canonicalRepairPath(f.TargetPath) + if stateID, ok := receiptStates[targetKey]; ok { + record.InstalledStateIDs[i] = stateID + delete(receiptStates, targetKey) + continue + } + info, statErr := os.Lstat(f.TargetPath) + if statErr != nil { + if os.IsNotExist(statErr) && f.MissingBefore { + record.InstalledStateIDs[i] = repairPlanReleaseNodeState(f.TargetPath) + continue + } + return nil, fmt.Errorf("record installed update: inspect %s: %w", filepath.Base(f.TargetPath), statErr) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("record installed update: %s is not a regular file", filepath.Base(f.TargetPath)) + } + return nil, fmt.Errorf("record installed update: publish receipt is missing for %s", filepath.Base(f.TargetPath)) + } + if len(receiptStates) != 0 { + return nil, fmt.Errorf("record installed update: publish receipt target is outside the release unit") + } + for i, f := range current.Files { + if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, record.InstalledStateIDs[i]); err != nil { + return nil, fmt.Errorf("record installed update: release unit changed while recording: %w", err) + } + } + if err := createInstalledFileUpdateState(current, record); err != nil { + return nil, fmt.Errorf("record installed update: %w", err) + } + installedUpdateAfterCreate(installedFileUpdateStatePath(current)) + latest, err := readPendingUpdateForLauncher(claimed.TargetPath) + if err != nil { + return nil, fmt.Errorf("record installed update: re-read pending transaction: %w", err) + } + if !reflect.DeepEqual(current, latest) { + return nil, fmt.Errorf("record installed update: pending transaction changed") + } + if _, _, err := installedFileUpdateTargets(latest, true); err != nil { + return nil, fmt.Errorf("record installed update: %w", err) + } + return latest, nil +} + +// PublishClaimedFileUpdateMember replaces one release-unit member without ever +// overwriting an unverified node. The platform updater must hold the claim +// returned by ClaimPendingFileUpdateExact for the whole release-unit operation. +// A concurrent recreation after the prepared node moves aside wins; the new +// bytes and the verified prior node remain staged for recovery. +func PublishClaimedFileUpdateMember(claimed *UpdateTransaction, targetPath string, content []byte, mode os.FileMode) error { + _, err := PublishClaimedFileUpdateMemberExact(claimed, targetPath, content, mode) + return err +} + +// ClaimPendingFileUpdateExact additionally binds the updater to every field in +// the transaction prepared by the desktop process. +func ClaimPendingFileUpdateExact( + expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string, + expectedTargetPaths []string, + timeout time.Duration, +) (*UpdateTransaction, func(), error) { + expectedTransactionID = strings.TrimSpace(expectedTransactionID) + if expectedTransactionID == "" { + return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete") + } + return claimPendingFileUpdate( + expectedToVersion, + expectedCreatedAt, + expectedTransactionID, + launcherPath, + expectedTargetPaths, + timeout, + ) +} + +// ClaimPendingFileUpdate binds an updater's actual replacement window to the +// exact transaction and release-unit paths prepared by the desktop. The +// launcher path is explicit because the Windows helper runs from a cache +// directory rather than from the installation it is authorized to replace. +func ClaimPendingFileUpdate( + expectedToVersion, expectedCreatedAt, launcherPath string, + expectedTargetPaths []string, + timeout time.Duration, +) (*UpdateTransaction, func(), error) { + tx, err := readPendingUpdateForLauncher(launcherPath) + if err != nil { + return nil, nil, fmt.Errorf("claim file update: read pending transaction: %w", err) + } + if tx.TargetKind != "file" || + strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || + strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { + return nil, nil, fmt.Errorf("claim file update: pending transaction does not match") + } + return claimPendingFileUpdate( + expectedToVersion, + expectedCreatedAt, + UpdateTransactionID(tx), + launcherPath, + expectedTargetPaths, + timeout, + ) +} + +// ClaimPendingAppBundleUpdateHandoff authorizes a detached child to perform the +// recorded bundle swap. It returns with both the pending transaction lock and +// the target mutation locks held; release must be called on every path. +func ClaimPendingAppBundleUpdateHandoff(expectedToVersion, expectedCreatedAt string, timeout time.Duration) (*UpdateTransaction, func(), error) { + tx, err := ReadPendingUpdate() + if err != nil { + return nil, nil, fmt.Errorf("claim update handoff: read pending transaction: %w", err) + } + if tx.TargetKind != "app-bundle" || + strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || + strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { + return nil, nil, fmt.Errorf("claim update handoff: pending transaction does not match") + } + return claimPendingAppBundleUpdateHandoff( + expectedToVersion, + expectedCreatedAt, + UpdateTransactionID(tx), + timeout, + ) +} + +// PrepareAppBundleUpdate records the sibling bundle backup that the macOS +// handoff script creates. The script performs the directory move after exit. +func PrepareAppBundleUpdate(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) { + tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath) + if err != nil { + return nil, err + } + unlock, err := acquirePendingUpdateLock() + if err != nil { + return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err) + } + defer unlock() + if err := ensureNoPendingUpdate(); err != nil { + return nil, err + } + unlockTargets, lockErr := lockRepairMutations(tx.TargetPath, tx.BackupPath) + if lockErr != nil { + return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr) + } + defer unlockTargets() + tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath) + if err != nil { + return nil, fmt.Errorf("prepare update: current bundle digest: %w", err) + } + if err := ensureNoPendingUpdate(); err != nil { + return nil, err + } + if err := createPendingUpdate(tx); err != nil { + return nil, err + } + return tx, nil +} + +// PrepareFileUpdate snapshots the current desktop executable — plus any sibling +// binaries of the release unit the installer also replaces (Guard, launcher, +// update helper) — and records an update transaction before an updater applies +// the replacement. Sibling paths that do not exist are recorded explicitly so +// rollback can remove files introduced by the replacement release. +func PrepareFileUpdate(fromVersion, toVersion, targetPath string, siblingPaths ...string) (*UpdateTransaction, error) { + targetPath = filepath.Clean(strings.TrimSpace(targetPath)) + if targetPath == "" || targetPath == "." { + return nil, fmt.Errorf("prepare update: empty target path") + } + root := config.MemoryUserDir() + if root == "" { + return nil, fmt.Errorf("prepare update: Reasonix state directory is unavailable") + } + unlock, err := acquirePendingUpdateLock() + if err != nil { + return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err) + } + defer unlock() + if err := ensureNoPendingUpdate(); err != nil { + return nil, err + } + // Hold the same target locks as rollback so prepare/snapshot cannot race + // a concurrent Guard restore of the release unit. + lockPaths := append([]string{targetPath}, siblingPaths...) + unlockTargets, lockErr := lockRepairMutations(lockPaths...) + if lockErr != nil { + return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr) + } + defer unlockTargets() + backupDir := filepath.Join(root, "repair", "updates") + if err := os.MkdirAll(backupDir, 0o700); err != nil { + return nil, err + } + if !pathInsideResolvedRoot(filepath.Join(root, "repair"), backupDir) { + return nil, fmt.Errorf("prepare update: backup directory resolves outside the repair directory") + } + tx := &UpdateTransaction{ + SchemaVersion: updateTransactionVersion, + FromVersion: fromVersion, + ToVersion: toVersion, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + TargetKind: "file", + TargetPath: targetPath, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + seen := map[string]bool{} + for i, path := range append([]string{targetPath}, siblingPaths...) { + path = filepath.Clean(strings.TrimSpace(path)) + key := canonicalRepairPath(path) + if path == "" || path == "." || key == "" || seen[key] { + continue + } + seen[key] = true + info, statErr := os.Lstat(path) + if statErr != nil { + if i > 0 && os.IsNotExist(statErr) { + tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, MissingBefore: true}) + continue + } + return nil, fmt.Errorf("prepare update backup: %w", statErr) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("prepare update backup: release file %s is not a regular file", filepath.Base(path)) + } + backupIdentity := repairPlanStateID(struct { + CreatedAt string `json:"createdAt"` + TargetPath string `json:"targetPath"` + Index int `json:"index"` + }{ + CreatedAt: tx.CreatedAt, + TargetPath: canonicalRepairPath(path), + Index: i, + }) + backupPath := filepath.Join( + backupDir, + fmt.Sprintf("%s.%s.previous", filepath.Base(path), backupIdentity[:16]), + ) + hash, err := copyFileWithHashCreate(path, backupPath, 0o700) + if err != nil { + return nil, fmt.Errorf("prepare update backup: %w", err) + } + tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, BackupPath: backupPath, SHA256: hash}) + if i == 0 { + tx.BackupPath = backupPath + tx.BackupSHA256 = hash + } + } + if err := verifyPreparedFileUpdateTargets(tx); err != nil { + return nil, fmt.Errorf("prepare update: %w", err) + } + if err := ensureNoPendingUpdate(); err != nil { + return nil, err + } + if err := createPendingUpdate(tx); err != nil { + return nil, err + } + return tx, nil +} diff --git a/internal/platform/repair/update.go b/internal/platform/repair/update.go index a3e16d8e57..c78470da49 100644 --- a/internal/platform/repair/update.go +++ b/internal/platform/repair/update.go @@ -218,139 +218,6 @@ func lockPendingUpdateStrict() (func(), error) { var acquirePendingUpdateLock = lockPendingUpdateStrict -// PrepareFileUpdate snapshots the current desktop executable — plus any sibling -// binaries of the release unit the installer also replaces (Guard, launcher, -// update helper) — and records an update transaction before an updater applies -// the replacement. Sibling paths that do not exist are recorded explicitly so -// rollback can remove files introduced by the replacement release. -func PrepareFileUpdate(fromVersion, toVersion, targetPath string, siblingPaths ...string) (*UpdateTransaction, error) { - targetPath = filepath.Clean(strings.TrimSpace(targetPath)) - if targetPath == "" || targetPath == "." { - return nil, fmt.Errorf("prepare update: empty target path") - } - root := config.MemoryUserDir() - if root == "" { - return nil, fmt.Errorf("prepare update: Reasonix state directory is unavailable") - } - unlock, err := acquirePendingUpdateLock() - if err != nil { - return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err) - } - defer unlock() - if err := ensureNoPendingUpdate(); err != nil { - return nil, err - } - // Hold the same target locks as rollback so prepare/snapshot cannot race - // a concurrent Guard restore of the release unit. - lockPaths := append([]string{targetPath}, siblingPaths...) - unlockTargets, lockErr := lockRepairMutations(lockPaths...) - if lockErr != nil { - return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr) - } - defer unlockTargets() - backupDir := filepath.Join(root, "repair", "updates") - if err := os.MkdirAll(backupDir, 0o700); err != nil { - return nil, err - } - if !pathInsideResolvedRoot(filepath.Join(root, "repair"), backupDir) { - return nil, fmt.Errorf("prepare update: backup directory resolves outside the repair directory") - } - tx := &UpdateTransaction{ - SchemaVersion: updateTransactionVersion, - FromVersion: fromVersion, - ToVersion: toVersion, - Platform: runtime.GOOS + "/" + runtime.GOARCH, - TargetKind: "file", - TargetPath: targetPath, - CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), - } - seen := map[string]bool{} - for i, path := range append([]string{targetPath}, siblingPaths...) { - path = filepath.Clean(strings.TrimSpace(path)) - key := canonicalRepairPath(path) - if path == "" || path == "." || key == "" || seen[key] { - continue - } - seen[key] = true - info, statErr := os.Lstat(path) - if statErr != nil { - if i > 0 && os.IsNotExist(statErr) { - tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, MissingBefore: true}) - continue - } - return nil, fmt.Errorf("prepare update backup: %w", statErr) - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("prepare update backup: release file %s is not a regular file", filepath.Base(path)) - } - backupIdentity := repairPlanStateID(struct { - CreatedAt string `json:"createdAt"` - TargetPath string `json:"targetPath"` - Index int `json:"index"` - }{ - CreatedAt: tx.CreatedAt, - TargetPath: canonicalRepairPath(path), - Index: i, - }) - backupPath := filepath.Join( - backupDir, - fmt.Sprintf("%s.%s.previous", filepath.Base(path), backupIdentity[:16]), - ) - hash, err := copyFileWithHashCreate(path, backupPath, 0o700) - if err != nil { - return nil, fmt.Errorf("prepare update backup: %w", err) - } - tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, BackupPath: backupPath, SHA256: hash}) - if i == 0 { - tx.BackupPath = backupPath - tx.BackupSHA256 = hash - } - } - if err := verifyPreparedFileUpdateTargets(tx); err != nil { - return nil, fmt.Errorf("prepare update: %w", err) - } - if err := ensureNoPendingUpdate(); err != nil { - return nil, err - } - if err := createPendingUpdate(tx); err != nil { - return nil, err - } - return tx, nil -} - -// PrepareAppBundleUpdate records the sibling bundle backup that the macOS -// handoff script creates. The script performs the directory move after exit. -func PrepareAppBundleUpdate(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) { - tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath) - if err != nil { - return nil, err - } - unlock, err := acquirePendingUpdateLock() - if err != nil { - return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err) - } - defer unlock() - if err := ensureNoPendingUpdate(); err != nil { - return nil, err - } - unlockTargets, lockErr := lockRepairMutations(tx.TargetPath, tx.BackupPath) - if lockErr != nil { - return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr) - } - defer unlockTargets() - tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath) - if err != nil { - return nil, fmt.Errorf("prepare update: current bundle digest: %w", err) - } - if err := ensureNoPendingUpdate(); err != nil { - return nil, err - } - if err := createPendingUpdate(tx); err != nil { - return nil, err - } - return tx, nil -} - // PrepareAppBundleUpdateHandoff records every path the detached macOS updater // may mutate. The child receives only the transaction identity and must claim // these recorded paths under the pending-update and mutation locks. @@ -439,27 +306,6 @@ func newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath s return tx, nil } -// ClaimPendingAppBundleUpdateHandoff authorizes a detached child to perform the -// recorded bundle swap. It returns with both the pending transaction lock and -// the target mutation locks held; release must be called on every path. -func ClaimPendingAppBundleUpdateHandoff(expectedToVersion, expectedCreatedAt string, timeout time.Duration) (*UpdateTransaction, func(), error) { - tx, err := ReadPendingUpdate() - if err != nil { - return nil, nil, fmt.Errorf("claim update handoff: read pending transaction: %w", err) - } - if tx.TargetKind != "app-bundle" || - strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || - strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { - return nil, nil, fmt.Errorf("claim update handoff: pending transaction does not match") - } - return claimPendingAppBundleUpdateHandoff( - expectedToVersion, - expectedCreatedAt, - UpdateTransactionID(tx), - timeout, - ) -} - // ClaimPendingAppBundleUpdateHandoffExact additionally binds the detached // updater to the complete transaction prepared by the parent process. func ClaimPendingAppBundleUpdateHandoffExact( @@ -556,55 +402,6 @@ func claimPendingAppBundleUpdateHandoff( return current, release, nil } -// ClaimPendingFileUpdate binds an updater's actual replacement window to the -// exact transaction and release-unit paths prepared by the desktop. The -// launcher path is explicit because the Windows helper runs from a cache -// directory rather than from the installation it is authorized to replace. -func ClaimPendingFileUpdate( - expectedToVersion, expectedCreatedAt, launcherPath string, - expectedTargetPaths []string, - timeout time.Duration, -) (*UpdateTransaction, func(), error) { - tx, err := readPendingUpdateForLauncher(launcherPath) - if err != nil { - return nil, nil, fmt.Errorf("claim file update: read pending transaction: %w", err) - } - if tx.TargetKind != "file" || - strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || - strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { - return nil, nil, fmt.Errorf("claim file update: pending transaction does not match") - } - return claimPendingFileUpdate( - expectedToVersion, - expectedCreatedAt, - UpdateTransactionID(tx), - launcherPath, - expectedTargetPaths, - timeout, - ) -} - -// ClaimPendingFileUpdateExact additionally binds the updater to every field in -// the transaction prepared by the desktop process. -func ClaimPendingFileUpdateExact( - expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string, - expectedTargetPaths []string, - timeout time.Duration, -) (*UpdateTransaction, func(), error) { - expectedTransactionID = strings.TrimSpace(expectedTransactionID) - if expectedTransactionID == "" { - return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete") - } - return claimPendingFileUpdate( - expectedToVersion, - expectedCreatedAt, - expectedTransactionID, - launcherPath, - expectedTargetPaths, - timeout, - ) -} - func claimPendingFileUpdate( expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string, expectedTargetPaths []string, @@ -735,16 +532,6 @@ func verifyPreparedFileUpdateBackups(tx *UpdateTransaction) error { return nil } -// PublishClaimedFileUpdateMember replaces one release-unit member without ever -// overwriting an unverified node. The platform updater must hold the claim -// returned by ClaimPendingFileUpdateExact for the whole release-unit operation. -// A concurrent recreation after the prepared node moves aside wins; the new -// bytes and the verified prior node remain staged for recovery. -func PublishClaimedFileUpdateMember(claimed *UpdateTransaction, targetPath string, content []byte, mode os.FileMode) error { - _, err := PublishClaimedFileUpdateMemberExact(claimed, targetPath, content, mode) - return err -} - // PublishClaimedFileUpdateMemberExact returns proof of the exact node it // published. Callers must retain every receipt and pass them to // RecordClaimedFileUpdateInstalled before releasing the update claim. @@ -946,100 +733,6 @@ func stageFileUpdateContent(targetPath string, content []byte, mode os.FileMode) return path, hex.EncodeToString(sum[:]), installedStateID, nil } -// RecordClaimedFileUpdateInstalled binds the complete post-install release unit -// while the platform updater still holds the claim's pending and target locks. -// The binding is a transaction-unique create-only sidecar: pending-update.json -// stays immutable, so a process crash can never strand rollback state in the -// gap between displacing the old pending file and publishing a replacement. -func RecordClaimedFileUpdateInstalled( - claimed *UpdateTransaction, - receipts ...FileUpdateInstallReceipt, -) (*UpdateTransaction, error) { - if claimed == nil || claimed.TargetKind != "file" { - return nil, fmt.Errorf("record installed update: transaction identity is incomplete") - } - current, err := readPendingUpdateForLauncher(claimed.TargetPath) - if err != nil { - return nil, fmt.Errorf("record installed update: read pending transaction: %w", err) - } - if !reflect.DeepEqual(claimed, current) { - return nil, fmt.Errorf("record installed update: pending transaction changed") - } - if len(current.Files) == 0 { - return nil, fmt.Errorf("record installed update: release unit is incomplete") - } - record := &installedFileUpdateState{ - SchemaVersion: 1, - UpdateTransactionID: UpdateTransactionID(current), - InstalledStateIDs: make([]string, len(current.Files)), - } - receiptStates := make(map[string]string, len(receipts)) - for _, receipt := range receipts { - if strings.TrimSpace(receipt.UpdateTransactionID) != record.UpdateTransactionID { - return nil, fmt.Errorf("record installed update: publish receipt belongs to a different transaction") - } - targetKey := canonicalRepairPath(receipt.TargetPath) - if targetKey == "" { - return nil, fmt.Errorf("record installed update: publish receipt target is invalid") - } - stateID := strings.TrimSpace(receipt.InstalledStateID) - if len(stateID) != sha256.Size*2 { - return nil, fmt.Errorf("record installed update: publish receipt state is invalid") - } - if _, err := hex.DecodeString(stateID); err != nil { - return nil, fmt.Errorf("record installed update: publish receipt state is invalid") - } - if _, exists := receiptStates[targetKey]; exists { - return nil, fmt.Errorf("record installed update: duplicate publish receipt") - } - receiptStates[targetKey] = stateID - } - for i := range current.Files { - f := ¤t.Files[i] - targetKey := canonicalRepairPath(f.TargetPath) - if stateID, ok := receiptStates[targetKey]; ok { - record.InstalledStateIDs[i] = stateID - delete(receiptStates, targetKey) - continue - } - info, statErr := os.Lstat(f.TargetPath) - if statErr != nil { - if os.IsNotExist(statErr) && f.MissingBefore { - record.InstalledStateIDs[i] = repairPlanReleaseNodeState(f.TargetPath) - continue - } - return nil, fmt.Errorf("record installed update: inspect %s: %w", filepath.Base(f.TargetPath), statErr) - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("record installed update: %s is not a regular file", filepath.Base(f.TargetPath)) - } - return nil, fmt.Errorf("record installed update: publish receipt is missing for %s", filepath.Base(f.TargetPath)) - } - if len(receiptStates) != 0 { - return nil, fmt.Errorf("record installed update: publish receipt target is outside the release unit") - } - for i, f := range current.Files { - if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, record.InstalledStateIDs[i]); err != nil { - return nil, fmt.Errorf("record installed update: release unit changed while recording: %w", err) - } - } - if err := createInstalledFileUpdateState(current, record); err != nil { - return nil, fmt.Errorf("record installed update: %w", err) - } - installedUpdateAfterCreate(installedFileUpdateStatePath(current)) - latest, err := readPendingUpdateForLauncher(claimed.TargetPath) - if err != nil { - return nil, fmt.Errorf("record installed update: re-read pending transaction: %w", err) - } - if !reflect.DeepEqual(current, latest) { - return nil, fmt.Errorf("record installed update: pending transaction changed") - } - if _, _, err := installedFileUpdateTargets(latest, true); err != nil { - return nil, fmt.Errorf("record installed update: %w", err) - } - return latest, nil -} - func installedFileUpdateStatePath(tx *UpdateTransaction) string { if tx == nil { return "" @@ -1205,31 +898,6 @@ func removeInstalledFileUpdateState(tx *UpdateTransaction) error { }, false) } -// CancelPendingAppBundleUpdateHandoff abandons an exact handoff only when the -// original installed bundle is still the tree captured during prepare. This is -// the safe recovery path when source verification fails after the desktop has -// exited but before any bundle swap occurred. -func CancelPendingAppBundleUpdateHandoff( - expectedToVersion, expectedCreatedAt string, - timeout time.Duration, -) (*UpdateTransaction, error) { - tx, err := ReadPendingUpdate() - if err != nil { - return nil, fmt.Errorf("cancel update handoff: read pending transaction: %w", err) - } - if tx.TargetKind != "app-bundle" || - strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) || - strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) { - return nil, fmt.Errorf("cancel update handoff: pending transaction does not match") - } - return cancelPendingAppBundleUpdateHandoff( - expectedToVersion, - expectedCreatedAt, - timeout, - UpdateTransactionID(tx), - ) -} - // CancelPendingAppBundleUpdateHandoffExact abandons only the full transaction // read or prepared by the caller. It is safe to use after a PID wait or failed // claim where pending state may have been rewritten with copied scalar IDs. @@ -1956,11 +1624,6 @@ func readPendingUpdateUnchecked() (*UpdateTransaction, error) { return &tx, nil } -func HasPendingUpdate() bool { - _, err := ReadPendingUpdate() - return err == nil -} - // PendingUpdateExists reports the on-disk marker even when its contents are // malformed. It is intended only for progress/UI decisions; callers must use // ReadPendingUpdate or ReconcilePendingUpdate before authorizing mutations. @@ -2127,40 +1790,6 @@ func CommitProbationaryPendingUpdate(runningVersion string) (bool, error) { return !PendingUpdateExists(), nil } -// AbandonPendingUpdate is the user-initiated recovery path for a stuck -// transaction: commit if possible, else reconcile, else force-retire. -func AbandonPendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) { - committed, commitErr := CommitProbationaryPendingUpdate(runningVersion) - if commitErr == nil && committed { - return PendingUpdateReconcileResult{Cleared: true, Healthy: true}, nil - } - if commitErr != nil { - // Keep going: a drifted backup must not block explicit discard. - slog.Debug("repair: probationary commit during abandon failed; continuing", - "err", commitErr) - } - result, reconcileErr := ReconcilePendingUpdate(runningVersion) - if reconcileErr == nil { - return result, nil - } - // Force-retire when still AwaitingHealth with the live target installed. - if errors.Is(reconcileErr, ErrPendingUpdateAwaitingHealth) { - if retired, retireErr := forceRetireProbationaryPendingUpdate(runningVersion); retireErr != nil { - return result, fmt.Errorf("abandon pending update: %w", retireErr) - } else if retired { - result.Pending = false - result.AwaitingHealth = false - result.Healthy = true - result.Cleared = true - return result, nil - } - } - if commitErr != nil && reconcileErr != nil { - return result, fmt.Errorf("abandon pending update: %w", errors.Join(reconcileErr, commitErr)) - } - return result, reconcileErr -} - // forceRetireProbationaryPendingUpdate retires a probationary marker when the // live target is installed but MarkUpdateHealthy cannot finish (e.g. bad backup). func forceRetireProbationaryPendingUpdate(runningVersion string) (bool, error) { @@ -2287,17 +1916,6 @@ func MarkUpdateHealthy(runningVersion string) error { return markUpdateHealthyInvocation(runningVersion, "", "") } -// MarkUpdateHealthyMatching commits only the exact pending transaction observed -// when this desktop process started. The creation identity prevents an older -// process from blessing a later same-version retry. -func MarkUpdateHealthyMatching(runningVersion, expectedCreatedAt string) error { - expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) - if expectedCreatedAt == "" { - return nil - } - return markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, "") -} - // MarkUpdateHealthyExact commits only the complete transaction captured before // the replacement process started. func MarkUpdateHealthyExact(runningVersion, expectedCreatedAt, expectedTransactionID string) error { @@ -2415,17 +2033,6 @@ func markUpdateHealthyMatching(runningVersion, expectedCreatedAt, expectedTransa return nil } -// CancelPendingUpdateMatching removes only the exact transaction prepared by -// the caller. It is used by updater failure paths where a same-version retry can -// replace pending-update.json before cleanup runs. -func CancelPendingUpdateMatching(toVersion, expectedCreatedAt string) error { - expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) - if expectedCreatedAt == "" { - return nil - } - return cancelPendingUpdateInvocation(toVersion, expectedCreatedAt, "") -} - // CancelPendingUpdateExact removes only the complete transaction returned by // prepare. This is the updater failure path: copied creation timestamps are not // sufficient authorization if pending-update.json itself was rewritten. @@ -2630,18 +2237,6 @@ func RollbackPendingUpdate() (UpdateRollbackResult, error) { return rollbackPendingUpdateInvocation("", "", "") } -// RollbackPendingUpdateMatching rolls back only the exact transaction prepared -// by the caller. This is used when an apply attempt fails after another process -// may already have replaced pending-update.json with a same-version retry. -func RollbackPendingUpdateMatching(expectedToVersion, expectedCreatedAt string) (UpdateRollbackResult, error) { - expectedToVersion = strings.TrimSpace(expectedToVersion) - expectedCreatedAt = strings.TrimSpace(expectedCreatedAt) - if expectedToVersion == "" || expectedCreatedAt == "" { - return UpdateRollbackResult{}, fmt.Errorf("rollback update: transaction identity is incomplete") - } - return rollbackPendingUpdateInvocation(expectedToVersion, expectedCreatedAt, "") -} - func rollbackPendingUpdateState(expectedStateID string, expectedStates map[string]string) (UpdateRollbackResult, error) { return rollbackPendingUpdateMatching("", "", expectedStateID, expectedStates, "", true) } diff --git a/internal/platform/update/apply_other.go b/internal/platform/update/apply_other.go index e698156cbc..adbc13b067 100644 --- a/internal/platform/update/apply_other.go +++ b/internal/platform/update/apply_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !linux && !darwin +//go:build !linux && !darwin package update diff --git a/internal/platform/update/apply_windows.go b/internal/platform/update/apply_windows.go deleted file mode 100644 index 167a449fe9..0000000000 --- a/internal/platform/update/apply_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build windows - -package update - -import ( - "context" - "fmt" -) - -// apply hands the verified installer to the update helper. Windows cannot -// replace a running executable, so the install happens after this process -// exits — the helper is what survives to observe whether it worked. -func (v VersionedInstaller) apply(_ context.Context, c Cached) error { - if !v.Layout.Versioned() { - return fmt.Errorf("update: %s predates the versioned layout; reinstall from the download page", v.Layout.Root) - } - return WindowsHandoff{ - InstallerPath: c.Path, - InstallerSHA256: c.SHA256, - InstallDir: v.Layout.Root, - RelaunchPath: v.Layout.Launcher, - StagingDir: v.Staging, - }.StartVersioned(c.Version) -} diff --git a/internal/platform/update/install.go b/internal/platform/update/install.go deleted file mode 100644 index 5b0bf634e2..0000000000 --- a/internal/platform/update/install.go +++ /dev/null @@ -1,12 +0,0 @@ -package update - -// WindowsHandoff is what the update helper needs to finish an install after -// this process is gone. A value, not five positional strings: every field is a -// path, and swapping two of them compiles. -type WindowsHandoff struct { - InstallerPath string // verified artifact in the update cache - InstallerSHA256 string // digest the helper re-checks before running it - InstallDir string // InstallRoot: owns current.json, or the flat dir - RelaunchPath string // launcher to start once the install lands - StagingDir string // where the verified helper copy is placed -} diff --git a/internal/platform/update/install_args.go b/internal/platform/update/install_args.go deleted file mode 100644 index f443652f42..0000000000 --- a/internal/platform/update/install_args.go +++ /dev/null @@ -1,52 +0,0 @@ -package update - -import ( - "fmt" - "strconv" -) - -func installerCommandLine(installer, dir string) string { - // Keep this in sync with cmd/update-helper/args.go. The desktop uses it for - // its Windows command invariant tests; the copied helper extracts to a - // transaction-owned staging directory after the desktop exits. - line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer) - if dir != "" { - line += " /D=" + dir - } - return line -} - -func windowsUpdateHandoffArgs(parentPID int, installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string) []string { - args := []string{ - "--parent-pid", strconv.Itoa(parentPID), - "--installer", installer, - "--installer-sha256", installerSHA256, - "--to-version", toVersion, - "--created-at", createdAt, - "--transaction-id", transactionID, - } - if installDir != "" { - args = append(args, "--install-dir", installDir) - } - if relaunch != "" { - args = append(args, "--relaunch", relaunch) - } - return args -} - -func windowsVersionedUpdateHandoffArgs(parentPID int, installer, installerSHA256, installDir, relaunch, toVersion string) []string { - args := []string{ - "--parent-pid", strconv.Itoa(parentPID), - "--installer", installer, - "--installer-sha256", installerSHA256, - "--to-version", toVersion, - "--install-layout", "versioned-v1", - } - if installDir != "" { - args = append(args, "--install-dir", installDir) - } - if relaunch != "" { - args = append(args, "--relaunch", relaunch) - } - return args -} diff --git a/internal/platform/update/install_args_test.go b/internal/platform/update/install_args_test.go deleted file mode 100644 index 5ad757c247..0000000000 --- a/internal/platform/update/install_args_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package update - -import ( - "os" - "strings" - "testing" -) - -func TestInstallerCommandLineUsesVisibleUpdateModeAndKeepsDFlagLast(t *testing.T) { - got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`) - want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App` - if got != want { - t.Fatalf("installerCommandLine = %q, want %q", got, want) - } - if strings.Contains(got, " /S") { - t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got) - } - if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) { - t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got) - } -} - -func TestWindowsUpdateHandoffArgsCarryParentInstallAndRelaunch(t *testing.T) { - got := windowsUpdateHandoffArgs( - 4242, - `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`, - strings.Repeat("a", 64), - `D:\Tools\Reasonix App`, - `D:\Tools\Reasonix App\reasonix-desktop.exe`, - "v1.6.0", - "2026-07-29T00:00:00Z", - "transaction-1", - ) - want := []string{ - "--parent-pid", "4242", - "--installer", `C:\Users\Jane Doe\AppData\Local\Reasonix\updates\Reasonix-windows-amd64-installer.exe`, - "--installer-sha256", strings.Repeat("a", 64), - "--to-version", "v1.6.0", - "--created-at", "2026-07-29T00:00:00Z", - "--transaction-id", "transaction-1", - "--install-dir", `D:\Tools\Reasonix App`, - "--relaunch", `D:\Tools\Reasonix App\reasonix-desktop.exe`, - } - if strings.Join(got, "\x00") != strings.Join(want, "\x00") { - t.Fatalf("args = %#v, want %#v", got, want) - } -} - -func TestWindowsVersionedUpdateHandoffArgsDoNotRequireLegacyPendingIdentity(t *testing.T) { - got := windowsVersionedUpdateHandoffArgs( - 4242, - `C:\Temp\Reasonix-installer.exe`, - strings.Repeat("b", 64), - `D:\Tools\Reasonix`, - `D:\Tools\Reasonix\reasonix-launcher.exe`, - "v1.20.0", - ) - want := []string{ - "--parent-pid", "4242", - "--installer", `C:\Temp\Reasonix-installer.exe`, - "--installer-sha256", strings.Repeat("b", 64), - "--to-version", "v1.20.0", - "--install-layout", "versioned-v1", - "--install-dir", `D:\Tools\Reasonix`, - "--relaunch", `D:\Tools\Reasonix\reasonix-launcher.exe`, - } - if strings.Join(got, "\x00") != strings.Join(want, "\x00") { - t.Fatalf("args = %#v, want %#v", got, want) - } - for _, legacy := range []string{"--created-at", "--transaction-id"} { - if strings.Contains(strings.Join(got, " "), legacy) { - t.Fatalf("versioned handoff must not carry legacy field %s", legacy) - } - } -} - -func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) { - data, err := os.ReadFile("install_windows.go") - if err != nil { - t.Fatal(err) - } - source := string(data) - if !strings.Contains(source, "return startWindowsUpdateHelper(") { - t.Fatal("Windows update handoff does not require the observed helper path") - } - if strings.Contains(source, "return installerCommand(") { - t.Fatal("Windows update silently falls back to an unobserved installer") - } - if !strings.Contains(source, "cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}") { - t.Fatal("Windows handoff helper should stay hidden while NSIS shows update progress") - } - // The helper is still built by Studio's release line and so still lives in - // the desktop module; this contract spans both sides of that line. - helperData, err := os.ReadFile("../../../desktop/cmd/update-helper/main_windows.go") - if err != nil { - t.Fatal(err) - } - helperSource := string(helperData) - if !strings.Contains(helperSource, "reconcileWindowsUninstallRegistrationFn(installDir, toVersion)") { - t.Fatal("versioned Windows activation must refresh its managed uninstall registration") - } - if strings.Contains(helperSource, "installerCommandLine(installer, installDir), HideWindow: true") { - t.Fatal("update helper still hides the NSIS progress window") - } -} diff --git a/internal/platform/update/install_other.go b/internal/platform/update/install_other.go deleted file mode 100644 index 7cdf2535b2..0000000000 --- a/internal/platform/update/install_other.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build !windows - -package update - -import ( - "os/exec" - - "reasonix/internal/platform/repair" -) - -// installerCommand exists only so the handoff compiles off Windows; the Windows -// apply path is never dispatched there. -func installerCommand(name, _ string) *exec.Cmd { - return exec.Command(name) -} - -func (h WindowsHandoff) Start(_ *repair.UpdateTransaction) error { - return installerCommand(h.InstallerPath, h.InstallDir).Start() -} - -func (h WindowsHandoff) StartVersioned(_ string) error { - return installerCommand(h.InstallerPath, h.InstallDir).Start() -} diff --git a/internal/platform/update/install_windows.go b/internal/platform/update/install_windows.go deleted file mode 100644 index 4493c6103e..0000000000 --- a/internal/platform/update/install_windows.go +++ /dev/null @@ -1,327 +0,0 @@ -//go:build windows - -package update - -import ( - "bytes" - "crypto/sha256" - "debug/pe" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "syscall" - "time" - - "golang.org/x/sys/windows" - - "reasonix/internal/platform/installlayout" - "reasonix/internal/platform/repair" -) - -// resolveWindowsUpdateHelperSource finds the on-disk helper for the running -// install: versioned active dir first, then the flat InstallRoot layout. -func resolveWindowsUpdateHelperSource(installDir string) string { - if path, err := installlayout.ActiveUpdateHelperPath(installDir); err == nil { - return path - } - return filepath.Join(installDir, windowsUpdateHelperFileName) -} - -const windowsUpdateHelperFileName = "reasonix-update-helper.exe" - -var claimWindowsUpdateHelperExecutionFn = claimVerifiedWindowsUpdateHelperExecution - -// installerCommand runs the NSIS updater in its visible, progress-only staging -// mode, forcing $INSTDIR to dir via /D= so the signed payload is extracted away -// from the live install. NSIS requires /D= to be the final, unquoted token taken -// verbatim to the end of the line, so the raw command line is set directly — -// exec.Command would quote a path containing spaces (e.g. C:\Users\Jane Doe\...) -// and NSIS would then mis-parse the target directory. -func installerCommand(name, dir string) *exec.Cmd { - cmd := exec.Command(name) - cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(name, dir)} - return cmd -} - -// Start hands a legacy flat install to the helper — the only process left to -// observe an installer failure after the desktop exits. Starting NSIS directly -// would make a partial install look like a successful handoff, so a missing or -// quarantined helper fails here instead. -func (h WindowsHandoff) Start(prepared *repair.UpdateTransaction) error { - return startWindowsUpdateHelper(h, prepared) -} - -// StartVersioned hands a versioned-v1 install to the helper. It needs no -// prepared transaction: the version directory is published beside the running -// one and current.json swaps last, so there is nothing to roll back by hand. -func (h WindowsHandoff) StartVersioned(targetVersion string) error { - if h.InstallDir == "" { - return os.ErrNotExist - } - helperPath, helperSHA256, err := prepareVersionedWindowsUpdateHelper(h.InstallDir, h.StagingDir) - if err != nil { - return err - } - releaseExecution, err := claimWindowsUpdateHelperExecutionFn(helperPath, helperSHA256) - if err != nil { - return fmt.Errorf("claim copied Windows update helper: %w", err) - } - defer releaseExecution() - err = retryWindowsUpdateHelperStart(func() error { - cmd := exec.Command(helperPath, windowsVersionedUpdateHandoffArgs( - os.Getpid(), h.InstallerPath, h.InstallerSHA256, h.InstallDir, h.RelaunchPath, targetVersion, - )...) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - return cmd.Start() - }) - if err != nil { - return windowsUpdateHelperStartError(err) - } - return nil -} - -func startWindowsUpdateHelper(h WindowsHandoff, prepared *repair.UpdateTransaction) error { - if h.InstallDir == "" { - return os.ErrNotExist - } - preparedHelperSHA256, err := preparedWindowsUpdateHelperSHA256(prepared) - if err != nil { - return err - } - helperPath, helperSHA256, err := prepareWindowsUpdateHelper(h.InstallDir, h.StagingDir, preparedHelperSHA256) - if err != nil { - return err - } - releaseExecution, err := claimWindowsUpdateHelperExecutionFn(helperPath, helperSHA256) - if err != nil { - return fmt.Errorf("claim copied Windows update helper: %w", err) - } - defer releaseExecution() - err = retryWindowsUpdateHelperStart(func() error { - cmd := exec.Command(helperPath, windowsUpdateHandoffArgs( - os.Getpid(), - h.InstallerPath, - h.InstallerSHA256, - h.InstallDir, - h.RelaunchPath, - prepared.ToVersion, - prepared.CreatedAt, - repair.UpdateTransactionID(prepared), - )...) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - return cmd.Start() - }) - if err != nil { - return windowsUpdateHelperStartError(err) - } - return nil -} - -func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction) (string, error) { - if prepared == nil || prepared.TargetKind != "file" || repair.UpdateTransactionID(prepared) == "" { - return "", fmt.Errorf("prepare Windows update helper: transaction identity is incomplete") - } - // Match by basename so versioned layouts (versions//helper) and flat - // layouts (InstallRoot/helper) share one prepare path. - for _, file := range prepared.Files { - if !strings.EqualFold(filepath.Base(file.TargetPath), windowsUpdateHelperFileName) { - continue - } - if file.MissingBefore || !validWindowsSHA256(file.SHA256) { - return "", fmt.Errorf("prepare Windows update helper: prepared helper identity is incomplete") - } - return strings.TrimSpace(file.SHA256), nil - } - return "", fmt.Errorf("prepare Windows update helper: helper is outside the prepared release unit") -} - -func validWindowsSHA256(value string) bool { - value = strings.TrimSpace(value) - if len(value) != sha256.Size*2 { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -func prepareWindowsUpdateHelper(installDir, stagingDir, preparedSHA256 string) (string, [sha256.Size]byte, error) { - data, err := os.ReadFile(resolveWindowsUpdateHelperSource(installDir)) - if err != nil { - return "", [sha256.Size]byte{}, err - } - expectedSHA256 := sha256.Sum256(data) - // Identity before shape: a helper that drifted from the prepared transaction - // is the specific failure, and saying so beats "invalid PE image". - if !strings.EqualFold(hex.EncodeToString(expectedSHA256[:]), strings.TrimSpace(preparedSHA256)) { - return "", [sha256.Size]byte{}, fmt.Errorf("packaged Windows update helper changed after transaction prepare") - } - return stageValidatedWindowsUpdateHelper(data, expectedSHA256, stagingDir) -} - -func prepareVersionedWindowsUpdateHelper(installDir, stagingDir string) (string, [sha256.Size]byte, error) { - data, err := os.ReadFile(resolveWindowsUpdateHelperSource(installDir)) - if err != nil { - return "", [sha256.Size]byte{}, err - } - return stageValidatedWindowsUpdateHelper(data, sha256.Sum256(data), stagingDir) -} - -func stageValidatedWindowsUpdateHelper(data []byte, sum [sha256.Size]byte, stagingDir string) (string, [sha256.Size]byte, error) { - if err := validateWindowsUpdateHelper(data, runtime.GOARCH); err != nil { - return "", [sha256.Size]byte{}, fmt.Errorf("validate packaged Windows update helper: %w", err) - } - dst, err := stageWindowsUpdateHelperCopy(stagingDir, data) - if err != nil { - return "", [sha256.Size]byte{}, err - } - return dst, sum, nil -} - -func stageWindowsUpdateHelperCopy(dir string, data []byte) (string, error) { - staged, err := os.CreateTemp(dir, "reasonix-update-helper-*.exe") - if err != nil { - return "", err - } - dst := staged.Name() - fail := func(err error) (string, error) { - _ = staged.Close() - // Preserve the exclusively-created node on failure. A path-based remove - // after close could delete an unrelated replacement. - return "", err - } - if _, err := staged.Write(data); err != nil { - return fail(err) - } - if err := staged.Sync(); err != nil { - return fail(err) - } - if err := staged.Chmod(0o700); err != nil { - return fail(err) - } - if err := staged.Close(); err != nil { - return "", err - } - return dst, nil -} - -func claimVerifiedWindowsUpdateHelperExecution(path string, expectedSHA256 [sha256.Size]byte) (func(), error) { - pathUTF16, err := windows.UTF16PtrFromString(path) - if err != nil { - return nil, err - } - handle, err := windows.CreateFile( - pathUTF16, - windows.GENERIC_READ, - windows.FILE_SHARE_READ, - nil, - windows.OPEN_EXISTING, - windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN, - 0, - ) - if err != nil { - return nil, err - } - file := os.NewFile(uintptr(handle), path) - fail := func(err error) (func(), error) { - _ = file.Close() - return nil, err - } - info, err := file.Stat() - if err != nil { - return fail(err) - } - if !info.Mode().IsRegular() { - return fail(fmt.Errorf("copied Windows update helper is not a regular file")) - } - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return fail(err) - } - if !bytes.Equal(hash.Sum(nil), expectedSHA256[:]) { - return fail(fmt.Errorf("copied Windows update helper changed before execution")) - } - var once sync.Once - return func() { - once.Do(func() { _ = file.Close() }) - }, nil -} - -func validateWindowsUpdateHelper(data []byte, goarch string) error { - f, err := pe.NewFile(bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("invalid PE image: %w", err) - } - defer f.Close() - want, ok := windowsPEMachine(goarch) - if !ok { - return fmt.Errorf("unsupported Windows architecture %q", goarch) - } - if f.Machine != want { - return fmt.Errorf("PE machine 0x%x does not match %s", f.Machine, goarch) - } - return nil -} - -func windowsPEMachine(goarch string) (uint16, bool) { - switch goarch { - case "amd64": - return pe.IMAGE_FILE_MACHINE_AMD64, true - case "arm64": - return pe.IMAGE_FILE_MACHINE_ARM64, true - case "386": - return pe.IMAGE_FILE_MACHINE_I386, true - default: - return 0, false - } -} - -const windowsHelperStartAttempts = 3 - -var windowsHelperStartBackoff = func(attempt int) time.Duration { - return time.Duration(attempt) * 250 * time.Millisecond -} - -func retryWindowsUpdateHelperStart(start func() error) error { - var err error - for attempt := 1; attempt <= windowsHelperStartAttempts; attempt++ { - if err = start(); err == nil { - return nil - } - if !isRetryableWindowsHelperStartError(err) || attempt == windowsHelperStartAttempts { - break - } - time.Sleep(windowsHelperStartBackoff(attempt)) - } - return err -} - -func isRetryableWindowsHelperStartError(err error) bool { - return errors.Is(err, windows.ERROR_ACCESS_DENIED) || - errors.Is(err, windows.ERROR_SHARING_VIOLATION) || - errors.Is(err, windows.ERROR_LOCK_VIOLATION) -} - -func windowsUpdateHelperStartError(err error) error { - switch { - case errors.Is(err, windows.ERROR_FILE_NOT_FOUND), errors.Is(err, windows.ERROR_PATH_NOT_FOUND): - return fmt.Errorf("start Windows update helper: the helper disappeared after verification; security software may have quarantined it") - case errors.Is(err, windows.ERROR_BAD_EXE_FORMAT): - return fmt.Errorf("start Windows update helper: Windows rejected the helper as corrupt or incompatible") - case errors.Is(err, windows.ERROR_ELEVATION_REQUIRED): - return fmt.Errorf("start Windows update helper: Windows unexpectedly requested administrator elevation") - case errors.Is(err, windows.ERROR_ACCESS_DENIED): - return fmt.Errorf("start Windows update helper: Windows or security software denied process creation") - } - var errno syscall.Errno - if errors.As(err, &errno) { - return fmt.Errorf("start Windows update helper: Windows error %w (%s)", errno, errno.Error()) - } - return fmt.Errorf("start Windows update helper: process creation failed") -} diff --git a/internal/platform/update/install_windows_test.go b/internal/platform/update/install_windows_test.go deleted file mode 100644 index d038563c6c..0000000000 --- a/internal/platform/update/install_windows_test.go +++ /dev/null @@ -1,197 +0,0 @@ -//go:build windows - -package update - -import ( - "crypto/sha256" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "golang.org/x/sys/windows" - - "reasonix/internal/platform/repair" - - "reasonix/internal/base/tempdir" -) - -func TestInstallerCommandShowsUpdateProgressAndPassesUnquotedDFlagLast(t *testing.T) { - cmd := installerCommand(`C:\Temp\reasonix-update-1.exe`, `D:\Tools\Reasonix App`) - if cmd.SysProcAttr == nil { - t.Fatal("expected a raw command line forcing the install dir") - } - got := cmd.SysProcAttr.CmdLine - want := `"C:\Temp\reasonix-update-1.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App` - if got != want { - t.Fatalf("CmdLine = %q, want %q", got, want) - } - if cmd.SysProcAttr.HideWindow { - t.Fatal("NSIS update progress window must remain visible") - } -} - -func TestInstallerCommandWithoutDirSkipsDFlag(t *testing.T) { - cmd := installerCommand(`C:\Temp\reasonix-update-1.exe`, "") - if cmd.SysProcAttr == nil { - t.Fatal("expected a raw command line for visible updater installs") - } - got := cmd.SysProcAttr.CmdLine - want := `"C:\Temp\reasonix-update-1.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1` - if got != want { - t.Fatalf("CmdLine = %q, want %q", got, want) - } -} - -func TestWindowsHelperStartRetriesTransientSecurityScan(t *testing.T) { - originalBackoff := windowsHelperStartBackoff - windowsHelperStartBackoff = func(int) time.Duration { return 0 } - t.Cleanup(func() { windowsHelperStartBackoff = originalBackoff }) - - calls := 0 - err := retryWindowsUpdateHelperStart(func() error { - calls++ - if calls < 3 { - return &os.PathError{Op: "fork/exec", Path: `C:\private\helper.exe`, Err: windows.ERROR_SHARING_VIOLATION} - } - return nil - }) - if err != nil { - t.Fatalf("retryWindowsUpdateHelperStart: %v", err) - } - if calls != 3 { - t.Fatalf("start calls = %d, want 3", calls) - } -} - -func TestWindowsHelperStartErrorIsActionableAndPathSafe(t *testing.T) { - raw := &os.PathError{Op: "fork/exec", Path: `C:\Users\Private\helper.exe`, Err: windows.ERROR_ACCESS_DENIED} - err := windowsUpdateHelperStartError(raw) - if !strings.Contains(err.Error(), "security software") { - t.Fatalf("error is not actionable: %v", err) - } - if strings.Contains(err.Error(), `C:\Users`) { - t.Fatalf("error leaks local path: %v", err) - } - if !errors.Is(raw, windows.ERROR_ACCESS_DENIED) { - t.Fatal("test setup does not expose the wrapped Windows error") - } -} - -func TestWindowsPEMachineMatchesSupportedArchitectures(t *testing.T) { - for _, arch := range []string{"amd64", "arm64", "386"} { - if machine, ok := windowsPEMachine(arch); !ok || machine == 0 { - t.Fatalf("windowsPEMachine(%q) = (0x%x, %v)", arch, machine, ok) - } - } - if _, ok := windowsPEMachine("mips"); ok { - t.Fatal("unsupported architecture was accepted") - } - if err := validateWindowsUpdateHelper([]byte("not a PE image"), "amd64"); err == nil { - t.Fatal("malformed helper image was accepted") - } -} - -func TestClaimVerifiedWindowsUpdateHelperExecutionFreezesPath(t *testing.T) { - path := filepath.Join(tempdir.New(t), "reasonix-update-helper.exe") - content := []byte("verified-helper") - if err := os.WriteFile(path, content, 0o700); err != nil { - t.Fatal(err) - } - expected := sha256.Sum256(content) - release, err := claimVerifiedWindowsUpdateHelperExecution(path, expected) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil { - release() - t.Fatal("copied helper remained writable while execution claim was held") - } - if err := os.Rename(path, path+".replaced"); err == nil { - release() - t.Fatal("copied helper remained renameable while execution claim was held") - } - release() - release() - if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil { - t.Fatalf("copied helper remained frozen after claim release: %v", err) - } -} - -func TestClaimVerifiedWindowsUpdateHelperExecutionRejectsHashDrift(t *testing.T) { - path := filepath.Join(tempdir.New(t), "reasonix-update-helper.exe") - if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil { - t.Fatal(err) - } - if release, err := claimVerifiedWindowsUpdateHelperExecution(path, sha256.Sum256([]byte("expected"))); err == nil { - release() - t.Fatal("copied helper with the wrong SHA-256 received an execution claim") - } -} - -func TestStageWindowsUpdateHelperCopyAllocatesExclusiveNodes(t *testing.T) { - dir := tempdir.New(t) - content := []byte("verified-helper") - first, err := stageWindowsUpdateHelperCopy(dir, content) - if err != nil { - t.Fatal(err) - } - second, err := stageWindowsUpdateHelperCopy(dir, content) - if err != nil { - t.Fatal(err) - } - if first == second { - t.Fatalf("exclusive helper copies reused path %q", first) - } - for _, path := range []string{first, second} { - got, err := os.ReadFile(path) - if err != nil || string(got) != string(content) { - t.Fatalf("staged helper %q = %q, %v", path, got, err) - } - } -} - -func TestPreparedWindowsUpdateHelperSHA256BindsReleaseUnitMember(t *testing.T) { - installDir := tempdir.New(t) - helper := filepath.Join(installDir, windowsUpdateHelperFileName) - expected := strings.Repeat("a", sha256.Size*2) - prepared := &repair.UpdateTransaction{ - SchemaVersion: 1, - TargetKind: "file", - TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), - ToVersion: "v2", - Platform: "windows/amd64", - CreatedAt: "2026-07-29T00:00:00Z", - Files: []repair.UpdateTransactionFile{{ - TargetPath: helper, - SHA256: expected, - }}, - } - got, err := preparedWindowsUpdateHelperSHA256(prepared) - if err != nil || got != expected { - t.Fatalf("prepared helper SHA-256 = %q, %v", got, err) - } - prepared.Files[0].MissingBefore = true - if _, err := preparedWindowsUpdateHelperSHA256(prepared); err == nil { - t.Fatal("prepared transaction with a missing helper authorized execution") - } -} - -func TestPrepareWindowsUpdateHelperRejectsPreparedHashDrift(t *testing.T) { - installDir := tempdir.New(t) - if err := os.WriteFile( - filepath.Join(installDir, windowsUpdateHelperFileName), - []byte("changed-helper"), - 0o700, - ); err != nil { - t.Fatal(err) - } - prepared := sha256.Sum256([]byte("prepared-helper")) - _, _, err := prepareWindowsUpdateHelper(installDir, tempdir.New(t), fmt.Sprintf("%x", prepared)) - if err == nil || !strings.Contains(err.Error(), "changed after transaction prepare") { - t.Fatalf("changed packaged helper = %v", err) - } -} diff --git a/internal/platform/update/windows_payload.go b/internal/platform/update/windows_payload.go deleted file mode 100644 index 784958e93f..0000000000 --- a/internal/platform/update/windows_payload.go +++ /dev/null @@ -1,128 +0,0 @@ -package update - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "strings" -) - -const ( - WindowsPayloadManifestSchemaVersion = 1 - WindowsPayloadManifestName = "reasonix-payload.json" - WindowsPayloadSignatureName = WindowsPayloadManifestName + ".minisig" -) - -var windowsPayloadFileNames = [...]string{ - "reasonix-desktop.exe", - "reasonix-guard.exe", - "reasonix-launcher.exe", - "reasonix-update-helper.exe", - "reasonix-cli.exe", -} - -type WindowsPayloadManifest struct { - SchemaVersion int `json:"schemaVersion"` - Version string `json:"version"` - Files []WindowsPayloadManifestFile `json:"files"` -} - -type WindowsPayloadManifestFile struct { - Name string `json:"name"` - SHA256 string `json:"sha256"` -} - -func WindowsPayloadFileNames() []string { - return append([]string(nil), windowsPayloadFileNames[:]...) -} - -func EncodeWindowsPayloadManifest(version string, hashes map[string]string) ([]byte, error) { - version = strings.TrimSpace(version) - if version == "" { - return nil, fmt.Errorf("Windows payload manifest version is empty") - } - manifest := WindowsPayloadManifest{ - SchemaVersion: WindowsPayloadManifestSchemaVersion, - Version: version, - Files: make([]WindowsPayloadManifestFile, 0, len(windowsPayloadFileNames)), - } - for _, name := range windowsPayloadFileNames { - hash := strings.ToLower(strings.TrimSpace(hashes[name])) - if !validWindowsPayloadSHA256(hash) { - return nil, fmt.Errorf("Windows payload manifest hash for %s is invalid", name) - } - manifest.Files = append(manifest.Files, WindowsPayloadManifestFile{ - Name: name, - SHA256: hash, - }) - } - if len(hashes) != len(manifest.Files) { - return nil, fmt.Errorf("Windows payload manifest contains unexpected files") - } - b, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return nil, err - } - return append(b, '\n'), nil -} - -func DecodeWindowsPayloadManifest(data []byte, expectedVersion string) (map[string]string, error) { - var manifest WindowsPayloadManifest - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(&manifest); err != nil { - return nil, fmt.Errorf("decode Windows payload manifest: %w", err) - } - var trailing any - if err := dec.Decode(&trailing); err != io.EOF { - if err == nil { - return nil, fmt.Errorf("decode Windows payload manifest: trailing JSON value") - } - return nil, fmt.Errorf("decode Windows payload manifest: %w", err) - } - expectedVersion = strings.TrimSpace(expectedVersion) - if manifest.SchemaVersion != WindowsPayloadManifestSchemaVersion || - expectedVersion == "" || - manifest.Version != expectedVersion { - return nil, fmt.Errorf("Windows payload manifest identity does not match the pending update") - } - expected := make(map[string]struct{}, len(windowsPayloadFileNames)) - for _, name := range windowsPayloadFileNames { - expected[name] = struct{}{} - } - hashes := make(map[string]string, len(manifest.Files)) - for _, file := range manifest.Files { - name := file.Name - hash := file.SHA256 - if _, ok := expected[name]; !ok || !validWindowsPayloadSHA256(hash) { - return nil, fmt.Errorf("Windows payload manifest member is invalid") - } - if _, duplicate := hashes[name]; duplicate { - return nil, fmt.Errorf("Windows payload manifest contains duplicate members") - } - hashes[name] = hash - } - if len(hashes) != len(expected) { - return nil, fmt.Errorf("Windows payload manifest is incomplete") - } - return hashes, nil -} - -func WindowsPayloadSHA256(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -func validWindowsPayloadSHA256(value string) bool { - if len(value) != sha256.Size*2 { - return false - } - if value != strings.ToLower(value) { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} diff --git a/internal/platform/update/windows_payload_test.go b/internal/platform/update/windows_payload_test.go deleted file mode 100644 index e82b676cde..0000000000 --- a/internal/platform/update/windows_payload_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package update - -import ( - "strings" - "testing" -) - -func TestWindowsPayloadManifestRoundTripBindsVersionAndExactMembers(t *testing.T) { - hashes := make(map[string]string, len(windowsPayloadFileNames)) - for _, name := range windowsPayloadFileNames { - hashes[name] = WindowsPayloadSHA256([]byte(name)) - } - b, err := EncodeWindowsPayloadManifest("v2.0.0", hashes) - if err != nil { - t.Fatal(err) - } - decoded, err := DecodeWindowsPayloadManifest(b, "v2.0.0") - if err != nil { - t.Fatal(err) - } - if len(decoded) != len(hashes) { - t.Fatalf("decoded members = %d, want %d", len(decoded), len(hashes)) - } - if _, err := DecodeWindowsPayloadManifest(b, "v2.0.1"); err == nil { - t.Fatal("payload manifest authorized a different release version") - } -} - -func TestWindowsPayloadManifestRejectsUnknownOrMissingMembers(t *testing.T) { - hashes := make(map[string]string, len(windowsPayloadFileNames)) - for _, name := range windowsPayloadFileNames { - hashes[name] = strings.Repeat("a", 64) - } - delete(hashes, windowsPayloadFileNames[0]) - if _, err := EncodeWindowsPayloadManifest("v2", hashes); err == nil { - t.Fatal("incomplete payload manifest encoded successfully") - } - hashes[windowsPayloadFileNames[0]] = strings.Repeat("a", 64) - hashes["other.exe"] = strings.Repeat("b", 64) - if _, err := EncodeWindowsPayloadManifest("v2", hashes); err == nil { - t.Fatal("payload manifest with an extra member encoded successfully") - } -} - -func TestWindowsPayloadManifestRejectsDuplicateAndTrailingData(t *testing.T) { - hashes := make(map[string]string, len(windowsPayloadFileNames)) - for _, name := range windowsPayloadFileNames { - hashes[name] = strings.Repeat("a", 64) - } - b, err := EncodeWindowsPayloadManifest("v2", hashes) - if err != nil { - t.Fatal(err) - } - duplicate := strings.Replace( - string(b), - `"files": [`, - `"files": [{"name":"reasonix-desktop.exe","sha256":"`+strings.Repeat("a", 64)+`"},`, - 1, - ) - if _, err := DecodeWindowsPayloadManifest([]byte(duplicate), "v2"); err == nil { - t.Fatal("duplicate payload member was accepted") - } - if _, err := DecodeWindowsPayloadManifest(append(b, []byte(`{}`)...), "v2"); err == nil { - t.Fatal("trailing JSON value was accepted") - } -} - -func TestWindowsPayloadManifestRejectsNonCanonicalIdentity(t *testing.T) { - hashes := make(map[string]string, len(windowsPayloadFileNames)) - for _, name := range windowsPayloadFileNames { - hashes[name] = strings.Repeat("a", 64) - } - b, err := EncodeWindowsPayloadManifest("v2", hashes) - if err != nil { - t.Fatal(err) - } - for _, changed := range [][]byte{ - []byte(strings.Replace(string(b), `"version": "v2"`, `"version": " v2 "`, 1)), - []byte(strings.Replace(string(b), `"name": "reasonix-desktop.exe"`, `"name": "Reasonix-Desktop.exe"`, 1)), - []byte(strings.Replace(string(b), strings.Repeat("a", 64), strings.Repeat("A", 64), 1)), - } { - if _, err := DecodeWindowsPayloadManifest(changed, "v2"); err == nil { - t.Fatalf("non-canonical payload manifest was accepted: %s", changed) - } - } -} diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 428bfe8970..00bc1a0799 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -5,23 +5,23 @@ "claude-dialect": 1, "codeowners": 0, "commented-code": 0, - "complexity": 759, + "complexity": 711, "css-important": 210, - "css-size": 9882, + "css-size": 9865, "doc-language": 0, "doc-owner": 27, - "doc-prose": 2147, + "doc-prose": 2140, "error-text": 9, - "essay": 2737, - "file-size": 20867, + "essay": 2698, + "file-size": 19639, "flat-view": 0, - "frontend-parity": 28, - "function-size": 2946, + "frontend-parity": 27, + "function-size": 2679, "layer-order": 0, "layering": 0, "marker": 0, "narrative": 0, - "orphan": 75, + "orphan": 66, "refusal-path": 0, "release-note": 661, "stray-file": 0, @@ -38,7 +38,7 @@ "doc-prose": 1 }, "REASONIX.md": { - "doc-prose": 92 + "doc-prose": 90 }, "SECURITY.md": { "doc-owner": 1, @@ -60,20 +60,9 @@ "desktop/cmd/sign/main.go": { "essay": 14 }, - "desktop/cmd/update-helper/args.go": { - "essay": 1 - }, "desktop/cmd/update-helper/main_linux.go": { "error-text": 4 }, - "desktop/cmd/update-helper/main_windows.go": { - "complexity": 13, - "essay": 3, - "function-size": 65 - }, - "desktop/cmd/update-helper/versioned_windows.go": { - "essay": 10 - }, "desktop/frontend-next/perf/README.md": { "doc-owner": 1, "doc-prose": 21 @@ -88,11 +77,11 @@ }, "desktop/frontend-next/src/styles/app.css": { "css-important": 28, - "css-size": 5631 + "css-size": 5624 }, "desktop/frontend-next/src/styles/studio.css": { "css-important": 182, - "css-size": 4251 + "css-size": 4241 }, "desktop/internal/winuninstall/registry_windows.go": { "essay": 1 @@ -107,7 +96,7 @@ }, "docs/CLI.md": { "doc-owner": 1, - "doc-prose": 50 + "doc-prose": 49 }, "docs/CLI.zh-CN.md": { "doc-owner": 1, @@ -126,11 +115,11 @@ }, "docs/GUIDE.md": { "doc-owner": 1, - "doc-prose": 364 + "doc-prose": 362 }, "docs/GUIDE.zh-CN.md": { "doc-owner": 1, - "doc-prose": 173 + "doc-prose": 171 }, "docs/HISTORY_SEARCH_CATALOG.md": { "doc-owner": 1, @@ -201,8 +190,7 @@ "essay": 32 }, "internal/assembly/boot/runtime.go": { - "essay": 50, - "function-size": 1 + "essay": 50 }, "internal/assembly/boot/token_profile.go": { "essay": 1 @@ -249,11 +237,11 @@ }, "internal/contract/config/config.go": { "essay": 30, - "file-size": 938 + "file-size": 931 }, "internal/contract/config/credentials.go": { "essay": 1, - "file-size": 47 + "file-size": 44 }, "internal/contract/config/credentials_keyring_unix.go": { "essay": 7 @@ -273,15 +261,15 @@ }, "internal/contract/config/effort.go": { "complexity": 1, - "essay": 10 + "essay": 7 }, "internal/contract/config/effort_test.go": { "essay": 2 }, "internal/contract/config/load.go": { "essay": 24, - "file-size": 1191, - "function-size": 43, + "file-size": 1181, + "function-size": 22, "orphan": 2 }, "internal/contract/config/main_test.go": { @@ -313,13 +301,13 @@ }, "internal/contract/config/provider_presets.go": { "claude-dialect": 1, - "file-size": 219 + "file-size": 74 }, "internal/contract/config/render.go": { - "complexity": 91, - "essay": 2, - "file-size": 296, - "function-size": 404 + "complexity": 86, + "essay": 1, + "file-size": 273, + "function-size": 381 }, "internal/contract/config/vision.go": { "essay": 1, @@ -338,8 +326,7 @@ "orphan": 1 }, "internal/contract/provider/provider.go": { - "essay": 37, - "file-size": 8 + "essay": 37 }, "internal/contract/provider/retry.go": { "essay": 10 @@ -492,9 +479,6 @@ "internal/ext/hook/windows_compat.go": { "essay": 1 }, - "internal/ext/installsource/apply.go": { - "essay": 3 - }, "internal/ext/installsource/install_source.go": { "essay": 2 }, @@ -505,9 +489,9 @@ "essay": 1 }, "internal/ext/installsource/plugin_package.go": { - "complexity": 9, + "complexity": 3, "essay": 7, - "function-size": 38 + "function-size": 20 }, "internal/ext/installsource/skill.go": { "essay": 4 @@ -538,7 +522,7 @@ }, "internal/ext/plugin/plugin.go": { "essay": 28, - "file-size": 874, + "file-size": 841, "orphan": 3 }, "internal/ext/plugin/plugin_test.go": { @@ -563,8 +547,7 @@ "essay": 10 }, "internal/ext/pluginpkg/claude_compat.go": { - "essay": 19, - "function-size": 6 + "essay": 19 }, "internal/ext/pluginpkg/manifest_v1.go": { "essay": 7 @@ -572,7 +555,7 @@ "internal/ext/pluginpkg/pluginpkg.go": { "complexity": 1, "essay": 2, - "file-size": 396 + "file-size": 369 }, "internal/ext/pluginpkg/state_lock_test.go": { "essay": 1 @@ -587,8 +570,8 @@ "orphan": 1 }, "internal/ext/skill/skill.go": { - "essay": 7, - "file-size": 551 + "essay": 5, + "file-size": 548 }, "internal/ext/skill/tools.go": { "essay": 2 @@ -656,16 +639,16 @@ }, "internal/frontend/cli/acp.go": { "essay": 7, - "function-size": 31 + "function-size": 22 }, "internal/frontend/cli/buildinfo.go": { "essay": 7 }, "internal/frontend/cli/cli.go": { - "complexity": 24, + "complexity": 23, "essay": 32, - "file-size": 976, - "function-size": 128 + "file-size": 970, + "function-size": 124 }, "internal/frontend/cli/cli_test.go": { "essay": 9 @@ -735,7 +718,7 @@ }, "internal/frontend/serve/serve.go": { "essay": 22, - "file-size": 346 + "file-size": 255 }, "internal/frontend/serve/serve_lease_test.go": { "essay": 3 @@ -743,18 +726,12 @@ "internal/frontend/serve/serve_lock_test.go": { "essay": 1 }, - "internal/frontend/serve/settings.go": { - "essay": 1 - }, "internal/frontend/serve/switch_recovery_test.go": { "essay": 2 }, "internal/frontend/termrender/mathnode.go": { "essay": 1 }, - "internal/frontend/termrender/md.go": { - "essay": 7 - }, "internal/frontend/termrender/style.go": { "orphan": 1 }, @@ -765,10 +742,10 @@ "essay": 1 }, "internal/model/anthropic/anthropic.go": { - "complexity": 39, + "complexity": 38, "essay": 31, - "file-size": 73, - "function-size": 142, + "file-size": 67, + "function-size": 138, "struct-state": 5 }, "internal/model/anthropic/anthropic_test.go": { @@ -778,10 +755,10 @@ "essay": 1 }, "internal/model/openai/openai.go": { - "complexity": 58, + "complexity": 56, "essay": 30, - "file-size": 506, - "function-size": 242 + "file-size": 476, + "function-size": 227 }, "internal/model/openai/openai_test.go": { "essay": 5 @@ -847,7 +824,7 @@ "essay": 2 }, "internal/platform/repair/apply_failure.go": { - "orphan": 2 + "orphan": 1 }, "internal/platform/repair/config.go": { "complexity": 12 @@ -885,9 +862,9 @@ "internal/platform/repair/update.go": { "complexity": 109, "essay": 43, - "file-size": 2895, + "file-size": 2490, "function-size": 308, - "orphan": 12 + "orphan": 4 }, "internal/platform/repair/update_handoff_darwin_smoke_test.go": { "essay": 4 @@ -908,17 +885,14 @@ "essay": 1, "function-size": 180 }, - "internal/platform/update/install_windows.go": { - "essay": 1 - }, "internal/platform/update/manifest.go": { "essay": 1 }, "internal/runtime/agent/agent.go": { "complexity": 34, "essay": 33, - "file-size": 1204, - "function-size": 97 + "file-size": 1188, + "function-size": 95 }, "internal/runtime/agent/agent_options.go": { "essay": 10 @@ -964,10 +938,10 @@ }, "internal/runtime/agent/execute_batch.go": { "complexity": 22, - "function-size": 108 + "function-size": 107 }, "internal/runtime/agent/execute_one.go": { - "essay": 14 + "essay": 12 }, "internal/runtime/agent/extensions.go": { "essay": 64 @@ -1046,7 +1020,7 @@ "internal/runtime/delegation/task.go": { "complexity": 12, "essay": 12, - "file-size": 259, + "file-size": 249, "function-size": 94, "orphan": 1 }, @@ -1107,7 +1081,7 @@ }, "internal/runtime/usecap/usecapability.go": { "essay": 9, - "file-size": 713, + "file-size": 712, "function-size": 2 }, "internal/runtime/writeclaim/scheduler.go": { @@ -1156,9 +1130,6 @@ "internal/safety/sandbox/session_temp_env.go": { "essay": 1 }, - "internal/safety/sandbox/shell.go": { - "essay": 1 - }, "internal/safety/shellsafe/bash_redirect.go": { "essay": 5 }, @@ -1186,7 +1157,7 @@ }, "internal/session/control/controller.go": { "essay": 29, - "file-size": 467, + "file-size": 450, "frontend-parity": 5 }, "internal/session/control/controller_test.go": { @@ -1232,7 +1203,7 @@ "essay": 7 }, "internal/session/control/mcp_admin.go": { - "frontend-parity": 7 + "frontend-parity": 6 }, "internal/session/control/mcp_servers.go": { "essay": 2, @@ -1283,7 +1254,7 @@ "essay": 4 }, "internal/session/control/slash.go": { - "complexity": 4, + "complexity": 1, "essay": 2 }, "internal/session/control/steer_fallback_test.go": { @@ -1311,16 +1282,16 @@ }, "internal/state/checkpoint/checkpoint.go": { "essay": 8, - "file-size": 291 + "file-size": 250 }, "internal/state/checkpoint/observer.go": { "essay": 1 }, "internal/state/checkpoint/transaction.go": { - "complexity": 23, + "complexity": 21, "essay": 2, - "file-size": 736, - "function-size": 70 + "file-size": 664, + "function-size": 33 }, "internal/state/history/indexed.go": { "orphan": 1 @@ -1347,10 +1318,10 @@ "orphan": 3 }, "internal/state/sessionstore/migrate.go": { - "complexity": 32, + "complexity": 17, "essay": 23, - "file-size": 192, - "function-size": 73 + "file-size": 135, + "function-size": 13 }, "internal/state/sessionstore/normalize.go": { "essay": 14 @@ -1370,7 +1341,7 @@ "internal/state/sessionstore/save.go": { "complexity": 36, "essay": 109, - "file-size": 1269, + "file-size": 1267, "function-size": 95 }, "internal/state/sessionstore/save_test.go": { @@ -1474,7 +1445,7 @@ "essay": 7 }, "internal/tools/builtin/readfile.go": { - "function-size": 8 + "function-size": 7 }, "internal/tools/builtin/session_guard.go": { "essay": 25 @@ -1496,7 +1467,7 @@ }, "internal/tools/jobs/jobs.go": { "essay": 42, - "file-size": 1124, + "file-size": 1123, "orphan": 1 }, "internal/tools/jobs/jobs_extra_test.go": { @@ -1505,9 +1476,6 @@ "internal/tools/jobs/jobs_security_test.go": { "essay": 4 }, - "internal/tools/shellrun/runner.go": { - "essay": 4 - }, "release-notes/studio/2.0.0.md": { "release-note": 29 }, @@ -1594,7 +1562,7 @@ }, "sdk/go/sdk.go": { "essay": 27, - "file-size": 342 + "file-size": 128 }, "sdk/go/types_ext.go": { "essay": 7