Prototype secure ZIP extraction (T-102)
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractorExtractsPayloadOnly(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{"entrypoint":"bin/App.exe"}`)},
|
||||
{name: "files.json", body: []byte(`{"files":[]}`)},
|
||||
{name: "payload/bin/", mode: os.ModeDir | 0o755},
|
||||
{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())
|
||||
|
||||
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractFile() error = %v", err)
|
||||
}
|
||||
if result.Files != 2 {
|
||||
t.Fatalf("Files = %d, want 2", result.Files)
|
||||
}
|
||||
if result.Bytes != int64(len("executable")+len("hello")) {
|
||||
t.Fatalf("Bytes = %d, want %d", result.Bytes, len("executable")+len("hello"))
|
||||
}
|
||||
if _, err := os.Stat(result.EntrypointPath); err != nil {
|
||||
t.Fatalf("entrypoint stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("app.json should not be extracted, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRejectsAttackArchives(t *testing.T) {
|
||||
base := []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("ok")},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []testZIPEntry
|
||||
entrypoint string
|
||||
limits Limits
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "absolute path",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "/payload/evil.exe", body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrPathEscape,
|
||||
},
|
||||
{
|
||||
name: "drive path",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "C:/payload/evil.exe", body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrPathEscape,
|
||||
},
|
||||
{
|
||||
name: "ADS path",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/App.exe:stream", body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrPathEscape,
|
||||
},
|
||||
{
|
||||
name: "dot dot traversal",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/../evil.exe", body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrPathEscape,
|
||||
},
|
||||
{
|
||||
name: "backslash traversal",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: `payload\..\evil.exe`, body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrPathEscape,
|
||||
},
|
||||
{
|
||||
name: "encrypted entry",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/secret.bin", body: []byte("x"), flags: 0x1}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrEncryptedEntry,
|
||||
},
|
||||
{
|
||||
name: "symlink",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{
|
||||
name: "payload/link",
|
||||
body: []byte("../../outside"),
|
||||
mode: os.ModeSymlink | 0o777,
|
||||
}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrUnsupportedEntry,
|
||||
},
|
||||
{
|
||||
name: "special file",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/pipe", mode: os.ModeNamedPipe | 0o600}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrUnsupportedEntry,
|
||||
},
|
||||
{
|
||||
name: "case folded duplicate",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/app.exe", body: []byte("duplicate")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrDuplicateEntry,
|
||||
},
|
||||
{
|
||||
name: "unexpected top level entry",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "install.bat", body: []byte("echo unsafe")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: testLimits(),
|
||||
wantErr: ErrUnexpectedEntry,
|
||||
},
|
||||
{
|
||||
name: "too many entries",
|
||||
entries: appendEntries(base,
|
||||
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
|
||||
entrypoint: "App.exe",
|
||||
limits: Limits{
|
||||
MaxEntries: 2,
|
||||
MaxUncompressedBytes: 1024,
|
||||
MaxCompressionRatio: 100,
|
||||
},
|
||||
wantErr: ErrTooManyEntries,
|
||||
},
|
||||
{
|
||||
name: "expanded size",
|
||||
entries: []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("0123456789")},
|
||||
},
|
||||
entrypoint: "App.exe",
|
||||
limits: Limits{
|
||||
MaxEntries: 10,
|
||||
MaxUncompressedBytes: 8,
|
||||
MaxCompressionRatio: 100,
|
||||
},
|
||||
wantErr: ErrExpandedTooLarge,
|
||||
},
|
||||
{
|
||||
name: "compression ratio",
|
||||
entries: []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{
|
||||
name: "payload/App.exe",
|
||||
body: bytes.Repeat([]byte("A"), 4096),
|
||||
method: zip.Deflate,
|
||||
},
|
||||
},
|
||||
entrypoint: "App.exe",
|
||||
limits: Limits{
|
||||
MaxEntries: 10,
|
||||
MaxUncompressedBytes: 8192,
|
||||
MaxCompressionRatio: 2,
|
||||
},
|
||||
wantErr: ErrCompressionRatio,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, test.entries)
|
||||
destination := filepath.Join(t.TempDir(), "staging")
|
||||
extractor := mustExtractor(t, test.limits)
|
||||
|
||||
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("ok")},
|
||||
})
|
||||
tests := []struct {
|
||||
entrypoint string
|
||||
wantErr error
|
||||
}{
|
||||
{entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid},
|
||||
{entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid},
|
||||
{entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid},
|
||||
{entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid},
|
||||
{entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.entrypoint, func(t *testing.T) {
|
||||
destination := filepath.Join(t.TempDir(), "staging")
|
||||
extractor := mustExtractor(t, testLimits())
|
||||
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid entrypoint left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRejectsExistingDestination(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("ok")},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "staging")
|
||||
if err := os.Mkdir(destination, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir() error = %v", err)
|
||||
}
|
||||
extractor := mustExtractor(t, testLimits())
|
||||
|
||||
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
||||
if !errors.Is(err, ErrDestinationExists) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
|
||||
})
|
||||
corruptZIPEntryData(t, archivePath, "payload/App.exe")
|
||||
destination := filepath.Join(t.TempDir(), "staging")
|
||||
extractor := mustExtractor(t, testLimits())
|
||||
|
||||
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
||||
if !errors.Is(err, ErrArchiveCorrupt) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type testZIPEntry struct {
|
||||
name string
|
||||
body []byte
|
||||
mode os.FileMode
|
||||
method uint16
|
||||
flags uint16
|
||||
}
|
||||
|
||||
func testLimits() Limits {
|
||||
return Limits{
|
||||
MaxEntries: 20,
|
||||
MaxUncompressedBytes: 16 * 1024,
|
||||
MaxCompressionRatio: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func mustExtractor(t *testing.T, limits Limits) Extractor {
|
||||
t.Helper()
|
||||
extractor, err := NewExtractor(limits)
|
||||
if err != nil {
|
||||
t.Fatalf("NewExtractor() error = %v", err)
|
||||
}
|
||||
return extractor
|
||||
}
|
||||
|
||||
func appendEntries(base []testZIPEntry, extra ...testZIPEntry) []testZIPEntry {
|
||||
result := append([]testZIPEntry(nil), base...)
|
||||
return append(result, extra...)
|
||||
}
|
||||
|
||||
func writeTestZIP(t *testing.T, entries []testZIPEntry) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "package.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create ZIP: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for _, entry := range entries {
|
||||
header := &zip.FileHeader{
|
||||
Name: entry.name,
|
||||
Method: entry.method,
|
||||
Flags: entry.flags,
|
||||
}
|
||||
mode := entry.mode
|
||||
if mode == 0 {
|
||||
mode = 0o600
|
||||
}
|
||||
header.SetMode(mode)
|
||||
part, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
file.Close()
|
||||
t.Fatalf("create ZIP entry %s: %v", entry.name, err)
|
||||
}
|
||||
if _, err := part.Write(entry.body); err != nil {
|
||||
writer.Close()
|
||||
file.Close()
|
||||
t.Fatalf("write ZIP entry %s: %v", entry.name, err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("close ZIP writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close ZIP file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func corruptZIPEntryData(t *testing.T, archivePath, entryName string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open ZIP for corruption: %v", err)
|
||||
}
|
||||
var offset int64 = -1
|
||||
for _, file := range reader.File {
|
||||
if file.Name == entryName {
|
||||
offset, err = file.DataOffset()
|
||||
if err != nil {
|
||||
reader.Close()
|
||||
t.Fatalf("entry data offset: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatalf("close ZIP reader: %v", err)
|
||||
}
|
||||
if offset < 0 {
|
||||
t.Fatalf("entry %s not found", entryName)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ZIP for corruption: %v", err)
|
||||
}
|
||||
data[offset] ^= 0xff
|
||||
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
|
||||
t.Fatalf("write corrupted ZIP: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLimitsAreValid(t *testing.T) {
|
||||
if _, err := NewExtractor(DefaultLimits()); err != nil {
|
||||
t.Fatalf("NewExtractor(DefaultLimits()) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRejectsInvalidLimits(t *testing.T) {
|
||||
tests := []Limits{
|
||||
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
|
||||
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
|
||||
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
|
||||
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
|
||||
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
|
||||
}
|
||||
|
||||
for index, limits := range tests {
|
||||
if _, err := NewExtractor(limits); !errors.Is(err, ErrInvalidLimits) {
|
||||
t.Errorf("case %d NewExtractor() error = %v, want %v", index, err, ErrInvalidLimits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
|
||||
|
||||
const (
|
||||
DefaultMaxEntries = 10_000
|
||||
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
|
||||
DefaultMaxCompressionRatio = 200.0
|
||||
)
|
||||
|
||||
// Limits bounds archive metadata and decompressed output.
|
||||
type Limits struct {
|
||||
MaxEntries int
|
||||
MaxUncompressedBytes int64
|
||||
MaxCompressionRatio float64
|
||||
}
|
||||
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{
|
||||
MaxEntries: DefaultMaxEntries,
|
||||
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
|
||||
MaxCompressionRatio: DefaultMaxCompressionRatio,
|
||||
}
|
||||
}
|
||||
|
||||
func (limits Limits) validate() error {
|
||||
if limits.MaxEntries <= 0 {
|
||||
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
|
||||
}
|
||||
if limits.MaxUncompressedBytes <= 0 {
|
||||
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
|
||||
}
|
||||
if limits.MaxCompressionRatio <= 0 ||
|
||||
math.IsNaN(limits.MaxCompressionRatio) ||
|
||||
math.IsInf(limits.MaxCompressionRatio, 0) {
|
||||
return fmt.Errorf("%w: MaxCompressionRatio must be finite and positive", ErrInvalidLimits)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user