mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
a3c025cc41
commit
6694713cf9
31 files changed
+690
-3861
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 := ¤t.Files[i]
|
||||
targetKey := canonicalRepairPath(f.TargetPath)
|
||||
if stateID, ok := receiptStates[targetKey]; ok {
|
||||
record.InstalledStateIDs[i] = stateID
|
||||
delete(receiptStates, targetKey)
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Lstat(f.TargetPath)
|
||||
if statErr != nil {
|
||||
if os.IsNotExist(statErr) && f.MissingBefore {
|
||||
record.InstalledStateIDs[i] = repairPlanReleaseNodeState(f.TargetPath)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("record installed update: inspect %s: %w", filepath.Base(f.TargetPath), statErr)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("record installed update: %s is not a regular file", filepath.Base(f.TargetPath))
|
||||
}
|
||||
return nil, fmt.Errorf("record installed update: publish receipt is missing for %s", filepath.Base(f.TargetPath))
|
||||
}
|
||||
if len(receiptStates) != 0 {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt target is outside the release unit")
|
||||
}
|
||||
for i, f := range current.Files {
|
||||
if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, record.InstalledStateIDs[i]); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: release unit changed while recording: %w", err)
|
||||
}
|
||||
}
|
||||
if err := createInstalledFileUpdateState(current, record); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: %w", err)
|
||||
}
|
||||
installedUpdateAfterCreate(installedFileUpdateStatePath(current))
|
||||
latest, err := readPendingUpdateForLauncher(claimed.TargetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("record installed update: re-read pending transaction: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(current, latest) {
|
||||
return nil, fmt.Errorf("record installed update: pending transaction changed")
|
||||
}
|
||||
if _, _, err := installedFileUpdateTargets(latest, true); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// PublishClaimedFileUpdateMember replaces one release-unit member without ever
|
||||
// overwriting an unverified node. The platform updater must hold the claim
|
||||
// returned by ClaimPendingFileUpdateExact for the whole release-unit operation.
|
||||
// A concurrent recreation after the prepared node moves aside wins; the new
|
||||
// bytes and the verified prior node remain staged for recovery.
|
||||
func PublishClaimedFileUpdateMember(claimed *UpdateTransaction, targetPath string, content []byte, mode os.FileMode) error {
|
||||
_, err := PublishClaimedFileUpdateMemberExact(claimed, targetPath, content, mode)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClaimPendingFileUpdateExact additionally binds the updater to every field in
|
||||
// the transaction prepared by the desktop process.
|
||||
func ClaimPendingFileUpdateExact(
|
||||
expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string,
|
||||
expectedTargetPaths []string,
|
||||
timeout time.Duration,
|
||||
) (*UpdateTransaction, func(), error) {
|
||||
expectedTransactionID = strings.TrimSpace(expectedTransactionID)
|
||||
if expectedTransactionID == "" {
|
||||
return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete")
|
||||
}
|
||||
return claimPendingFileUpdate(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
expectedTransactionID,
|
||||
launcherPath,
|
||||
expectedTargetPaths,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// ClaimPendingFileUpdate binds an updater's actual replacement window to the
|
||||
// exact transaction and release-unit paths prepared by the desktop. The
|
||||
// launcher path is explicit because the Windows helper runs from a cache
|
||||
// directory rather than from the installation it is authorized to replace.
|
||||
func ClaimPendingFileUpdate(
|
||||
expectedToVersion, expectedCreatedAt, launcherPath string,
|
||||
expectedTargetPaths []string,
|
||||
timeout time.Duration,
|
||||
) (*UpdateTransaction, func(), error) {
|
||||
tx, err := readPendingUpdateForLauncher(launcherPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("claim file update: read pending transaction: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" ||
|
||||
strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
|
||||
strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
|
||||
return nil, nil, fmt.Errorf("claim file update: pending transaction does not match")
|
||||
}
|
||||
return claimPendingFileUpdate(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
UpdateTransactionID(tx),
|
||||
launcherPath,
|
||||
expectedTargetPaths,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// ClaimPendingAppBundleUpdateHandoff authorizes a detached child to perform the
|
||||
// recorded bundle swap. It returns with both the pending transaction lock and
|
||||
// the target mutation locks held; release must be called on every path.
|
||||
func ClaimPendingAppBundleUpdateHandoff(expectedToVersion, expectedCreatedAt string, timeout time.Duration) (*UpdateTransaction, func(), error) {
|
||||
tx, err := ReadPendingUpdate()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("claim update handoff: read pending transaction: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "app-bundle" ||
|
||||
strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
|
||||
strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
|
||||
return nil, nil, fmt.Errorf("claim update handoff: pending transaction does not match")
|
||||
}
|
||||
return claimPendingAppBundleUpdateHandoff(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
UpdateTransactionID(tx),
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// PrepareAppBundleUpdate records the sibling bundle backup that the macOS
|
||||
// handoff script creates. The script performs the directory move after exit.
|
||||
func PrepareAppBundleUpdate(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) {
|
||||
tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlock, err := acquirePendingUpdateLock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlockTargets, lockErr := lockRepairMutations(tx.TargetPath, tx.BackupPath)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
|
||||
}
|
||||
defer unlockTargets()
|
||||
tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: current bundle digest: %w", err)
|
||||
}
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := createPendingUpdate(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// PrepareFileUpdate snapshots the current desktop executable — plus any sibling
|
||||
// binaries of the release unit the installer also replaces (Guard, launcher,
|
||||
// update helper) — and records an update transaction before an updater applies
|
||||
// the replacement. Sibling paths that do not exist are recorded explicitly so
|
||||
// rollback can remove files introduced by the replacement release.
|
||||
func PrepareFileUpdate(fromVersion, toVersion, targetPath string, siblingPaths ...string) (*UpdateTransaction, error) {
|
||||
targetPath = filepath.Clean(strings.TrimSpace(targetPath))
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("prepare update: empty target path")
|
||||
}
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("prepare update: Reasonix state directory is unavailable")
|
||||
}
|
||||
unlock, err := acquirePendingUpdateLock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hold the same target locks as rollback so prepare/snapshot cannot race
|
||||
// a concurrent Guard restore of the release unit.
|
||||
lockPaths := append([]string{targetPath}, siblingPaths...)
|
||||
unlockTargets, lockErr := lockRepairMutations(lockPaths...)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
|
||||
}
|
||||
defer unlockTargets()
|
||||
backupDir := filepath.Join(root, "repair", "updates")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInsideResolvedRoot(filepath.Join(root, "repair"), backupDir) {
|
||||
return nil, fmt.Errorf("prepare update: backup directory resolves outside the repair directory")
|
||||
}
|
||||
tx := &UpdateTransaction{
|
||||
SchemaVersion: updateTransactionVersion,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: toVersion,
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
TargetKind: "file",
|
||||
TargetPath: targetPath,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, path := range append([]string{targetPath}, siblingPaths...) {
|
||||
path = filepath.Clean(strings.TrimSpace(path))
|
||||
key := canonicalRepairPath(path)
|
||||
if path == "" || path == "." || key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
info, statErr := os.Lstat(path)
|
||||
if statErr != nil {
|
||||
if i > 0 && os.IsNotExist(statErr) {
|
||||
tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, MissingBefore: true})
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("prepare update backup: %w", statErr)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("prepare update backup: release file %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
backupIdentity := repairPlanStateID(struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
Index int `json:"index"`
|
||||
}{
|
||||
CreatedAt: tx.CreatedAt,
|
||||
TargetPath: canonicalRepairPath(path),
|
||||
Index: i,
|
||||
})
|
||||
backupPath := filepath.Join(
|
||||
backupDir,
|
||||
fmt.Sprintf("%s.%s.previous", filepath.Base(path), backupIdentity[:16]),
|
||||
)
|
||||
hash, err := copyFileWithHashCreate(path, backupPath, 0o700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update backup: %w", err)
|
||||
}
|
||||
tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, BackupPath: backupPath, SHA256: hash})
|
||||
if i == 0 {
|
||||
tx.BackupPath = backupPath
|
||||
tx.BackupSHA256 = hash
|
||||
}
|
||||
}
|
||||
if err := verifyPreparedFileUpdateTargets(tx); err != nil {
|
||||
return nil, fmt.Errorf("prepare update: %w", err)
|
||||
}
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := createPendingUpdate(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
@@ -218,139 +218,6 @@ func lockPendingUpdateStrict() (func(), error) {
|
||||
|
||||
var acquirePendingUpdateLock = lockPendingUpdateStrict
|
||||
|
||||
// PrepareFileUpdate snapshots the current desktop executable — plus any sibling
|
||||
// binaries of the release unit the installer also replaces (Guard, launcher,
|
||||
// update helper) — and records an update transaction before an updater applies
|
||||
// the replacement. Sibling paths that do not exist are recorded explicitly so
|
||||
// rollback can remove files introduced by the replacement release.
|
||||
func PrepareFileUpdate(fromVersion, toVersion, targetPath string, siblingPaths ...string) (*UpdateTransaction, error) {
|
||||
targetPath = filepath.Clean(strings.TrimSpace(targetPath))
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("prepare update: empty target path")
|
||||
}
|
||||
root := config.MemoryUserDir()
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("prepare update: Reasonix state directory is unavailable")
|
||||
}
|
||||
unlock, err := acquirePendingUpdateLock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hold the same target locks as rollback so prepare/snapshot cannot race
|
||||
// a concurrent Guard restore of the release unit.
|
||||
lockPaths := append([]string{targetPath}, siblingPaths...)
|
||||
unlockTargets, lockErr := lockRepairMutations(lockPaths...)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
|
||||
}
|
||||
defer unlockTargets()
|
||||
backupDir := filepath.Join(root, "repair", "updates")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInsideResolvedRoot(filepath.Join(root, "repair"), backupDir) {
|
||||
return nil, fmt.Errorf("prepare update: backup directory resolves outside the repair directory")
|
||||
}
|
||||
tx := &UpdateTransaction{
|
||||
SchemaVersion: updateTransactionVersion,
|
||||
FromVersion: fromVersion,
|
||||
ToVersion: toVersion,
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
TargetKind: "file",
|
||||
TargetPath: targetPath,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, path := range append([]string{targetPath}, siblingPaths...) {
|
||||
path = filepath.Clean(strings.TrimSpace(path))
|
||||
key := canonicalRepairPath(path)
|
||||
if path == "" || path == "." || key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
info, statErr := os.Lstat(path)
|
||||
if statErr != nil {
|
||||
if i > 0 && os.IsNotExist(statErr) {
|
||||
tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, MissingBefore: true})
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("prepare update backup: %w", statErr)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("prepare update backup: release file %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
backupIdentity := repairPlanStateID(struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
Index int `json:"index"`
|
||||
}{
|
||||
CreatedAt: tx.CreatedAt,
|
||||
TargetPath: canonicalRepairPath(path),
|
||||
Index: i,
|
||||
})
|
||||
backupPath := filepath.Join(
|
||||
backupDir,
|
||||
fmt.Sprintf("%s.%s.previous", filepath.Base(path), backupIdentity[:16]),
|
||||
)
|
||||
hash, err := copyFileWithHashCreate(path, backupPath, 0o700)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update backup: %w", err)
|
||||
}
|
||||
tx.Files = append(tx.Files, UpdateTransactionFile{TargetPath: path, BackupPath: backupPath, SHA256: hash})
|
||||
if i == 0 {
|
||||
tx.BackupPath = backupPath
|
||||
tx.BackupSHA256 = hash
|
||||
}
|
||||
}
|
||||
if err := verifyPreparedFileUpdateTargets(tx); err != nil {
|
||||
return nil, fmt.Errorf("prepare update: %w", err)
|
||||
}
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := createPendingUpdate(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// PrepareAppBundleUpdate records the sibling bundle backup that the macOS
|
||||
// handoff script creates. The script performs the directory move after exit.
|
||||
func PrepareAppBundleUpdate(fromVersion, toVersion, appPath, backupPath string) (*UpdateTransaction, error) {
|
||||
tx, err := newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlock, err := acquirePendingUpdateLock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock pending transaction: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlockTargets, lockErr := lockRepairMutations(tx.TargetPath, tx.BackupPath)
|
||||
if lockErr != nil {
|
||||
return nil, fmt.Errorf("prepare update: lock targets: %w", lockErr)
|
||||
}
|
||||
defer unlockTargets()
|
||||
tx.BackupTreeID, err = repairPlanTreeContentStateID(tx.TargetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare update: current bundle digest: %w", err)
|
||||
}
|
||||
if err := ensureNoPendingUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := createPendingUpdate(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// PrepareAppBundleUpdateHandoff records every path the detached macOS updater
|
||||
// may mutate. The child receives only the transaction identity and must claim
|
||||
// these recorded paths under the pending-update and mutation locks.
|
||||
@@ -439,27 +306,6 @@ func newAppBundleUpdateTransaction(fromVersion, toVersion, appPath, backupPath s
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// ClaimPendingAppBundleUpdateHandoff authorizes a detached child to perform the
|
||||
// recorded bundle swap. It returns with both the pending transaction lock and
|
||||
// the target mutation locks held; release must be called on every path.
|
||||
func ClaimPendingAppBundleUpdateHandoff(expectedToVersion, expectedCreatedAt string, timeout time.Duration) (*UpdateTransaction, func(), error) {
|
||||
tx, err := ReadPendingUpdate()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("claim update handoff: read pending transaction: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "app-bundle" ||
|
||||
strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
|
||||
strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
|
||||
return nil, nil, fmt.Errorf("claim update handoff: pending transaction does not match")
|
||||
}
|
||||
return claimPendingAppBundleUpdateHandoff(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
UpdateTransactionID(tx),
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// ClaimPendingAppBundleUpdateHandoffExact additionally binds the detached
|
||||
// updater to the complete transaction prepared by the parent process.
|
||||
func ClaimPendingAppBundleUpdateHandoffExact(
|
||||
@@ -556,55 +402,6 @@ func claimPendingAppBundleUpdateHandoff(
|
||||
return current, release, nil
|
||||
}
|
||||
|
||||
// ClaimPendingFileUpdate binds an updater's actual replacement window to the
|
||||
// exact transaction and release-unit paths prepared by the desktop. The
|
||||
// launcher path is explicit because the Windows helper runs from a cache
|
||||
// directory rather than from the installation it is authorized to replace.
|
||||
func ClaimPendingFileUpdate(
|
||||
expectedToVersion, expectedCreatedAt, launcherPath string,
|
||||
expectedTargetPaths []string,
|
||||
timeout time.Duration,
|
||||
) (*UpdateTransaction, func(), error) {
|
||||
tx, err := readPendingUpdateForLauncher(launcherPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("claim file update: read pending transaction: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" ||
|
||||
strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
|
||||
strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
|
||||
return nil, nil, fmt.Errorf("claim file update: pending transaction does not match")
|
||||
}
|
||||
return claimPendingFileUpdate(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
UpdateTransactionID(tx),
|
||||
launcherPath,
|
||||
expectedTargetPaths,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
// ClaimPendingFileUpdateExact additionally binds the updater to every field in
|
||||
// the transaction prepared by the desktop process.
|
||||
func ClaimPendingFileUpdateExact(
|
||||
expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string,
|
||||
expectedTargetPaths []string,
|
||||
timeout time.Duration,
|
||||
) (*UpdateTransaction, func(), error) {
|
||||
expectedTransactionID = strings.TrimSpace(expectedTransactionID)
|
||||
if expectedTransactionID == "" {
|
||||
return nil, nil, fmt.Errorf("claim file update: transaction identity is incomplete")
|
||||
}
|
||||
return claimPendingFileUpdate(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
expectedTransactionID,
|
||||
launcherPath,
|
||||
expectedTargetPaths,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
func claimPendingFileUpdate(
|
||||
expectedToVersion, expectedCreatedAt, expectedTransactionID, launcherPath string,
|
||||
expectedTargetPaths []string,
|
||||
@@ -735,16 +532,6 @@ func verifyPreparedFileUpdateBackups(tx *UpdateTransaction) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishClaimedFileUpdateMember replaces one release-unit member without ever
|
||||
// overwriting an unverified node. The platform updater must hold the claim
|
||||
// returned by ClaimPendingFileUpdateExact for the whole release-unit operation.
|
||||
// A concurrent recreation after the prepared node moves aside wins; the new
|
||||
// bytes and the verified prior node remain staged for recovery.
|
||||
func PublishClaimedFileUpdateMember(claimed *UpdateTransaction, targetPath string, content []byte, mode os.FileMode) error {
|
||||
_, err := PublishClaimedFileUpdateMemberExact(claimed, targetPath, content, mode)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishClaimedFileUpdateMemberExact returns proof of the exact node it
|
||||
// published. Callers must retain every receipt and pass them to
|
||||
// RecordClaimedFileUpdateInstalled before releasing the update claim.
|
||||
@@ -946,100 +733,6 @@ func stageFileUpdateContent(targetPath string, content []byte, mode os.FileMode)
|
||||
return path, hex.EncodeToString(sum[:]), installedStateID, nil
|
||||
}
|
||||
|
||||
// RecordClaimedFileUpdateInstalled binds the complete post-install release unit
|
||||
// while the platform updater still holds the claim's pending and target locks.
|
||||
// The binding is a transaction-unique create-only sidecar: pending-update.json
|
||||
// stays immutable, so a process crash can never strand rollback state in the
|
||||
// gap between displacing the old pending file and publishing a replacement.
|
||||
func RecordClaimedFileUpdateInstalled(
|
||||
claimed *UpdateTransaction,
|
||||
receipts ...FileUpdateInstallReceipt,
|
||||
) (*UpdateTransaction, error) {
|
||||
if claimed == nil || claimed.TargetKind != "file" {
|
||||
return nil, fmt.Errorf("record installed update: transaction identity is incomplete")
|
||||
}
|
||||
current, err := readPendingUpdateForLauncher(claimed.TargetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("record installed update: read pending transaction: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(claimed, current) {
|
||||
return nil, fmt.Errorf("record installed update: pending transaction changed")
|
||||
}
|
||||
if len(current.Files) == 0 {
|
||||
return nil, fmt.Errorf("record installed update: release unit is incomplete")
|
||||
}
|
||||
record := &installedFileUpdateState{
|
||||
SchemaVersion: 1,
|
||||
UpdateTransactionID: UpdateTransactionID(current),
|
||||
InstalledStateIDs: make([]string, len(current.Files)),
|
||||
}
|
||||
receiptStates := make(map[string]string, len(receipts))
|
||||
for _, receipt := range receipts {
|
||||
if strings.TrimSpace(receipt.UpdateTransactionID) != record.UpdateTransactionID {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt belongs to a different transaction")
|
||||
}
|
||||
targetKey := canonicalRepairPath(receipt.TargetPath)
|
||||
if targetKey == "" {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt target is invalid")
|
||||
}
|
||||
stateID := strings.TrimSpace(receipt.InstalledStateID)
|
||||
if len(stateID) != sha256.Size*2 {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt state is invalid")
|
||||
}
|
||||
if _, err := hex.DecodeString(stateID); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt state is invalid")
|
||||
}
|
||||
if _, exists := receiptStates[targetKey]; exists {
|
||||
return nil, fmt.Errorf("record installed update: duplicate publish receipt")
|
||||
}
|
||||
receiptStates[targetKey] = stateID
|
||||
}
|
||||
for i := range current.Files {
|
||||
f := ¤t.Files[i]
|
||||
targetKey := canonicalRepairPath(f.TargetPath)
|
||||
if stateID, ok := receiptStates[targetKey]; ok {
|
||||
record.InstalledStateIDs[i] = stateID
|
||||
delete(receiptStates, targetKey)
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Lstat(f.TargetPath)
|
||||
if statErr != nil {
|
||||
if os.IsNotExist(statErr) && f.MissingBefore {
|
||||
record.InstalledStateIDs[i] = repairPlanReleaseNodeState(f.TargetPath)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("record installed update: inspect %s: %w", filepath.Base(f.TargetPath), statErr)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("record installed update: %s is not a regular file", filepath.Base(f.TargetPath))
|
||||
}
|
||||
return nil, fmt.Errorf("record installed update: publish receipt is missing for %s", filepath.Base(f.TargetPath))
|
||||
}
|
||||
if len(receiptStates) != 0 {
|
||||
return nil, fmt.Errorf("record installed update: publish receipt target is outside the release unit")
|
||||
}
|
||||
for i, f := range current.Files {
|
||||
if err := verifyRepairPlanReleaseNodeStateFor(f.TargetPath, f.TargetPath, record.InstalledStateIDs[i]); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: release unit changed while recording: %w", err)
|
||||
}
|
||||
}
|
||||
if err := createInstalledFileUpdateState(current, record); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: %w", err)
|
||||
}
|
||||
installedUpdateAfterCreate(installedFileUpdateStatePath(current))
|
||||
latest, err := readPendingUpdateForLauncher(claimed.TargetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("record installed update: re-read pending transaction: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(current, latest) {
|
||||
return nil, fmt.Errorf("record installed update: pending transaction changed")
|
||||
}
|
||||
if _, _, err := installedFileUpdateTargets(latest, true); err != nil {
|
||||
return nil, fmt.Errorf("record installed update: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func installedFileUpdateStatePath(tx *UpdateTransaction) string {
|
||||
if tx == nil {
|
||||
return ""
|
||||
@@ -1205,31 +898,6 @@ func removeInstalledFileUpdateState(tx *UpdateTransaction) error {
|
||||
}, false)
|
||||
}
|
||||
|
||||
// CancelPendingAppBundleUpdateHandoff abandons an exact handoff only when the
|
||||
// original installed bundle is still the tree captured during prepare. This is
|
||||
// the safe recovery path when source verification fails after the desktop has
|
||||
// exited but before any bundle swap occurred.
|
||||
func CancelPendingAppBundleUpdateHandoff(
|
||||
expectedToVersion, expectedCreatedAt string,
|
||||
timeout time.Duration,
|
||||
) (*UpdateTransaction, error) {
|
||||
tx, err := ReadPendingUpdate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cancel update handoff: read pending transaction: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "app-bundle" ||
|
||||
strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(expectedToVersion) ||
|
||||
strings.TrimSpace(tx.CreatedAt) != strings.TrimSpace(expectedCreatedAt) {
|
||||
return nil, fmt.Errorf("cancel update handoff: pending transaction does not match")
|
||||
}
|
||||
return cancelPendingAppBundleUpdateHandoff(
|
||||
expectedToVersion,
|
||||
expectedCreatedAt,
|
||||
timeout,
|
||||
UpdateTransactionID(tx),
|
||||
)
|
||||
}
|
||||
|
||||
// CancelPendingAppBundleUpdateHandoffExact abandons only the full transaction
|
||||
// read or prepared by the caller. It is safe to use after a PID wait or failed
|
||||
// claim where pending state may have been rewritten with copied scalar IDs.
|
||||
@@ -1956,11 +1624,6 @@ func readPendingUpdateUnchecked() (*UpdateTransaction, error) {
|
||||
return &tx, nil
|
||||
}
|
||||
|
||||
func HasPendingUpdate() bool {
|
||||
_, err := ReadPendingUpdate()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PendingUpdateExists reports the on-disk marker even when its contents are
|
||||
// malformed. It is intended only for progress/UI decisions; callers must use
|
||||
// ReadPendingUpdate or ReconcilePendingUpdate before authorizing mutations.
|
||||
@@ -2127,40 +1790,6 @@ func CommitProbationaryPendingUpdate(runningVersion string) (bool, error) {
|
||||
return !PendingUpdateExists(), nil
|
||||
}
|
||||
|
||||
// AbandonPendingUpdate is the user-initiated recovery path for a stuck
|
||||
// transaction: commit if possible, else reconcile, else force-retire.
|
||||
func AbandonPendingUpdate(runningVersion string) (PendingUpdateReconcileResult, error) {
|
||||
committed, commitErr := CommitProbationaryPendingUpdate(runningVersion)
|
||||
if commitErr == nil && committed {
|
||||
return PendingUpdateReconcileResult{Cleared: true, Healthy: true}, nil
|
||||
}
|
||||
if commitErr != nil {
|
||||
// Keep going: a drifted backup must not block explicit discard.
|
||||
slog.Debug("repair: probationary commit during abandon failed; continuing",
|
||||
"err", commitErr)
|
||||
}
|
||||
result, reconcileErr := ReconcilePendingUpdate(runningVersion)
|
||||
if reconcileErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
// Force-retire when still AwaitingHealth with the live target installed.
|
||||
if errors.Is(reconcileErr, ErrPendingUpdateAwaitingHealth) {
|
||||
if retired, retireErr := forceRetireProbationaryPendingUpdate(runningVersion); retireErr != nil {
|
||||
return result, fmt.Errorf("abandon pending update: %w", retireErr)
|
||||
} else if retired {
|
||||
result.Pending = false
|
||||
result.AwaitingHealth = false
|
||||
result.Healthy = true
|
||||
result.Cleared = true
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if commitErr != nil && reconcileErr != nil {
|
||||
return result, fmt.Errorf("abandon pending update: %w", errors.Join(reconcileErr, commitErr))
|
||||
}
|
||||
return result, reconcileErr
|
||||
}
|
||||
|
||||
// forceRetireProbationaryPendingUpdate retires a probationary marker when the
|
||||
// live target is installed but MarkUpdateHealthy cannot finish (e.g. bad backup).
|
||||
func forceRetireProbationaryPendingUpdate(runningVersion string) (bool, error) {
|
||||
@@ -2287,17 +1916,6 @@ func MarkUpdateHealthy(runningVersion string) error {
|
||||
return markUpdateHealthyInvocation(runningVersion, "", "")
|
||||
}
|
||||
|
||||
// MarkUpdateHealthyMatching commits only the exact pending transaction observed
|
||||
// when this desktop process started. The creation identity prevents an older
|
||||
// process from blessing a later same-version retry.
|
||||
func MarkUpdateHealthyMatching(runningVersion, expectedCreatedAt string) error {
|
||||
expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
|
||||
if expectedCreatedAt == "" {
|
||||
return nil
|
||||
}
|
||||
return markUpdateHealthyInvocation(runningVersion, expectedCreatedAt, "")
|
||||
}
|
||||
|
||||
// MarkUpdateHealthyExact commits only the complete transaction captured before
|
||||
// the replacement process started.
|
||||
func MarkUpdateHealthyExact(runningVersion, expectedCreatedAt, expectedTransactionID string) error {
|
||||
@@ -2415,17 +2033,6 @@ func markUpdateHealthyMatching(runningVersion, expectedCreatedAt, expectedTransa
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelPendingUpdateMatching removes only the exact transaction prepared by
|
||||
// the caller. It is used by updater failure paths where a same-version retry can
|
||||
// replace pending-update.json before cleanup runs.
|
||||
func CancelPendingUpdateMatching(toVersion, expectedCreatedAt string) error {
|
||||
expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
|
||||
if expectedCreatedAt == "" {
|
||||
return nil
|
||||
}
|
||||
return cancelPendingUpdateInvocation(toVersion, expectedCreatedAt, "")
|
||||
}
|
||||
|
||||
// CancelPendingUpdateExact removes only the complete transaction returned by
|
||||
// prepare. This is the updater failure path: copied creation timestamps are not
|
||||
// sufficient authorization if pending-update.json itself was rewritten.
|
||||
@@ -2630,18 +2237,6 @@ func RollbackPendingUpdate() (UpdateRollbackResult, error) {
|
||||
return rollbackPendingUpdateInvocation("", "", "")
|
||||
}
|
||||
|
||||
// RollbackPendingUpdateMatching rolls back only the exact transaction prepared
|
||||
// by the caller. This is used when an apply attempt fails after another process
|
||||
// may already have replaced pending-update.json with a same-version retry.
|
||||
func RollbackPendingUpdateMatching(expectedToVersion, expectedCreatedAt string) (UpdateRollbackResult, error) {
|
||||
expectedToVersion = strings.TrimSpace(expectedToVersion)
|
||||
expectedCreatedAt = strings.TrimSpace(expectedCreatedAt)
|
||||
if expectedToVersion == "" || expectedCreatedAt == "" {
|
||||
return UpdateRollbackResult{}, fmt.Errorf("rollback update: transaction identity is incomplete")
|
||||
}
|
||||
return rollbackPendingUpdateInvocation(expectedToVersion, expectedCreatedAt, "")
|
||||
}
|
||||
|
||||
func rollbackPendingUpdateState(expectedStateID string, expectedStates map[string]string) (UpdateRollbackResult, error) {
|
||||
return rollbackPendingUpdateMatching("", "", expectedStateID, expectedStates, "", true)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
//go:build !linux && !darwin
|
||||
|
||||
package update
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user