From 14589abb31d935fc6fc2227ef678e5cf4dc565ac Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 18 Jul 2026 17:55:01 +0800 Subject: [PATCH] Integrate verified installation flow (T-302) --- core/application/install/service.go | 233 ++++++++++++++ core/application/install/service_test.go | 353 ++++++++++++++++++++ core/installer/extractor.go | 34 +- core/installer/switcher.go | 6 +- core/installer/verified_package.go | 391 +++++++++++++++++++++++ core/installer/verified_package_test.go | 203 ++++++++++++ core/installer/zipscan.go | 66 +++- core/storage/installed_app.go | 15 +- docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 4 +- docs/06-tasks.md | 4 +- docs/api.md | 4 +- docs/current-state.md | 14 +- docs/tasks/T-302.md | 18 +- 14 files changed, 1318 insertions(+), 31 deletions(-) create mode 100644 core/application/install/service.go create mode 100644 core/application/install/service_test.go create mode 100644 core/installer/verified_package.go create mode 100644 core/installer/verified_package_test.go diff --git a/core/application/install/service.go b/core/application/install/service.go new file mode 100644 index 0000000..60bdec3 --- /dev/null +++ b/core/application/install/service.go @@ -0,0 +1,233 @@ +package install + +import ( + "errors" + "fmt" + "path/filepath" + + "softbox.local/core/catalog" + "softbox.local/core/installer" + "softbox.local/core/storage" +) + +var ( + ErrInstallServiceConfig = errors.New("invalid install service configuration") + ErrInstallRequestInvalid = errors.New("invalid install request") + ErrInstallRecordWrite = errors.New("write installed app record") +) + +// InstallStage makes the security-sensitive installation path observable to a +// background caller without giving the Gio layout any filesystem work. +type InstallStage string + +const ( + InstallStageVerify InstallStage = "verify" + InstallStageManifest InstallStage = "manifest" + InstallStageExtract InstallStage = "extract" + InstallStageRecover InstallStage = "recover" + InstallStageSwitch InstallStage = "switch" + InstallStageHealth InstallStage = "health" + InstallStageRecord InstallStage = "record" + InstallStageRollback InstallStage = "rollback" +) + +// InstallError preserves a stable stage and its underlying cause. +type InstallError struct { + Stage InstallStage + Err error +} + +func (err *InstallError) Error() string { + return fmt.Sprintf("install %s: %v", err.Stage, err.Err) +} + +func (err *InstallError) Unwrap() error { + return err.Err +} + +// InstallRecordStore provides the app-root and installed-app record boundary +// needed by an installation transaction. +type InstallRecordStore interface { + EnsureAppRoot(appID string) (string, error) + Write(record storage.InstalledApp) error +} + +// InstallRequest joins an untrusted completed download with the trusted +// Catalog selection that describes it. +type InstallRequest struct { + Entry catalog.Entry + Architecture catalog.Architecture + DownloadPath string +} + +// InstallResult describes an installed version after the switch commits. +type InstallResult struct { + AppID string + Version string + EntrypointPath string + Recovery installer.RecoveryResult +} + +// InstallService implements the core-only verified package installation use +// case. The caller must supply entries produced by catalog.Client. +type InstallService struct { + extractor installer.Extractor + records InstallRecordStore + health installer.HealthCheck +} + +func NewInstallService( + extractor installer.Extractor, + records InstallRecordStore, + health installer.HealthCheck, +) (*InstallService, error) { + if records == nil { + return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig) + } + if health == nil { + return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired) + } + return &InstallService{ + extractor: extractor, + records: records, + health: health, + }, nil +} + +// Install verifies and extracts one completed download, then atomically +// switches staging into current. Metadata is written inside the Switcher +// health phase so a write failure follows the same rollback path as health. +func (service *InstallService) Install(request InstallRequest) (InstallResult, error) { + expectation, record, err := resolveInstallRequest(request) + if err != nil { + return InstallResult{}, installError(InstallStageVerify, err) + } + + appRoot, err := service.records.EnsureAppRoot(record.ID) + if err != nil { + return InstallResult{}, installError(InstallStageRecover, err) + } + recovery, err := installer.Recover(appRoot) + if err != nil { + return InstallResult{}, installError(InstallStageRecover, err) + } + + extracted, err := service.extractor.ExtractVerifiedFile( + request.DownloadPath, + filepath.Join(appRoot, "staging"), + expectation, + ) + if err != nil { + return InstallResult{}, installError(stageForPackageError(err), err) + } + record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles)) + for _, file := range extracted.PayloadFiles { + record.Files = append(record.Files, storage.InstalledFile{ + Path: file.Path, + Size: file.Size, + SHA256: file.SHA256, + }) + } + + var recordWriteErr error + switcher := installer.NewSwitcher(func(currentPath string) error { + if err := service.health(currentPath); err != nil { + return err + } + if err := service.records.Write(record); err != nil { + recordWriteErr = err + return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err) + } + return nil + }) + if err := switcher.Switch(appRoot); err != nil { + return InstallResult{}, installError(stageForSwitchError(err, recordWriteErr), err) + } + return InstallResult{ + AppID: record.ID, + Version: record.Version, + EntrypointPath: filepath.Join(appRoot, "current", request.Entry.App.EntryEXE), + Recovery: recovery, + }, nil +} + +func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) { + if request.DownloadPath == "" { + return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( + "%w: completed download path is empty", + ErrInstallRequestInvalid, + ) + } + if !request.Entry.Installable || request.Entry.Package == nil { + return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( + "%w: Catalog entry is not installable", + ErrInstallRequestInvalid, + ) + } + if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 { + return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( + "%w: unsupported architecture %q", + ErrInstallRequestInvalid, + request.Architecture, + ) + } + publishedPackage, exists := request.Entry.App.Packages[request.Architecture] + if !exists || publishedPackage != *request.Entry.Package { + return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf( + "%w: selected package does not match app architecture", + ErrInstallRequestInvalid, + ) + } + + app := request.Entry.App + return installer.PackageExpectation{ + Size: publishedPackage.Size, + SHA256: publishedPackage.SHA256, + App: installer.AppExpectation{ + ID: app.ID, + Version: app.Version, + Channel: string(app.Channel), + MinOS: string(app.MinOS), + Architecture: string(request.Architecture), + Entrypoint: app.EntryEXE, + RequiresAdmin: app.RequiresAdmin, + }, + }, storage.InstalledApp{ + SchemaVersion: 1, + ID: app.ID, + Version: app.Version, + Architecture: string(request.Architecture), + Channel: string(app.Channel), + Files: []storage.InstalledFile{}, + }, nil +} + +func installError(stage InstallStage, err error) error { + return &InstallError{Stage: stage, Err: err} +} + +func stageForPackageError(err error) InstallStage { + var packageErr *installer.PackageError + if errors.As(err, &packageErr) { + switch packageErr.Stage { + case installer.PackageStageManifest: + return InstallStageManifest + case installer.PackageStageExtract: + return InstallStageExtract + } + } + return InstallStageVerify +} + +func stageForSwitchError(err error, recordWriteErr error) InstallStage { + if errors.Is(err, installer.ErrRollbackFailed) { + return InstallStageRollback + } + if recordWriteErr != nil { + return InstallStageRecord + } + if errors.Is(err, installer.ErrHealthCheckFailed) { + return InstallStageHealth + } + return InstallStageSwitch +} diff --git a/core/application/install/service_test.go b/core/application/install/service_test.go new file mode 100644 index 0000000..f417e40 --- /dev/null +++ b/core/application/install/service_test.go @@ -0,0 +1,353 @@ +package install + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + + "softbox.local/core/catalog" + "softbox.local/core/installer" + "softbox.local/core/storage" +) + +func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.T) { + archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable") + appsRoot := filepath.Join(t.TempDir(), "apps") + store := storage.NewInstalledAppStore(appsRoot) + service := newInstallService(t, store, func(currentPath string) error { + _, err := os.Stat(filepath.Join(currentPath, "bin", "App.exe")) + return err + }) + + result, err := service.Install(InstallRequest{ + Entry: installEntry(publishedPackage, "1.2.3"), + Architecture: catalog.ArchitectureAMD64, + DownloadPath: archivePath, + }) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if result.AppID != "test-app" || result.Version != "1.2.3" || result.Recovery.Action != installer.RecoveryNone { + t.Fatalf("result = %#v", result) + } + if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "new executable" { + t.Fatalf("current entrypoint = %q", got) + } + record, found, err := store.Read("test-app") + if err != nil || !found { + t.Fatalf("Read() found=%t err=%v", found, err) + } + if record.Version != "1.2.3" || len(record.Files) != 2 { + t.Fatalf("record = %#v", record) + } + if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) { + t.Fatalf("record first file = %#v", record.Files[0]) + } + hash := sha256.Sum256([]byte("new executable")) + if record.Files[0].SHA256 != hex.EncodeToString(hash[:]) { + t.Fatalf("record first hash = %q", record.Files[0].SHA256) + } +} + +func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) { + archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable") + tests := []struct { + name string + entry catalog.Entry + wantErr error + }{ + { + name: "selected package differs from architecture package", + entry: func() catalog.Entry { + entry := installEntry(publishedPackage, "1.2.3") + forged := publishedPackage + forged.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000" + entry.Package = &forged + return entry + }(), + wantErr: ErrInstallRequestInvalid, + }, + { + name: "download hash differs from Catalog", + entry: func() catalog.Entry { + entry := installEntry(publishedPackage, "1.2.3") + entry.App.Packages[catalog.ArchitectureAMD64] = catalog.Package{ + Size: publishedPackage.Size, + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + Signature: publishedPackage.Signature, + URL: publishedPackage.URL, + } + *entry.Package = entry.App.Packages[catalog.ArchitectureAMD64] + return entry + }(), + wantErr: installer.ErrPackageHashMismatch, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + appsRoot := filepath.Join(t.TempDir(), "apps") + store := storage.NewInstalledAppStore(appsRoot) + service := newInstallService(t, store, func(string) error { return nil }) + + _, err := service.Install(InstallRequest{ + Entry: test.entry, + 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 != InstallStageVerify { + t.Fatalf("stage = %q, want %q", stage, InstallStageVerify) + } + if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) { + t.Fatalf("rejected install left staging, stat error = %v", statErr) + } + }) + } +} + +func TestInstallServiceRollsBackHealthAndRecordWriteFailure(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) + } + + tests := []struct { + name string + service func(t *testing.T) *InstallService + wantStage InstallStage + wantErr error + }{ + { + name: "health failure", + service: func(t *testing.T) *InstallService { + return newInstallService(t, store, func(string) error { return errors.New("health failed") }) + }, + wantStage: InstallStageHealth, + wantErr: installer.ErrHealthCheckFailed, + }, + { + name: "record write failure", + service: func(t *testing.T) *InstallService { + return newInstallService(t, &failingRecordStore{ + InstalledAppStore: store, + writeErr: errors.New("record disk error"), + }, func(string) error { return nil }) + }, + wantStage: InstallStageRecord, + wantErr: ErrInstallRecordWrite, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable") + _, err := test.service(t).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 != test.wantStage { + t.Fatalf("stage = %q, want %q", stage, test.wantStage) + } + if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "old executable" { + t.Fatalf("current entrypoint after failure = %q", got) + } + record, found, readErr := store.Read("test-app") + if readErr != nil || !found || record.Version != "1.0.0" { + t.Fatalf("record after failure found=%t record=%#v err=%v", found, record, readErr) + } + }) + } +} + +func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) { + appsRoot := filepath.Join(t.TempDir(), "apps") + store := storage.NewInstalledAppStore(appsRoot) + appRoot, err := store.EnsureAppRoot("test-app") + if err != nil { + t.Fatalf("EnsureAppRoot() error = %v", err) + } + if err := os.MkdirAll(filepath.Join(appRoot, "current"), 0o700); err != nil { + t.Fatalf("create current: %v", err) + } + if err := os.MkdirAll(filepath.Join(appRoot, "staging"), 0o700); err != nil { + t.Fatalf("create stale staging: %v", err) + } + if err := os.WriteFile( + filepath.Join(appRoot, "install-transaction.json"), + []byte(`{"schema_version":1,"phase":"prepared","had_current":true}`), + 0o600, + ); err != nil { + t.Fatalf("write transaction: %v", err) + } + + archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable") + service := newInstallService(t, store, func(string) error { return nil }) + result, err := service.Install(InstallRequest{ + Entry: installEntry(publishedPackage, "1.2.3"), + Architecture: catalog.ArchitectureAMD64, + DownloadPath: archivePath, + }) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if result.Recovery.Action != installer.RecoveryAborted { + t.Fatalf("recovery action = %q, want %q", result.Recovery.Action, installer.RecoveryAborted) + } + if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "new executable" { + t.Fatalf("current entrypoint = %q", got) + } + if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) { + t.Fatalf("staging remains, stat error = %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(appRoot, "install-transaction.json")); !os.IsNotExist(statErr) { + t.Fatalf("transaction remains, stat error = %v", statErr) + } +} + +type failingRecordStore struct { + *storage.InstalledAppStore + writeErr error +} + +func (store *failingRecordStore) Write(storage.InstalledApp) error { + return store.writeErr +} + +func newInstallService( + t *testing.T, + store InstallRecordStore, + health installer.HealthCheck, +) *InstallService { + t.Helper() + extractor, err := installer.NewExtractor(installTestLimits()) + if err != nil { + t.Fatalf("NewExtractor() error = %v", err) + } + service, err := NewInstallService(extractor, store, health) + if err != nil { + t.Fatalf("NewInstallService() error = %v", err) + } + return service +} + +func installTestLimits() installer.Limits { + return installer.Limits{ + MaxEntries: 20, + MaxArchiveBytes: 64 * 1024, + MaxCentralDirectoryBytes: 4 * 1024, + MaxUncompressedBytes: 64 * 1024, + MaxCompressionRatio: 100, + } +} + +func installEntry(publishedPackage catalog.Package, version string) catalog.Entry { + selectedPackage := publishedPackage + return catalog.Entry{ + App: catalog.App{ + ID: "test-app", + Version: version, + Channel: catalog.ReleaseStable, + Status: catalog.CatalogStatusActive, + MinOS: catalog.Windows10, + Architectures: []catalog.Architecture{catalog.ArchitectureAMD64}, + EntryEXE: "bin/App.exe", + Packages: map[catalog.Architecture]catalog.Package{ + catalog.ArchitectureAMD64: publishedPackage, + }, + }, + Package: &selectedPackage, + Installable: true, + } +} + +func writeInstallPackage(t *testing.T, version, executable string) (string, catalog.Package) { + t.Helper() + path := filepath.Join(t.TempDir(), "package.download") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create package: %v", err) + } + writer := zip.NewWriter(file) + entries := []struct { + name string + body []byte + mode os.FileMode + }{ + {name: "app.json", body: installManifest(version)}, + {name: "payload/bin/App.exe", body: []byte(executable), mode: 0o755}, + {name: "payload/readme.txt", body: []byte("readme")}, + } + for _, entry := range entries { + header := &zip.FileHeader{Name: entry.name} + mode := entry.mode + if mode == 0 { + mode = 0o600 + } + header.SetMode(mode) + part, err := writer.CreateHeader(header) + if err != nil { + t.Fatalf("create ZIP entry: %v", err) + } + if _, err := part.Write(entry.body); err != nil { + t.Fatalf("write ZIP entry: %v", err) + } + } + if err := writer.Close(); err != nil { + file.Close() + t.Fatalf("close ZIP writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close package: %v", err) + } + document, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read package: %v", err) + } + hash := sha256.Sum256(document) + return path, catalog.Package{ + URL: "https://download.invalid/test-app.zip", + Size: int64(len(document)), + SHA256: hex.EncodeToString(hash[:]), + Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + } +} + +func installManifest(version string) []byte { + return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"bin/App.exe","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`) +} + +func installErrorStage(t *testing.T, err error) InstallStage { + t.Helper() + var installErr *InstallError + if !errors.As(err, &installErr) { + t.Fatalf("error %v is not InstallError", err) + } + return installErr.Stage +} + +func mustReadFile(t *testing.T, path string) string { + t.Helper() + document, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(document) +} diff --git a/core/installer/extractor.go b/core/installer/extractor.go index e3112cb..503d4d3 100644 --- a/core/installer/extractor.go +++ b/core/installer/extractor.go @@ -2,6 +2,8 @@ package installer import ( "archive/zip" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -43,6 +45,15 @@ type ExtractResult struct { Files int Bytes int64 EntrypointPath string + PayloadFiles []ExtractedFile +} + +// ExtractedFile is one payload file written to staging after ZIP CRC and +// length validation. Its digest is calculated from the bytes written there. +type ExtractedFile struct { + Path string + Size int64 + SHA256 string } type plannedEntry struct { @@ -89,7 +100,6 @@ func (extractor Extractor) extract( if err := extractor.limits.validate(); err != nil { return ExtractResult{}, err } - fence := effectiveDurability(extractor.durability) normalizedEntrypoint, err := normalizeEntrypoint(entrypoint) if err != nil { return ExtractResult{}, err @@ -98,9 +108,18 @@ func (extractor Extractor) extract( if err != nil { return ExtractResult{}, err } + return extractor.extractPlan(destination, normalizedEntrypoint, plan) +} + +func (extractor Extractor) extractPlan( + destination string, + entrypoint string, + plan []plannedEntry, +) (result ExtractResult, err error) { + fence := effectiveDurability(extractor.durability) destinationRoot, entrypointPath, err := planOutputPaths( destination, - normalizedEntrypoint, + entrypoint, plan, ) if err != nil { @@ -161,7 +180,11 @@ func (extractor Extractor) extract( if readLimit < math.MaxInt64 { readLimit++ } - copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit)) + digest := sha256.New() + copied, copyErr := io.Copy( + io.MultiWriter(output, digest), + io.LimitReader(source, readLimit), + ) closeSourceErr := source.Close() if copyErr != nil { _ = output.Close() @@ -194,6 +217,11 @@ func (extractor Extractor) extract( } written += copied result.Files++ + result.PayloadFiles = append(result.PayloadFiles, ExtractedFile{ + Path: entry.outputPath, + Size: copied, + SHA256: hex.EncodeToString(digest.Sum(nil)), + }) } if err := syncStagingTree(fence, destinationRoot); err != nil { return ExtractResult{}, err diff --git a/core/installer/switcher.go b/core/installer/switcher.go index 2a11c90..b8d09a2 100644 --- a/core/installer/switcher.go +++ b/core/installer/switcher.go @@ -36,8 +36,8 @@ func (err *RollbackError) Error() string { return fmt.Sprintf("%s: health=%v; rollback=%v", ErrRollbackFailed, err.Health, err.Rollback) } -func (err *RollbackError) Unwrap() error { - return ErrRollbackFailed +func (err *RollbackError) Unwrap() []error { + return []error{ErrRollbackFailed, err.Health, err.Rollback} } // Switcher activates a verified staging directory and runs an injected check. @@ -141,7 +141,7 @@ func (switcher *Switcher) Switch(root string) error { if err := removeTransactionWithFence(layout, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } - return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr) + return fmt.Errorf("%w: %w", ErrHealthCheckFailed, healthErr) } record.Phase = phaseCommitted diff --git a/core/installer/verified_package.go b/core/installer/verified_package.go new file mode 100644 index 0000000..8da5642 --- /dev/null +++ b/core/installer/verified_package.go @@ -0,0 +1,391 @@ +package installer + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + + "softbox.local/core/domain" + "softbox.local/core/internal/safepath" +) + +const MaxAppManifestBytes = 1 << 20 + +var ( + ErrPackageExpectationInvalid = errors.New("invalid verified package expectation") + ErrPackageHashMismatch = errors.New("package SHA-256 does not match Catalog") + ErrAppManifestTooLarge = errors.New("package app.json exceeds size limit") + ErrAppManifestInvalid = errors.New("package app.json is invalid") + ErrPackageIdentityMismatch = errors.New("package app.json does not match Catalog") +) + +var packageIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) + +// PackageStage identifies the point at which a verified package install +// stopped. It is intentionally independent of UI wording. +type PackageStage string + +const ( + PackageStageVerify PackageStage = "verify" + PackageStageManifest PackageStage = "manifest" + PackageStageExtract PackageStage = "extract" +) + +// PackageError preserves the underlying safe failure while making the package +// boundary observable to its application caller. +type PackageError struct { + Stage PackageStage + Err error +} + +func (err *PackageError) Error() string { + return fmt.Sprintf("package %s: %v", err.Stage, err.Err) +} + +func (err *PackageError) Unwrap() error { + return err.Err +} + +// AppExpectation is the portion of app.json that must equal the selected +// signed Catalog entry. The remaining v1 fields are still validated locally. +type AppExpectation struct { + ID string + Version string + Channel string + MinOS string + Architecture string + Entrypoint string + RequiresAdmin bool +} + +// PackageExpectation is selected from a verified Catalog package. The outer +// Catalog signature is the trust root for Size and SHA256. +type PackageExpectation struct { + Size int64 + SHA256 string + App AppExpectation +} + +type packageAppManifest struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Name string `json:"name"` + Vendor string `json:"vendor"` + Version string `json:"version"` + Channel string `json:"channel"` + MinOS string `json:"min_os"` + Architecture string `json:"architecture"` + Entrypoint string `json:"entrypoint"` + WorkingDir string `json:"working_directory"` + ProductID string `json:"product_id"` + SupportsTrial bool `json:"supports_trial"` + RequiresAdmin bool `json:"requires_admin"` + DataPolicy string `json:"data_policy"` + UpdatePolicy string `json:"update_policy"` +} + +var appManifestFields = map[string]struct{}{ + "schema_version": {}, + "id": {}, + "name": {}, + "vendor": {}, + "version": {}, + "channel": {}, + "min_os": {}, + "architecture": {}, + "entrypoint": {}, + "working_directory": {}, + "product_id": {}, + "supports_trial": {}, + "requires_admin": {}, + "data_policy": {}, + "update_policy": {}, +} + +// ExtractVerifiedFile preserves one file handle from Catalog size/SHA-256 +// verification through ZIP scanning, manifest comparison and safe extraction. +func (extractor Extractor) ExtractVerifiedFile( + zipPath string, + destination string, + expectation PackageExpectation, +) (ExtractResult, error) { + expectedHash, err := expectation.validate() + if err != nil { + return ExtractResult{}, packageError(PackageStageVerify, err) + } + + file, size, err := extractor.openArchiveFile(zipPath, expectation.Size) + if err != nil { + return ExtractResult{}, packageError(PackageStageVerify, err) + } + defer file.Close() + + if err := verifyPackageSHA256(file, size, expectedHash); err != nil { + return ExtractResult{}, packageError(PackageStageVerify, err) + } + if err := extractor.scanOpenedArchive(file, size); err != nil { + return ExtractResult{}, packageError(PackageStageVerify, err) + } + archive, err := zip.NewReader(file, size) + if err != nil { + return ExtractResult{}, packageError( + PackageStageVerify, + fmt.Errorf("%w: %v", ErrInvalidArchive, err), + ) + } + + normalizedEntrypoint, err := normalizeEntrypoint(expectation.App.Entrypoint) + if err != nil { + return ExtractResult{}, packageError(PackageStageManifest, err) + } + plan, err := extractor.preflight(archive, normalizedEntrypoint) + if err != nil { + return ExtractResult{}, packageError(PackageStageVerify, err) + } + manifest, err := readPackageAppManifest(archive) + if err != nil { + return ExtractResult{}, packageError(PackageStageManifest, err) + } + if err := manifest.matches(expectation.App); err != nil { + return ExtractResult{}, packageError(PackageStageManifest, err) + } + result, err := extractor.extractPlan(destination, normalizedEntrypoint, plan) + if err != nil { + return ExtractResult{}, packageError(PackageStageExtract, err) + } + return result, nil +} + +func packageError(stage PackageStage, err error) error { + return &PackageError{Stage: stage, Err: err} +} + +func (expectation PackageExpectation) validate() ([]byte, error) { + if expectation.Size <= 0 { + return nil, fmt.Errorf("%w: package size must be positive", ErrPackageExpectationInvalid) + } + expectedHash, err := hex.DecodeString(expectation.SHA256) + if err != nil || len(expectedHash) != sha256.Size { + return nil, fmt.Errorf("%w: SHA-256 must be 32 bytes", ErrPackageExpectationInvalid) + } + if err := validateExpectationApp(expectation.App); err != nil { + return nil, err + } + return expectedHash, nil +} + +func validateExpectationApp(expectation AppExpectation) error { + if !packageIDPattern.MatchString(expectation.ID) { + return fmt.Errorf("%w: invalid app id", ErrPackageExpectationInvalid) + } + if _, err := domain.ParseSemVer(expectation.Version); err != nil { + return fmt.Errorf("%w: version: %v", ErrPackageExpectationInvalid, err) + } + if expectation.Channel != "stable" { + return fmt.Errorf("%w: channel=%q", ErrPackageExpectationInvalid, expectation.Channel) + } + if !isSupportedMinOS(expectation.MinOS) { + return fmt.Errorf("%w: min_os=%q", ErrPackageExpectationInvalid, expectation.MinOS) + } + if expectation.Architecture != "386" && expectation.Architecture != "amd64" { + return fmt.Errorf("%w: architecture=%q", ErrPackageExpectationInvalid, expectation.Architecture) + } + if _, err := normalizeEntrypoint(expectation.Entrypoint); err != nil { + return fmt.Errorf("%w: %v", ErrPackageExpectationInvalid, err) + } + return nil +} + +func verifyPackageSHA256(file *os.File, size int64, expectedHash []byte) error { + if file == nil || size <= 0 { + return fmt.Errorf("%w: package handle or size is invalid", ErrInvalidArchive) + } + hasher := sha256.New() + copied, err := io.Copy(hasher, io.NewSectionReader(file, 0, size)) + if err != nil { + return fmt.Errorf("%w: read package: %v", ErrInvalidArchive, err) + } + if copied != size { + return fmt.Errorf("%w: got %d bytes while hashing, expected %d", ErrArchiveSizeMismatch, copied, size) + } + info, err := file.Stat() + if err != nil { + return fmt.Errorf("%w: stat package after hashing: %v", ErrInvalidArchive, err) + } + if !info.Mode().IsRegular() || info.Size() != size { + return fmt.Errorf("%w: package changed while hashing", ErrArchiveSizeMismatch) + } + if subtle.ConstantTimeCompare(hasher.Sum(nil), expectedHash) != 1 { + return ErrPackageHashMismatch + } + return nil +} + +func readPackageAppManifest(archive *zip.Reader) (packageAppManifest, error) { + var appFile *zip.File + for _, file := range archive.File { + if file.Name != "app.json" { + continue + } + if appFile != nil { + return packageAppManifest{}, fmt.Errorf("%w: duplicate app.json", ErrAppManifestInvalid) + } + appFile = file + } + if appFile == nil { + return packageAppManifest{}, ErrAppManifestMissing + } + + reader, err := appFile.Open() + if err != nil { + return packageAppManifest{}, fmt.Errorf("%w: open: %v", ErrAppManifestInvalid, err) + } + document, readErr := io.ReadAll(io.LimitReader(reader, MaxAppManifestBytes+1)) + closeErr := reader.Close() + if readErr != nil { + return packageAppManifest{}, fmt.Errorf("%w: read: %v", ErrAppManifestInvalid, readErr) + } + if closeErr != nil { + return packageAppManifest{}, fmt.Errorf("%w: close: %v", ErrAppManifestInvalid, closeErr) + } + if len(document) > MaxAppManifestBytes { + return packageAppManifest{}, ErrAppManifestTooLarge + } + return parsePackageAppManifest(document) +} + +func parsePackageAppManifest(document []byte) (packageAppManifest, error) { + if err := validateManifestObject(document); err != nil { + return packageAppManifest{}, err + } + var manifest packageAppManifest + decoder := json.NewDecoder(bytes.NewReader(document)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return packageAppManifest{}, fmt.Errorf("%w: decode: %v", ErrAppManifestInvalid, err) + } + if err := ensureManifestEOF(decoder); err != nil { + return packageAppManifest{}, err + } + if err := manifest.validate(); err != nil { + return packageAppManifest{}, err + } + return manifest, nil +} + +func validateManifestObject(document []byte) error { + decoder := json.NewDecoder(bytes.NewReader(document)) + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("%w: read object: %v", ErrAppManifestInvalid, err) + } + delimiter, ok := token.(json.Delim) + if !ok || delimiter != '{' { + return fmt.Errorf("%w: root must be an object", ErrAppManifestInvalid) + } + seen := make(map[string]struct{}, len(appManifestFields)) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("%w: read field: %v", ErrAppManifestInvalid, err) + } + name, ok := token.(string) + if !ok { + return fmt.Errorf("%w: field name is not a string", ErrAppManifestInvalid) + } + if _, exists := appManifestFields[name]; !exists { + return fmt.Errorf("%w: unknown field %q", ErrAppManifestInvalid, name) + } + if _, exists := seen[name]; exists { + return fmt.Errorf("%w: duplicate field %q", ErrAppManifestInvalid, name) + } + seen[name] = struct{}{} + var discard json.RawMessage + if err := decoder.Decode(&discard); err != nil { + return fmt.Errorf("%w: read field %q: %v", ErrAppManifestInvalid, name, err) + } + } + if _, err := decoder.Token(); err != nil { + return fmt.Errorf("%w: close object: %v", ErrAppManifestInvalid, err) + } + if len(seen) != len(appManifestFields) { + for field := range appManifestFields { + if _, exists := seen[field]; !exists { + return fmt.Errorf("%w: missing field %q", ErrAppManifestInvalid, field) + } + } + } + return ensureManifestEOF(decoder) +} + +func ensureManifestEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("%w: trailing JSON value", ErrAppManifestInvalid) + } + return fmt.Errorf("%w: trailing data: %v", ErrAppManifestInvalid, err) + } + return nil +} + +func (manifest packageAppManifest) validate() error { + if manifest.SchemaVersion != 1 { + return fmt.Errorf("%w: schema_version=%d", ErrAppManifestInvalid, manifest.SchemaVersion) + } + if !packageIDPattern.MatchString(manifest.ID) { + return fmt.Errorf("%w: invalid id", ErrAppManifestInvalid) + } + if manifest.Name == "" || manifest.Vendor == "" { + return fmt.Errorf("%w: name and vendor must not be empty", ErrAppManifestInvalid) + } + if _, err := domain.ParseSemVer(manifest.Version); err != nil { + return fmt.Errorf("%w: version: %v", ErrAppManifestInvalid, err) + } + if manifest.Channel != "stable" || !isSupportedMinOS(manifest.MinOS) { + return fmt.Errorf("%w: channel or min_os", ErrAppManifestInvalid) + } + if manifest.Architecture != "386" && manifest.Architecture != "amd64" { + return fmt.Errorf("%w: architecture=%q", ErrAppManifestInvalid, manifest.Architecture) + } + if _, err := normalizeEntrypoint(manifest.Entrypoint); err != nil { + return fmt.Errorf("%w: %v", ErrAppManifestInvalid, err) + } + if manifest.WorkingDir != "." { + if err := safepath.ValidateRelative(manifest.WorkingDir); err != nil { + return fmt.Errorf("%w: working_directory: %v", ErrAppManifestInvalid, err) + } + } + if !packageIDPattern.MatchString(manifest.ProductID) { + return fmt.Errorf("%w: invalid product_id", ErrAppManifestInvalid) + } + if manifest.DataPolicy != "local-app-data" || manifest.UpdatePolicy != "managed-by-softbox" { + return fmt.Errorf("%w: data_policy or update_policy", ErrAppManifestInvalid) + } + return nil +} + +func (manifest packageAppManifest) matches(expectation AppExpectation) error { + if manifest.ID != expectation.ID || + manifest.Version != expectation.Version || + manifest.Channel != expectation.Channel || + manifest.MinOS != expectation.MinOS || + manifest.Architecture != expectation.Architecture || + manifest.Entrypoint != expectation.Entrypoint || + manifest.RequiresAdmin != expectation.RequiresAdmin { + return ErrPackageIdentityMismatch + } + return nil +} + +func isSupportedMinOS(value string) bool { + return value == "windows-7-sp1" || value == "windows-10" || value == "windows-11" +} diff --git a/core/installer/verified_package_test.go b/core/installer/verified_package_test.go new file mode 100644 index 0000000..ee5b27b --- /dev/null +++ b/core/installer/verified_package_test.go @@ -0,0 +1,203 @@ +package installer + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestExtractorExtractVerifiedFile(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()) + + result, err := extractor.ExtractVerifiedFile( + archivePath, + destination, + verifiedExpectation(t, archivePath, "1.2.3", "bin/App.exe"), + ) + if err != nil { + t.Fatalf("ExtractVerifiedFile() error = %v", err) + } + if result.Files != 2 || len(result.PayloadFiles) != 2 { + t.Fatalf("files = %d, payload files = %d, want 2", result.Files, len(result.PayloadFiles)) + } + if result.PayloadFiles[0].Path != "bin/App.exe" || result.PayloadFiles[0].Size != int64(len("executable")) { + t.Fatalf("first payload file = %#v", result.PayloadFiles[0]) + } + wantHash := sha256.Sum256([]byte("executable")) + if result.PayloadFiles[0].SHA256 != hex.EncodeToString(wantHash[:]) { + t.Fatalf("first payload hash = %q", result.PayloadFiles[0].SHA256) + } + if _, err := os.Stat(result.EntrypointPath); err != nil { + t.Fatalf("entrypoint stat error = %v", err) + } +} + +func TestExtractorExtractVerifiedFileRejectsBeforeStaging(t *testing.T) { + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: validAppManifest("1.2.3", "App.exe")}, + {name: "payload/App.exe", body: []byte("executable"), mode: 0o755}, + }) + + tests := []struct { + name string + modify func(PackageExpectation) PackageExpectation + wantErr error + stage PackageStage + }{ + { + name: "hash mismatch", + modify: func(expectation PackageExpectation) PackageExpectation { + expectation.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000" + return expectation + }, + wantErr: ErrPackageHashMismatch, + stage: PackageStageVerify, + }, + { + name: "app manifest identity mismatch", + modify: func(expectation PackageExpectation) PackageExpectation { + expectation.App.Version = "9.9.9" + return expectation + }, + wantErr: ErrPackageIdentityMismatch, + stage: PackageStageManifest, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + expectation := test.modify(verifiedExpectation(t, archivePath, "1.2.3", "App.exe")) + + _, err := extractor.ExtractVerifiedFile(archivePath, destination, expectation) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, test.wantErr) + } + var packageErr *PackageError + if !errors.As(err, &packageErr) || packageErr.Stage != test.stage { + t.Fatalf("package error = %#v, want stage %q", packageErr, test.stage) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("rejected package left staging, stat error = %v", statErr) + } + }) + } +} + +func TestExtractorExtractVerifiedFileRejectsStrictAppManifest(t *testing.T) { + manifest := validAppManifest("1.2.3", "App.exe") + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: append(manifest[:len(manifest)-1], []byte(`,"unexpected":true}`)...)}, + {name: "payload/App.exe", body: []byte("executable"), mode: 0o755}, + }) + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + + _, err := extractor.ExtractVerifiedFile( + archivePath, + destination, + verifiedExpectation(t, archivePath, "1.2.3", "App.exe"), + ) + if !errors.Is(err, ErrAppManifestInvalid) { + t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrAppManifestInvalid) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("invalid manifest left staging, stat error = %v", statErr) + } +} + +func TestExtractorExtractVerifiedFileRejectsNonRegularDownload(t *testing.T) { + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, testLimits()) + _, err := extractor.ExtractVerifiedFile( + t.TempDir(), + destination, + PackageExpectation{ + Size: 1, + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + App: AppExpectation{ + ID: "test-app", + Version: "1.2.3", + Channel: "stable", + MinOS: "windows-10", + Architecture: "amd64", + Entrypoint: "App.exe", + }, + }, + ) + if !errors.Is(err, ErrInvalidArchive) { + t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrInvalidArchive) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("non-regular download left staging, stat error = %v", statErr) + } +} + +func TestExtractorExtractVerifiedFileBoundsAppManifest(t *testing.T) { + manifest := append(validAppManifest("1.2.3", "App.exe"), bytes.Repeat([]byte(" "), MaxAppManifestBytes)...) + archivePath := writeTestZIP(t, []testZIPEntry{ + {name: "app.json", body: manifest}, + {name: "payload/App.exe", body: []byte("executable"), mode: 0o755}, + }) + destination := filepath.Join(t.TempDir(), "staging") + extractor := mustExtractor(t, Limits{ + MaxEntries: 20, + MaxArchiveBytes: 2 * MaxAppManifestBytes, + MaxCentralDirectoryBytes: 4 * 1024, + MaxUncompressedBytes: 2 * MaxAppManifestBytes, + MaxCompressionRatio: 100, + }) + + _, err := extractor.ExtractVerifiedFile( + archivePath, + destination, + verifiedExpectation(t, archivePath, "1.2.3", "App.exe"), + ) + if !errors.Is(err, ErrAppManifestTooLarge) { + t.Fatalf("ExtractVerifiedFile() error = %v, want %v", err, ErrAppManifestTooLarge) + } + var packageErr *PackageError + if !errors.As(err, &packageErr) || packageErr.Stage != PackageStageManifest { + t.Fatalf("package error = %#v, want manifest stage", packageErr) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("oversized manifest left staging, stat error = %v", statErr) + } +} + +func verifiedExpectation(t *testing.T, archivePath, version, entrypoint string) PackageExpectation { + t.Helper() + document, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read archive: %v", err) + } + hash := sha256.Sum256(document) + return PackageExpectation{ + Size: int64(len(document)), + SHA256: hex.EncodeToString(hash[:]), + App: AppExpectation{ + ID: "test-app", + Version: version, + Channel: "stable", + MinOS: "windows-10", + Architecture: "amd64", + Entrypoint: entrypoint, + RequiresAdmin: false, + }, + } +} + +func validAppManifest(version, entrypoint string) []byte { + return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"` + entrypoint + `","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`) +} diff --git a/core/installer/zipscan.go b/core/installer/zipscan.go index 1841652..7ed4e7d 100644 --- a/core/installer/zipscan.go +++ b/core/installer/zipscan.go @@ -33,6 +33,24 @@ type endOfCentralDirectory struct { func (extractor Extractor) openAndScanArchive( zipPath string, expectedPackageSize int64, +) (*os.File, int64, error) { + file, size, err := extractor.openArchiveFile(zipPath, expectedPackageSize) + if err != nil { + return nil, 0, err + } + if err := extractor.scanOpenedArchive(file, size); err != nil { + _ = file.Close() + return nil, 0, err + } + return file, size, nil +} + +// openArchiveFile opens the completed package once and proves the path still +// names that same ordinary file. Later verification and parsing must retain +// this handle rather than reopening zipPath. +func (extractor Extractor) openArchiveFile( + zipPath string, + expectedPackageSize int64, ) (*os.File, int64, error) { if err := extractor.limits.validate(); err != nil { return nil, 0, err @@ -64,6 +82,17 @@ func (extractor Extractor) openAndScanArchive( if !info.Mode().IsRegular() { return closeWithError(fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive)) } + if !os.SameFile(pathInfo, info) { + return closeWithError(fmt.Errorf("%w: package changed while opening", ErrInvalidArchive)) + } + pathInfoAfterOpen, err := os.Lstat(zipPath) + if err != nil { + return closeWithError(fmt.Errorf("%w: recheck package: %v", ErrInvalidArchive, err)) + } + if pathInfoAfterOpen.Mode()&os.ModeSymlink != 0 || !pathInfoAfterOpen.Mode().IsRegular() || + !os.SameFile(info, pathInfoAfterOpen) { + return closeWithError(fmt.Errorf("%w: package changed while opening", ErrInvalidArchive)) + } size := info.Size() if size != expectedPackageSize { return closeWithError(fmt.Errorf( @@ -81,12 +110,43 @@ func (extractor Extractor) openAndScanArchive( extractor.limits.MaxArchiveBytes, )) } - if err := scanCentralDirectory(file, size, extractor.limits); err != nil { - return closeWithError(err) - } return file, size, nil } +func (extractor Extractor) scanOpenedArchive(file *os.File, expectedPackageSize int64) error { + if file == nil { + return fmt.Errorf("%w: package handle is nil", ErrInvalidArchive) + } + if err := extractor.limits.validate(); err != nil { + return err + } + info, err := file.Stat() + if err != nil { + return fmt.Errorf("%w: stat package before ZIP scan: %v", ErrInvalidArchive, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive) + } + size := info.Size() + if size != expectedPackageSize { + return fmt.Errorf( + "%w: got %d, expected %d", + ErrArchiveSizeMismatch, + size, + expectedPackageSize, + ) + } + if size > extractor.limits.MaxArchiveBytes { + return fmt.Errorf( + "%w: got %d, limit %d", + ErrArchiveTooLarge, + size, + extractor.limits.MaxArchiveBytes, + ) + } + return scanCentralDirectory(file, size, extractor.limits) +} + func scanCentralDirectory(file *os.File, size int64, limits Limits) error { end, err := findEndOfCentralDirectory(file, size) if err != nil { diff --git a/core/storage/installed_app.go b/core/storage/installed_app.go index 6631031..16569f7 100644 --- a/core/storage/installed_app.go +++ b/core/storage/installed_app.go @@ -64,6 +64,15 @@ func NewInstalledAppStore(appsRoot string) *InstalledAppStore { return &InstalledAppStore{appsRoot: appsRoot} } +// EnsureAppRoot creates and validates the real apps/ directory used by an +// installer transaction. It does not write an installed-app record. +func (store *InstalledAppStore) EnsureAppRoot(appID string) (string, error) { + store.mu.Lock() + defer store.mu.Unlock() + + return store.ensureAppRoot(appID) +} + // Write validates and atomically replaces one installed-app.json. func (store *InstalledAppStore) Write(record InstalledApp) error { store.mu.Lock() @@ -413,7 +422,11 @@ func replaceInstalledAppFile(directory, target, backup string, document []byte) } if movedTarget || hadBackup { if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove installed app backup: %w", err) + // The new target is already atomically active. Reporting a cleanup + // failure here would make callers roll back a healthy current + // directory while this record already names the new version. Keep the + // regular recovery backup for a later successful replacement instead. + return nil } } return nil diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 28c38c7..2aebd74 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-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序以及 Catalog canonicalization/签名静态 corpus 均已关闭;下一步正式落成 T-302。物理断电、文件锁与杀毒软件干扰验证仍后置到 T-302/T-601。 +当前项目已完成 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 发布前环境验证。 优先路径: @@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 已完成 T-606~T-614:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序和 Catalog 静态签名向量;下一步正式落成并执行 T-302,再继续 T-303 与 Phase 4-6。T-302/T-601 仍须补真实 Windows 环境的断电/干扰注入。 +5. 已完成 T-606~T-614:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序和 Catalog 静态签名向量;已完成 T-302:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路。下一步正式落成并执行 T-303,再继续 Phase 4-6。T-601 仍须补真实 Windows 环境的断电/干扰注入。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 630c42a..7d312ba 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -158,7 +158,7 @@ T-301 下载队列: - cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。 - 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。 - 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。 -- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 的 `application.InstallService` 不得把它们当信任根,而是接收已验签/过滤 Catalog `Entry` + architecture,确认其与 `App.Packages[architecture]` 精确对应。它将该 Catalog size/SHA-256 交给 `Extractor.ExtractVerifiedFile`,后者以同一打开的普通完成文件先 size、再 SHA-256、再 ZIP 预扫描/解析;package `signature` 仅由外层 Catalog 签名覆盖,当前没有独立包级验签域。 +- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 的 `core/application/install.InstallService` 不得把它们当信任根,而是接收已验签/过滤 Catalog `Entry` + architecture,确认其与 `App.Packages[architecture]` 精确对应。它将该 Catalog size/SHA-256 交给 `Extractor.ExtractVerifiedFile`,后者以同一打开的普通完成文件先 size、再 SHA-256、再 ZIP 预扫描/解析;package `signature` 仅由外层 Catalog 签名覆盖,当前没有独立包级验签域。 ### 4.3 事件模型 @@ -185,7 +185,7 @@ Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同 T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度检查后 `Sync`/`Close` → staging 子目录到根及其父目录同步 → prepared journal 的临时文件 `Sync`/`Close` 与 root 栅栏 → 每次目录 rename 的 root 栅栏与下一 phase journal → committed journal 的 root 栅栏 → backup/journal 清理的 root 栅栏。所有栅栏通过 installer 内部接口复用;非 Windows 使用目录 `File.Sync`,Windows 用 Win7 已有的 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 读写目录句柄和 `FlushFileBuffers`,失败一律 fail closed。测试可注入失败并验证状态机保留可恢复 journal,但这仍不是物理掉电证明。 -真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-302/T-601 在 Windows VM/真机做故障注入,当前结论不替代硬件级断电验证。 +真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。 盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 64bba72..2cfbd66 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,13 +42,13 @@ #### Phase 1 交叉审核加固 -Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612、T-613 与 T-614 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,T-614 冻结 Catalog canonicalization/签名静态 corpus 与客户端拒绝规则。下一步按路线图正式落成并执行 T-302;物理断电验证仍后置到 T-302/T-601。 +Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612、T-613 与 T-614 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,T-614 冻结 Catalog canonicalization/签名静态 corpus 与客户端拒绝规则。T-302 已将这些原型整合到同句柄 Catalog size/SHA、严格 app.json、安全 staging/switch/回滚的安装 use case;物理断电验证保留到 T-601。 | ID | 任务 | 依赖 | 验收要点 | | --- | --- | --- | --- | | T-605 | 统一 Windows 安全路径校验并封堵 ZIP 逃逸 | T-102, T-201, T-202, T-604 | Catalog/ZIP/installed-app 共用逐段 Windows 安全相对路径策略;拒绝尾随空格/点与 DOS 设备名;输出路径增加 destination 包含性兜底;原生 Windows 用例证明不写出 staging | | T-612 | 在 ZIP 打开前限制包大小与中央目录元数据 | T-605 | 已验签 Catalog size、已完成普通下载文件长度与同句柄 EOCD/ZIP64 预扫描一致;在 `zip.NewReader` 前限制原始包、中央目录与声明条目数 | -| T-613 | 建立安装文件与目录事务耐久顺序 | T-612 | payload Sync、staging tree/journal/rename/remove 的目录栅栏;Windows `FlushFileBuffers` fail-closed;物理断电故障注入仍后置到 T-302/T-601 | +| T-613 | 建立安装文件与目录事务耐久顺序 | T-612 | payload Sync、staging tree/journal/rename/remove 的目录栅栏;Windows `FlushFileBuffers` fail-closed;物理断电故障注入仍后置到 T-601 | | T-614 | 冻结 Catalog 规范化与签名跨实现测试向量 | T-613 | 静态 canonical bytes/Ed25519 test vectors 覆盖 Unicode、surrogate、`-0`/大整数、嵌套 signature 与 Base64;客户端不自举期望值,外部发布端可消费同一 corpus | ### Phase 2 · 清单与软件列表 diff --git a/docs/api.md b/docs/api.md index 66c5d3f..a1c1c37 100644 --- a/docs/api.md +++ b/docs/api.md @@ -153,7 +153,7 @@ T-102 Phase 1 原型进一步固定: - ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。 - 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。 - 拒绝符号链接、设备/管道等特殊文件和加密条目。 -- T-302 的 `application.InstallService` 只接收已验签、严格解析并按目标过滤后的 Catalog `Entry` + architecture 与 `.download` 候选路径。它必须确认 entry/package 与 `App.Packages[architecture]` 精确对应;不得从 T-301 task、`DownloadCompleted` payload 或本地 metadata 取得 app/version/size/hash。外层 Catalog Ed25519 签名覆盖嵌套 package 的 `size`、`sha256` 与 `signature` 文本;当前协议未定义 package `signature` 的独立待签名字节/公钥域,客户端不得臆造第二套包级验签。 +- T-302 的 `core/application/install.InstallService` 只接收已验签、严格解析并按目标过滤后的 Catalog `Entry` + architecture 与 `.download` 候选路径。它必须确认 entry/package 与 `App.Packages[architecture]` 精确对应;不得从 T-301 task、`DownloadCompleted` payload 或本地 metadata 取得 app/version/size/hash。外层 Catalog Ed25519 签名覆盖嵌套 package 的 `size`、`sha256` 与 `signature` 文本;当前协议未定义 package `signature` 的独立待签名字节/公钥域,客户端不得臆造第二套包级验签。 - `Extractor.ExtractVerifiedFile` 必须接收该 Catalog package 的 `size`、`sha256` 与 app identity expectation。它以一次 `Lstat → open → fstat → Lstat` 取得普通 `.download` 文件,并在**同一打开句柄**上先精确核对 `expectedPackageSize`、再计算/常量时间比较 SHA-256;任一失败时不得构造 ZIP reader、读取 app.json 或创建 staging。T-301 的 known-total 完成文件已经以 Catalog size 限长,但 T-302 仍须执行上述重新对账,不能信任可篡改的下载 metadata。 - 构造 `zip.Reader` 前只读取文件尾部至多 65,557 字节以定位 EOCD,并按需读取固定的 ZIP64 locator/EOCD 记录;校验单磁盘、中央目录 offset/size/entries 的边界及 entries/中央目录大小硬上限。当前默认上限为:原始包 4 GiB、中央目录 64 MiB、10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。大小不一致、原始包超限、中央目录超限、声明条目超限分别保留 `ErrArchiveSizeMismatch`、`ErrArchiveTooLarge`、`ErrCentralDirectoryTooLarge`、`ErrTooManyEntries` 错误链;格式、截断、跨盘或不一致 ZIP64 归入 `ErrInvalidArchive`。 - SHA-256 通过后,预扫描继续使用**同一文件句柄**和已核对的长度创建 `zip.NewReader`;完整中央目录/路径/entrypoint/类型/CRC/展开量预检仍是第二道防线。合法 ZIP64 被支持,不因 32 位 EOCD 哨兵值误拒绝。T-302 按真实包体分布复核上述暂定限额后再冻结。 @@ -218,7 +218,7 @@ T-613 为该原型建立了 fail-closed 的耐久顺序:每个 payload 先完成 T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再原子写入新 `installed-app.json`;health 或记录写失败都必须触发既有 rollback,使旧 current/记录保持可用。只有 health 与记录均成功后才写 committed 并清理 backup/journal。 -这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302/T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。 +这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302 已完成代码整合;T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。 ### 2.6 下载任务元数据 download-task.json(本地) diff --git a/docs/current-state.md b/docs/current-state.md index 93824a4..0c472a2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-18 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-614 已完成;T-302 安装流程整合已正式落成、待领取并执行 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列与 T-302 安装流程整合已完成;审核整改 T-604~T-614 已完成;下一步应正式落成并执行 T-303 - 技术栈:根 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 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数,每个 payload Sync/Close 后同步 staging tree 及父目录;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 原始包/中央目录/条目数预扫描、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 安装 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` 详情语义;安装恢复矩阵保持通过 - 数据:`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` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;T-614 已以静态 corpus 冻结客户端 Catalog canonicalization/签名行为,外部 `softbox-catalog` 消费 corpus 的 CI 证据仍需跨仓库协调,但不阻止落成 T-302。物理断电、文件锁/杀毒软件干扰仍需 T-302/T-601 的目标 Windows VM/真机故障注入 +- 当前 blocker:无;T-614 已以静态 corpus 冻结客户端 Catalog canonicalization/签名行为,外部 `softbox-catalog` 消费 corpus 的 CI 证据仍需跨仓库协调,但不阻止 T-303。物理断电、文件锁/杀毒软件干扰仍需 T-601 的目标 Windows VM/真机故障注入 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-614 已完成;T-302 安装流程整合已落成且可领取 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301、T-302 与 T-604~T-614 已完成;下一步按路线图落成 T-303 失败处理与磁盘预检查任务 | | `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-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-302`;审核整改 `T-604`~`T-614`。 - 正在进行:无。 -- 下一个可领取任务:T-302(依赖 T-301/T-102/T-103 均已完成)。T-302/T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 +- 下一个可领取任务:暂无;应按 Phase 3 路线图先将 T-303 失败处理与磁盘预检查正式落成任务文件,再领取。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/tasks/T-302.md b/docs/tasks/T-302.md index faaa7ea..50dfde6 100644 --- a/docs/tasks/T-302.md +++ b/docs/tasks/T-302.md @@ -3,12 +3,12 @@ id: T-302 title: 安装流程整合 phase: 3 deps: [T-301, T-102, T-103] -status: TODO +status: DONE created: 2026-07-18 issue: null -context_ref: null +context_ref: 6575c9ad9b997366514277a925196b2e52f16f75 claim_branch: null -work_branch: null +work_branch: agent/codex/T-302 write_paths: - docs/tasks/T-302.md - core/application/ @@ -16,6 +16,8 @@ write_paths: - core/storage/ - docs/api.md - docs/04-architecture.md + - docs/00-ai-start-here.md + - docs/06-tasks.md - docs/current-state.md --- @@ -27,7 +29,7 @@ T-301 只能把网络字节可靠地落为隔离的 `.download`;下载任务元 ## 方案 -1. 在 `core/application` 落地 `InstallService`:请求包含已过滤的 `catalog.Entry`、目标 architecture 和 `.download` 路径。服务必须拒绝不可安装 entry、缺失 package、architecture 与 `App.Packages[architecture]` 不一致的选择;不得从 `downloader.Task`、`DownloadCompletedPayload` 或本地 metadata 重新取得 app/version/size/hash。 +1. 在 `core/application/install` 落地 `InstallService`:请求包含已过滤的 `catalog.Entry`、目标 architecture 和 `.download` 路径。服务必须拒绝不可安装 entry、缺失 package、architecture 与 `App.Packages[architecture]` 不一致的选择;不得从 `downloader.Task`、`DownloadCompletedPayload` 或本地 metadata 重新取得 app/version/size/hash。该 application 子包独立于被 Catalog 图标投递依赖的父包,避免反向 import cycle。 2. 在 `core/installer` 增加经过验证的包提取入口。它以一次 `Lstat → open → fstat → Lstat` 得到普通文件句柄,先确认实际长度精确等于 Catalog size、再以**同一文件句柄**计算 SHA-256,并以常量时间比较 Catalog hash;哈希不符时不得构造 `zip.Reader`、读取 `app.json` 或创建 staging。随后仍以同一句柄完成 EOCD/ZIP64/中央目录预扫描和 `zip.Reader` 构造,不能按路径重新打开。 3. 安全读取 ZIP 根目录唯一的 `app.json`(最大 1 MiB,严格 JSON、无未知字段/尾随值),校验 v1 的全部字段与共享 Windows 安全相对路径规则。身份字段必须与 Catalog 选择一致:`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin`;其余 v1 常量和值也必须符合 `schemas/app.schema.json`。只有通过比对后,才复用既有两阶段 ZIP 检查把 `payload/` 解压到全新的 `apps//staging`。 4. 解压复制时为每个实际 payload 文件计算 size/SHA-256,作为 `ExtractResult` 的已观察文件清单。`InstallService` 将此清单写入 `installed-app.json`,不信任或依赖可选的 `files.json` 作为新的信任根;保留 v1 `files.json` 的协议语义和后续修复功能边界。 @@ -47,7 +49,7 @@ T-301 只能把网络字节可靠地落为隔离的 `.download`;下载任务元 - 不修改 Catalog 签名协议、Schema 或密钥;不为 nested package `signature` 虚构独立验签算法。外层已验签 Catalog 是本任务唯一的密码学身份来源。 - 不做磁盘空间预检、程序占用/退出等待、面向 UI 的完整错误码映射或下载重试策略(T-303/T-401);不强杀或自动启动包内程序。 -- 不做 Gio 安装面板、下载完成到安装调用的 UI 编排或物理断电/杀毒软件/文件锁故障注入。T-302 只提供无头 core use case;真实 Windows VM/真机故障注入仍由 T-302/T-601 发布前环境验证完成。 +- 不做 Gio 安装面板、下载完成到安装调用的 UI 编排或物理断电/杀毒软件/文件锁故障注入。T-302 只提供无头 core use case;真实 Windows VM/真机故障注入转入 T-601 发布前环境验证。 - 不改变 `files.json` 的 v1/v1.1 协议或实现修复功能,不引入数据库、第三方包、Gio、Windows API 或 Go 1.21+ API。 ## 协作约束 @@ -58,4 +60,8 @@ T-301 只能把网络字节可靠地落为隔离的 `.download`;下载任务元 ## 执行记录 -- 2026-07-18:正式落成。冻结可信输入、同句柄 size/SHA/ZIP 顺序、严格 app.json 对齐、实际 payload 文件记录、health 内记录写入回滚语义和后续任务边界;待领取后执行基线验证与实现。 +- 2026-07-18:正式落成。冻结可信输入、同句柄 size/SHA/ZIP 顺序、严格 app.json 对齐、实际 payload 文件记录、health 内记录写入回滚语义和后续任务边界。 +- 2026-07-18:领取任务,基线为 `6575c9ad9b997366514277a925196b2e52f16f75`,工作分支 `agent/codex/T-302`;下一步运行统一初始化/完整基线,再开始实现。 +- 2026-07-18:基线通过:`./init.ps1` 完成治理、core 架构/Go 版本闸门、Go 1.20 core vet/test、modern/Win7 test/build 与 Python harness 校验。 +- 2026-07-18:实现 `core/application/install.InstallService`、同句柄 `Extractor.ExtractVerifiedFile`、严格且有 1 MiB 上限的 app.json 校验、payload 观测 hash 清单和 `InstalledAppStore.EnsureAppRoot`;Catalog 依赖父 application 包的既有图标投递链会产生 import cycle,因此 use case 放在独立 application 子包,未改变 Gio/UI 边界。 +- 2026-07-18:复核成功/size+hash+选择失败/严格 manifest/非普通文件/manifest 上限/transaction recovery/health 与记录写失败回滚;`go -C core vet ./...`、`go -C core test -count=1 ./...` 和 `go -C core test -count=10 ./installer ./application/install` 全部通过。真实 Windows 断电、文件锁/杀毒软件故障注入未在当前环境执行,保留为 T-601 发布前验证。