diff --git a/core/application/install/service.go b/core/application/install/service.go index 60bdec3..3873296 100644 --- a/core/application/install/service.go +++ b/core/application/install/service.go @@ -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 diff --git a/core/application/install/service_test.go b/core/application/install/service_test.go index f417e40..7f5040d 100644 --- a/core/application/install/service_test.go +++ b/core/application/install/service_test.go @@ -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) diff --git a/core/installer/extractor.go b/core/installer/extractor.go index 503d4d3..516503e 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -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 diff --git a/core/installer/verified_package.go b/core/installer/verified_package.go index 8da5642..d64f0a1 100644 --- a/core/installer/verified_package.go +++ b/core/installer/verified_package.go @@ -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) diff --git a/core/installer/verified_package_test.go b/core/installer/verified_package_test.go index ee5b27b..45af047 100644 --- a/core/installer/verified_package_test.go +++ b/core/installer/verified_package_test.go @@ -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")}, diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index b793838..55a4206 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301、T-302 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已正式落成,下一步按其规格领取并实现失败处理与磁盘预检。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。 +当前项目已完成 Phase 0~2、T-301~T-303 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core。下一步正式落成并执行 T-401 进程检测与软件启动。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。 优先路径: diff --git a/docs/current-state.md b/docs/current-state.md index 7070937..eae596f 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,10 +13,10 @@ ## 当前快照 - 日期:2026-07-18 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列与 T-302 安装流程整合已完成;审核整改 T-604~T-614 已完成;T-303 失败处理与磁盘预检查已正式落成,下一步领取并执行 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合与 T-303 失败处理/磁盘预检查已完成;审核整改 T-604~T-614 已完成;下一步应正式落成并执行 T-401 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,`Extractor.ExtractVerifiedFile` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→安全 staging 的顺序处理,每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 -- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302 同句柄 package size/SHA、严格/有界 app.json、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,失败返回稳定 code 且不触发 switch;每个实际 payload 文件 hash 写入 installed-app;health 或记录写失败经 Switcher 回滚),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 +- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、payload hash 记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` @@ -28,7 +28,7 @@ | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-302 与 T-604~T-614 已完成;T-303 失败处理与磁盘预检查已正式落成,待领取执行 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303 与 T-604~T-614 已完成;下一步按路线图落成 T-401 进程检测与软件启动任务 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`、`T-302`;审核整改 `T-604`~`T-614`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303`;审核整改 `T-604`~`T-614`。 - 正在进行:无。 -- 下一个可领取任务:T-303(依赖 T-302 已完成);T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 +- 下一个可领取任务:暂无;应按 Phase 4 路线图先将 T-401 进程检测与软件启动正式落成任务文件,再领取。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/tasks/T-303.md b/docs/tasks/T-303.md index 4bd5534..d0db774 100644 --- a/docs/tasks/T-303.md +++ b/docs/tasks/T-303.md @@ -3,12 +3,12 @@ id: T-303 title: 失败处理与磁盘预检查 phase: 3 deps: [T-302] -status: TODO +status: DONE created: 2026-07-18 issue: null -context_ref: null +context_ref: 449b1832741bef57fd6ef9bc771d2702a9120dc5 claim_branch: null -work_branch: null +work_branch: agent/codex/T-303 write_paths: - docs/tasks/T-303.md - core/application/install/ @@ -56,3 +56,7 @@ T-302 已把已验签 Catalog selection、同句柄 package 校验、严格 `app ## 执行记录 - 2026-07-18:正式落成。冻结 verified-package hook、64 MiB 解压保留、强制注入 disk/target-state checker、稳定失败码及 T-401 的进程检测边界。 +- 2026-07-18:领取任务,基线为 `449b1832741bef57fd6ef9bc771d2702a9120dc5`,工作分支 `agent/codex/T-303`;下一步运行统一初始化/完整基线,再开始实现。 +- 2026-07-18:基线通过:`./init.ps1` 完成治理、core 架构/Go 版本闸门、Go 1.20 core vet/test、modern/Win7 test/build 与 Python harness 校验。 +- 2026-07-18:实现 verified-package pre-extract hook(仅暴露已验证 payload bytes/files/entrypoint)和 `InstallServiceConfig` 的强制 disk/target-state 注入;预检在严格 ZIP/app manifest 验证后、staging 创建前执行,空间阈值固定为 payload 加 64 MiB。稳定 `FailureCode` 保留原始 `errors.Is` 原因,未引入 Windows API 或命令层装配。 +- 2026-07-18:复核成功、精确容量阈值、首次/更新磁盘不足、磁盘/运行状态查询故障、运行中、哈希不符、CRC 损坏、hook 顺序及旧版本/记录保护;`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./installer ./application/install`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py` 与 `python scripts/validate_harness_governance.py` 全部通过。Windows Toolhelp/正常退出等待和命令层实际装配仍保留给 T-401。