Files
soft_quay/core/installer/extractor.go
T

407 lines
12 KiB
Go

package installer
import (
"archive/zip"
"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
}
type plannedEntry struct {
file *zip.File
archivePath string
outputPath string
targetPath string
directory bool
}
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
}
fence := effectiveDurability(extractor.durability)
normalizedEntrypoint, err := normalizeEntrypoint(entrypoint)
if err != nil {
return ExtractResult{}, err
}
plan, err := extractor.preflight(archive, normalizedEntrypoint)
if err != nil {
return ExtractResult{}, err
}
destinationRoot, entrypointPath, err := planOutputPaths(
destination,
normalizedEntrypoint,
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++
}
copied, copyErr := io.Copy(output, 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++
}
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
}