Files
logpile/internal/parser/archive.go
T
Mikhail ChusavitinandClaude Sonnet 5 9d701885da feat(parser): bound single-file gzip by ratio guard instead of fixed byte cap
Plain gzipped logs (nvidia-bug-report-*.log.gz) routinely exceed the old
50MB decompression cap, which silently dropped the tail. Replace it with a
decompression-ratio bomb guard plus a 1GB absolute memory ceiling.

See ADL-054.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:05:33 +03:00

481 lines
12 KiB
Go

package parser
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const maxSingleFileSize = 10 * 1024 * 1024
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
const maxZipArchiveSize = 50 * 1024 * 1024
// A plain gzipped log file (e.g. nvidia-bug-report-*.log.gz) is decompressed
// fully into memory, but without a fixed byte cap — real bug-report dumps
// routinely exceed 50MB and a hard cap silently drops the tail. Instead it's
// bounded by a decompression-ratio guard (catches gzip bombs, which achieve
// enormous ratios) plus a generous absolute ceiling as a memory safety net.
const gzipBombRatio = 300 // decompressed:compressed ratio that trips the bomb guard
const gzipBombMinCompressed = 4096 // don't evaluate the ratio until this many compressed bytes have actually been consumed, to avoid false positives from gzip header/buffering overhead on tiny inputs
const gzipAbsoluteCeiling = 1024 * 1024 * 1024 // 1GB hard safety net regardless of ratio, matches maxSingleFileSizeLarge
var supportedArchiveExt = map[string]struct{}{
".ahs": {},
".gz": {},
".tgz": {},
".tar": {},
".sds": {},
".zip": {},
".txt": {},
".log": {},
}
// ExtractedFile represents a file extracted from archive
type ExtractedFile struct {
Path string
Content []byte
ModTime time.Time
Truncated bool
TruncatedMessage string
}
// ExtractArchive extracts tar.gz or zip archive and returns file contents
func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
if !IsSupportedArchiveFilename(archivePath) {
return nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(archivePath)))
}
ext := strings.ToLower(filepath.Ext(archivePath))
switch ext {
case ".ahs":
return extractSingleFileWithLimit(archivePath, maxSingleFileSizeLarge)
case ".gz", ".tgz":
return extractTarGz(archivePath)
case ".tar", ".sds":
return extractTar(archivePath)
case ".zip":
return extractZip(archivePath)
case ".txt", ".log":
return extractSingleFileWithLimit(archivePath, maxSingleFileSize)
default:
return nil, fmt.Errorf("unsupported archive format: %s", ext)
}
}
// ExtractArchiveFromReader extracts archive from reader
func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
if !IsSupportedArchiveFilename(filename) {
return nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(filename)))
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".ahs":
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSizeLarge)
case ".gz", ".tgz":
return extractTarGzFromReader(r, filename)
case ".tar", ".sds":
return extractTarFromReader(r)
case ".zip":
return extractZipFromReader(r)
case ".txt", ".log":
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSize)
default:
return nil, fmt.Errorf("unsupported archive format: %s", ext)
}
}
// IsSupportedArchiveFilename reports whether filename extension is supported by archive extractor.
func IsSupportedArchiveFilename(filename string) bool {
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename)))
if ext == "" {
return false
}
_, ok := supportedArchiveExt[ext]
return ok
}
// SupportedArchiveExtensions returns sorted list of archive/file extensions
// accepted by archive extractor.
func SupportedArchiveExtensions() []string {
out := make([]string, 0, len(supportedArchiveExt))
for ext := range supportedArchiveExt {
out = append(out, ext)
}
sort.Strings(out)
return out
}
func extractTarGz(archivePath string) ([]ExtractedFile, error) {
f, err := os.Open(archivePath)
if err != nil {
return nil, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
return extractTarGzFromReader(f, filepath.Base(archivePath))
}
func extractTar(archivePath string) ([]ExtractedFile, error) {
f, err := os.Open(archivePath)
if err != nil {
return nil, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
return extractTarFromReader(f)
}
func extractTarFromReader(r io.Reader) ([]ExtractedFile, error) {
tr := tar.NewReader(r)
var files []ExtractedFile
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar read: %w", err)
}
// Skip directories
if header.Typeflag == tar.TypeDir {
continue
}
// Skip large files (>10MB)
if header.Size > 10*1024*1024 {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", header.Name, err)
}
files = append(files, ExtractedFile{
Path: header.Name,
Content: content,
ModTime: header.ModTime,
})
}
return files, nil
}
func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
compressed := &countingReader{r: r}
gzr, err := gzip.NewReader(compressed)
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
defer gzr.Close()
// Peek the first tar block (512 bytes) to decide whether this is a tar
// archive or a single gzipped file, without consuming/truncating the
// rest of the stream. A raw byte-level cap on the whole decompressed
// payload would otherwise cut a tar archive mid-entry and corrupt its
// structure, turning "large but valid archive" into "unexpected EOF"
// for the entire file instead of extracting what's actually there.
buf := bufio.NewReaderSize(gzr, 64*1024)
head, peekErr := buf.Peek(512)
looksLikeTar := peekErr == nil || (peekErr == io.EOF && len(head) > 0)
if looksLikeTar {
if _, err := tar.NewReader(bytes.NewReader(head)).Next(); err != nil {
looksLikeTar = false
}
}
if looksLikeTar {
return extractTarEntriesWithLimit(buf, maxSingleFileSizeLarge)
}
// Not a tar archive - treat as a single gzipped file. No fixed byte cap:
// stream the decompression and only stop early on a runaway compression
// ratio (gzip bomb) or the absolute memory safety net.
decompressed, truncMsg, err := readGzipWithBombGuard(buf, compressed)
if err != nil {
return nil, err
}
baseName := strings.TrimSuffix(filename, ".gz")
if gzr.Name != "" {
baseName = gzr.Name
}
file := ExtractedFile{
Path: baseName,
Content: decompressed,
ModTime: gzr.ModTime,
}
if truncMsg != "" {
file.Truncated = true
file.TruncatedMessage = truncMsg
}
return []ExtractedFile{file}, nil
}
// countingReader tracks the number of bytes read from the underlying reader.
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
// readGzipWithBombGuard streams dec (a gzip decompression stream) to
// completion, tracking bytes consumed from the compressed source via
// compressed. It aborts early — returning what was decompressed so far plus
// a non-empty reason — if the decompression ratio suggests a gzip bomb, or
// if the absolute ceiling is hit regardless of ratio.
func readGzipWithBombGuard(dec io.Reader, compressed *countingReader) ([]byte, string, error) {
var out bytes.Buffer
chunk := make([]byte, 256*1024)
for {
n, readErr := dec.Read(chunk)
if n > 0 {
out.Write(chunk[:n])
if int64(out.Len()) > gzipAbsoluteCeiling {
return out.Bytes(), fmt.Sprintf(
"decompressed gzip content exceeded the %d byte safety ceiling and was truncated",
gzipAbsoluteCeiling,
), nil
}
if compressed.n >= gzipBombMinCompressed && int64(out.Len()) > compressed.n*gzipBombRatio {
return out.Bytes(), fmt.Sprintf(
"decompression ratio exceeded %dx (likely a gzip bomb) and was truncated after %d bytes",
gzipBombRatio, out.Len(),
), nil
}
}
if readErr == io.EOF {
return out.Bytes(), "", nil
}
if readErr != nil {
return nil, "", fmt.Errorf("read gzip content: %w", readErr)
}
}
}
// extractTarEntriesWithLimit streams tar entries from r, skipping individual
// files over 10MB, and stops once the cumulative extracted content reaches
// totalLimit — always at an entry boundary, never mid-file.
func extractTarEntriesWithLimit(r io.Reader, totalLimit int64) ([]ExtractedFile, error) {
tr := tar.NewReader(r)
var files []ExtractedFile
var totalExtracted int64
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar read: %w", err)
}
if header.Typeflag == tar.TypeDir {
continue
}
if header.Size > 10*1024*1024 {
continue
}
if totalExtracted+header.Size > totalLimit {
break
}
content, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", header.Name, err)
}
files = append(files, ExtractedFile{
Path: header.Name,
Content: content,
ModTime: header.ModTime,
})
totalExtracted += int64(len(content))
}
return files, nil
}
func extractZip(archivePath string) ([]ExtractedFile, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
defer r.Close()
var files []ExtractedFile
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
// Skip large files (>10MB)
if f.FileInfo().Size() > 10*1024*1024 {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open file %s: %w", f.Name, err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("read file %s: %w", f.Name, err)
}
files = append(files, ExtractedFile{
Path: f.Name,
Content: content,
ModTime: f.Modified,
})
}
return files, nil
}
func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
// Read all data into memory with a hard cap
data, err := io.ReadAll(io.LimitReader(r, maxZipArchiveSize+1))
if err != nil {
return nil, fmt.Errorf("read zip data: %w", err)
}
if len(data) > maxZipArchiveSize {
return nil, fmt.Errorf("zip too large: max %d bytes", maxZipArchiveSize)
}
// Create a ReaderAt from the byte slice
readerAt := bytes.NewReader(data)
// Open the zip archive
zipReader, err := zip.NewReader(readerAt, int64(len(data)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var files []ExtractedFile
for _, f := range zipReader.File {
if f.FileInfo().IsDir() {
continue
}
// Skip large files (>10MB)
if f.FileInfo().Size() > 10*1024*1024 {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open file %s: %w", f.Name, err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("read file %s: %w", f.Name, err)
}
files = append(files, ExtractedFile{
Path: f.Name,
Content: content,
ModTime: f.Modified,
})
}
return files, nil
}
func extractSingleFileWithLimit(path string, limit int64) ([]ExtractedFile, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer f.Close()
files, err := extractSingleFileFromReaderWithLimit(f, filepath.Base(path), limit)
if err != nil {
return nil, err
}
if len(files) > 0 {
files[0].ModTime = info.ModTime()
}
return files, nil
}
func extractSingleFileFromReaderWithLimit(r io.Reader, filename string, limit int64) ([]ExtractedFile, error) {
content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, fmt.Errorf("read file content: %w", err)
}
truncated := int64(len(content)) > limit
if truncated {
content = content[:limit]
}
file := ExtractedFile{
Path: filepath.Base(filename),
Content: content,
}
if truncated {
file.Truncated = true
file.TruncatedMessage = fmt.Sprintf(
"file exceeded %d bytes and was truncated",
limit,
)
}
return []ExtractedFile{file}, nil
}
// FindFileByPattern finds files matching pattern in extracted files
func FindFileByPattern(files []ExtractedFile, patterns ...string) []ExtractedFile {
var result []ExtractedFile
for _, f := range files {
for _, pattern := range patterns {
if strings.Contains(strings.ToLower(f.Path), strings.ToLower(pattern)) {
result = append(result, f)
break
}
}
}
return result
}
// FindFileByName finds file by exact name (case-insensitive)
func FindFileByName(files []ExtractedFile, name string) *ExtractedFile {
for _, f := range files {
if strings.EqualFold(filepath.Base(f.Path), name) {
return &f
}
}
return nil
}