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
+31 -3
View File
@@ -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
+3 -3
View File
@@ -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
+391
View File
@@ -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"
}
+203
View File
@@ -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"}`)
}
+63 -3
View File
@@ -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 {