Integrate verified installation flow (T-302)

This commit is contained in:
ila
2026-07-18 17:55:01 +08:00
parent 6575c9ad9b
commit 14589abb31
14 changed files with 1318 additions and 31 deletions
+233
View File
@@ -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
}
+353
View File
@@ -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)
}