Add install preflight safeguards (T-303)

This commit is contained in:
ila
2026-07-18 18:14:25 +08:00
parent 449b183ca3
commit ae3f64c407
8 changed files with 655 additions and 44 deletions
+19
View File
@@ -64,6 +64,25 @@ type plannedEntry struct {
directory bool
}
func verifiedPackageFromPlan(plan []plannedEntry, entrypoint string) (VerifiedPackage, error) {
verified := VerifiedPackage{Entrypoint: entrypoint}
for _, entry := range plan {
if entry.directory {
continue
}
if entry.file == nil || entry.file.UncompressedSize64 > uint64(math.MaxInt64) {
return VerifiedPackage{}, ErrExpandedTooLarge
}
size := int64(entry.file.UncompressedSize64)
if verified.PayloadBytes > math.MaxInt64-size {
return VerifiedPackage{}, ErrExpandedTooLarge
}
verified.PayloadBytes += size
verified.PayloadFiles++
}
return verified, nil
}
func NewExtractor(limits Limits) (Extractor, error) {
if err := limits.validate(); err != nil {
return Extractor{}, err
+39 -3
View File
@@ -34,9 +34,10 @@ var packageIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
type PackageStage string
const (
PackageStageVerify PackageStage = "verify"
PackageStageManifest PackageStage = "manifest"
PackageStageExtract PackageStage = "extract"
PackageStageVerify PackageStage = "verify"
PackageStageManifest PackageStage = "manifest"
PackageStagePreflight PackageStage = "preflight"
PackageStageExtract PackageStage = "extract"
)
// PackageError preserves the underlying safe failure while making the package
@@ -74,6 +75,20 @@ type PackageExpectation struct {
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"`
@@ -116,6 +131,18 @@ 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 {
@@ -157,6 +184,15 @@ func (extractor Extractor) ExtractVerifiedFile(
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)
+39
View File
@@ -42,6 +42,45 @@ func TestExtractorExtractVerifiedFile(t *testing.T) {
}
}
func TestExtractorExtractVerifiedFileWithCheckPreflightsBeforeStaging(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())
stop := errors.New("pre-extract check stopped")
_, err := extractor.ExtractVerifiedFileWithCheck(
archivePath,
destination,
verifiedExpectation(t, archivePath, "1.2.3", "bin/App.exe"),
func(verified VerifiedPackage) error {
if verified.Entrypoint != "bin/App.exe" {
t.Fatalf("entrypoint = %q", verified.Entrypoint)
}
if verified.PayloadFiles != 2 {
t.Fatalf("payload files = %d, want 2", verified.PayloadFiles)
}
if verified.PayloadBytes != int64(len("executable")+len("hello")) {
t.Fatalf("payload bytes = %d", verified.PayloadBytes)
}
return stop
},
)
if !errors.Is(err, stop) {
t.Fatalf("ExtractVerifiedFileWithCheck() error = %v, want %v", err, stop)
}
var packageErr *PackageError
if !errors.As(err, &packageErr) || packageErr.Stage != PackageStagePreflight {
t.Fatalf("package error = %#v, want preflight stage", packageErr)
}
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
}
}
func TestExtractorExtractVerifiedFileRejectsBeforeStaging(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: validAppManifest("1.2.3", "App.exe")},