From 8665f79fd6172dfdb47ce1d980326f66106afcb5 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Sat, 15 Aug 2026 14:00:55 +0300 Subject: [PATCH] fix(parser,exporter): fix large tar.gz truncation and empty device_class defaulting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - archive.go: extractTarGzFromReader truncated decompressed tar.gz content at a hard 50MB byte boundary before checking whether it was a tar archive, corrupting the tar structure mid-entry for any legitimately large archive (e.g. a 200MB decompressed NVIDIA bug-report bundle) and causing the whole file to fail with "tar read: unexpected EOF" instead of extracting what's there. Now peeks the first 512-byte tar block to detect tar vs. single gzipped file without consuming the stream, and streams tar entries with a cumulative (not raw-byte) size limit that only ever stops at an entry boundary. The byte-level cap still applies to the single-gzipped-file case, where it's safe since there's no container structure to corrupt. - reanimator_converter.go: normalizeLegacyPCIeDeviceClass mapped an empty device_class to "NetworkController" by accident (grouped into the same case as "network"/"ethernet" aliases). Sources that never populate a class at all (e.g. Dell's DCIM_PCIDeviceView) got every such device — including NVMe drives and SATA controllers — mislabeled as network controllers. Empty now stays empty instead of being guessed. Co-Authored-By: Claude Sonnet 5 --- internal/exporter/reanimator_converter.go | 9 +- internal/parser/archive.go | 126 +++++++++++++--------- 2 files changed, 81 insertions(+), 54 deletions(-) diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index e412c82..b0fe113 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -2545,7 +2545,14 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string { func normalizeLegacyPCIeDeviceClass(deviceClass string) string { switch strings.ToLower(strings.TrimSpace(deviceClass)) { - case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller": + case "": + // Unknown class must stay unknown — it must not be guessed as + // "NetworkController" just because that happened to be the first + // case in this switch. An empty class is common for device + // sources (e.g. Dell DCIM_PCIDeviceView) that never carry a class + // at all, including storage/GPU/other non-network devices. + return "" + case "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller": return "NetworkController" case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc": return "FibreChannelController" diff --git a/internal/parser/archive.go b/internal/parser/archive.go index 842bd41..d4dcef2 100644 --- a/internal/parser/archive.go +++ b/internal/parser/archive.go @@ -3,6 +3,7 @@ package parser import ( "archive/tar" "archive/zip" + "bufio" "bytes" "compress/gzip" "fmt" @@ -172,9 +173,28 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro } defer gzr.Close() - // Read decompressed content with a hard cap. - // When the payload exceeds the cap, keep the first chunk and mark it as truncated. - decompressed, err := io.ReadAll(io.LimitReader(gzr, maxGzipDecompressedSize+1)) + // 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. A hard byte cap + // is safe here since there's no container structure to corrupt. + decompressed, err := io.ReadAll(io.LimitReader(buf, maxGzipDecompressedSize+1)) if err != nil { return nil, fmt.Errorf("read gzip content: %w", err) } @@ -183,65 +203,65 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro decompressed = decompressed[:maxGzipDecompressedSize] } - // Try to read as tar archive - tr := tar.NewReader(bytes.NewReader(decompressed)) - var files []ExtractedFile - - header, err := tr.Next() - if err != nil { - // Not a tar archive - treat as a single gzipped file - if strings.Contains(err.Error(), "invalid tar header") || err == io.EOF { - // Get base filename without .gz extension - baseName := strings.TrimSuffix(filename, ".gz") - if gzr.Name != "" { - baseName = gzr.Name - } - - file := ExtractedFile{ - Path: baseName, - Content: decompressed, - ModTime: gzr.ModTime, - } - if gzipTruncated { - file.Truncated = true - file.TruncatedMessage = fmt.Sprintf( - "decompressed gzip content exceeded %d bytes and was truncated", - maxGzipDecompressedSize, - ) - } - - return []ExtractedFile{file}, nil - } - return nil, fmt.Errorf("tar read: %w", err) + baseName := strings.TrimSuffix(filename, ".gz") + if gzr.Name != "" { + baseName = gzr.Name } - // It's a valid tar archive, process it + file := ExtractedFile{ + Path: baseName, + Content: decompressed, + ModTime: gzr.ModTime, + } + if gzipTruncated { + file.Truncated = true + file.TruncatedMessage = fmt.Sprintf( + "decompressed gzip content exceeded %d bytes and was truncated", + maxGzipDecompressedSize, + ) + } + + return []ExtractedFile{file}, nil +} + +// 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 { - // Skip directories - if header.Typeflag != tar.TypeDir { - // Skip large files (>10MB) - if header.Size <= 10*1024*1024 { - 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, - }) - } - } - - // Read next header - header, err = tr.Next() + 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