package installer import ( "archive/zip" "errors" "fmt" "io" "math" "os" "path" "path/filepath" "strings" "unicode/utf8" ) 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") 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 } type ExtractResult struct { Files int Bytes int64 EntrypointPath string } type plannedEntry struct { file *zip.File archivePath string outputPath string directory bool } func NewExtractor(limits Limits) (Extractor, error) { if err := limits.validate(); err != nil { return Extractor{}, err } return Extractor{limits: limits}, nil } // ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks. func (extractor Extractor) ExtractFile( zipPath string, destination string, entrypoint string, ) (ExtractResult, error) { archive, err := zip.OpenReader(zipPath) if err != nil { return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err) } defer archive.Close() return extractor.extract(&archive.Reader, 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 } if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging parent: %w", err) } if err := os.Mkdir(destination, 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(destination) } }() var written int64 for _, entry := range plan { target := filepath.Join(destination, filepath.FromSlash(entry.outputPath)) if entry.directory { if entry.outputPath == "" { continue } if err := os.MkdirAll(target, 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging directory: %w", err) } continue } if err := os.MkdirAll(filepath.Dir(target), 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(target, 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)) closeOutputErr := output.Close() closeSourceErr := source.Close() if copyErr != nil { return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr) } if closeOutputErr != nil { return ExtractResult{}, fmt.Errorf("close staging file: %w", closeOutputErr) } if closeSourceErr != nil { return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr) } if copied > remaining { return ExtractResult{}, ErrExpandedTooLarge } if uint64(copied) != entry.file.UncompressedSize64 { return ExtractResult{}, fmt.Errorf( "%w: %s expanded to %d bytes, header declares %d", ErrArchiveCorrupt, entry.archivePath, copied, entry.file.UncompressedSize64, ) } written += copied result.Files++ } result.Bytes = written result.EntrypointPath = filepath.Join( destination, filepath.FromSlash(normalizedEntrypoint), ) complete = true return result, 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 := strings.ToLower(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) { if name == "" || !utf8.ValidString(name) || strings.ContainsRune(name, '\x00') { return "", false, fmt.Errorf("%w: invalid entry name", ErrPathEscape) } if strings.Contains(name, `\`) || strings.Contains(name, ":") { return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) } directory := strings.HasSuffix(name, "/") trimmed := strings.TrimSuffix(name, "/") if trimmed == "" || path.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") { return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) } cleaned := path.Clean(trimmed) if cleaned != trimmed || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name) } return cleaned, directory, nil } func normalizeEntrypoint(entrypoint string) (string, error) { if entrypoint == "" || !utf8.ValidString(entrypoint) || strings.ContainsRune(entrypoint, '\x00') || strings.Contains(entrypoint, `\`) || strings.Contains(entrypoint, ":") || strings.HasSuffix(entrypoint, "/") || path.IsAbs(entrypoint) { return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint) } cleaned := path.Clean(entrypoint) if cleaned != entrypoint || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned == "payload" || strings.HasPrefix(cleaned, "payload/") { return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint) } return cleaned, 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 }