Add install preflight safeguards (T-303)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")},
|
||||
|
||||
Reference in New Issue
Block a user