fix(parser,exporter): fix large tar.gz truncation and empty device_class defaulting

- 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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-15 14:00:55 +03:00
co-authored by Claude Sonnet 5
parent 4fa16c78a5
commit 8665f79fd6
2 changed files with 81 additions and 54 deletions
+8 -1
View File
@@ -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"
+49 -29
View File
@@ -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,15 +203,6 @@ 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
@@ -212,15 +223,34 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
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 {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar read: %w", err)
}
// It's a valid tar archive, process it
for {
// Skip directories
if header.Typeflag != tar.TypeDir {
// Skip large files (>10MB)
if header.Size <= 10*1024*1024 {
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)
@@ -231,17 +261,7 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
Content: content,
ModTime: header.ModTime,
})
}
}
// Read next header
header, err = tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("tar read: %w", err)
}
totalExtracted += int64(len(content))
}
return files, nil