package install import ( "errors" "fmt" "path/filepath" "softbox.local/core/catalog" "softbox.local/core/installer" "softbox.local/core/storage" ) var ( ErrInstallServiceConfig = errors.New("invalid install service configuration") ErrInstallRequestInvalid = errors.New("invalid install request") ErrInstallRecordWrite = errors.New("write installed app record") ) // InstallStage makes the security-sensitive installation path observable to a // background caller without giving the Gio layout any filesystem work. type InstallStage string const ( InstallStageVerify InstallStage = "verify" InstallStageManifest InstallStage = "manifest" InstallStageExtract InstallStage = "extract" InstallStageRecover InstallStage = "recover" InstallStageSwitch InstallStage = "switch" InstallStageHealth InstallStage = "health" InstallStageRecord InstallStage = "record" InstallStageRollback InstallStage = "rollback" ) // InstallError preserves a stable stage and its underlying cause. type InstallError struct { Stage InstallStage Err error } func (err *InstallError) Error() string { return fmt.Sprintf("install %s: %v", err.Stage, err.Err) } func (err *InstallError) Unwrap() error { return err.Err } // InstallRecordStore provides the app-root and installed-app record boundary // needed by an installation transaction. type InstallRecordStore interface { EnsureAppRoot(appID string) (string, error) Write(record storage.InstalledApp) error } // InstallRequest joins an untrusted completed download with the trusted // Catalog selection that describes it. type InstallRequest struct { Entry catalog.Entry Architecture catalog.Architecture DownloadPath string } // InstallResult describes an installed version after the switch commits. type InstallResult struct { AppID string Version string EntrypointPath string Recovery installer.RecoveryResult } // InstallService implements the core-only verified package installation use // case. The caller must supply entries produced by catalog.Client. type InstallService struct { extractor installer.Extractor records InstallRecordStore health installer.HealthCheck } func NewInstallService( extractor installer.Extractor, records InstallRecordStore, health installer.HealthCheck, ) (*InstallService, error) { if records == nil { return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig) } if health == nil { return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired) } return &InstallService{ extractor: extractor, records: records, health: health, }, nil } // Install verifies and extracts one completed download, then atomically // switches staging into current. Metadata is written inside the Switcher // health phase so a write failure follows the same rollback path as health. func (service *InstallService) Install(request InstallRequest) (InstallResult, error) { expectation, record, err := resolveInstallRequest(request) if err != nil { return InstallResult{}, installError(InstallStageVerify, err) } appRoot, err := service.records.EnsureAppRoot(record.ID) if err != nil { return InstallResult{}, installError(InstallStageRecover, err) } recovery, err := installer.Recover(appRoot) if err != nil { return InstallResult{}, installError(InstallStageRecover, err) } extracted, err := service.extractor.ExtractVerifiedFile( request.DownloadPath, filepath.Join(appRoot, "staging"), expectation, ) if err != nil { return InstallResult{}, installError(stageForPackageError(err), err) } record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles)) for _, file := range extracted.PayloadFiles { record.Files = append(record.Files, storage.InstalledFile{ Path: file.Path, Size: file.Size, SHA256: file.SHA256, }) } var recordWriteErr error switcher := installer.NewSwitcher(func(currentPath string) error { if err := service.health(currentPath); err != nil { return err } if err := service.records.Write(record); err != nil { recordWriteErr = err return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err) } return nil }) if err := switcher.Switch(appRoot); err != nil { return InstallResult{}, installError(stageForSwitchError(err, recordWriteErr), err) } return InstallResult{ AppID: record.ID, Version: record.Version, EntrypointPath: filepath.Join(appRoot, "current", request.Entry.App.EntryEXE), Recovery: recovery, }, nil } func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) { if request.DownloadPath == "" { return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( "%w: completed download path is empty", ErrInstallRequestInvalid, ) } if !request.Entry.Installable || request.Entry.Package == nil { return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( "%w: Catalog entry is not installable", ErrInstallRequestInvalid, ) } if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 { return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( "%w: unsupported architecture %q", ErrInstallRequestInvalid, request.Architecture, ) } publishedPackage, exists := request.Entry.App.Packages[request.Architecture] if !exists || publishedPackage != *request.Entry.Package { return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( "%w: selected package does not match app architecture", ErrInstallRequestInvalid, ) } app := request.Entry.App return installer.PackageExpectation{ Size: publishedPackage.Size, SHA256: publishedPackage.SHA256, App: installer.AppExpectation{ ID: app.ID, Version: app.Version, Channel: string(app.Channel), MinOS: string(app.MinOS), Architecture: string(request.Architecture), Entrypoint: app.EntryEXE, RequiresAdmin: app.RequiresAdmin, }, }, storage.InstalledApp{ SchemaVersion: 1, ID: app.ID, Version: app.Version, Architecture: string(request.Architecture), Channel: string(app.Channel), Files: []storage.InstalledFile{}, }, nil } func installError(stage InstallStage, err error) error { return &InstallError{Stage: stage, Err: err} } func stageForPackageError(err error) InstallStage { var packageErr *installer.PackageError if errors.As(err, &packageErr) { switch packageErr.Stage { case installer.PackageStageManifest: return InstallStageManifest case installer.PackageStageExtract: return InstallStageExtract } } return InstallStageVerify } func stageForSwitchError(err error, recordWriteErr error) InstallStage { if errors.Is(err, installer.ErrRollbackFailed) { return InstallStageRollback } if recordWriteErr != nil { return InstallStageRecord } if errors.Is(err, installer.ErrHealthCheckFailed) { return InstallStageHealth } return InstallStageSwitch }