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) { file, size, err := extractor.openArchiveFile(zipPath, expectedPackageSize) if err != nil { return nil, 0, err } if err := extractor.scanOpenedArchive(file, size); err != nil { _ = file.Close() return nil, 0, err } return file, size, nil } // openArchiveFile opens the completed package once and proves the path still // names that same ordinary file. Later verification and parsing must retain // this handle rather than reopening zipPath. func (extractor Extractor) openArchiveFile( 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)) } if !os.SameFile(pathInfo, info) { return closeWithError(fmt.Errorf("%w: package changed while opening", ErrInvalidArchive)) } pathInfoAfterOpen, err := os.Lstat(zipPath) if err != nil { return closeWithError(fmt.Errorf("%w: recheck package: %v", ErrInvalidArchive, err)) } if pathInfoAfterOpen.Mode()&os.ModeSymlink != 0 || !pathInfoAfterOpen.Mode().IsRegular() || !os.SameFile(info, pathInfoAfterOpen) { return closeWithError(fmt.Errorf("%w: package changed while opening", 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, )) } return file, size, nil } func (extractor Extractor) scanOpenedArchive(file *os.File, expectedPackageSize int64) error { if file == nil { return fmt.Errorf("%w: package handle is nil", ErrInvalidArchive) } if err := extractor.limits.validate(); err != nil { return err } info, err := file.Stat() if err != nil { return fmt.Errorf("%w: stat package before ZIP scan: %v", ErrInvalidArchive, err) } if !info.Mode().IsRegular() { return fmt.Errorf("%w: opened package is not a regular file", ErrInvalidArchive) } size := info.Size() if size != expectedPackageSize { return fmt.Errorf( "%w: got %d, expected %d", ErrArchiveSizeMismatch, size, expectedPackageSize, ) } if size > extractor.limits.MaxArchiveBytes { return fmt.Errorf( "%w: got %d, limit %d", ErrArchiveTooLarge, size, extractor.limits.MaxArchiveBytes, ) } return scanCentralDirectory(file, size, extractor.limits) } 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 }