Preflight ZIP central directory metadata (T-612)

This commit is contained in:
ila
2026-07-18 15:40:44 +08:00
parent 65ff7a3f23
commit 0f69fa330e
14 changed files with 702 additions and 68 deletions
+28 -18
View File
@@ -14,20 +14,23 @@ import (
)
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")
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.
@@ -56,18 +59,25 @@ func NewExtractor(limits Limits) (Extractor, error) {
return Extractor{limits: limits}, nil
}
// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks.
// 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) {
archive, err := zip.OpenReader(zipPath)
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)
}
defer archive.Close()
return extractor.extract(&archive.Reader, destination, entrypoint)
return extractor.extract(archive, destination, entrypoint)
}
func (extractor Extractor) extract(
+73 -23
View File
@@ -21,7 +21,12 @@ func TestExtractorExtractsPayloadOnly(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
result, err := extractor.ExtractFile(
archivePath,
destination,
"bin/App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -216,9 +221,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 2,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
MaxEntries: 2,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 1024,
MaxCompressionRatio: 100,
},
wantErr: ErrTooManyEntries,
},
@@ -230,9 +237,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8,
MaxCompressionRatio: 100,
},
wantErr: ErrExpandedTooLarge,
},
@@ -248,9 +257,11 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
},
entrypoint: "App.exe",
limits: Limits{
MaxEntries: 10,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
MaxEntries: 10,
MaxArchiveBytes: 16 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 8192,
MaxCompressionRatio: 2,
},
wantErr: ErrCompressionRatio,
},
@@ -263,7 +274,12 @@ func TestExtractorRejectsAttackArchives(t *testing.T) {
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
@@ -303,7 +319,12 @@ func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
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)
_, err := extractor.ExtractFile(
archivePath,
destination,
test.entrypoint,
archiveSize(t, archivePath),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
@@ -323,7 +344,12 @@ func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe")
result, err := extractor.ExtractFile(
archivePath,
destination,
"工具/解析器.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
@@ -346,7 +372,12 @@ func TestExtractorRejectsExistingDestination(t *testing.T) {
}
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrDestinationExists) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
}
@@ -361,7 +392,12 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrArchiveCorrupt) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
}
@@ -380,12 +416,23 @@ type testZIPEntry struct {
func testLimits() Limits {
return Limits{
MaxEntries: 20,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
MaxEntries: 20,
MaxArchiveBytes: 64 * 1024,
MaxCentralDirectoryBytes: 1024,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
}
}
func archiveSize(t *testing.T, archivePath string) int64 {
t.Helper()
info, err := os.Stat(archivePath)
if err != nil {
t.Fatalf("stat archive: %v", err)
}
return info.Size()
}
func mustExtractor(t *testing.T, limits Limits) Extractor {
t.Helper()
extractor, err := NewExtractor(limits)
@@ -483,11 +530,14 @@ func TestDefaultLimitsAreValid(t *testing.T) {
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)},
{MaxEntries: 0, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
{MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
}
for index, limits := range tests {
+6 -1
View File
@@ -19,7 +19,12 @@ func TestExtractorRejectsWindowsNormalizedEscapeOnNativeFilesystem(t *testing.T)
destination := filepath.Join(root, "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrPathEscape) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrPathEscape)
}
+24 -9
View File
@@ -9,23 +9,29 @@ import (
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
const (
DefaultMaxEntries = 10_000
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCompressionRatio = 200.0
DefaultMaxEntries = 10_000
DefaultMaxArchiveBytes = int64(4 * 1024 * 1024 * 1024)
DefaultMaxCentralDirectoryBytes = int64(64 * 1024 * 1024)
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
MaxEntries int
MaxArchiveBytes int64
MaxCentralDirectoryBytes int64
MaxUncompressedBytes int64
MaxCompressionRatio float64
}
func DefaultLimits() Limits {
return Limits{
MaxEntries: DefaultMaxEntries,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
MaxEntries: DefaultMaxEntries,
MaxArchiveBytes: DefaultMaxArchiveBytes,
MaxCentralDirectoryBytes: DefaultMaxCentralDirectoryBytes,
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
MaxCompressionRatio: DefaultMaxCompressionRatio,
}
}
@@ -33,6 +39,15 @@ func (limits Limits) validate() error {
if limits.MaxEntries <= 0 {
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
}
if limits.MaxArchiveBytes <= 0 {
return fmt.Errorf("%w: MaxArchiveBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes <= 0 {
return fmt.Errorf("%w: MaxCentralDirectoryBytes must be positive", ErrInvalidLimits)
}
if limits.MaxCentralDirectoryBytes > limits.MaxArchiveBytes {
return fmt.Errorf("%w: MaxCentralDirectoryBytes exceeds MaxArchiveBytes", ErrInvalidLimits)
}
if limits.MaxUncompressedBytes <= 0 {
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
}
+240
View File
@@ -0,0 +1,240 @@
package installer
import (
"encoding/binary"
"fmt"
"io"
"os"
)
const (
endOfCentralDirectorySignature = 0x06054b50
zip64EndSignature = 0x06064b50
zip64LocatorSignature = 0x07064b50
endOfCentralDirectoryLength = 22
zip64EndLength = 56
zip64LocatorLength = 20
maxZIPCommentLength = 1<<16 - 1
maxEOCDSearchLength = endOfCentralDirectoryLength + maxZIPCommentLength
maxInt64 = 1<<63 - 1
)
type endOfCentralDirectory struct {
offset int64
diskNumber uint32
centralDirectoryDisk uint32
entriesOnThisDisk uint64
centralDirectoryEntries uint64
centralDirectorySize uint64
centralDirectoryOffset uint64
}
func (extractor Extractor) openAndScanArchive(
zipPath string,
expectedPackageSize int64,
) (*os.File, int64, error) {
if err := extractor.limits.validate(); err != nil {
return nil, 0, err
}
if expectedPackageSize <= 0 {
return nil, 0, fmt.Errorf("%w: expected size must be positive", ErrArchiveSizeMismatch)
}
pathInfo, err := os.Lstat(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: inspect package: %v", ErrInvalidArchive, err)
}
if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() {
return nil, 0, fmt.Errorf("%w: package is not a regular file", ErrInvalidArchive)
}
file, err := os.Open(zipPath)
if err != nil {
return nil, 0, fmt.Errorf("%w: open package: %v", ErrInvalidArchive, err)
}
closeWithError := func(err error) (*os.File, int64, error) {
_ = file.Close()
return nil, 0, err
}
info, err := file.Stat()
if err != nil {
return closeWithError(fmt.Errorf("%w: stat package: %v", ErrInvalidArchive, err))
}
if !info.Mode().IsRegular() {
return closeWithError(fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive))
}
size := info.Size()
if size != expectedPackageSize {
return closeWithError(fmt.Errorf(
"%w: got %d, expected %d",
ErrArchiveSizeMismatch,
size,
expectedPackageSize,
))
}
if size > extractor.limits.MaxArchiveBytes {
return closeWithError(fmt.Errorf(
"%w: got %d, limit %d",
ErrArchiveTooLarge,
size,
extractor.limits.MaxArchiveBytes,
))
}
if err := scanCentralDirectory(file, size, extractor.limits); err != nil {
return closeWithError(err)
}
return file, size, nil
}
func scanCentralDirectory(file *os.File, size int64, limits Limits) error {
end, err := findEndOfCentralDirectory(file, size)
if err != nil {
return err
}
if requiresZIP64(end) {
if err := readZIP64EndOfCentralDirectory(file, &end); err != nil {
return err
}
}
if end.diskNumber != 0 || end.centralDirectoryDisk != 0 ||
end.entriesOnThisDisk != end.centralDirectoryEntries {
return fmt.Errorf("%w: multi-disk archives are unsupported", ErrInvalidArchive)
}
if end.centralDirectoryEntries > uint64(limits.MaxEntries) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrTooManyEntries,
end.centralDirectoryEntries,
limits.MaxEntries,
)
}
if end.centralDirectorySize > uint64(limits.MaxCentralDirectoryBytes) {
return fmt.Errorf(
"%w: got %d, limit %d",
ErrCentralDirectoryTooLarge,
end.centralDirectorySize,
limits.MaxCentralDirectoryBytes,
)
}
if end.centralDirectorySize > uint64(maxInt64) ||
end.centralDirectoryOffset > uint64(maxInt64) {
return fmt.Errorf("%w: central directory exceeds int64", ErrInvalidArchive)
}
centralDirectorySize := int64(end.centralDirectorySize)
centralDirectoryOffset := int64(end.centralDirectoryOffset)
if centralDirectorySize > end.offset {
return fmt.Errorf("%w: central directory exceeds end record", ErrInvalidArchive)
}
centralDirectoryStart := end.offset - centralDirectorySize
if centralDirectoryOffset > centralDirectoryStart {
return fmt.Errorf("%w: central directory offset is outside archive", ErrInvalidArchive)
}
baseOffset := centralDirectoryStart - centralDirectoryOffset
if baseOffset < 0 || centralDirectoryStart > size ||
centralDirectoryStart+centralDirectorySize != end.offset {
return fmt.Errorf("%w: central directory bounds are inconsistent", ErrInvalidArchive)
}
return nil
}
func findEndOfCentralDirectory(file *os.File, size int64) (endOfCentralDirectory, error) {
if size < endOfCentralDirectoryLength {
return endOfCentralDirectory{}, fmt.Errorf("%w: archive is shorter than EOCD", ErrInvalidArchive)
}
readLength := int64(maxEOCDSearchLength)
if readLength > size {
readLength = size
}
buffer := make([]byte, int(readLength))
readOffset := size - readLength
if err := readAtExactly(file, buffer, readOffset); err != nil {
return endOfCentralDirectory{}, err
}
for offset := len(buffer) - endOfCentralDirectoryLength; offset >= 0; offset-- {
if binary.LittleEndian.Uint32(buffer[offset:]) != endOfCentralDirectorySignature {
continue
}
commentLength := int(binary.LittleEndian.Uint16(buffer[offset+20:]))
if offset+endOfCentralDirectoryLength+commentLength != len(buffer) {
continue
}
return endOfCentralDirectory{
offset: readOffset + int64(offset),
diskNumber: uint32(binary.LittleEndian.Uint16(buffer[offset+4:])),
centralDirectoryDisk: uint32(binary.LittleEndian.Uint16(buffer[offset+6:])),
entriesOnThisDisk: uint64(binary.LittleEndian.Uint16(buffer[offset+8:])),
centralDirectoryEntries: uint64(binary.LittleEndian.Uint16(buffer[offset+10:])),
centralDirectorySize: uint64(binary.LittleEndian.Uint32(buffer[offset+12:])),
centralDirectoryOffset: uint64(binary.LittleEndian.Uint32(buffer[offset+16:])),
}, nil
}
return endOfCentralDirectory{}, fmt.Errorf("%w: EOCD is missing or malformed", ErrInvalidArchive)
}
func requiresZIP64(end endOfCentralDirectory) bool {
return end.entriesOnThisDisk == 0xffff ||
end.centralDirectoryEntries == 0xffff ||
end.centralDirectorySize == 0xffffffff ||
end.centralDirectoryOffset == 0xffffffff
}
func readZIP64EndOfCentralDirectory(file *os.File, end *endOfCentralDirectory) error {
locatorOffset := end.offset - zip64LocatorLength
if locatorOffset < 0 {
return fmt.Errorf("%w: ZIP64 locator is missing", ErrInvalidArchive)
}
locator := make([]byte, zip64LocatorLength)
if err := readAtExactly(file, locator, locatorOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(locator) != zip64LocatorSignature ||
binary.LittleEndian.Uint32(locator[4:]) != 0 ||
binary.LittleEndian.Uint32(locator[16:]) != 1 {
return fmt.Errorf("%w: ZIP64 locator is invalid", ErrInvalidArchive)
}
zip64EndOffset, ok := uint64AsInt64(binary.LittleEndian.Uint64(locator[8:]))
if !ok || zip64EndOffset < 0 || zip64EndOffset > locatorOffset-zip64EndLength {
return fmt.Errorf("%w: ZIP64 end offset is invalid", ErrInvalidArchive)
}
zip64End := make([]byte, zip64EndLength)
if err := readAtExactly(file, zip64End, zip64EndOffset); err != nil {
return err
}
if binary.LittleEndian.Uint32(zip64End) != zip64EndSignature {
return fmt.Errorf("%w: ZIP64 end record is invalid", ErrInvalidArchive)
}
recordSize := binary.LittleEndian.Uint64(zip64End[4:])
if recordSize < 44 || recordSize > uint64(locatorOffset-zip64EndOffset-12) {
return fmt.Errorf("%w: ZIP64 end record length is invalid", ErrInvalidArchive)
}
end.offset = zip64EndOffset
end.diskNumber = binary.LittleEndian.Uint32(zip64End[16:])
end.centralDirectoryDisk = binary.LittleEndian.Uint32(zip64End[20:])
end.entriesOnThisDisk = binary.LittleEndian.Uint64(zip64End[24:])
end.centralDirectoryEntries = binary.LittleEndian.Uint64(zip64End[32:])
end.centralDirectorySize = binary.LittleEndian.Uint64(zip64End[40:])
end.centralDirectoryOffset = binary.LittleEndian.Uint64(zip64End[48:])
return nil
}
func readAtExactly(file *os.File, buffer []byte, offset int64) error {
count, err := file.ReadAt(buffer, offset)
if err == nil && count == len(buffer) {
return nil
}
if err == nil {
err = io.ErrUnexpectedEOF
}
return fmt.Errorf("%w: read ZIP metadata: %v", ErrInvalidArchive, err)
}
func uint64AsInt64(value uint64) (int64, bool) {
if value > uint64(maxInt64) {
return 0, false
}
return int64(value), true
}
+300
View File
@@ -0,0 +1,300 @@
package installer
import (
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
)
func TestExtractorRejectsArchiveSizeBeforeZIPReader(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
size := archiveSize(t, archivePath)
tests := []struct {
name string
limits Limits
expected int64
wantErr error
}{
{
name: "expected size mismatch",
limits: testLimits(),
expected: size + 1,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "expected size is unknown",
limits: testLimits(),
expected: 0,
wantErr: ErrArchiveSizeMismatch,
},
{
name: "archive exceeds raw size limit",
limits: Limits{
MaxEntries: 20,
MaxArchiveBytes: size - 1,
MaxCentralDirectoryBytes: size - 1,
MaxUncompressedBytes: 16 * 1024,
MaxCompressionRatio: 100,
},
expected: size,
wantErr: ErrArchiveTooLarge,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, test.limits)
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
test.expected,
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsCentralDirectoryMetadataBeforeZIPReader(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, data []byte, eocdOffset int)
want error
}{
{
name: "declared entries exceed limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+8:], 21)
binary.LittleEndian.PutUint16(data[eocdOffset+10:], 21)
},
want: ErrTooManyEntries,
},
{
name: "central directory exceeds limit",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+12:], 1025)
},
want: ErrCentralDirectoryTooLarge,
},
{
name: "multi disk archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint16(data[eocdOffset+4:], 1)
},
want: ErrInvalidArchive,
},
{
name: "central directory offset is outside archive",
mutate: func(t *testing.T, data []byte, eocdOffset int) {
t.Helper()
binary.LittleEndian.PutUint32(data[eocdOffset+16:], 0xfffffffe)
},
want: ErrInvalidArchive,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
test.mutate(t, data, eocdOffset)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, test.want) {
t.Fatalf("ExtractFile() error = %v, want %v", err, test.want)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorRejectsTruncatedEOCD(t *testing.T) {
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
if err := os.Truncate(archivePath, archiveSize(t, archivePath)-1); err != nil {
t.Fatalf("truncate archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func TestExtractorRejectsMissingOrInvalidZIP64End(t *testing.T) {
tests := []struct {
name string
mutate func(data []byte)
}{
{
name: "missing locator",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
data[eocdOffset-zip64LocatorLength] ^= 0xff
},
},
{
name: "invalid record length",
mutate: func(data []byte) {
eocdOffset := len(data) - endOfCentralDirectoryLength
zip64EndOffset := int(binary.LittleEndian.Uint64(data[eocdOffset-zip64LocatorLength+8:]))
binary.LittleEndian.PutUint64(data[zip64EndOffset+4:], 43)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read archive: %v", err)
}
test.mutate(data)
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err = extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
})
}
}
func TestExtractorAcceptsZIP64EndOfCentralDirectory(t *testing.T) {
archivePath := writeZIP64TestZIP(t)
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
result, err := extractor.ExtractFile(
archivePath,
destination,
"App.exe",
archiveSize(t, archivePath),
)
if err != nil {
t.Fatalf("ExtractFile() error = %v", err)
}
if result.Files != 1 {
t.Fatalf("Files = %d, want 1", result.Files)
}
}
func TestExtractorRejectsNonRegularArchive(t *testing.T) {
directory := t.TempDir()
destination := filepath.Join(t.TempDir(), "staging")
extractor := mustExtractor(t, testLimits())
_, err := extractor.ExtractFile(directory, destination, "App.exe", 1)
if !errors.Is(err, ErrInvalidArchive) {
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrInvalidArchive)
}
assertStagingAbsent(t, destination)
}
func writeZIP64TestZIP(t *testing.T) string {
t.Helper()
archivePath := writeTestZIP(t, []testZIPEntry{
{name: "app.json", body: []byte(`{}`)},
{name: "payload/App.exe", body: []byte("ok")},
})
data, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("read ZIP: %v", err)
}
eocdOffset := len(data) - endOfCentralDirectoryLength
entries := binary.LittleEndian.Uint16(data[eocdOffset+10:])
centralDirectorySize := binary.LittleEndian.Uint32(data[eocdOffset+12:])
centralDirectoryOffset := binary.LittleEndian.Uint32(data[eocdOffset+16:])
zip64EndOffset := len(data) - endOfCentralDirectoryLength
zip64End := make([]byte, zip64EndLength)
binary.LittleEndian.PutUint32(zip64End, zip64EndSignature)
binary.LittleEndian.PutUint64(zip64End[4:], 44)
binary.LittleEndian.PutUint16(zip64End[12:], 45)
binary.LittleEndian.PutUint16(zip64End[14:], 45)
binary.LittleEndian.PutUint64(zip64End[24:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[32:], uint64(entries))
binary.LittleEndian.PutUint64(zip64End[40:], uint64(centralDirectorySize))
binary.LittleEndian.PutUint64(zip64End[48:], uint64(centralDirectoryOffset))
locator := make([]byte, zip64LocatorLength)
binary.LittleEndian.PutUint32(locator, zip64LocatorSignature)
binary.LittleEndian.PutUint64(locator[8:], uint64(zip64EndOffset))
binary.LittleEndian.PutUint32(locator[16:], 1)
classicEnd := make([]byte, endOfCentralDirectoryLength)
binary.LittleEndian.PutUint32(classicEnd, endOfCentralDirectorySignature)
binary.LittleEndian.PutUint16(classicEnd[8:], 0xffff)
binary.LittleEndian.PutUint16(classicEnd[10:], 0xffff)
binary.LittleEndian.PutUint32(classicEnd[12:], 0xffffffff)
binary.LittleEndian.PutUint32(classicEnd[16:], 0xffffffff)
zip64Data := make([]byte, 0, len(data)+zip64EndLength+zip64LocatorLength)
zip64Data = append(zip64Data, data[:eocdOffset]...)
zip64Data = append(zip64Data, zip64End...)
zip64Data = append(zip64Data, locator...)
zip64Data = append(zip64Data, classicEnd...)
if err := os.WriteFile(archivePath, zip64Data, 0o600); err != nil {
t.Fatalf("write ZIP64 archive: %v", err)
}
return archivePath
}
func assertStagingAbsent(t *testing.T, destination string) {
t.Helper()
if _, err := os.Stat(destination); !os.IsNotExist(err) {
t.Fatalf("rejected archive left staging, stat error = %v", err)
}
}