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>
481 lines
12 KiB
Go
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
|
|
}
|