431 lines
14 KiB
Go
431 lines
14 KiB
Go
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"
|
|
PackageStagePreflight PackageStage = "preflight"
|
|
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
|
|
}
|
|
|
|
// VerifiedPackage describes the payload plan after the download, ZIP layout,
|
|
// and app manifest have all been verified. It intentionally contains no ZIP
|
|
// handles or destination paths, so callers cannot bypass safe extraction.
|
|
type VerifiedPackage struct {
|
|
PayloadBytes int64
|
|
PayloadFiles int
|
|
Entrypoint string
|
|
}
|
|
|
|
// PreExtractCheck runs after package verification but before the extraction
|
|
// destination is created. It lets application code enforce environment
|
|
// preconditions without introducing application or platform dependencies here.
|
|
type PreExtractCheck func(VerifiedPackage) error
|
|
|
|
type packageAppManifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
ID string `json:"id"`
|
|
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) {
|
|
return extractor.ExtractVerifiedFileWithCheck(zipPath, destination, expectation, nil)
|
|
}
|
|
|
|
// ExtractVerifiedFileWithCheck preserves one file handle from Catalog
|
|
// size/SHA-256 verification through ZIP scanning, manifest comparison, an
|
|
// optional environment precheck, and safe extraction.
|
|
func (extractor Extractor) ExtractVerifiedFileWithCheck(
|
|
zipPath string,
|
|
destination string,
|
|
expectation PackageExpectation,
|
|
beforeExtract PreExtractCheck,
|
|
) (ExtractResult, error) {
|
|
expectedHash, err := expectation.validate()
|
|
if err != nil {
|
|
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)
|
|
}
|
|
if beforeExtract != nil {
|
|
verified, err := verifiedPackageFromPlan(plan, normalizedEntrypoint)
|
|
if err != nil {
|
|
return ExtractResult{}, packageError(PackageStageVerify, err)
|
|
}
|
|
if err := beforeExtract(verified); err != nil {
|
|
return ExtractResult{}, packageError(PackageStagePreflight, err)
|
|
}
|
|
}
|
|
result, err := extractor.extractPlan(destination, normalizedEntrypoint, plan)
|
|
if err != nil {
|
|
return ExtractResult{}, packageError(PackageStageExtract, err)
|
|
}
|
|
result.WorkingDir = manifest.WorkingDir
|
|
result.ProductID = manifest.ProductID
|
|
result.SupportsTrial = manifest.SupportsTrial
|
|
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"
|
|
}
|