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 }