454 lines
13 KiB
Go
454 lines
13 KiB
Go
package installer
|
|
|
|
import (
|
|
"archive/zip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"softbox.local/core/internal/safepath"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidArchive = errors.New("invalid ZIP archive")
|
|
ErrPathEscape = errors.New("ZIP path escapes payload")
|
|
ErrUnsupportedEntry = errors.New("unsupported ZIP entry")
|
|
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
|
|
ErrDuplicateEntry = errors.New("duplicate ZIP entry")
|
|
ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported")
|
|
ErrArchiveSizeMismatch = errors.New("ZIP archive size does not match expected package size")
|
|
ErrArchiveTooLarge = errors.New("ZIP archive size limit exceeded")
|
|
ErrCentralDirectoryTooLarge = errors.New("ZIP central directory size limit exceeded")
|
|
ErrTooManyEntries = errors.New("ZIP entry limit exceeded")
|
|
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
|
|
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
|
|
ErrEntrypointInvalid = errors.New("invalid package entrypoint")
|
|
ErrEntrypointMissing = errors.New("package entrypoint is missing")
|
|
ErrAppManifestMissing = errors.New("package app.json is missing")
|
|
ErrDestinationExists = errors.New("staging destination already exists")
|
|
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
|
|
)
|
|
|
|
// Extractor writes only payload/ contents from a pre-verified package ZIP.
|
|
type Extractor struct {
|
|
limits Limits
|
|
durability durabilityFence
|
|
}
|
|
|
|
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 {
|
|
file *zip.File
|
|
archivePath string
|
|
outputPath string
|
|
targetPath string
|
|
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
|
|
}
|
|
return Extractor{limits: limits, durability: defaultDurability()}, nil
|
|
}
|
|
|
|
// ExtractFile requires expectedPackageSize from the verified Catalog package.
|
|
// The completed download file must have precisely that size before any ZIP data is parsed.
|
|
func (extractor Extractor) ExtractFile(
|
|
zipPath string,
|
|
destination string,
|
|
entrypoint string,
|
|
expectedPackageSize int64,
|
|
) (ExtractResult, error) {
|
|
file, size, err := extractor.openAndScanArchive(zipPath, expectedPackageSize)
|
|
if err != nil {
|
|
return ExtractResult{}, err
|
|
}
|
|
defer file.Close()
|
|
|
|
archive, err := zip.NewReader(file, size)
|
|
if err != nil {
|
|
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
|
|
}
|
|
return extractor.extract(archive, destination, entrypoint)
|
|
}
|
|
|
|
func (extractor Extractor) extract(
|
|
archive *zip.Reader,
|
|
destination string,
|
|
entrypoint string,
|
|
) (result ExtractResult, err error) {
|
|
if err := extractor.limits.validate(); err != nil {
|
|
return ExtractResult{}, err
|
|
}
|
|
normalizedEntrypoint, err := normalizeEntrypoint(entrypoint)
|
|
if err != nil {
|
|
return ExtractResult{}, err
|
|
}
|
|
plan, err := extractor.preflight(archive, normalizedEntrypoint)
|
|
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,
|
|
entrypoint,
|
|
plan,
|
|
)
|
|
if err != nil {
|
|
return ExtractResult{}, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
|
|
return ExtractResult{}, fmt.Errorf("create staging parent: %w", err)
|
|
}
|
|
if err := os.Mkdir(destinationRoot, 0o700); err != nil {
|
|
if os.IsExist(err) {
|
|
return ExtractResult{}, ErrDestinationExists
|
|
}
|
|
return ExtractResult{}, fmt.Errorf("create staging destination: %w", err)
|
|
}
|
|
complete := false
|
|
defer func() {
|
|
if !complete {
|
|
_ = os.RemoveAll(destinationRoot)
|
|
}
|
|
}()
|
|
|
|
var written int64
|
|
for _, entry := range plan {
|
|
if entry.directory {
|
|
if entry.outputPath == "" {
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(entry.targetPath, 0o700); err != nil {
|
|
return ExtractResult{}, fmt.Errorf("create staging directory: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
|
|
return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err)
|
|
}
|
|
|
|
source, err := entry.file.Open()
|
|
if err != nil {
|
|
return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err)
|
|
}
|
|
mode := os.FileMode(0o600)
|
|
if entry.file.Mode().Perm()&0o111 != 0 {
|
|
mode = 0o700
|
|
}
|
|
output, err := os.OpenFile(
|
|
entry.targetPath,
|
|
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
|
|
mode,
|
|
)
|
|
if err != nil {
|
|
source.Close()
|
|
return ExtractResult{}, fmt.Errorf("create staging file: %w", err)
|
|
}
|
|
|
|
remaining := extractor.limits.MaxUncompressedBytes - written
|
|
readLimit := remaining
|
|
if readLimit < math.MaxInt64 {
|
|
readLimit++
|
|
}
|
|
digest := sha256.New()
|
|
copied, copyErr := io.Copy(
|
|
io.MultiWriter(output, digest),
|
|
io.LimitReader(source, readLimit),
|
|
)
|
|
closeSourceErr := source.Close()
|
|
if copyErr != nil {
|
|
_ = output.Close()
|
|
return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr)
|
|
}
|
|
if closeSourceErr != nil {
|
|
_ = output.Close()
|
|
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
|
|
}
|
|
if copied > remaining {
|
|
_ = output.Close()
|
|
return ExtractResult{}, ErrExpandedTooLarge
|
|
}
|
|
if uint64(copied) != entry.file.UncompressedSize64 {
|
|
_ = output.Close()
|
|
return ExtractResult{}, fmt.Errorf(
|
|
"%w: %s expanded to %d bytes, header declares %d",
|
|
ErrArchiveCorrupt,
|
|
entry.archivePath,
|
|
copied,
|
|
entry.file.UncompressedSize64,
|
|
)
|
|
}
|
|
if err := syncFileWithFence(fence, output, "staging payload"); err != nil {
|
|
_ = output.Close()
|
|
return ExtractResult{}, err
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return ExtractResult{}, fmt.Errorf("close staging file: %w", err)
|
|
}
|
|
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
|
|
}
|
|
|
|
result.Bytes = written
|
|
result.EntrypointPath = entrypointPath
|
|
complete = true
|
|
return result, nil
|
|
}
|
|
|
|
func planOutputPaths(
|
|
destination string,
|
|
entrypoint string,
|
|
plan []plannedEntry,
|
|
) (string, string, error) {
|
|
if destination == "" {
|
|
return "", "", fmt.Errorf("%w: staging destination is empty", ErrPathEscape)
|
|
}
|
|
destinationRoot, err := filepath.Abs(destination)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("%w: resolve staging destination: %v", ErrPathEscape, err)
|
|
}
|
|
destinationRoot = filepath.Clean(destinationRoot)
|
|
for index := range plan {
|
|
if plan[index].outputPath == "" {
|
|
plan[index].targetPath = destinationRoot
|
|
continue
|
|
}
|
|
target, err := safepath.JoinUnder(destinationRoot, plan[index].outputPath)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf(
|
|
"%w: output %q: %v",
|
|
ErrPathEscape,
|
|
plan[index].outputPath,
|
|
err,
|
|
)
|
|
}
|
|
plan[index].targetPath = target
|
|
}
|
|
entrypointPath, err := safepath.JoinUnder(destinationRoot, entrypoint)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf(
|
|
"%w: entrypoint %q: %v",
|
|
ErrEntrypointInvalid,
|
|
entrypoint,
|
|
err,
|
|
)
|
|
}
|
|
return destinationRoot, entrypointPath, nil
|
|
}
|
|
|
|
func (extractor Extractor) preflight(
|
|
archive *zip.Reader,
|
|
entrypoint string,
|
|
) ([]plannedEntry, error) {
|
|
if len(archive.File) > extractor.limits.MaxEntries {
|
|
return nil, fmt.Errorf(
|
|
"%w: got %d, limit %d",
|
|
ErrTooManyEntries,
|
|
len(archive.File),
|
|
extractor.limits.MaxEntries,
|
|
)
|
|
}
|
|
|
|
entrypointArchivePath := "payload/" + entrypoint
|
|
seenPaths := make(map[string]string, len(archive.File))
|
|
plan := make([]plannedEntry, 0, len(archive.File))
|
|
var totalUncompressed uint64
|
|
var totalCompressed uint64
|
|
appManifestFound := false
|
|
entrypointFound := false
|
|
|
|
for _, file := range archive.File {
|
|
normalized, directory, err := validateArchiveEntry(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
folded := safepath.CollisionKey(normalized)
|
|
if previous, exists := seenPaths[folded]; exists {
|
|
return nil, fmt.Errorf(
|
|
"%w: %q conflicts with %q",
|
|
ErrDuplicateEntry,
|
|
normalized,
|
|
previous,
|
|
)
|
|
}
|
|
seenPaths[folded] = normalized
|
|
|
|
if file.UncompressedSize64 > uint64(extractor.limits.MaxUncompressedBytes)-totalUncompressed {
|
|
return nil, ErrExpandedTooLarge
|
|
}
|
|
totalUncompressed += file.UncompressedSize64
|
|
if ^uint64(0)-totalCompressed < file.CompressedSize64 {
|
|
return nil, fmt.Errorf("%w: compressed size overflow", ErrInvalidArchive)
|
|
}
|
|
totalCompressed += file.CompressedSize64
|
|
if exceedsCompressionRatio(
|
|
file.UncompressedSize64,
|
|
file.CompressedSize64,
|
|
extractor.limits.MaxCompressionRatio,
|
|
) {
|
|
return nil, fmt.Errorf("%w: %s", ErrCompressionRatio, normalized)
|
|
}
|
|
|
|
switch {
|
|
case normalized == "app.json":
|
|
if directory {
|
|
return nil, fmt.Errorf("%w: app.json is a directory", ErrUnexpectedEntry)
|
|
}
|
|
appManifestFound = true
|
|
case normalized == "files.json":
|
|
if directory {
|
|
return nil, fmt.Errorf("%w: files.json is a directory", ErrUnexpectedEntry)
|
|
}
|
|
case normalized == "payload":
|
|
if !directory {
|
|
return nil, fmt.Errorf("%w: payload must be a directory", ErrUnexpectedEntry)
|
|
}
|
|
plan = append(plan, plannedEntry{
|
|
file: file,
|
|
archivePath: normalized,
|
|
outputPath: "",
|
|
directory: true,
|
|
})
|
|
case strings.HasPrefix(normalized, "payload/"):
|
|
outputPath := strings.TrimPrefix(normalized, "payload/")
|
|
plan = append(plan, plannedEntry{
|
|
file: file,
|
|
archivePath: normalized,
|
|
outputPath: outputPath,
|
|
directory: directory,
|
|
})
|
|
if normalized == entrypointArchivePath && !directory {
|
|
entrypointFound = true
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnexpectedEntry, normalized)
|
|
}
|
|
}
|
|
|
|
if !appManifestFound {
|
|
return nil, ErrAppManifestMissing
|
|
}
|
|
if exceedsCompressionRatio(
|
|
totalUncompressed,
|
|
totalCompressed,
|
|
extractor.limits.MaxCompressionRatio,
|
|
) {
|
|
return nil, fmt.Errorf("%w: whole archive", ErrCompressionRatio)
|
|
}
|
|
if !entrypointFound {
|
|
return nil, fmt.Errorf("%w: %s", ErrEntrypointMissing, entrypoint)
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func validateArchiveEntry(file *zip.File) (string, bool, error) {
|
|
if file.Flags&0x1 != 0 {
|
|
return "", false, fmt.Errorf("%w: %s", ErrEncryptedEntry, file.Name)
|
|
}
|
|
normalized, directory, err := normalizeArchivePath(file.Name)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
mode := file.Mode()
|
|
if mode&os.ModeSymlink != 0 {
|
|
return "", false, fmt.Errorf("%w: symlink %s", ErrUnsupportedEntry, normalized)
|
|
}
|
|
if directory {
|
|
if !mode.IsDir() {
|
|
return "", false, fmt.Errorf("%w: non-directory mode for %s", ErrUnsupportedEntry, normalized)
|
|
}
|
|
return normalized, true, nil
|
|
}
|
|
if !mode.IsRegular() {
|
|
return "", false, fmt.Errorf("%w: special file %s", ErrUnsupportedEntry, normalized)
|
|
}
|
|
return normalized, false, nil
|
|
}
|
|
|
|
func normalizeArchivePath(name string) (string, bool, error) {
|
|
directory := strings.HasSuffix(name, "/")
|
|
trimmed := strings.TrimSuffix(name, "/")
|
|
if err := safepath.ValidateRelative(trimmed); err != nil {
|
|
return "", false, fmt.Errorf("%w: %q: %v", ErrPathEscape, name, err)
|
|
}
|
|
return trimmed, directory, nil
|
|
}
|
|
|
|
func normalizeEntrypoint(entrypoint string) (string, error) {
|
|
if err := safepath.ValidateRelative(entrypoint); err != nil {
|
|
return "", fmt.Errorf("%w: %q: %v", ErrEntrypointInvalid, entrypoint, err)
|
|
}
|
|
if entrypoint == "payload" || strings.HasPrefix(entrypoint, "payload/") {
|
|
return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint)
|
|
}
|
|
return entrypoint, nil
|
|
}
|
|
|
|
func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool {
|
|
if uncompressed == 0 {
|
|
return false
|
|
}
|
|
if compressed == 0 {
|
|
return true
|
|
}
|
|
return float64(uncompressed)/float64(compressed) > maximum
|
|
}
|