// Package updater performs the constrained on-disk activation of a prepared // SoftBox self-update. It deliberately does not download, verify, or select an // update package. package updater import ( "context" "errors" "fmt" "time" ) const ( // ProductExecutableName is the only executable the updater may start. ProductExecutableName = "SoftBox.exe" // InternalHealthFlag is accepted only by SoftBox itself after an update. InternalHealthFlag = "--softbox-update-health" ) var ( ErrInvalidRequest = errors.New("invalid self-update request") ErrUnsafeLayout = errors.New("unsafe self-update layout") ErrStagingMissing = errors.New("self-update staging directory is missing") ErrBackupExists = errors.New("self-update backup directory already exists") ErrTransactionCorrupt = errors.New("self-update transaction is corrupt") ErrRecoveryRequired = errors.New("self-update recovery is required") ErrParentWait = errors.New("wait for SoftBox parent process") ErrLaunch = errors.New("launch updated SoftBox") ErrHealthTimeout = errors.New("updated SoftBox health confirmation timed out") ErrHealthInvalid = errors.New("updated SoftBox health confirmation is invalid") ) // Request identifies one prepared self-update. StagingDir and TargetDir are // both checked against the fixed layout; callers cannot choose arbitrary move // endpoints. type Request struct { ParentPID int StagingDir string TargetDir string RequestID string } // StartCommand contains the only process launch allowed by this package. // Platform adapters must pass precisely the fixed internal health flag. type StartCommand struct { Entrypoint string WorkingDirectory string HealthRequestID string } // ProcessWaiter waits for the old main-process PID to end naturally. type ProcessWaiter interface { WaitForProcessExit(context.Context, int, time.Duration) error } // Launcher starts the verified new main executable without a shell. type Launcher interface { StartSelfUpdate(StartCommand) (int, error) } // DirectorySyncer is implemented by the platform boundary. Directory flushing // requires a Windows handle implementation and must not leak into core. type DirectorySyncer interface { SyncDirectory(path string) error } // HealthWaiter observes the minimal acknowledgement written by the new main // process. It must only accept the exact request ID. type HealthWaiter interface { WaitForHealth(context.Context, string, string, time.Duration) error } // Timeouts controls externally-blocking update operations. type Timeouts struct { ParentExit time.Duration Health time.Duration } // Service owns one constrained self-update orchestration. type Service struct { waiter ProcessWaiter launcher Launcher syncer DirectorySyncer health HealthWaiter timeouts Timeouts } // NewService constructs an updater. Missing dependencies are reported by // Update, keeping command composition straightforward. func NewService( waiter ProcessWaiter, launcher Launcher, syncer DirectorySyncer, health HealthWaiter, timeouts Timeouts, ) *Service { if timeouts.ParentExit <= 0 { timeouts.ParentExit = 2 * time.Minute } if timeouts.Health <= 0 { timeouts.Health = 45 * time.Second } return &Service{waiter: waiter, launcher: launcher, syncer: syncer, health: health, timeouts: timeouts} } // Update waits for the old process, recovers a previous interrupted switch if // needed, and activates the prepared directory. It never kills a process. func (service *Service) Update(ctx context.Context, request Request) error { if service == nil || service.waiter == nil || service.launcher == nil || service.syncer == nil || service.health == nil { return fmt.Errorf("%w: updater dependencies are incomplete", ErrInvalidRequest) } layout, err := inspectRequest(request) if err != nil { return err } if err := service.waiter.WaitForProcessExit(ctx, request.ParentPID, service.timeouts.ParentExit); err != nil { return fmt.Errorf("%w: %w", ErrParentWait, err) } if err := recoverLayout(layout, service.syncer); err != nil { return err } if err := layout.validateReady(service.syncer); err != nil { return err } if err := removeHealth(layout, service.syncer); err != nil { return err } if err := writeTransaction(layout, phasePrepared, service.syncer); err != nil { return err } if err := renameDirectory(layout.target, layout.backup, layout, service.syncer, "back up current app"); err != nil { return service.failBeforeActivation(layout, err) } if err := writeTransaction(layout, phaseTargetBackedUp, service.syncer); err != nil { return service.rollback(layout, err) } if err := renameDirectory(layout.staging, layout.target, layout, service.syncer, "activate staged app"); err != nil { return service.rollback(layout, err) } if err := writeTransaction(layout, phaseStagingActivated, service.syncer); err != nil { return service.rollback(layout, err) } entrypoint := layout.entrypoint() if err := validateRegularFile(entrypoint); err != nil { return service.rollback(layout, fmt.Errorf("%w: %v", ErrUnsafeLayout, err)) } if _, err := service.launcher.StartSelfUpdate(StartCommand{ Entrypoint: entrypoint, WorkingDirectory: layout.target, HealthRequestID: layout.requestID, }); err != nil { return service.rollback(layout, fmt.Errorf("%w: %w", ErrLaunch, err)) } if err := writeTransaction(layout, phaseLaunched, service.syncer); err != nil { return service.rollback(layout, err) } if err := service.health.WaitForHealth(ctx, layout.target, layout.requestID, service.timeouts.Health); err != nil { return service.rollback(layout, err) } if err := writeTransaction(layout, phaseCommitted, service.syncer); err != nil { return service.rollback(layout, err) } if err := removeManagedTree(layout.backup, layout, service.syncer); err != nil { return fmt.Errorf("commit self-update: %w", err) } if err := removeTransaction(layout, service.syncer); err != nil { return err } if err := removeHealth(layout, service.syncer); err != nil { return err } return nil } func (service *Service) failBeforeActivation(layout updateLayout, cause error) error { prepareErr := fmt.Errorf("prepare self-update: %w", cause) if recoveryErr := recoverLayout(layout, service.syncer); recoveryErr != nil { return errors.Join(prepareErr, recoveryErr) } return prepareErr } func (service *Service) rollback(layout updateLayout, cause error) error { rollbackErr := rollbackLayout(layout, service.syncer) if rollbackErr != nil { return errors.Join(cause, rollbackErr) } return cause } // Recover restores or finalizes a previously interrupted transaction for the // fixed target directory. Callers must wait for any old parent process first. func Recover(targetDir string, syncer DirectorySyncer) error { if syncer == nil { return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest) } layout, err := inspectTarget(targetDir) if err != nil { return err } return recoverLayout(layout, syncer) }