Add install preflight safeguards (T-303)

This commit is contained in:
ila
2026-07-18 18:14:25 +08:00
parent 449b183ca3
commit ae3f64c407
8 changed files with 655 additions and 44 deletions
+163 -25
View File
@@ -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
+381 -6
View File
@@ -56,9 +56,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
tests := []struct {
name string
entry catalog.Entry
wantErr error
name string
entry catalog.Entry
wantErr error
wantCode FailureCode
}{
{
name: "selected package differs from architecture package",
@@ -69,7 +70,8 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
entry.Package = &forged
return entry
}(),
wantErr: ErrInstallRequestInvalid,
wantErr: ErrInstallRequestInvalid,
wantCode: FailureCodeInstallFailed,
},
{
name: "download hash differs from Catalog",
@@ -84,7 +86,8 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
return entry
}(),
wantErr: installer.ErrPackageHashMismatch,
wantErr: installer.ErrPackageHashMismatch,
wantCode: FailureCodeHashMismatch,
},
}
@@ -105,6 +108,9 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
if stage := installErrorStage(t, err); stage != InstallStageVerify {
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
t.Fatalf("rejected install left staging, stat error = %v", statErr)
}
@@ -177,6 +183,293 @@ func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
}
}
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
config := InstallServiceConfig{
Extractor: extractor,
Records: store,
Health: func(string) error { return nil },
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes, nil
}),
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
}
tests := []struct {
name string
modify func(*InstallServiceConfig)
}{
{
name: "disk space checker",
modify: func(config *InstallServiceConfig) {
config.DiskSpace = nil
},
},
{
name: "target state checker",
modify: func(config *InstallServiceConfig) {
config.TargetState = nil
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := config
test.modify(&candidate)
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
}
})
}
}
func TestFailureCodeForPackageFailures(t *testing.T) {
tests := []struct {
err error
code FailureCode
}{
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
}
for _, test := range tests {
if got := failureCodeFor(test.err); got != test.code {
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
}
}
}
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
var diskRoot string
var targetAppID, targetEntrypoint string
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
diskRoot = appRoot
return required, nil
}),
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
targetAppID = appID
targetEntrypoint = entrypointPath
return false, nil
}),
)
if _, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
}); err != nil {
t.Fatalf("Install() error = %v", err)
}
appRoot := filepath.Join(appsRoot, "test-app")
if diskRoot != appRoot {
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
}
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
}
}
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
service := newInstallServiceWithCheckers(
t,
store,
func(string) error { return nil },
diskSpaceCheckerFunc(func(string) (int64, error) {
return 0, nil
}),
targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.2.3"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, ErrDiskSpaceInsufficient) {
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
}
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
}
appRoot := filepath.Join(appsRoot, "test-app")
for _, managed := range []string{"staging", "current"} {
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
}
}
}
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
diskProbeErr := errors.New("disk probe unavailable")
targetProbeErr := errors.New("target state unavailable")
tests := []struct {
name string
disk DiskSpaceChecker
target TargetStateChecker
wantErr error
wantCode FailureCode
}{
{
name: "insufficient disk space",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required - 1, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
wantErr: ErrDiskSpaceInsufficient,
wantCode: FailureCodeDiskFull,
},
{
name: "disk capacity check fails",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return 0, diskProbeErr
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
wantErr: diskProbeErr,
wantCode: FailureCodeDiskCheckFailed,
},
{
name: "current app is running",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return true, nil
}),
wantErr: ErrTargetRunning,
wantCode: FailureCodeAppRunning,
},
{
name: "target state check fails",
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
return required, nil
}),
target: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, targetProbeErr
}),
wantErr: targetProbeErr,
wantCode: FailureCodeTargetStateUnavailable,
},
}
appRoot := filepath.Join(appsRoot, "test-app")
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
_, err := service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
}
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
}
if code := installErrorCode(t, err); code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current entrypoint after failure = %q", got)
}
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
t.Fatal("installed-app record changed after preflight failure")
}
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
}
})
}
}
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
initial := newInstallService(t, store, func(string) error { return nil })
if _, err := initial.Install(InstallRequest{
Entry: installEntry(oldPackage, "1.0.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: oldArchive,
}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
document, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read corrupted archive: %v", err)
}
hash := sha256.Sum256(document)
publishedPackage.Size = int64(len(document))
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
service := newInstallService(t, store, func(string) error { return nil })
_, err = service.Install(InstallRequest{
Entry: installEntry(publishedPackage, "1.1.0"),
Architecture: catalog.ArchitectureAMD64,
DownloadPath: archivePath,
})
if !errors.Is(err, installer.ErrArchiveCorrupt) {
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
}
if stage := installErrorStage(t, err); stage != InstallStageExtract {
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
}
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
}
appRoot := filepath.Join(appsRoot, "test-app")
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
t.Fatalf("current entrypoint after corruption = %q", got)
}
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
}
}
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
appsRoot := filepath.Join(t.TempDir(), "apps")
store := storage.NewInstalledAppStore(appsRoot)
@@ -235,19 +528,57 @@ func newInstallService(
t *testing.T,
store InstallRecordStore,
health installer.HealthCheck,
) *InstallService {
return newInstallServiceWithCheckers(
t,
store,
health,
diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes + 64*1024, nil
}),
targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
)
}
func newInstallServiceWithCheckers(
t *testing.T,
store InstallRecordStore,
health installer.HealthCheck,
diskSpace DiskSpaceChecker,
targetState TargetStateChecker,
) *InstallService {
t.Helper()
extractor, err := installer.NewExtractor(installTestLimits())
if err != nil {
t.Fatalf("NewExtractor() error = %v", err)
}
service, err := NewInstallService(extractor, store, health)
service, err := NewInstallService(InstallServiceConfig{
Extractor: extractor,
Records: store,
Health: health,
DiskSpace: diskSpace,
TargetState: targetState,
})
if err != nil {
t.Fatalf("NewInstallService() error = %v", err)
}
return service
}
type diskSpaceCheckerFunc func(string) (int64, error)
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
return check(appRoot)
}
type targetStateCheckerFunc func(string, string) (bool, error)
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
return check(appID, entrypointPath)
}
func installTestLimits() installer.Limits {
return installer.Limits{
MaxEntries: 20,
@@ -343,6 +674,50 @@ func installErrorStage(t *testing.T, err error) InstallStage {
return installErr.Stage
}
func installErrorCode(t *testing.T, err error) FailureCode {
t.Helper()
var installErr *InstallError
if !errors.As(err, &installErr) {
t.Fatalf("error %v is not InstallError", err)
}
return installErr.Code
}
func corruptInstallPackageEntry(t *testing.T, archivePath, entryName string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open ZIP for corruption: %v", err)
}
var offset int64 = -1
for _, file := range reader.File {
if file.Name != entryName {
continue
}
offset, err = file.DataOffset()
if err != nil {
_ = reader.Close()
t.Fatalf("entry data offset: %v", err)
}
break
}
if err := reader.Close(); err != nil {
t.Fatalf("close ZIP reader: %v", err)
}
if offset < 0 {
t.Fatalf("entry %s not found", entryName)
}
document, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP for corruption: %v", err)
}
document[offset] ^= 0xff
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
t.Fatalf("write corrupted ZIP: %v", err)
}
}
func mustReadFile(t *testing.T, path string) string {
t.Helper()
document, err := os.ReadFile(path)
+19
View File
@@ -64,6 +64,25 @@ type plannedEntry struct {
directory bool
}
func verifiedPackageFromPlan(plan []plannedEntry, entrypoint string) (VerifiedPackage, error) {
verified := VerifiedPackage{Entrypoint: entrypoint}
for _, entry := range plan {
if entry.directory {
continue
}
if entry.file == nil || entry.file.UncompressedSize64 > uint64(math.MaxInt64) {
return VerifiedPackage{}, ErrExpandedTooLarge
}
size := int64(entry.file.UncompressedSize64)
if verified.PayloadBytes > math.MaxInt64-size {
return VerifiedPackage{}, ErrExpandedTooLarge
}
verified.PayloadBytes += size
verified.PayloadFiles++
}
return verified, nil
}
func NewExtractor(limits Limits) (Extractor, error) {
if err := limits.validate(); err != nil {
return Extractor{}, err
+39 -3
View File
@@ -34,9 +34,10 @@ var packageIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
type PackageStage string
const (
PackageStageVerify PackageStage = "verify"
PackageStageManifest PackageStage = "manifest"
PackageStageExtract PackageStage = "extract"
PackageStageVerify PackageStage = "verify"
PackageStageManifest PackageStage = "manifest"
PackageStagePreflight PackageStage = "preflight"
PackageStageExtract PackageStage = "extract"
)
// PackageError preserves the underlying safe failure while making the package
@@ -74,6 +75,20 @@ type PackageExpectation struct {
App AppExpectation
}
// VerifiedPackage describes the payload plan after the download, ZIP layout,
// and app manifest have all been verified. It intentionally contains no ZIP
// handles or destination paths, so callers cannot bypass safe extraction.
type VerifiedPackage struct {
PayloadBytes int64
PayloadFiles int
Entrypoint string
}
// PreExtractCheck runs after package verification but before the extraction
// destination is created. It lets application code enforce environment
// preconditions without introducing application or platform dependencies here.
type PreExtractCheck func(VerifiedPackage) error
type packageAppManifest struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
@@ -116,6 +131,18 @@ func (extractor Extractor) ExtractVerifiedFile(
zipPath string,
destination string,
expectation PackageExpectation,
) (ExtractResult, error) {
return extractor.ExtractVerifiedFileWithCheck(zipPath, destination, expectation, nil)
}
// ExtractVerifiedFileWithCheck preserves one file handle from Catalog
// size/SHA-256 verification through ZIP scanning, manifest comparison, an
// optional environment precheck, and safe extraction.
func (extractor Extractor) ExtractVerifiedFileWithCheck(
zipPath string,
destination string,
expectation PackageExpectation,
beforeExtract PreExtractCheck,
) (ExtractResult, error) {
expectedHash, err := expectation.validate()
if err != nil {
@@ -157,6 +184,15 @@ func (extractor Extractor) ExtractVerifiedFile(
if err := manifest.matches(expectation.App); err != nil {
return ExtractResult{}, packageError(PackageStageManifest, err)
}
if beforeExtract != nil {
verified, err := verifiedPackageFromPlan(plan, normalizedEntrypoint)
if err != nil {
return ExtractResult{}, packageError(PackageStageVerify, err)
}
if err := beforeExtract(verified); err != nil {
return ExtractResult{}, packageError(PackageStagePreflight, err)
}
}
result, err := extractor.extractPlan(destination, normalizedEntrypoint, plan)
if err != nil {
return ExtractResult{}, packageError(PackageStageExtract, err)
+39
View File
@@ -42,6 +42,45 @@ func TestExtractorExtractVerifiedFile(t *testing.T) {
}
}
func TestExtractorExtractVerifiedFileWithCheckPreflightsBeforeStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "bin/App.exe")},
{name: "payload/bin/App.exe", body: []byte("executable"), mode: 0o755},
{name: "payload/readme.txt", body: []byte("hello")},
})
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
stop := errors.New("pre-extract check stopped")
_, err := extractor.ExtractVerifiedFileWithCheck(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "bin/App.exe"),
func(verified VerifiedPackage) error {
if verified.Entrypoint != "bin/App.exe" {
t.Fatalf("entrypoint = %q", verified.Entrypoint)
}
if verified.PayloadFiles != 2 {
t.Fatalf("payload files = %d, want 2", verified.PayloadFiles)
}
if verified.PayloadBytes != int64(len("executable")+len("hello")) {
t.Fatalf("payload bytes = %d", verified.PayloadBytes)
}
return stop
},
)
if !errors.Is(err, stop) {
t.Fatalf("ExtractVerifiedFileWithCheck() error = %v, want %v", err, stop)
}
var packageErr *PackageError
if !errors.As(err, &packageErr) || packageErr.Stage != PackageStagePreflight {
t.Fatalf("package error = %#v, want preflight stage", packageErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
}
}
func TestExtractorExtractVerifiedFileRejectsBeforeStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "App.exe")},