Add install preflight safeguards (T-303)
This commit is contained in:
@@ -3,6 +3,7 @@ package install
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
|
||||
"softbox.local/core/catalog"
|
||||
@@ -14,31 +15,55 @@ 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"
|
||||
InstallStageExtract InstallStage = "extract"
|
||||
InstallStageRecover InstallStage = "recover"
|
||||
InstallStageSwitch InstallStage = "switch"
|
||||
InstallStageHealth InstallStage = "health"
|
||||
InstallStageRecord InstallStage = "record"
|
||||
InstallStageRollback InstallStage = "rollback"
|
||||
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: %v", err.Stage, err.Err)
|
||||
return fmt.Sprintf("install %s (%s): %v", err.Stage, err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *InstallError) Unwrap() error {
|
||||
@@ -52,6 +77,18 @@ type InstallRecordStore interface {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -68,29 +105,46 @@ type InstallResult struct {
|
||||
Recovery installer.RecoveryResult
|
||||
}
|
||||
|
||||
// InstallServiceConfig makes all external installation dependencies explicit.
|
||||
// Disk and target-state checks are mandatory so no caller can silently bypass
|
||||
// the pre-extract safety boundary.
|
||||
type InstallServiceConfig struct {
|
||||
Extractor installer.Extractor
|
||||
Records InstallRecordStore
|
||||
Health installer.HealthCheck
|
||||
DiskSpace DiskSpaceChecker
|
||||
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
|
||||
extractor installer.Extractor
|
||||
records InstallRecordStore
|
||||
health installer.HealthCheck
|
||||
diskSpace DiskSpaceChecker
|
||||
targetState TargetStateChecker
|
||||
}
|
||||
|
||||
func NewInstallService(
|
||||
extractor installer.Extractor,
|
||||
records InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
) (*InstallService, error) {
|
||||
if records == nil {
|
||||
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig)
|
||||
}
|
||||
if health == nil {
|
||||
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.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
|
||||
}
|
||||
return &InstallService{
|
||||
extractor: extractor,
|
||||
records: records,
|
||||
health: health,
|
||||
extractor: config.Extractor,
|
||||
records: config.Records,
|
||||
health: config.Health,
|
||||
diskSpace: config.DiskSpace,
|
||||
targetState: config.TargetState,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -112,10 +166,11 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
||||
return InstallResult{}, installError(InstallStageRecover, err)
|
||||
}
|
||||
|
||||
extracted, err := service.extractor.ExtractVerifiedFile(
|
||||
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
|
||||
request.DownloadPath,
|
||||
filepath.Join(appRoot, "staging"),
|
||||
expectation,
|
||||
service.preExtractCheck(appRoot, record.ID),
|
||||
)
|
||||
if err != nil {
|
||||
return InstallResult{}, installError(stageForPackageError(err), err)
|
||||
@@ -146,11 +201,52 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
|
||||
return InstallResult{
|
||||
AppID: record.ID,
|
||||
Version: record.Version,
|
||||
EntrypointPath: filepath.Join(appRoot, "current", request.Entry.App.EntryEXE),
|
||||
EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint),
|
||||
Recovery: recovery,
|
||||
}, 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(
|
||||
@@ -203,7 +299,7 @@ func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation
|
||||
}
|
||||
|
||||
func installError(stage InstallStage, err error) error {
|
||||
return &InstallError{Stage: stage, Err: err}
|
||||
return &InstallError{Stage: stage, Code: failureCodeFor(err), Err: err}
|
||||
}
|
||||
|
||||
func stageForPackageError(err error) InstallStage {
|
||||
@@ -212,6 +308,8 @@ func stageForPackageError(err error) InstallStage {
|
||||
switch packageErr.Stage {
|
||||
case installer.PackageStageManifest:
|
||||
return InstallStageManifest
|
||||
case installer.PackageStagePreflight:
|
||||
return InstallStagePreflight
|
||||
case installer.PackageStageExtract:
|
||||
return InstallStageExtract
|
||||
}
|
||||
@@ -219,6 +317,46 @@ func stageForPackageError(err error) InstallStage {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user