Integrate verified installation flow (T-302)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"}`)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -64,6 +64,15 @@ func NewInstalledAppStore(appsRoot string) *InstalledAppStore {
|
||||
return &InstalledAppStore{appsRoot: appsRoot}
|
||||
}
|
||||
|
||||
// EnsureAppRoot creates and validates the real apps/<id> 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
|
||||
|
||||
Reference in New Issue
Block a user