package install import ( "errors" "fmt" "math" "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") ErrDiskSpaceInsufficient = errors.New("insufficient disk space for staging") ErrDiskSpaceCheck = errors.New("disk space check failed") ErrTargetRunning = errors.New("installed app is running") ErrTargetStateCheck = errors.New("target state check failed") ) const StagingDiskReserveBytes int64 = 64 * 1024 * 1024 // 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" InstallStagePreflight InstallStage = "preflight" InstallStageExtract InstallStage = "extract" InstallStageRecover InstallStage = "recover" InstallStageSwitch InstallStage = "switch" InstallStageHealth InstallStage = "health" InstallStageRecord InstallStage = "record" InstallStageRollback InstallStage = "rollback" ) // FailureCode is the stable, non-localized result of an installation // attempt. UI code may localize this code but must not display raw errors. type FailureCode string const ( FailureCodeHashMismatch FailureCode = "hash_mismatch" FailureCodeZIPPathEscape FailureCode = "zip_path_escape" FailureCodeZIPCorrupt FailureCode = "zip_corrupt" FailureCodePackageInvalid FailureCode = "package_invalid" FailureCodeDiskFull FailureCode = "disk_full" FailureCodeDiskCheckFailed FailureCode = "disk_check_failed" FailureCodeAppRunning FailureCode = "app_running" FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable" FailureCodeInstallFailed FailureCode = "install_failed" ) // InstallError preserves a stable stage and its underlying cause. type InstallError struct { Stage InstallStage Code FailureCode Err error } func (err *InstallError) Error() string { return fmt.Sprintf("install %s (%s): %v", err.Stage, err.Code, 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 } // DiskSpaceChecker reports bytes currently available on the volume that // contains appRoot. Platform-specific implementations stay outside core. type DiskSpaceChecker interface { AvailableBytes(appRoot string) (int64, error) } // StorageFailureClassifier lets the platform identify a preserved staging I/O // failure as insufficient storage without importing platform APIs into core. type StorageFailureClassifier interface { IsDiskFull(err error) bool } // TargetStateChecker reports whether the verified current entrypoint is still // running. It never starts, waits for, or terminates a process. type TargetStateChecker interface { IsRunning(appID string, entrypointPath string) (bool, 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 } // InstallServiceConfig makes all external installation dependencies explicit. // Disk, storage-failure and target-state checks are mandatory so no caller can // silently bypass the pre-extract safety boundary or disk-full diagnosis. type InstallServiceConfig struct { Extractor installer.Extractor Records InstallRecordStore Health installer.HealthCheck DiskSpace DiskSpaceChecker StorageFailures StorageFailureClassifier TargetState TargetStateChecker } // 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 diskSpace DiskSpaceChecker storageFailures StorageFailureClassifier targetState TargetStateChecker } func NewInstallService(config InstallServiceConfig) (*InstallService, error) { if config.Records == nil { return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig) } if config.Health == nil { return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired) } if config.DiskSpace == nil { return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig) } if config.StorageFailures == nil { return nil, fmt.Errorf("%w: storage failure classifier is required", ErrInstallServiceConfig) } if config.TargetState == nil { return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig) } return &InstallService{ extractor: config.Extractor, records: config.Records, health: config.Health, diskSpace: config.DiskSpace, storageFailures: config.StorageFailures, targetState: config.TargetState, }, 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{}, service.installError(InstallStageVerify, err) } appRoot, err := service.records.EnsureAppRoot(record.ID) if err != nil { return InstallResult{}, service.installError(InstallStageRecover, err) } recovery, err := installer.Recover(appRoot) if err != nil { return InstallResult{}, service.installError(InstallStageRecover, err) } extracted, err := service.extractor.ExtractVerifiedFileWithCheck( request.DownloadPath, filepath.Join(appRoot, "staging"), expectation, service.preExtractCheck(appRoot, record.ID), ) if err != nil { return InstallResult{}, service.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, }) } record.Entrypoint = expectation.App.Entrypoint record.WorkingDirectory = extracted.WorkingDir record.MinOS = expectation.App.MinOS record.RequiresAdmin = expectation.App.RequiresAdmin var recordWriteErr error switcher := installer.NewSwitcherWithPreSwitchCheck(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 }, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint)) if err := switcher.Switch(appRoot); err != nil { return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err) } return InstallResult{ AppID: record.ID, Version: record.Version, EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint), Recovery: recovery, }, nil } func (service *InstallService) preSwitchCheck( appRoot string, appID string, entrypoint string, ) installer.PreSwitchCheck { return func() error { running, err := service.targetState.IsRunning( appID, filepath.Join(appRoot, "current", entrypoint), ) if err != nil { return fmt.Errorf("%w: %w", ErrTargetStateCheck, err) } if running { return ErrTargetRunning } return nil } } func (service *InstallService) preExtractCheck( appRoot string, appID string, ) installer.PreExtractCheck { return func(verified installer.VerifiedPackage) error { required, err := requiredStagingBytes(verified.PayloadBytes) if err != nil { return err } available, err := service.diskSpace.AvailableBytes(appRoot) if err != nil { return fmt.Errorf("%w: %w", ErrDiskSpaceCheck, err) } if available < 0 { return fmt.Errorf("%w: negative available bytes", ErrDiskSpaceCheck) } if available < required { return fmt.Errorf("%w: available=%d required=%d", ErrDiskSpaceInsufficient, available, required) } running, err := service.targetState.IsRunning( appID, filepath.Join(appRoot, "current", verified.Entrypoint), ) if err != nil { return fmt.Errorf("%w: %w", ErrTargetStateCheck, err) } if running { return ErrTargetRunning } return nil } } func requiredStagingBytes(payloadBytes int64) (int64, error) { if payloadBytes < 0 || payloadBytes > math.MaxInt64-StagingDiskReserveBytes { return 0, fmt.Errorf("%w: invalid payload size", ErrDiskSpaceCheck) } return payloadBytes + StagingDiskReserveBytes, 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 (service *InstallService) installError(stage InstallStage, err error) error { return &InstallError{Stage: stage, Code: service.failureCodeFor(err), Err: err} } func (service *InstallService) failureCodeFor(err error) FailureCode { if errors.Is(err, installer.ErrStagingOutput) && service.storageFailures.IsDiskFull(err) { return FailureCodeDiskFull } return failureCodeFor(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.PackageStagePreflight: return InstallStagePreflight case installer.PackageStageExtract: return InstallStageExtract } } return InstallStageVerify } func failureCodeFor(err error) FailureCode { switch { case errors.Is(err, ErrDiskSpaceInsufficient): return FailureCodeDiskFull case errors.Is(err, ErrDiskSpaceCheck): return FailureCodeDiskCheckFailed case errors.Is(err, ErrTargetRunning): return FailureCodeAppRunning case errors.Is(err, ErrTargetStateCheck): return FailureCodeTargetStateUnavailable case errors.Is(err, installer.ErrPackageHashMismatch), errors.Is(err, installer.ErrArchiveSizeMismatch): return FailureCodeHashMismatch case errors.Is(err, installer.ErrPathEscape), errors.Is(err, installer.ErrEntrypointInvalid): return FailureCodeZIPPathEscape case errors.Is(err, installer.ErrPackageExpectationInvalid), errors.Is(err, installer.ErrAppManifestTooLarge), errors.Is(err, installer.ErrAppManifestInvalid), errors.Is(err, installer.ErrAppManifestMissing), errors.Is(err, installer.ErrPackageIdentityMismatch), errors.Is(err, installer.ErrEntrypointMissing), errors.Is(err, installer.ErrUnexpectedEntry), errors.Is(err, installer.ErrUnsupportedEntry), errors.Is(err, installer.ErrEncryptedEntry): return FailureCodePackageInvalid case errors.Is(err, installer.ErrInvalidArchive), errors.Is(err, installer.ErrArchiveCorrupt), errors.Is(err, installer.ErrArchiveTooLarge), errors.Is(err, installer.ErrCentralDirectoryTooLarge), errors.Is(err, installer.ErrTooManyEntries), errors.Is(err, installer.ErrExpandedTooLarge), errors.Is(err, installer.ErrCompressionRatio), errors.Is(err, installer.ErrDuplicateEntry): return FailureCodeZIPCorrupt default: return FailureCodeInstallFailed } } 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 }