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.
This commit is contained in:
yhh committed 2026-10-11 15:57:26 -07:00
1 parent a3c025cc41
commit 6694713cf9
31 files changed
+690 -3861

No files matched your search

+1 -1
View File
@@ -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 |
+1 -30
View File
@@ -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 <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
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 <file> against <file>.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.
-26
View File
@@ -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)
}
}
}
-15
View File
@@ -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
}
-23
View File
@@ -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)
}
}
@@ -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
@@ -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)
}
+1 -1
View File
@@ -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)
}
-581
View File
@@ -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()
}
@@ -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")
}
}
-238
View File
@@ -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
}
@@ -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 })
}
@@ -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/<version>/
// 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/<ver>/, 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
}
@@ -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")
}
}
@@ -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
@@ -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"
}
@@ -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"
}
-138
View File
@@ -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
@@ -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 := &current.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
}
-405
View File
@@ -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 := &current.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)
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !windows && !linux && !darwin
//go:build !linux && !darwin
package update
-24
View File
@@ -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)
}
-12
View File
@@ -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
}
-52
View File
@@ -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
}
@@ -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")
}
}
-23
View File
@@ -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()
}
-327
View File
@@ -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/<ver>/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")
}
@@ -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)
}
}
-128
View File
@@ -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
}
@@ -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)
}
}
}
+67 -99
View File
@@ -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