Preflight ZIP central directory metadata (T-612)
This commit is contained in:
+28
-18
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user