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" }