package version import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "uuid" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/log" ) const ( updateJournalSchema = 2 updateTransactionPrefix = ".mpv-manager-update-" updateTransactionLock = ".mpv-manager-self-update.lock" updateJournalName = "transaction.json" updateHealthName = "health.json" updatePayloadBaseName = "manager-payload" updateHelperWaitTimeout = 2 * time.Minute updateLockHandoffTimeout = 30 * time.Second updateHealthTimeout = 20 * time.Second updateHealthStabilize = 1500 * time.Millisecond updateHelperRegisterWait = 5 * time.Second ) type UpdateTransactionState string const ( UpdateTransactionInitializing UpdateTransactionState = "initializing" UpdateTransactionPrepared UpdateTransactionState = "prepared" UpdateTransactionHelperStarted UpdateTransactionState = "helper_started" UpdateTransactionApplying UpdateTransactionState = "applying" UpdateTransactionAwaitingHealth UpdateTransactionState = "awaiting_health" UpdateTransactionCommitted UpdateTransactionState = "committed" UpdateTransactionRolledBack UpdateTransactionState = "rolled_back" UpdateTransactionFailed UpdateTransactionState = "failed" ) type UpdateTarget struct { Role string `json:"role"` Path string `json:"path"` StagedPath string `json:"staged_path"` BackupPath string `json:"backup_path"` OriginalSize int64 `json:"original_size,omitempty"` OriginalHash string `json:"original_blake3,omitempty"` OriginalIdentity BinaryIdentity `json:"original_identity,omitempty"` Applied bool `json:"applied"` Outcome string `json:"outcome,omitempty"` Error string `json:"error,omitempty"` } type UpdateJournal struct { Authentication string `json:"authentication,omitempty"` SchemaVersion int `json:"schema_version"` ID string `json:"id"` State UpdateTransactionState `json:"state"` Finalized bool `json:"finalized,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ParentPID int `json:"parent_pid"` ParentToken string `json:"parent_process_token,omitempty"` HelperPID int `json:"helper_pid,omitempty"` HelperToken string `json:"helper_process_token,omitempty"` ChildPID int `json:"child_pid,omitempty"` RelaunchMode string `json:"relaunch_mode"` LockPath string `json:"lock_path"` HelperPath string `json:"helper_path"` HealthPath string `json:"health_path"` PayloadPath string `json:"payload_path"` ManifestKeyID string `json:"manifest_key_id"` OriginJobID string `json:"origin_job_id,omitempty"` ExpectedSize int64 `json:"expected_size"` ExpectedHash string `json:"expected_blake3"` Expected BinaryIdentity `json:"expected_identity"` Targets []UpdateTarget `json:"targets"` Error string `json:"error,omitempty"` } type updateHealthAcknowledgement struct { TransactionID string `json:"transaction_id"` Identity BinaryIdentity `json:"identity"` AcknowledgedAt time.Time `json:"acknowledged_at"` } // PreparedSelfUpdate owns the cross-process lock until helper handoff. It is // intentionally opaque to callers except for stable transaction identifiers. type PreparedSelfUpdate struct { TransactionID string JournalPath string HelperPath string lock *updateLock targets []UpdateTarget handedOff bool } // SetOriginJobID binds a Web task to the detached helper's eventual durable // outcome. It must be called before helper handoff. func (p *PreparedSelfUpdate) SetOriginJobID(jobID string) error { if p == nil || p.JournalPath == "" || p.handedOff { return errors.New("update transaction is not available for origin binding") } parsed, err := uuid.Parse(jobID) if err != nil || parsed.String() != jobID { return fmt.Errorf("invalid update origin job ID %q", jobID) } journal, err := loadUpdateJournal(p.JournalPath) if err != nil { return err } journal.OriginJobID = jobID return writeUpdateJournal(p.JournalPath, journal) } // Abort removes a prepared transaction that has not been handed to a helper // and releases its cross-process lock. func (p *PreparedSelfUpdate) Abort() error { return p.abort() } func updateLockPath(executablePath string) string { return executablePath + ".update.lock" } // updateTransactionLockPath serializes every portable-manager transaction for // the current user. A transaction can replace both the running binary and a // configured secondary binary, so a primary-adjacent lock alone is not enough. // updateLockPath remains for validating journals written by the older scheme. func updateTransactionLockPath() string { path := filepath.Join(config.GetConfigDir(), updateTransactionLock) absolute, err := filepath.Abs(path) if err != nil { return filepath.Clean(path) } return filepath.Clean(absolute) } func updateTransactionDir(executablePath, id string) string { return filepath.Join(filepath.Dir(executablePath), updateTransactionPrefix+id) } func stagedTargetPath(targetPath, id string) string { return filepath.Join(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+".update-"+id+".new") } func backupTargetPath(targetPath, id string) string { return filepath.Join(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+".update-"+id+".backup") } func helperFileName() string { if runtime.GOOS == "windows" { return "mpv-manager-update-helper.exe" } return "mpv-manager-update-helper" } func updatePayloadFileName() string { if runtime.GOOS == "windows" { return updatePayloadBaseName + ".exe" } return updatePayloadBaseName } func canonicalExecutablePath(path string) (string, error) { absolute, err := filepath.Abs(path) if err != nil { return "", err } resolved, err := filepath.EvalSymlinks(absolute) if err == nil { absolute = resolved } return filepath.Clean(absolute), nil } func pathsEqual(a, b string) bool { a = filepath.Clean(a) b = filepath.Clean(b) if runtime.GOOS == "windows" { return strings.EqualFold(a, b) } return a == b } func newUpdateTarget(role, path, id string) (UpdateTarget, error) { resolved, err := canonicalExecutablePath(path) if err != nil { return UpdateTarget{}, fmt.Errorf("resolve %s update target: %w", role, err) } info, err := os.Stat(resolved) if err != nil { return UpdateTarget{}, fmt.Errorf("stat %s update target: %w", role, err) } if !info.Mode().IsRegular() { return UpdateTarget{}, fmt.Errorf("%s update target is not a regular file", role) } if err := validateUpdateRecoveryFile(resolved); err != nil { return UpdateTarget{}, err } originalSize, originalHash, originalIdentity, err := captureUpdateArtifact(resolved) if err != nil { return UpdateTarget{}, fmt.Errorf("capture %s update target evidence: %w", role, err) } return UpdateTarget{ Role: role, Path: resolved, StagedPath: stagedTargetPath(resolved, id), BackupPath: backupTargetPath(resolved, id), OriginalSize: originalSize, OriginalHash: originalHash, OriginalIdentity: originalIdentity, }, nil } func captureUpdateArtifact(path string) (int64, string, BinaryIdentity, error) { info, err := os.Stat(path) if err != nil { return 0, "", BinaryIdentity{}, err } if !info.Mode().IsRegular() { return 0, "", BinaryIdentity{}, fmt.Errorf("artifact is not a regular file") } hashBefore, err := fileBLAKE3(path) if err != nil { return 0, "", BinaryIdentity{}, err } identity, err := readBinaryIdentity(path) if err != nil { return 0, "", BinaryIdentity{}, err } if strings.TrimSpace(identity.Version) == "" { return 0, "", BinaryIdentity{}, fmt.Errorf("binary identity has no version") } expectedIdentity := BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: identity.Version, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } if err := validateBinaryIdentity(identity, expectedIdentity); err != nil { return 0, "", BinaryIdentity{}, err } infoAfter, err := os.Stat(path) if err != nil { return 0, "", BinaryIdentity{}, err } hashAfter, err := fileBLAKE3(path) if err != nil { return 0, "", BinaryIdentity{}, err } if infoAfter.Size() != info.Size() || hashAfter != hashBefore { return 0, "", BinaryIdentity{}, fmt.Errorf("binary changed while identity evidence was captured") } return info.Size(), hashBefore, identity, nil } // PrepareSelfUpdateFromCheck downloads and verifies one immutable release // selection, stages every configured manager target, and durably records the // transaction. No installed file is replaced until LaunchUpdateHelper is // called and the initiating process exits. func PrepareSelfUpdateFromCheck( executablePath string, check *VersionCheckResult, progressCallback SelfUpdateProgressCallback, ) (*PreparedSelfUpdate, error) { return PrepareSelfUpdateFromCheckContext(context.Background(), executablePath, check, progressCallback) } // PrepareSelfUpdateFromCheckContext is the cancellable form used by owned // frontend worker lifecycles. Cancellation aborts and cleans the prepared // transaction before returning. func PrepareSelfUpdateFromCheckContext( ctx context.Context, executablePath string, check *VersionCheckResult, progressCallback SelfUpdateProgressCallback, ) (*PreparedSelfUpdate, error) { if err := ctx.Err(); err != nil { return nil, err } if SelfUpdateDisabled == "true" { return nil, fmt.Errorf("self-update is disabled in this build (installed via package manager)") } if check == nil { return nil, fmt.Errorf("missing update selection") } if check.Error != nil { return nil, check.Error } if !check.UpdateAvailable { return nil, fmt.Errorf("no update available") } if err := validateAuthenticatedUpdateSelection(check); err != nil { return nil, err } if check.URL == "" { return nil, fmt.Errorf("no update asset available for this platform (%s/%s)", runtime.GOOS, runtime.GOARCH) } if strings.TrimSpace(check.ManifestKeyID) == "" { return nil, fmt.Errorf("selected update has no authenticated manifest key ID") } if check.AssetSize <= 0 { return nil, fmt.Errorf("selected update asset has no authenticated size") } if err := validateExpectedBLAKE3(check.BLAKE3); err != nil { return nil, err } primaryPath, err := canonicalExecutablePath(executablePath) if err != nil { return nil, fmt.Errorf("resolve executable path: %w", err) } if err := validateUpdateRecoveryDirectory(filepath.Dir(primaryPath), 1); err != nil { return nil, err } lock, err := tryAcquireUpdateLock(updateTransactionLockPath()) if err != nil { return nil, err } prepared := &PreparedSelfUpdate{lock: lock} cleanupOnError := true defer func() { if cleanupOnError { _ = prepared.abort() } }() if err := recoverTransactionsLocked(primaryPath); err != nil { return nil, err } id := uuid.NewV7().String() primary, err := newUpdateTarget("primary", primaryPath, id) if err != nil { return nil, err } targets := []UpdateTarget{primary} if secondaryPath := strings.TrimSpace(config.GetManagerBinPath()); secondaryPath != "" { if resolvedSecondary, resolveErr := canonicalExecutablePath(secondaryPath); resolveErr == nil && !pathsEqual(resolvedSecondary, primaryPath) { if _, statErr := os.Stat(resolvedSecondary); statErr == nil { if err := validateUpdateRecoveryDirectory(filepath.Dir(resolvedSecondary), 1); err != nil { return nil, err } secondary, targetErr := newUpdateTarget("secondary", resolvedSecondary, id) if targetErr != nil { return nil, targetErr } targets = append(targets, secondary) } else if !os.IsNotExist(statErr) { return nil, fmt.Errorf("stat secondary manager installation: %w", statErr) } } else if resolveErr != nil && !os.IsNotExist(resolveErr) { return nil, fmt.Errorf("resolve secondary manager installation: %w", resolveErr) } } prepared.targets = targets directory := updateTransactionDir(primaryPath, id) if err := os.Mkdir(directory, 0700); err != nil { return nil, fmt.Errorf("create update transaction directory: %w", err) } if err := syncDirectory(filepath.Dir(directory)); err != nil { return nil, fmt.Errorf("sync update transaction parent directory: %w", err) } prepared.TransactionID = id prepared.JournalPath = filepath.Join(directory, updateJournalName) prepared.HelperPath = filepath.Join(directory, helperFileName()) payloadPath := filepath.Join(directory, updatePayloadFileName()) expected := BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: normalizeVersion(check.LatestVersion), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } now := time.Now().UTC() parentToken, err := processIdentityToken(os.Getpid()) if err != nil { return nil, fmt.Errorf("capture initiating process identity: %w", err) } journal := &UpdateJournal{ SchemaVersion: updateJournalSchema, ID: id, State: UpdateTransactionInitializing, CreatedAt: now, UpdatedAt: now, ParentPID: os.Getpid(), ParentToken: parentToken, RelaunchMode: "none", LockPath: updateTransactionLockPath(), HelperPath: prepared.HelperPath, HealthPath: filepath.Join(directory, updateHealthName), PayloadPath: payloadPath, ManifestKeyID: check.ManifestKeyID, ExpectedSize: check.AssetSize, ExpectedHash: check.BLAKE3, Expected: expected, Targets: targets, } // Persist intent before the first fallible download or staging operation. if err := writeUpdateJournal(prepared.JournalPath, journal); err != nil { return nil, err } emitSelfUpdateProgress(progressCallback, SelfUpdateProgress{Phase: SelfUpdateDownloading, Message: "Downloading update"}) if err := downloadFileWithProgressContext(ctx, check.URL, payloadPath, func(written, total int64) { emitSelfUpdateProgress(progressCallback, SelfUpdateProgress{ Phase: SelfUpdateDownloading, Written: written, Total: total, Message: "Downloading update", }) }, 3); err != nil { return nil, err } info, statErr := os.Stat(payloadPath) if statErr != nil { return nil, fmt.Errorf("stat staged update: %w", statErr) } if info.Size() != check.AssetSize { return nil, fmt.Errorf("update size mismatch: got %d, want %d", info.Size(), check.AssetSize) } emitSelfUpdateProgress(progressCallback, SelfUpdateProgress{Phase: SelfUpdateVerifying, Message: "Verifying update"}) if err := verifySelfUpdate(payloadPath, check.BLAKE3); err != nil { return nil, err } if err := os.Chmod(payloadPath, 0755); err != nil { return nil, fmt.Errorf("make staged update executable: %w", err) } if err := verifyUpdatedBinaryContext(ctx, payloadPath, expected); err != nil { return nil, fmt.Errorf("verify staged update identity: %w", err) } for index := range targets { if err := ctx.Err(); err != nil { return nil, err } if err := copyFileDurable(payloadPath, targets[index].StagedPath, 0755); err != nil { return nil, fmt.Errorf("stage %s manager target: %w", targets[index].Role, err) } if err := verifyUpdateArtifact(targets[index].StagedPath, check.AssetSize, check.BLAKE3); err != nil { return nil, fmt.Errorf("verify staged %s manager integrity: %w", targets[index].Role, err) } if err := verifyUpdatedBinaryContext(ctx, targets[index].StagedPath, expected); err != nil { return nil, fmt.Errorf("verify staged %s manager target: %w", targets[index].Role, err) } } if err := copyFileDurable(primaryPath, prepared.HelperPath, 0755); err != nil { return nil, fmt.Errorf("stage update helper: %w", err) } journal.State = UpdateTransactionPrepared if err := writeUpdateJournal(prepared.JournalPath, journal); err != nil { return nil, err } emitSelfUpdateProgress(progressCallback, SelfUpdateProgress{ Phase: SelfUpdateReadyToRestart, Message: "Update verified and ready to install after restart", }) cleanupOnError = false return prepared, nil } // LaunchUpdateHelper hands the durable transaction to a copied helper process. // The helper waits for this process to exit before replacing any target. func (p *PreparedSelfUpdate) LaunchUpdateHelper(relaunchMode string) error { if p == nil || p.JournalPath == "" || p.HelperPath == "" || p.lock == nil { return errors.New("update transaction is not prepared") } if relaunchMode != "none" && relaunchMode != "tui" { return fmt.Errorf("unsupported update relaunch mode %q", relaunchMode) } journal, err := loadUpdateJournal(p.JournalPath) if err != nil { return err } journal.State = UpdateTransactionHelperStarted journal.ParentPID = os.Getpid() parentToken, err := processIdentityToken(journal.ParentPID) if err != nil { return fmt.Errorf("capture update initiator identity: %w", err) } journal.ParentToken = parentToken journal.RelaunchMode = relaunchMode journal.Error = "" if err := writeUpdateJournal(p.JournalPath, journal); err != nil { return err } cmd := newUpdateHelperCommand(p.HelperPath, p.JournalPath) if err := cmd.Start(); err != nil { journal.State = UpdateTransactionPrepared _ = writeUpdateJournal(p.JournalPath, journal) return fmt.Errorf("launch update helper: %w", err) } journal.HelperPID = cmd.Process.Pid helperToken, tokenErr := processIdentityToken(journal.HelperPID) if tokenErr != nil { _ = cmd.Process.Kill() _ = cmd.Wait() journal.State = UpdateTransactionPrepared journal.HelperPID = 0 _ = writeUpdateJournal(p.JournalPath, journal) return fmt.Errorf("capture update helper identity: %w", tokenErr) } journal.HelperToken = helperToken if err := writeUpdateJournal(p.JournalPath, journal); err != nil { _ = cmd.Process.Kill() _ = cmd.Wait() journal.State = UpdateTransactionPrepared journal.HelperPID = 0 journal.HelperToken = "" _ = writeUpdateJournal(p.JournalPath, journal) return fmt.Errorf("persist update helper identity: %w", err) } p.handedOff = true if err := cmd.Process.Release(); err != nil { log.Debug("Could not detach update helper process: " + err.Error()) } if err := p.lock.release(); err != nil { // The helper has already started and must own cleanup from this point. // Closing this process also releases the OS lock, so reporting launch // failure here would invite callers to delete a live transaction. log.Debug("Could not explicitly release update handoff lock: " + err.Error()) } p.lock = nil return nil } func newUpdateHelperCommand(helperPath, journalPath string) *exec.Cmd { cmd := exec.Command(helperPath, "--update-helper="+journalPath) return attachUpdateTerminal(cmd) } func newRelaunchedTUICommand(executablePath, journalPath string, updated bool) *exec.Cmd { arguments := []string{"--post-update-restart=tui"} if updated { arguments = append([]string{"--post-update=" + journalPath}, arguments...) } return attachUpdateTerminal(exec.Command(executablePath, arguments...)) } func attachUpdateTerminal(cmd *exec.Cmd) *exec.Cmd { // Keep the helper attached to the initiating terminal. The helper waits for // this process to exit before emitting output, and its relaunched TUI must // inherit the terminal rather than a helper.log file. cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd } func (p *PreparedSelfUpdate) abort() error { if p == nil { return nil } if p.handedOff { return nil } var cleanupErrors []error if p.JournalPath != "" { if journal, err := loadUpdateJournal(p.JournalPath); err == nil { cleanupErrors = append(cleanupErrors, removeTransactionFiles(journal, true)) } cleanupErrors = append(cleanupErrors, removeTransactionDirectory(filepath.Dir(p.JournalPath))) } for _, target := range p.targets { cleanupErrors = append(cleanupErrors, removeFileDurable(target.StagedPath), removeFileDurable(target.BackupPath)) } if p.lock != nil { cleanupErrors = append(cleanupErrors, p.lock.release()) p.lock = nil } return errors.Join(cleanupErrors...) } // RunUpdateHelper executes the internal post-exit replacement transaction. func RunUpdateHelper(journalPath string) error { return runUpdateHelper(journalPath, true) } func runUpdateHelper(journalPath string, requireCopiedHelper bool) error { journal, err := loadUpdateJournal(journalPath) if err != nil { return err } if requireCopiedHelper { executablePath, executableErr := os.Executable() if executableErr != nil { return fmt.Errorf("resolve update helper executable: %w", executableErr) } executablePath, executableErr = canonicalExecutablePath(executablePath) if executableErr != nil { return fmt.Errorf("resolve update helper path: %w", executableErr) } if !pathsEqual(executablePath, journal.HelperPath) { return fmt.Errorf("update helper must run from the prepared transaction directory") } deadline := time.Now().Add(updateHelperRegisterWait) for journal.HelperPID != os.Getpid() || !processMatches(journal.HelperPID, journal.HelperToken) { if time.Now().After(deadline) { return fmt.Errorf("update helper identity was not registered by the initiating process") } time.Sleep(25 * time.Millisecond) journal, err = loadUpdateJournal(journalPath) if err != nil { return err } } } if journal.State != UpdateTransactionHelperStarted { return fmt.Errorf("update helper cannot run transaction in state %q", journal.State) } if err := waitForProcessExit(journal.ParentPID, journal.ParentToken, updateHelperWaitTimeout); err != nil { journal.State = UpdateTransactionFailed journal.Error = err.Error() _ = writeUpdateJournal(journalPath, journal) recordSelfUpdateOutcome(journal) return err } lock, err := acquireUpdateLockWithRetry(journal.LockPath, updateLockHandoffTimeout) if err != nil { journal.State = UpdateTransactionFailed journal.Error = err.Error() _ = writeUpdateJournal(journalPath, journal) recordSelfUpdateOutcome(journal) return err } defer lock.release() journal, err = loadUpdateJournal(journalPath) if err != nil { return err } journal.State = UpdateTransactionApplying journal.Error = "" if err := writeUpdateJournal(journalPath, journal); err != nil { return err } if err := applyUpdateTargets(journalPath, journal); err != nil { return rollbackHelperTransaction(journalPath, journal, err) } if journal.RelaunchMode == "tui" { journal.State = UpdateTransactionAwaitingHealth if err := writeUpdateJournal(journalPath, journal); err != nil { return rollbackHelperTransaction(journalPath, journal, err) } primary := primaryUpdateTarget(journal) cmd := newRelaunchedTUICommand(primary.Path, journalPath, true) if err := cmd.Start(); err != nil { return rollbackHelperTransaction(journalPath, journal, fmt.Errorf("relaunch updated manager: %w", err)) } journal.ChildPID = cmd.Process.Pid if err := writeUpdateJournal(journalPath, journal); err != nil { _ = cmd.Process.Kill() _ = cmd.Wait() return rollbackHelperTransaction(journalPath, journal, err) } if err := waitForHealthAcknowledgement(journal, cmd, updateHealthTimeout); err != nil { return rollbackHelperTransaction(journalPath, journal, err) } } journal.State = UpdateTransactionCommitted journal.Error = "" for index := range journal.Targets { journal.Targets[index].Outcome = "committed" } if err := writeUpdateJournal(journalPath, journal); err != nil { return rollbackHelperTransaction(journalPath, journal, err) } if err := finalizeUpdateTransaction(journalPath, journal); err != nil { log.Warn("Updated manager committed, but transaction finalization is pending: " + err.Error()) return nil } if err := removeTransactionFiles(journal, false); err != nil { log.Warn("Updated manager committed, but transaction cleanup is incomplete: " + err.Error()) } return nil } func applyUpdateTargets(journalPath string, journal *UpdateJournal) error { for index := range journal.Targets { target := &journal.Targets[index] failTarget := func(err error) error { target.Outcome = "failed" target.Error = err.Error() _ = writeUpdateJournal(journalPath, journal) return err } if _, err := os.Stat(target.StagedPath); err != nil { return failTarget(fmt.Errorf("staged %s target is unavailable: %w", target.Role, err)) } if err := verifyUpdateArtifact(target.StagedPath, journal.ExpectedSize, journal.ExpectedHash); err != nil { return failTarget(fmt.Errorf("staged %s integrity check failed: %w", target.Role, err)) } // Never execute candidate bytes until the helper has independently // rechecked their authenticated size and digest. if err := verifyUpdatedBinary(target.StagedPath, journal.Expected); err != nil { return failTarget(fmt.Errorf("staged %s identity check failed: %w", target.Role, err)) } if err := replaceUpdateFile(target.Path, target.StagedPath, target.BackupPath); err != nil { return failTarget(fmt.Errorf("replace %s target: %w", target.Role, err)) } target.Applied = true target.Outcome = "applied" if err := os.Chmod(target.Path, 0755); err != nil { return failTarget(fmt.Errorf("set %s target permissions: %w", target.Role, err)) } if err := verifyUpdateArtifact(target.Path, journal.ExpectedSize, journal.ExpectedHash); err != nil { return failTarget(fmt.Errorf("verify %s replacement integrity: %w", target.Role, err)) } if err := verifyUpdatedBinary(target.Path, journal.Expected); err != nil { return failTarget(fmt.Errorf("verify %s replacement: %w", target.Role, err)) } if err := writeUpdateJournal(journalPath, journal); err != nil { return failTarget(err) } } return nil } func rollbackHelperTransaction(journalPath string, journal *UpdateJournal, updateErr error) error { rollbackErr := rollbackUpdateTargets(journal) journal.Error = updateErr.Error() if rollbackErr != nil { journal.State = UpdateTransactionFailed journal.Error = fmt.Sprintf("update failed (%v); rollback failed: %v", updateErr, rollbackErr) } else { journal.State = UpdateTransactionRolledBack } _ = writeUpdateJournal(journalPath, journal) if journal.RelaunchMode == "tui" && rollbackErr == nil { primary := primaryUpdateTarget(journal) cmd := newRelaunchedTUICommand(primary.Path, "", false) if err := cmd.Start(); err == nil { _ = cmd.Process.Release() } else { journal.Error += "; relaunch previous manager failed: " + err.Error() _ = writeUpdateJournal(journalPath, journal) } } recordSelfUpdateOutcome(journal) if rollbackErr != nil { return fmt.Errorf("update failed (%v) and rollback failed: %w", updateErr, rollbackErr) } return fmt.Errorf("update failed and previous version was restored: %w", updateErr) } func rollbackUpdateTargets(journal *UpdateJournal) error { var rollbackErrors []error for index := len(journal.Targets) - 1; index >= 0; index-- { target := &journal.Targets[index] if _, err := os.Stat(target.BackupPath); err != nil { // Restoring a backup consumes it on both supported replacement // implementations. A crash can occur before Applied=false reaches // the journal; accept that replay only after authenticating the live // original bytes, before executing them for the identity check. if os.IsNotExist(err) && target.Applied { if restoredErr := validateRestoredUpdateTarget(target); restoredErr == nil { target.Applied = false target.Outcome = "rolled_back" target.Error = "" continue } else { err = fmt.Errorf("backup missing and original target is not restored: %w", restoredErr) } } if target.Applied || !os.IsNotExist(err) { target.Outcome = "rollback_failed" target.Error = fmt.Sprintf("required backup unavailable: %v", err) rollbackErrors = append(rollbackErrors, fmt.Errorf("required %s backup unavailable: %w", target.Role, err)) } continue } if err := validateUpdateRecoveryDirectory(filepath.Dir(target.BackupPath), 1); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, err) continue } if err := validateUpdateRecoveryFile(target.BackupPath); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, err) continue } expectedSize := target.OriginalSize expectedHash := target.OriginalHash expectedIdentity := target.OriginalIdentity if expectedSize <= 0 || expectedHash == "" || expectedIdentity.Version == "" { target.Outcome = "rollback_failed" target.Error = "original backup evidence is missing; manual recovery required" rollbackErrors = append(rollbackErrors, fmt.Errorf("validate %s backup: %s", target.Role, target.Error)) continue } if err := verifyUpdateArtifact(target.BackupPath, expectedSize, expectedHash); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, fmt.Errorf("validate %s backup integrity: %w", target.Role, err)) continue } if err := verifyUpdatedBinary(target.BackupPath, expectedIdentity); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, fmt.Errorf("validate %s backup identity: %w", target.Role, err)) continue } if err := restoreUpdateFile(target.Path, target.BackupPath); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, fmt.Errorf("restore %s backup: %w", target.Role, err)) continue } if err := verifyUpdateArtifact(target.Path, expectedSize, expectedHash); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, fmt.Errorf("verify restored %s integrity: %w", target.Role, err)) continue } if err := verifyUpdatedBinary(target.Path, expectedIdentity); err != nil { target.Outcome = "rollback_failed" target.Error = err.Error() rollbackErrors = append(rollbackErrors, fmt.Errorf("verify restored %s identity: %w", target.Role, err)) continue } target.Applied = false target.Outcome = "rolled_back" target.Error = "" } return errors.Join(rollbackErrors...) } func validateRestoredUpdateTarget(target *UpdateTarget) error { if target.OriginalSize <= 0 || target.OriginalHash == "" || target.OriginalIdentity.Version == "" { return errors.New("original backup evidence is missing; manual recovery required") } if err := validateUpdateRecoveryDirectory(filepath.Dir(target.Path), 1); err != nil { return err } if err := validateUpdateRecoveryFile(target.Path); err != nil { return err } if err := verifyUpdateArtifact(target.Path, target.OriginalSize, target.OriginalHash); err != nil { return err } return verifyUpdatedBinary(target.Path, target.OriginalIdentity) } // Finalization retires rollback authority only after the terminal UX outcome // and its authenticated journal acknowledgement are durable. The helper file // can remain locked until the next startup without owning executable contents. func finalizeUpdateTransaction(journalPath string, journal *UpdateJournal) error { if journal.Finalized { return nil } if err := recordSelfUpdateOutcome(journal); err != nil { return err } journal.Finalized = true if err := writeUpdateJournal(journalPath, journal); err != nil { journal.Finalized = false return err } return nil } func primaryUpdateTarget(journal *UpdateJournal) UpdateTarget { for _, target := range journal.Targets { if target.Role == "primary" { return target } } return UpdateTarget{} } func waitForProcessExit(pid int, processToken string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for processMatches(pid, processToken) { if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for initiating process %d to exit", pid) } time.Sleep(100 * time.Millisecond) } return nil } func waitForHealthAcknowledgement(journal *UpdateJournal, cmd *exec.Cmd, timeout time.Duration) error { deadline := time.Now().Add(timeout) var acknowledgedAt time.Time childDone := make(chan error, 1) go func() { childDone <- cmd.Wait() }() stopAndWait := func(cause error) error { if cmd.Process != nil { _ = cmd.Process.Kill() } select { case <-childDone: return cause case <-time.After(5 * time.Second): return fmt.Errorf("%w; timed out waiting for updated manager to exit", cause) } } for time.Now().Before(deadline) { if acknowledgedAt.IsZero() { data, err := os.ReadFile(journal.HealthPath) if err == nil { var acknowledgement updateHealthAcknowledgement if err := json.Unmarshal(data, &acknowledgement); err != nil { return stopAndWait(fmt.Errorf("decode update health acknowledgement: %w", err)) } if acknowledgement.TransactionID != journal.ID { return stopAndWait(fmt.Errorf("health acknowledgement transaction mismatch")) } if err := validateBinaryIdentity(acknowledgement.Identity, journal.Expected); err != nil { return stopAndWait(fmt.Errorf("health acknowledgement identity mismatch: %w", err)) } acknowledgedAt = time.Now() } else if !os.IsNotExist(err) { return stopAndWait(fmt.Errorf("read update health acknowledgement: %w", err)) } } select { case childErr := <-childDone: if childErr == nil { return fmt.Errorf("updated manager exited before post-update health stabilization") } return fmt.Errorf("updated manager exited before post-update health stabilization: %w", childErr) default: } if !acknowledgedAt.IsZero() && time.Since(acknowledgedAt) >= updateHealthStabilize { return nil } time.Sleep(100 * time.Millisecond) } return stopAndWait(fmt.Errorf("updated manager did not acknowledge health within %s", timeout)) } // AcknowledgeSelfUpdate is called by the relaunched binary after normal // process initialization has reached the selected run mode. func AcknowledgeSelfUpdate(journalPath string) error { journal, err := loadUpdateJournal(journalPath) if err != nil { return err } if journal.State != UpdateTransactionAwaitingHealth { return fmt.Errorf("transaction is not awaiting health acknowledgement") } executablePath, err := os.Executable() if err != nil { return err } executablePath, err = canonicalExecutablePath(executablePath) if err != nil { return err } primary := primaryUpdateTarget(journal) if primary.Path == "" || !pathsEqual(primary.Path, executablePath) { return fmt.Errorf("health acknowledgement executable does not match transaction target") } identity := GetBinaryIdentity() if err := validateBinaryIdentity(identity, journal.Expected); err != nil { return err } acknowledgement := updateHealthAcknowledgement{ TransactionID: journal.ID, Identity: identity, AcknowledgedAt: time.Now().UTC(), } return writeJSONAtomicDurable(journal.HealthPath, acknowledgement, 0600) } func writeUpdateJournal(path string, journal *UpdateJournal) error { journal.UpdatedAt = time.Now().UTC() if err := validateUpdateJournal(path, journal); err != nil { return err } if err := authenticateUpdateJournal(journal); err != nil { return err } return writeJSONAtomicDurable(path, journal, 0600) } func loadUpdateJournal(path string) (*UpdateJournal, error) { // Persisted JSON is authority only when other users cannot plant or // replace it. A key ID inside the JSON is not proof of that authority. if err := validateUpdateRecoveryDirectory(filepath.Dir(path), 2); err != nil { return nil, err } if err := validateUpdateRecoveryFile(path); err != nil { return nil, err } file, err := openUpdateJournal(path) if err != nil { return nil, fmt.Errorf("open update journal: %w", err) } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) if err != nil { return nil, fmt.Errorf("read update journal: %w", err) } if len(data) > 1<<20 { return nil, fmt.Errorf("update journal exceeds 1 MiB") } var journal UpdateJournal if err := json.Unmarshal(data, &journal); err != nil { return nil, fmt.Errorf("decode update journal: %w", err) } if err := verifyUpdateJournalAuthentication(&journal); err != nil { return nil, err } if err := validateUpdateJournal(path, &journal); err != nil { return nil, err } return &journal, nil } func validateUpdateJournal(path string, journal *UpdateJournal) error { if journal.SchemaVersion != updateJournalSchema { return fmt.Errorf("unsupported update journal schema %d", journal.SchemaVersion) } if journal.Finalized && journal.State != UpdateTransactionCommitted && journal.State != UpdateTransactionRolledBack { return fmt.Errorf("finalized update journal is not successfully terminal") } parsedID, err := uuid.Parse(journal.ID) if err != nil || parsedID.String() != journal.ID { return fmt.Errorf("invalid update transaction ID %q", journal.ID) } directory := filepath.Clean(filepath.Dir(path)) if filepath.Base(directory) != updateTransactionPrefix+journal.ID || filepath.Base(path) != updateJournalName { return fmt.Errorf("update journal path does not match transaction ID") } if !pathsEqual(journal.HelperPath, filepath.Join(directory, helperFileName())) || !pathsEqual(journal.HealthPath, filepath.Join(directory, updateHealthName)) || !pathsEqual(journal.PayloadPath, filepath.Join(directory, updatePayloadFileName())) { return fmt.Errorf("update journal contains invalid transaction-local paths") } if len(journal.Targets) == 0 { return fmt.Errorf("update journal has no targets") } if strings.TrimSpace(journal.ManifestKeyID) == "" { return fmt.Errorf("update journal has no authenticated manifest key ID") } if journal.ExpectedSize <= 0 { return fmt.Errorf("update journal has no authenticated asset size") } if err := validateExpectedBLAKE3(journal.ExpectedHash); err != nil { return fmt.Errorf("update journal has invalid expected hash: %w", err) } if journal.Expected.Product != ProductID || journal.Expected.Component != ComponentID || journal.Expected.Version == "" || journal.Expected.GOOS != runtime.GOOS || journal.Expected.GOARCH != runtime.GOARCH { return fmt.Errorf("update journal contains an invalid expected binary identity") } if journal.OriginJobID != "" { originID, err := uuid.Parse(journal.OriginJobID) if err != nil || originID.String() != journal.OriginJobID { return fmt.Errorf("update journal contains an invalid origin job ID") } } primaryCount := 0 seen := make(map[string]bool) for _, target := range journal.Targets { if target.Role == "primary" { primaryCount++ } if target.Role != "primary" && target.Role != "secondary" { return fmt.Errorf("invalid update target role %q", target.Role) } if !filepath.IsAbs(target.Path) || seen[target.Path] { return fmt.Errorf("invalid or duplicate update target path %q", target.Path) } seen[target.Path] = true if !pathsEqual(target.StagedPath, stagedTargetPath(target.Path, journal.ID)) || !pathsEqual(target.BackupPath, backupTargetPath(target.Path, journal.ID)) { return fmt.Errorf("update target %q contains invalid staging paths", target.Path) } hasOriginalEvidence := target.OriginalSize > 0 || target.OriginalHash != "" || target.OriginalIdentity.Version != "" if hasOriginalEvidence { if target.OriginalSize <= 0 || target.OriginalHash == "" || target.OriginalIdentity.Version == "" { return fmt.Errorf("update target %q contains incomplete original evidence", target.Path) } if err := validateExpectedBLAKE3(target.OriginalHash); err != nil { return fmt.Errorf("update target %q contains invalid original hash: %w", target.Path, err) } expectedOriginal := BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: target.OriginalIdentity.Version, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } if err := validateBinaryIdentity(target.OriginalIdentity, expectedOriginal); err != nil { return fmt.Errorf("update target %q contains invalid original identity: %w", target.Path, err) } } } if primaryCount != 1 { return fmt.Errorf("update journal has %d primary targets", primaryCount) } primary := primaryUpdateTarget(journal) if !pathsEqual(filepath.Dir(directory), filepath.Dir(primary.Path)) { return fmt.Errorf("update transaction directory is not adjacent to its primary target") } if !pathsEqual(journal.LockPath, updateTransactionLockPath()) && !pathsEqual(journal.LockPath, updateLockPath(primary.Path)) { return fmt.Errorf("update journal lock path is not an accepted transaction lock") } return nil } func validateExpectedBLAKE3(expectedHash string) error { digest, ok := strings.CutPrefix(expectedHash, "blake3:") if !ok || len(digest) != 64 { return fmt.Errorf("selected update asset has an invalid BLAKE3 digest") } if _, err := hex.DecodeString(digest); err != nil { return fmt.Errorf("selected update asset has an invalid BLAKE3 digest: %w", err) } return nil } func verifyUpdateArtifact(path string, expectedSize int64, expectedHash string) error { info, err := os.Stat(path) if err != nil { return err } if info.Size() != expectedSize { return fmt.Errorf("size mismatch: got %d, want %d", info.Size(), expectedSize) } return verifySelfUpdate(path, expectedHash) } func writeJSONAtomicDurable(path string, value any, mode os.FileMode) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return err } temp, err := os.CreateTemp(filepath.Dir(path), ".journal-*.tmp") if err != nil { return err } tempPath := temp.Name() cleanup := true defer func() { _ = temp.Close() if cleanup { _ = os.Remove(tempPath) } }() if err := temp.Chmod(mode); err != nil { return err } if _, err := temp.Write(data); err != nil { return err } if err := temp.Sync(); err != nil { return err } if err := temp.Close(); err != nil { return err } if err := moveUpdateFileReplacing(tempPath, path); err != nil { return err } cleanup = false if err := syncDirectory(filepath.Dir(path)); err != nil { return fmt.Errorf("sync update transaction directory: %w", err) } return nil } func copyFileDurable(sourcePath, destinationPath string, mode os.FileMode) error { source, err := os.Open(sourcePath) if err != nil { return err } defer source.Close() destination, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) if err != nil { return err } removeOnError := true defer func() { _ = destination.Close() if removeOnError { _ = os.Remove(destinationPath) } }() if _, err := io.Copy(destination, source); err != nil { return err } if err := destination.Sync(); err != nil { return err } if err := destination.Close(); err != nil { return err } removeOnError = false return syncDirectory(filepath.Dir(destinationPath)) } func removeFileDurable(path string) error { if strings.TrimSpace(path) == "" { return nil } if err := os.Remove(path); err != nil { if os.IsNotExist(err) { return nil } return err } return syncDirectory(filepath.Dir(path)) } func removeTransactionDirectory(path string) error { if strings.TrimSpace(path) == "" { return nil } if err := os.RemoveAll(path); err != nil { return err } return syncDirectory(filepath.Dir(path)) } func removeTransactionFiles(journal *UpdateJournal, includeStaged bool) error { var cleanupErrors []error for _, target := range journal.Targets { cleanupErrors = append(cleanupErrors, removeFileDurable(target.BackupPath)) if includeStaged { cleanupErrors = append(cleanupErrors, removeFileDurable(target.StagedPath)) } } cleanupErrors = append(cleanupErrors, removeFileDurable(journal.PayloadPath), removeFileDurable(journal.HealthPath)) return errors.Join(cleanupErrors...) } // RecoverSelfUpdateTransactions safely rolls back or cleans any transaction // left by interruption. An active lock/helper is never disturbed. func RecoverSelfUpdateTransactions(executablePath string) { primaryPath, err := canonicalExecutablePath(executablePath) if err != nil { log.Error("Could not resolve executable for update recovery: " + err.Error()) return } lock, err := tryAcquireUpdateLock(updateTransactionLockPath()) if errors.Is(err, ErrUpdateInProgress) { return } if err != nil { log.Error("Could not acquire update recovery lock: " + err.Error()) return } defer lock.release() if err := recoverTransactionsLocked(primaryPath); err != nil { log.Error("Could not recover update transaction: " + err.Error()) } } func recoverTransactionsLocked(primaryPath string) error { pattern := filepath.Join(filepath.Dir(primaryPath), updateTransactionPrefix+"*") directories, err := filepath.Glob(pattern) if err != nil { return err } var recoveryErrors []error for _, directory := range directories { journalPath := filepath.Join(directory, updateJournalName) journal, err := loadUpdateJournal(journalPath) if err != nil { // A missing authentication key is not a missing journal. It must // never authorize pre-journal cleanup of recovery evidence. _, journalStatErr := os.Lstat(journalPath) if errors.Is(err, os.ErrNotExist) && errors.Is(journalStatErr, os.ErrNotExist) { if orphanErr := recoverPreJournalTransaction(primaryPath, directory); orphanErr != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("recover orphan %s: %w", directory, orphanErr)) } continue } recoveryErrors = append(recoveryErrors, fmt.Errorf("inspect %s: %w", journalPath, err)) continue } primary := primaryUpdateTarget(journal) if !pathsEqual(primary.Path, primaryPath) { continue } if (journal.State == UpdateTransactionHelperStarted && processMatches(journal.ParentPID, journal.ParentToken)) || (journal.HelperPID > 0 && processMatches(journal.HelperPID, journal.HelperToken)) { recoveryErrors = append(recoveryErrors, ErrUpdateInProgress) continue } switch journal.State { case UpdateTransactionCommitted: if validateErr := func() error { if journal.Finalized { return nil } return validateCommittedUpdateTargets(journal) }(); validateErr != nil { if rollbackErr := rollbackUpdateTargets(journal); rollbackErr != nil { journal.State = UpdateTransactionFailed journal.Error = fmt.Sprintf("committed target validation failed (%v); rollback failed: %v", validateErr, rollbackErr) _ = writeUpdateJournal(journalPath, journal) recoveryErrors = append(recoveryErrors, errors.New(journal.Error)) continue } journal.State = UpdateTransactionRolledBack journal.Error = "committed target validation failed during recovery; previous version restored: " + validateErr.Error() _ = writeUpdateJournal(journalPath, journal) log.Warn(fmt.Sprintf("Previous self-update %s rolled back: %s", journal.ID, journal.Error)) } if err := finalizeUpdateTransaction(journalPath, journal); err != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("finalize transaction %s: %w", journal.ID, err)) continue } if cleanupErr := removeTransactionFiles(journal, true); cleanupErr != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("clean transaction %s: %w", journal.ID, cleanupErr)) continue } case UpdateTransactionRolledBack: if journal.Error != "" { log.Warn(fmt.Sprintf("Previous self-update %s rolled back: %s", journal.ID, journal.Error)) } if err := finalizeUpdateTransaction(journalPath, journal); err != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("finalize transaction %s: %w", journal.ID, err)) continue } if cleanupErr := removeTransactionFiles(journal, true); cleanupErr != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("clean transaction %s: %w", journal.ID, cleanupErr)) continue } case UpdateTransactionInitializing, UpdateTransactionPrepared, UpdateTransactionHelperStarted: if cleanupErr := removeTransactionFiles(journal, true); cleanupErr != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("discard transaction %s: %w", journal.ID, cleanupErr)) continue } journal.State = UpdateTransactionRolledBack journal.Error = "stale prepared update discarded during startup recovery" for index := range journal.Targets { journal.Targets[index].Outcome = "discarded" } _ = writeUpdateJournal(journalPath, journal) case UpdateTransactionApplying, UpdateTransactionAwaitingHealth, UpdateTransactionFailed: if rollbackErr := rollbackUpdateTargets(journal); rollbackErr != nil { journal.State = UpdateTransactionFailed journal.Error = "interrupted update rollback failed during startup recovery: " + rollbackErr.Error() _ = writeUpdateJournal(journalPath, journal) recordSelfUpdateOutcome(journal) recoveryErrors = append(recoveryErrors, rollbackErr) continue } journal.State = UpdateTransactionRolledBack journal.Error = "interrupted update restored during startup recovery" _ = writeUpdateJournal(journalPath, journal) if err := finalizeUpdateTransaction(journalPath, journal); err != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("finalize recovered transaction %s: %w", journal.ID, err)) continue } if cleanupErr := removeTransactionFiles(journal, true); cleanupErr != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("clean recovered transaction %s: %w", journal.ID, cleanupErr)) continue } default: recoveryErrors = append(recoveryErrors, fmt.Errorf("transaction %s has unknown state %q", journal.ID, journal.State)) continue } if err := removeTransactionDirectory(directory); err != nil { recoveryErrors = append(recoveryErrors, fmt.Errorf("remove transaction directory %s: %w", directory, err)) } } return errors.Join(recoveryErrors...) } func recoverPreJournalTransaction(primaryPath, directory string) error { info, err := os.Lstat(directory) if err != nil { return err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("orphan transaction path is not a real directory") } name := filepath.Base(directory) id, ok := strings.CutPrefix(name, updateTransactionPrefix) if !ok { return fmt.Errorf("invalid orphan transaction directory name") } parsedID, err := uuid.Parse(id) if err != nil || parsedID.String() != id { return fmt.Errorf("invalid orphan transaction ID %q", id) } backupPath := backupTargetPath(primaryPath, id) if _, err := os.Stat(backupPath); err == nil { return fmt.Errorf("orphan has a primary backup and requires manual inspection: %s", backupPath) } else if !os.IsNotExist(err) { return fmt.Errorf("inspect orphan primary backup: %w", err) } if primaryInfo, err := os.Stat(primaryPath); err != nil || !primaryInfo.Mode().IsRegular() { if err != nil { return fmt.Errorf("inspect primary before orphan cleanup: %w", err) } return fmt.Errorf("primary is not a regular file") } if err := removeFileDurable(stagedTargetPath(primaryPath, id)); err != nil { return fmt.Errorf("remove orphan primary staging file: %w", err) } if err := removeTransactionDirectory(directory); err != nil { return err } log.Warn("Removed pre-journal self-update transaction " + id) return nil } func validateCommittedUpdateTargets(journal *UpdateJournal) error { var validationErrors []error for _, target := range journal.Targets { if err := verifyUpdateArtifact(target.Path, journal.ExpectedSize, journal.ExpectedHash); err != nil { validationErrors = append(validationErrors, fmt.Errorf("%s target integrity: %w", target.Role, err)) continue } if err := verifyUpdatedBinary(target.Path, journal.Expected); err != nil { validationErrors = append(validationErrors, fmt.Errorf("%s target identity: %w", target.Role, err)) } } return errors.Join(validationErrors...) }