From 7d1a02cb729ec70795f7c57857a0040c50c2f2a5 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 1 Mar 2026 17:08:11 +0300 Subject: [PATCH] Add H3C G5/G6 parsers with PSU and NIC extraction --- internal/parser/vendors/h3c/parser.go | 3516 ++++++++++++++++++++ internal/parser/vendors/h3c/parser_test.go | 962 ++++++ internal/parser/vendors/vendors.go | 2 +- 3 files changed, 4479 insertions(+), 1 deletion(-) create mode 100644 internal/parser/vendors/h3c/parser.go create mode 100644 internal/parser/vendors/h3c/parser_test.go diff --git a/internal/parser/vendors/h3c/parser.go b/internal/parser/vendors/h3c/parser.go new file mode 100644 index 0000000..267eab2 --- /dev/null +++ b/internal/parser/vendors/h3c/parser.go @@ -0,0 +1,3516 @@ +// Package h3c provides parser for H3C SDS diagnostic archives. +package h3c + +import ( + "bufio" + "encoding/csv" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" + "git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids" +) + +const ( + parserVersionG5 = "1.0.0" + parserVersionG6 = "1.0.0" +) + +func init() { + parser.Register(&G5Parser{}) + parser.Register(&G6Parser{}) +} + +// G5Parser implements VendorParser for H3C G5 SDS archives. +type G5Parser struct{} + +func (p *G5Parser) Name() string { return "H3C SDS Parser G5" } +func (p *G5Parser) Vendor() string { return "h3c_g5" } +func (p *G5Parser) Version() string { return parserVersionG5 } +func (p *G5Parser) Detect(files []parser.ExtractedFile) int { + return detectH3CG5(files) +} +func (p *G5Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + return parseH3CG5(files), nil +} + +// G6Parser implements VendorParser for H3C G6 SDS archives. +type G6Parser struct{} + +func (p *G6Parser) Name() string { return "H3C SDS Parser G6" } +func (p *G6Parser) Vendor() string { return "h3c_g6" } +func (p *G6Parser) Version() string { return parserVersionG6 } +func (p *G6Parser) Detect(files []parser.ExtractedFile) int { + return detectH3CG6(files) +} +func (p *G6Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + return parseH3CG6(files), nil +} + +func detectH3CG5(files []parser.ExtractedFile) int { + confidence := 0 + for _, f := range files { + path := strings.ToLower(f.Path) + switch { + case path == "bmc/pack.info": + confidence += 6 + case strings.HasSuffix(path, "/fruinfo.ini") || strings.HasSuffix(path, "fruinfo.ini"): + confidence += 10 + case strings.HasSuffix(path, "/hardware_info.ini") || strings.HasSuffix(path, "hardware_info.ini"): + confidence += 35 + case strings.HasSuffix(path, "/hardware.info") || strings.HasSuffix(path, "hardware.info"): + confidence += 24 + case strings.HasSuffix(path, "/firmware_version.ini") || strings.HasSuffix(path, "firmware_version.ini"): + confidence += 20 + case strings.HasSuffix(path, "/test.csv") || strings.HasSuffix(path, "/test1.csv"): + confidence += 20 + case strings.HasSuffix(path, "/raid.json") || strings.HasSuffix(path, "raid.json"): + confidence += 8 + } + + if strings.Contains(path, "fruinfo.ini") && containsH3CMarkers(f.Content) { + confidence += 10 + } + if confidence >= 100 { + return 100 + } + } + return minInt(confidence, 100) +} + +func detectH3CG6(files []parser.ExtractedFile) int { + confidence := 0 + for _, f := range files { + path := strings.ToLower(f.Path) + switch { + case path == "bmc/pack.info": + confidence += 10 + case strings.HasSuffix(path, "/fruinfo.ini") || strings.HasSuffix(path, "fruinfo.ini"): + confidence += 12 + case strings.HasSuffix(path, "/board_info.ini") || strings.HasSuffix(path, "board_info.ini"): + confidence += 14 + case strings.HasSuffix(path, "/firmware_version.json") || strings.HasSuffix(path, "firmware_version.json"): + confidence += 24 + case strings.HasSuffix(path, "/cpudetailinfo.xml") || strings.HasSuffix(path, "cpudetailinfo.xml"): + confidence += 22 + case strings.HasSuffix(path, "/memorydetailinfo.xml") || strings.HasSuffix(path, "memorydetailinfo.xml"): + confidence += 22 + case strings.HasSuffix(path, "/sel.json") || strings.HasSuffix(path, "sel.json"): + confidence += 18 + case strings.HasSuffix(path, "/sensor_info.ini") || strings.HasSuffix(path, "sensor_info.ini"): + confidence += 8 + } + + if (strings.Contains(path, "board_info.ini") || strings.Contains(path, "fruinfo.ini")) && containsH3CMarkers(f.Content) { + confidence += 10 + } + if confidence >= 100 { + return 100 + } + } + return minInt(confidence, 100) +} + +func containsH3CMarkers(content []byte) bool { + s := strings.ToLower(string(content)) + return strings.Contains(s, "h3c") || strings.Contains(s, "new h3c technologies") +} + +func newAnalysisResult() *models.AnalysisResult { + return &models.AnalysisResult{ + Events: make([]models.Event, 0), + FRU: make([]models.FRUInfo, 0), + Sensors: make([]models.SensorReading, 0), + Hardware: &models.HardwareConfig{ + Firmware: make([]models.FirmwareInfo, 0), + CPUs: make([]models.CPU, 0), + Memory: make([]models.MemoryDIMM, 0), + Storage: make([]models.Storage, 0), + Volumes: make([]models.StorageVolume, 0), + Devices: make([]models.HardwareDevice, 0), + NetworkCards: make([]models.NIC, 0), + NetworkAdapters: make([]models.NetworkAdapter, 0), + PowerSupply: make([]models.PSU, 0), + }, + } +} + +func parseH3CG5(files []parser.ExtractedFile) *models.AnalysisResult { + result := newAnalysisResult() + firmwareSeen := make(map[string]struct{}) + + if f := parser.FindFileByName(files, "FRUInfo.ini"); f != nil { + parseFRUInfoINI(f.Content, result) + } + if f := parser.FindFileByName(files, "board_info.ini"); f != nil { + parseBoardInfoINI(f.Content, result, firmwareSeen) + } + if f := parser.FindFileByName(files, "board_cfg.ini"); f != nil { + parseBoardCfgINI(f.Content, result) + } + if f := parser.FindFileByName(files, "firmware_version.ini"); f != nil { + parseFirmwareVersionINI(f.Content, result, firmwareSeen) + } + if f := parser.FindFileByName(files, "hardware_info.ini"); f != nil { + parseHardwareInfoINI(f.Content, result) + } + if f := parser.FindFileByName(files, "hardware.info"); f != nil { + appendUniqueStorages(&result.Hardware.Storage, parseHardwareInfoStorageINI(f.Content)) + } + if f := parser.FindFileByName(files, "storage_disk.ini"); f != nil { + appendUniqueStorages(&result.Hardware.Storage, parseStorageINI(f.Content)) + } + if f := parser.FindFileByName(files, "raid.json"); f != nil { + result.Hardware.Volumes = append(result.Hardware.Volumes, parseRAIDJSONVolumes(f.Content)...) + } + for _, f := range files { + path := strings.ToLower(f.Path) + if strings.Contains(path, "storage_raid-") && strings.HasSuffix(path, ".txt") { + storages, volumes := parseRAIDDetailTXT(f.Content) + appendUniqueStorages(&result.Hardware.Storage, storages) + result.Hardware.Volumes = append(result.Hardware.Volumes, volumes...) + } + } + if f := parser.FindFileByName(files, "NVMe_info.txt"); f != nil { + appendUniqueStorages(&result.Hardware.Storage, parseNVMeInfo(f.Content)) + } + if f := parser.FindFileByName(files, "Raid_BP_Conf_Info.ini"); f != nil { + result.Hardware.Devices = append(result.Hardware.Devices, parseRaidBPConfDevices(f.Content)...) + } + if f := parser.FindFileByName(files, "psu_cfg.ini"); f != nil { + appendUniquePSUs(&result.Hardware.PowerSupply, parsePSUCfgINI(f.Content)) + } + if f := parser.FindFileByName(files, "net_cfg.ini"); f != nil { + adapters := parseNetCfgNetworkAdapters(f.Content) + if len(result.Hardware.NetworkAdapters) == 0 { + appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters) + appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(adapters)) + } + } + enrichStorageWithSmartdata(&result.Hardware.Storage, files) + if f := parser.FindFileByName(files, "PCIe_arguments_table.xml"); f != nil { + enrichStorageFromPCIeArguments(&result.Hardware.Storage, f.Content) + } + if f := parser.FindFileByName(files, "sensor_info.ini"); f != nil { + result.Sensors = parseSensorInfoINI(f.Content) + } + + result.Events = parseSELCSVFiles(files) + if len(result.Events) == 0 { + if f := parser.FindFileByName(files, "Sel.json"); f != nil { + result.Events = parseSELJSON(f.Content) + } + } + if len(result.Events) == 0 { + if f := parser.FindFileByName(files, "sel_list.txt"); f != nil { + result.Events = parseSELListTXT(f.Content) + } + } + result.Hardware.Storage = dedupeStorage(result.Hardware.Storage) + result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes) + + return result +} + +func parseH3CG6(files []parser.ExtractedFile) *models.AnalysisResult { + result := newAnalysisResult() + firmwareSeen := make(map[string]struct{}) + + if f := parser.FindFileByName(files, "FRUInfo.ini"); f != nil { + parseFRUInfoINI(f.Content, result) + } + if f := parser.FindFileByName(files, "board_info.ini"); f != nil { + parseBoardInfoINI(f.Content, result, firmwareSeen) + } + if f := parser.FindFileByName(files, "firmware_version.json"); f != nil { + parseFirmwareJSON(f.Content, result, firmwareSeen) + } + if f := parser.FindFileByName(files, "CPUDetailInfo.xml"); f != nil { + parseCPUXML(f.Content, result) + } + if f := parser.FindFileByName(files, "MemoryDetailInfo.xml"); f != nil { + parseMemoryXML(f.Content, result) + } + if f := parser.FindFileByName(files, "hardware_info.ini"); f != nil { + parseHardwareInfoINI(f.Content, result) + } + if f := parser.FindFileByName(files, "storage_disk.ini"); f != nil { + appendUniqueStorages(&result.Hardware.Storage, parseStorageINI(f.Content)) + } + if f := parser.FindFileByName(files, "raid.json"); f != nil { + result.Hardware.Volumes = append(result.Hardware.Volumes, parseRAIDJSONVolumes(f.Content)...) + } + for _, f := range files { + path := strings.ToLower(f.Path) + if strings.Contains(path, "storage_raid-") && strings.HasSuffix(path, ".txt") { + storages, volumes := parseRAIDDetailTXT(f.Content) + appendUniqueStorages(&result.Hardware.Storage, storages) + result.Hardware.Volumes = append(result.Hardware.Volumes, volumes...) + } + } + if f := parser.FindFileByName(files, "NVMe_info.txt"); f != nil { + appendUniqueStorages(&result.Hardware.Storage, parseNVMeInfo(f.Content)) + } + if f := parser.FindFileByName(files, "psu_cfg.ini"); f != nil { + appendUniquePSUs(&result.Hardware.PowerSupply, parsePSUCfgINI(f.Content)) + } + if f := parser.FindFileByName(files, "net_cfg.ini"); f != nil { + adapters := parseNetCfgNetworkAdapters(f.Content) + if len(result.Hardware.NetworkAdapters) == 0 { + appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters) + appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(adapters)) + } + } + if f := parser.FindFileByName(files, "PCIe_arguments_table.xml"); f != nil { + enrichStorageFromPCIeArguments(&result.Hardware.Storage, f.Content) + } + if f := parser.FindFileByName(files, "sensor_info.ini"); f != nil { + result.Sensors = parseSensorInfoINI(f.Content) + } + + if f := parser.FindFileByName(files, "Sel.json"); f != nil { + result.Events = parseSELJSON(f.Content) + } + if len(result.Events) == 0 { + if f := parser.FindFileByName(files, "sel_list.txt"); f != nil { + result.Events = parseSELListTXT(f.Content) + } + } + result.Hardware.Storage = dedupeStorage(result.Hardware.Storage) + result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes) + + return result +} + +func parseFRUInfoINI(content []byte, result *models.AnalysisResult) { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + + for _, sectionName := range names { + section := sections[sectionName] + fru := models.FRUInfo{ + DeviceID: sectionName, + Description: sectionName, + ChassisType: getSectionValue(section, "Chassis Type"), + Manufacturer: getSectionValue(section, "Board Manufacturer", "Product Manufacturer"), + ProductName: getSectionValue(section, "Product Product Name", "Board Product Name"), + SerialNumber: getSectionValue(section, "Product Serial Number", "Board Top Serial Number", "Board Serial Number", "Chassis Serial Number"), + PartNumber: getSectionValue(section, "Product Part Number", "Board Part Number", "Chassis Part Number"), + Version: getSectionValue(section, "Product Version"), + MfgDate: getSectionValue(section, "Mfg.Date"), + AssetTag: getSectionValue(section, "Product Asset Tag"), + } + + if isEmptyFRU(fru) { + continue + } + result.FRU = append(result.FRU, fru) + + if strings.EqualFold(sectionName, "baseboard") { + applyBoardFromFRU(&result.Hardware.BoardInfo, fru) + } + } +} + +func parseBoardInfoINI(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + + for _, sectionName := range names { + section := sections[sectionName] + + if strings.EqualFold(sectionName, "System board") { + board := &result.Hardware.BoardInfo + setIfEmpty(&board.Manufacturer, getSectionValue(section, "BoardMfr")) + setIfEmpty(&board.ProductName, getSectionValue(section, "BoardProductName")) + setIfEmpty(&board.SerialNumber, getSectionValue(section, "BoardSerialNum")) + setIfEmpty(&board.PartNumber, getSectionValue(section, "BoardPartNum")) + setIfEmpty(&board.Version, getSectionValue(section, "PCB Version")) + } + + for key, value := range section { + keyTrim := strings.TrimSpace(key) + valueTrim := strings.TrimSpace(value) + if valueTrim == "" || strings.EqualFold(valueTrim, "N/A") { + continue + } + if !strings.Contains(strings.ToLower(keyTrim), "version") { + continue + } + deviceName := strings.TrimSpace(fmt.Sprintf("%s %s", sectionName, keyTrim)) + appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{ + DeviceName: deviceName, + Version: valueTrim, + }) + } + } +} + +func parseFirmwareVersionINI(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + + for _, sectionName := range names { + section := sections[sectionName] + + if strings.EqualFold(sectionName, "System board") { + setIfEmpty(&result.Hardware.BoardInfo.Version, getSectionValue(section, "PCB Version")) + } + + for key, value := range section { + keyTrim := strings.TrimSpace(key) + valueTrim := strings.TrimSpace(value) + if valueTrim == "" || strings.EqualFold(valueTrim, "N/A") { + continue + } + + normKey := normalizeKey(keyTrim) + if !strings.Contains(normKey, "version") { + continue + } + + appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{ + DeviceName: strings.TrimSpace(fmt.Sprintf("%s %s", sectionName, keyTrim)), + Version: valueTrim, + }) + } + } +} + +func parseHardwareInfoINI(content []byte, result *models.AnalysisResult) { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + + for _, sectionName := range names { + section := sections[sectionName] + normSection := normalizeKey(sectionName) + + switch { + case strings.HasPrefix(normSection, "processorsprocessor"): + cpu := parseCPUFromHardwareInfoSection(sectionName, section) + if cpu.Model == "" { + continue + } + if idx := findCPUIndex(result.Hardware.CPUs, cpu); idx >= 0 { + mergeCPU(&result.Hardware.CPUs[idx], cpu) + continue + } + result.Hardware.CPUs = append(result.Hardware.CPUs, cpu) + case strings.HasPrefix(normSection, "memorydetailsdimmindex"): + dimm := parseDIMMFromHardwareInfoSection(sectionName, section) + if dimm.Slot == "" && dimm.SerialNumber == "" { + continue + } + if idx := findMemoryIndex(result.Hardware.Memory, dimm); idx >= 0 { + mergeMemoryDIMM(&result.Hardware.Memory[idx], dimm) + continue + } + result.Hardware.Memory = append(result.Hardware.Memory, dimm) + } + } + + adapters := parseHardwareInfoNetworkAdapters(content) + appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters) + appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(result.Hardware.NetworkAdapters)) +} + +func parseHardwareInfoNetworkAdapters(content []byte) []models.NetworkAdapter { + blocks := parseKeyValueBlocks(string(content)) + out := make([]models.NetworkAdapter, 0) + + for _, block := range blocks { + deviceType := strings.ToLower(strings.TrimSpace(getSectionValue(block, "Device Type"))) + hasNetworkHints := deviceType == "nic" || + getSectionValue(block, "Network Port") != "" || + getSectionValue(block, "MAC Address") != "" || + getSectionValue(block, "Vendor ID") != "" || + getSectionValue(block, "Device ID") != "" + if !hasNetworkHints { + continue + } + + slot := normalizePCIeSlotLabel(getSectionValue(block, "Location", "Slot", "PCIe Slot")) + if slot == "" { + slot = firstNonEmpty(getSectionValue(block, "Network Port"), "NIC") + } + + mac := normalizeMACAddress(getSectionValue(block, "MAC Address")) + productName := normalizeUnknownString(getSectionValue(block, "Product Name", "Model")) + vendorID := parseMaybeInt(getSectionValue(block, "Vendor ID")) + deviceID := parseMaybeInt(getSectionValue(block, "Device ID")) + vendor := normalizeUnknownString(getSectionValue(block, "Vendor", "Manufacturer")) + if vendor == "" && vendorID > 0 { + vendor = strings.TrimSpace(pciids.VendorName(vendorID)) + } + + networkPort := normalizeUnknownString(getSectionValue(block, "Network Port")) + speed := normalizeUnknownString(firstNonEmpty(getSectionValue(block, "Current Speed"), getSectionValue(block, "Speed"))) + bandwidth := normalizeUnknownString(getSectionValue(block, "Current Bandwidth", "Link Width")) + status := normalizeComponentStatus(firstNonEmpty(getSectionValue(block, "Network Status"), getSectionValue(block, "Status"))) + if status == "" { + status = "ok" + } + + descParts := make([]string, 0, 3) + if networkPort != "" { + descParts = append(descParts, networkPort) + } + if speed != "" { + descParts = append(descParts, "speed "+speed) + } + if bandwidth != "" { + descParts = append(descParts, "link "+bandwidth) + } + + adapter := models.NetworkAdapter{ + Slot: slot, + Location: slot, + Present: status != "absent", + Model: firstNonEmpty(productName, "Ethernet Adapter"), + Description: strings.Join(descParts, "; "), + Vendor: vendor, + VendorID: vendorID, + DeviceID: deviceID, + SerialNumber: normalizeUnknownString(getSectionValue(block, "Serial Number", "SerialNumber", "SN")), + PartNumber: normalizeUnknownString(getSectionValue(block, "Part Number", "PartNumber", "PN")), + Firmware: normalizeUnknownString(getSectionValue(block, "Firmware Version", "Firmware")), + PortCount: inferPortCountFromNetworkFields(productName, networkPort), + Status: status, + } + if mac != "" { + adapter.MACAddresses = []string{mac} + if adapter.PortCount == 0 { + adapter.PortCount = 1 + } + } + + if isNetworkAdapterEmpty(adapter) { + continue + } + out = append(out, adapter) + } + + return out +} + +func parseCPUFromHardwareInfoSection(sectionName string, section map[string]string) models.CPU { + socket := parseSectionIndex(sectionName, `(?i)processor\s+(\d+)`) + statusRaw := getSectionValue(section, "Status") + + return models.CPU{ + Socket: socket, + Model: getSectionValue(section, "Model"), + Cores: parseMaybeIntLoose(getSectionValue(section, "Cores")), + Threads: parseMaybeIntLoose(getSectionValue(section, "Threads")), + FrequencyMHz: parseMaybeIntLoose(getSectionValue(section, "Frequency", "Processor Speed")), + L1CacheKB: parseMaybeIntLoose(getSectionValue(section, "L1 Cache")), + L2CacheKB: parseMaybeIntLoose(getSectionValue(section, "L2 Cache")), + L3CacheKB: parseMaybeIntLoose(getSectionValue(section, "L3 Cache")), + PPIN: getSectionValue(section, "CPU PPIN", "PPIN"), + SerialNumber: getSectionValue(section, "Processor ID", "Serial Number"), + Status: normalizeComponentStatus(statusRaw), + } +} + +func parseDIMMFromHardwareInfoSection(sectionName string, section map[string]string) models.MemoryDIMM { + statusRaw := getSectionValue(section, "Status") + status := normalizeComponentStatus(statusRaw) + present := status != "absent" + + slot := firstNonEmpty( + getSectionValue(section, "Socket ID", "Slot", "Name"), + sectionName, + ) + location := strings.TrimSpace(getSectionValue(section, "Location")) + channel := strings.TrimSpace(getSectionValue(section, "Channel")) + if location != "" && channel != "" { + location = location + " CH" + channel + } + + return models.MemoryDIMM{ + Slot: slot, + Location: location, + Present: present, + SizeMB: parseMaybeIntLoose(getSectionValue(section, "Size", "DIMM Size")), + Type: firstNonEmpty(getSectionValue(section, "Type"), getSectionValue(section, "Technology"), "DIMM"), + Technology: getSectionValue(section, "Technology"), + MaxSpeedMHz: parseMaybeIntLoose(getSectionValue(section, "Maximum Frequency", "Max Frequency")), + CurrentSpeedMHz: parseMaybeIntLoose(getSectionValue(section, "Current Frequency", "Operating Frequency")), + Manufacturer: getSectionValue(section, "Manufacture", "Manufacturer"), + SerialNumber: getSectionValue(section, "Serial Number", "SerialNumber"), + PartNumber: getSectionValue(section, "Part Number"), + Status: status, + Ranks: parseMaybeIntLoose(getSectionValue(section, "Ranks")), + } +} + +func parseHardwareInfoStorageINI(content []byte) []models.Storage { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + out := make([]models.Storage, 0) + + for _, sectionName := range names { + section := sections[sectionName] + normSection := normalizeKey(sectionName) + if !strings.HasPrefix(normSection, "disk") && !strings.HasPrefix(normSection, "nvme") { + continue + } + + slotNum := getSectionValue(section, "SlotNum") + frontRear := getSectionValue(section, "FrontOrRear") + slot := firstNonEmpty( + getSectionValue(section, "NvmePhySlot", "DiskSlotDesc", "Position", "Slot"), + formatSlot(frontRear, slotNum), + slotNum, + sectionName, + ) + + presentRaw := getSectionValue(section, "Present") + present := true + if strings.TrimSpace(presentRaw) != "" { + present = isTruthy(presentRaw) + } + status := normalizeStorageStatus(getSectionValue(section, "Status"), present) + if status == "" { + status = normalizeStorageStatus(presentRaw, present) + } + + storageType := "disk" + if strings.HasPrefix(normSection, "nvme") || strings.Contains(strings.ToLower(slot), "nvme") { + storageType = "nvme" + } + + location := getSectionValue(section, "FrontOrRear", "Location") + if location == "" { + slotLower := strings.ToLower(slot) + switch { + case strings.Contains(slotLower, "front"): + location = "Front" + case strings.Contains(slotLower, "rear"): + location = "Rear" + } + } + + item := models.Storage{ + Slot: slot, + Type: storageType, + SerialNumber: getSectionValue(section, "SerialNumber"), + Present: present, + Location: location, + Status: status, + } + if isStorageEmpty(item) { + continue + } + out = append(out, item) + } + + return dedupeStorage(out) +} + +func parseSmartdataStorages(files []parser.ExtractedFile) []models.Storage { + out := make([]models.Storage, 0) + + for _, f := range files { + path := strings.ToLower(strings.TrimSpace(f.Path)) + if !strings.HasPrefix(path, "static/smartdata/") || !strings.HasSuffix(path, ".txt") { + continue + } + + model, serial := parseSmartdataDevice(f.Content) + if serial == "" { + continue + } + + slot := smartdataSlotFromPath(f.Path) + st := models.Storage{ + Slot: slot, + Type: inferStorageTypeFromValues(slot, "", "", model), + Model: model, + SerialNumber: serial, + Present: true, + Status: "ok", + } + if st.Type == "" { + st.Type = "disk" + } + out = append(out, st) + } + + return dedupeStorage(out) +} + +func enrichStorageWithSmartdata(storages *[]models.Storage, files []parser.ExtractedFile) { + for _, add := range parseSmartdataStorages(files) { + idx := findStorageIndex(*storages, add) + if idx < 0 { + continue + } + mergeStorage(&(*storages)[idx], add) + } +} + +type pcieStorageProfile struct { + Model string + Manufacturer string + IsNVMe bool +} + +func enrichStorageFromPCIeArguments(storages *[]models.Storage, content []byte) { + profiles := parseStorageProfilesFromPCIeArgumentsXML(content) + if len(profiles) == 0 { + return + } + + nvmeVendor, nvmeModel := chooseNVMeProfileFallback(*storages, profiles) + nonNVMeVendor, nonNVMeModel := selectUniqueVendorModel(filterPCIeStorageProfilesByNVMe(profiles, false)) + + for i := range *storages { + s := &(*storages)[i] + isNVMeStorage := strings.EqualFold(strings.TrimSpace(s.Type), "nvme") + + vendor, model := nonNVMeVendor, nonNVMeModel + if isNVMeStorage { + vendor, model = nvmeVendor, nvmeModel + } + if vendor == "" && model == "" { + continue + } + if strings.TrimSpace(s.Manufacturer) == "" && vendor != "" { + s.Manufacturer = vendor + } + if strings.TrimSpace(s.Model) == "" { + if model != "" { + s.Model = model + } else if vendor != "" && isNVMeStorage { + s.Model = "NVMe SSD" + } + } + } +} + +func chooseNVMeProfileFallback(storages []models.Storage, profiles []pcieStorageProfile) (vendor, model string) { + nvmeProfiles := filterPCIeStorageProfilesByNVMe(profiles, true) + if len(nvmeProfiles) == 0 { + return "", "" + } + + vendor, model = selectUniqueVendorModel(nvmeProfiles) + if vendor != "" || model != "" { + return vendor, model + } + + frontBayNVMe := false + for _, s := range storages { + if strings.ToLower(strings.TrimSpace(s.Type)) != "nvme" { + continue + } + slotLower := strings.ToLower(strings.TrimSpace(s.Slot)) + if strings.Contains(slotLower, "front") { + frontBayNVMe = true + break + } + if n := parseMaybeInt(slotLower); n >= 100 { + frontBayNVMe = true + break + } + } + if !frontBayNVMe { + return "", "" + } + + sffProfiles := make([]pcieStorageProfile, 0) + for _, p := range nvmeProfiles { + nameLower := strings.ToLower(strings.TrimSpace(p.Model)) + if strings.Contains(nameLower, "sff") { + sffProfiles = append(sffProfiles, p) + } + } + if len(sffProfiles) == 0 { + return "", "" + } + return selectUniqueVendorModel(sffProfiles) +} + +func selectUniqueVendorModel(profiles []pcieStorageProfile) (vendor, model string) { + vendorSet := make(map[string]struct{}) + modelSet := make(map[string]struct{}) + for _, p := range profiles { + if strings.TrimSpace(p.Manufacturer) != "" { + vendorSet[p.Manufacturer] = struct{}{} + } + if strings.TrimSpace(p.Model) != "" && !strings.EqualFold(strings.TrimSpace(p.Model), "N/A") { + modelSet[p.Model] = struct{}{} + } + } + + if len(vendorSet) == 1 { + for v := range vendorSet { + vendor = v + } + } + if len(modelSet) == 1 { + for m := range modelSet { + model = m + } + } + return vendor, model +} + +func filterPCIeStorageProfilesByNVMe(profiles []pcieStorageProfile, isNVMe bool) []pcieStorageProfile { + out := make([]pcieStorageProfile, 0, len(profiles)) + for _, p := range profiles { + if p.IsNVMe == isNVMe { + out = append(out, p) + } + } + return out +} + +func parseStorageProfilesFromPCIeArgumentsXML(content []byte) []pcieStorageProfile { + root, ok := parseXMLRoot(content) + if !ok { + return nil + } + + out := make([]pcieStorageProfile, 0) + seen := make(map[string]struct{}) + + for _, node := range root.Nodes { + name := strings.ToUpper(strings.TrimSpace(node.XMLName.Local)) + if !strings.HasPrefix(name, "PCIE") { + continue + } + + base := findChildXMLNode(node, "base_args") + typeGet := findChildXMLNode(node, "type_get_args") + if base == nil || typeGet == nil { + continue + } + + baseFields := xmlNodeFields(*base) + itemName := strings.TrimSpace(baseFields["name"]) + itemType := strings.ToLower(strings.TrimSpace(baseFields["type"])) + if itemName == "" { + continue + } + + lowerName := strings.ToLower(itemName) + if strings.HasPrefix(strings.ToUpper(itemName), "EX-") { + continue + } + isNVMe := strings.Contains(lowerName, "nvme") || strings.Contains(itemType, "nvme") + if !isNVMe && + !containsAny(lowerName, "ssd", "hdd", "disk", "sas", "sata") && + !containsAny(itemType, "ssd", "hdd", "disk", "sas", "sata") { + continue + } + + bios := findChildXMLNode(*typeGet, "bios_args") + if bios == nil { + continue + } + vendorRaw := strings.TrimSpace(xmlNodeFields(*bios)["vendor_id"]) + vendorID := parseMaybeInt(vendorRaw) + if vendorID == 0 || strings.EqualFold(vendorRaw, "0xFFFF") { + continue + } + + vendorName := strings.TrimSpace(pciids.VendorName(vendorID)) + if vendorName == "" { + vendorName = fmt.Sprintf("0x%X", vendorID) + } + + profile := pcieStorageProfile{ + Model: itemName, + Manufacturer: vendorName, + IsNVMe: isNVMe, + } + key := strings.ToLower(profile.Model + "|" + profile.Manufacturer + "|" + strconv.FormatBool(profile.IsNVMe)) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, profile) + } + + return out +} + +func findChildXMLNode(parent xmlNode, childName string) *xmlNode { + target := strings.ToLower(strings.TrimSpace(childName)) + for i := range parent.Nodes { + if strings.ToLower(strings.TrimSpace(parent.Nodes[i].XMLName.Local)) == target { + return &parent.Nodes[i] + } + } + return nil +} + +var ( + netCfgHeaderRE = regexp.MustCompile(`^([^\s]+)\s+Link\s+encap:`) + netCfgHWAddrRE = regexp.MustCompile(`(?i)\bHWaddr\s+([0-9a-f]{2}(?::[0-9a-f]{2}){5})`) + netCfgInet4OldRE = regexp.MustCompile(`\binet addr:([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)`) + netCfgInet4NewRE = regexp.MustCompile(`\binet\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)(?:/[0-9]+)?`) + netCfgInet6OldRE = regexp.MustCompile(`\binet6 addr:\s*([0-9a-f:]+(?:/[0-9]+)?)`) + netCfgInet6NewRE = regexp.MustCompile(`\binet6\s+([0-9a-f:]+(?:/[0-9]+)?)`) + pcieSlotDigitsRE = regexp.MustCompile(`(?i)(\d+)`) + portCountPattRE = regexp.MustCompile(`(?i)(\d+)\s*p\b`) + portCountMulRE = regexp.MustCompile(`(?i)(\d+)\s*\*\s*\d+\s*g`) + portSuffixRE = regexp.MustCompile(`(?i)port\s+(\d+)`) +) + +type netCfgAdapterState struct { + Slot string + Names []string + MACs []string + IPv4 []string + IPv6 []string + Up bool +} + +func parseNetCfgNetworkAdapters(content []byte) []models.NetworkAdapter { + lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") + byKey := make(map[string]*netCfgAdapterState) + order := make([]string, 0) + + var current *netCfgAdapterState + var currentName string + + for _, raw := range lines { + line := strings.TrimRight(raw, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" { + current = nil + currentName = "" + continue + } + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + current = nil + currentName = "" + continue + } + + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + m := netCfgHeaderRE.FindStringSubmatch(line) + if len(m) != 2 { + current = nil + currentName = "" + continue + } + + ifaceName := strings.TrimSpace(m[1]) + baseName := baseInterfaceName(ifaceName) + if strings.EqualFold(baseName, "lo") { + current = nil + currentName = "" + continue + } + + mac := normalizeMACAddress(extractFirstRegexpGroup(line, netCfgHWAddrRE)) + key := "if:" + strings.ToLower(baseName) + if mac != "" { + key = "mac:" + strings.ToLower(mac) + } + + state, ok := byKey[key] + if !ok { + state = &netCfgAdapterState{Slot: baseName} + byKey[key] = state + order = append(order, key) + } + if state.Slot == "" { + state.Slot = baseName + } + appendStringUnique(&state.Names, ifaceName) + if mac != "" { + appendStringUnique(&state.MACs, mac) + } + + current = state + currentName = ifaceName + continue + } + + if current == nil { + continue + } + + upper := strings.ToUpper(trimmed) + if strings.Contains(upper, "UP") && !strings.Contains(upper, "DOWN") { + current.Up = true + } + appendMatchesUnique(¤t.IPv4, trimmed, netCfgInet4OldRE) + appendMatchesUnique(¤t.IPv4, trimmed, netCfgInet4NewRE) + appendMatchesUnique(¤t.IPv6, strings.ToLower(trimmed), netCfgInet6OldRE) + appendMatchesUnique(¤t.IPv6, strings.ToLower(trimmed), netCfgInet6NewRE) + if currentName != "" { + appendStringUnique(¤t.Names, currentName) + } + } + + out := make([]models.NetworkAdapter, 0, len(order)) + for _, key := range order { + state := byKey[key] + if state == nil { + continue + } + if strings.TrimSpace(state.Slot) == "" && len(state.MACs) == 0 { + continue + } + + status := "down" + if state.Up { + status = "ok" + } + + descParts := make([]string, 0, 3) + if len(state.Names) > 0 { + descParts = append(descParts, "interfaces: "+strings.Join(state.Names, ", ")) + } + if len(state.IPv4) > 0 { + descParts = append(descParts, "ipv4: "+strings.Join(state.IPv4, ", ")) + } + if len(state.IPv6) > 0 { + descParts = append(descParts, "ipv6: "+strings.Join(state.IPv6, ", ")) + } + + out = append(out, models.NetworkAdapter{ + Slot: strings.TrimSpace(state.Slot), + Location: strings.TrimSpace(firstNonEmpty(state.Slot, firstSliceValue(state.Names))), + Present: true, + Model: "Ethernet Interface", + Description: strings.Join(descParts, "; "), + PortCount: maxInt(1, len(state.MACs)), + MACAddresses: append([]string(nil), state.MACs...), + Status: status, + }) + } + + return out +} + +func networkCardsFromAdapters(items []models.NetworkAdapter) []models.NIC { + out := make([]models.NIC, 0, len(items)) + for _, item := range items { + mac := firstSliceValue(item.MACAddresses) + if strings.TrimSpace(item.Model) == "" && strings.TrimSpace(mac) == "" { + continue + } + out = append(out, models.NIC{ + Name: firstNonEmpty(item.Slot, item.Location, "NIC"), + Model: firstNonEmpty(item.Model, "Ethernet Interface"), + Description: item.Description, + MACAddress: mac, + }) + } + return out +} + +func extractFirstRegexpGroup(s string, re *regexp.Regexp) string { + if re == nil { + return "" + } + m := re.FindStringSubmatch(s) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +func appendMatchesUnique(dst *[]string, text string, re *regexp.Regexp) { + if re == nil { + return + } + matches := re.FindAllStringSubmatch(text, -1) + for _, m := range matches { + if len(m) < 2 { + continue + } + appendStringUnique(dst, strings.TrimSpace(m[1])) + } +} + +func appendStringUnique(dst *[]string, v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + for _, cur := range *dst { + if strings.EqualFold(strings.TrimSpace(cur), v) { + return + } + } + *dst = append(*dst, v) +} + +func normalizeMACAddress(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + if len(s) == 17 { + return s + } + s = strings.ReplaceAll(s, "-", "") + s = strings.ReplaceAll(s, ":", "") + if len(s) != 12 { + return "" + } + parts := make([]string, 0, 6) + for i := 0; i < len(s); i += 2 { + parts = append(parts, s[i:i+2]) + } + return strings.Join(parts, ":") +} + +func baseInterfaceName(name string) string { + name = strings.TrimSpace(name) + if idx := strings.Index(name, "."); idx > 0 { + return strings.TrimSpace(name[:idx]) + } + return name +} + +func firstSliceValue(items []string) string { + for _, item := range items { + if strings.TrimSpace(item) != "" { + return strings.TrimSpace(item) + } + } + return "" +} + +func normalizeUnknownString(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + lower := strings.ToLower(s) + if lower == "n/a" || lower == "na" || lower == "-" || lower == "unknown" || lower == "none" { + return "" + } + return s +} + +func normalizePCIeSlotLabel(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.EqualFold(raw, "N/A") || strings.EqualFold(raw, "-") { + return "" + } + m := pcieSlotDigitsRE.FindStringSubmatch(raw) + if len(m) >= 2 { + if n := parseMaybeInt(m[1]); n > 0 { + return fmt.Sprintf("PCIe %d", n) + } + } + return raw +} + +func inferPortCountFromNetworkFields(model, networkPort string) int { + modelLower := strings.ToLower(strings.TrimSpace(model)) + if modelLower != "" { + if m := portCountPattRE.FindStringSubmatch(modelLower); len(m) == 2 { + if n := parseMaybeInt(m[1]); n > 0 { + return n + } + } + if m := portCountMulRE.FindStringSubmatch(modelLower); len(m) == 2 { + if n := parseMaybeInt(m[1]); n > 0 { + return n + } + } + } + if m := portSuffixRE.FindStringSubmatch(strings.ToLower(strings.TrimSpace(networkPort))); len(m) == 2 { + if n := parseMaybeInt(m[1]); n > 0 { + return n + } + } + return 0 +} + +func parseSmartdataDevice(content []byte) (model, serial string) { + scanner := bufio.NewScanner(strings.NewReader(string(content))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.Contains(line, ":") { + continue + } + + key, value, ok := parseColonKVLine(line) + if !ok { + continue + } + keyNorm := normalizeKey(key) + val := strings.TrimSpace(value) + if val == "" { + continue + } + + switch keyNorm { + case "modelinfo", "devicemodel", "modelnumber": + if model == "" { + model = val + } + case "serialnumber": + if serial == "" { + serial = val + } + } + } + + return strings.TrimSpace(model), strings.TrimSpace(serial) +} + +func smartdataSlotFromPath(path string) string { + path = strings.ReplaceAll(path, "\\", "/") + parts := strings.Split(path, "/") + for i := range parts { + if strings.EqualFold(parts[i], "smartdata") && i+1 < len(parts) { + return strings.TrimSpace(parts[i+1]) + } + } + return "" +} + +func parsePSUCfgINI(content []byte) []models.PSU { + section := "" + current := make(map[string]string) + records := make([]map[string]string, 0) + isPSUSection := func(name string) bool { + norm := normalizeKey(name) + return norm == "activestandbyconfiguration" || strings.HasPrefix(norm, "psu") + } + + flush := func() { + if len(current) == 0 { + return + } + records = append(records, current) + current = make(map[string]string) + } + + scanner := bufio.NewScanner(strings.NewReader(string(content))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + if isPSUSection(section) { + flush() + } + section = strings.TrimSpace(line[1 : len(line)-1]) + continue + } + + if !isPSUSection(section) { + continue + } + + key, value, ok := parseFlexibleKVLine(line) + if !ok { + continue + } + keyNorm := normalizeKey(key) + if keyNorm == "powerid" && len(current) > 0 && getSectionValue(current, "Power ID", "PowerID") != "" { + flush() + } + current[key] = value + } + if isPSUSection(section) { + flush() + } + + out := make([]models.PSU, 0, len(records)) + for _, rec := range records { + id := strings.TrimSpace(getSectionValue(rec, "Power ID", "PowerID")) + slot := firstNonEmpty(id, getSectionValue(rec, "Slot", "Position")) + if slot != "" && !strings.HasPrefix(strings.ToUpper(slot), "PSU") { + slot = "PSU" + slot + } + + present := true + presentRaw := strings.TrimSpace(getSectionValue(rec, "Present Status", "Present")) + if presentRaw != "" { + present = containsAny(strings.ToLower(presentRaw), "present", "yes", "ok", "active") + } + + status := "absent" + if present { + status = "ok" + } + cold := strings.TrimSpace(getSectionValue(rec, "Cold Status")) + if strings.Contains(strings.ToLower(cold), "active") { + status = "ok" + } + + psu := models.PSU{ + Slot: slot, + Present: present, + Model: strings.TrimSpace(getSectionValue(rec, "Model")), + SerialNumber: strings.TrimSpace(getSectionValue(rec, "SN", "Serial Number")), + WattageW: parseMaybeIntLoose(getSectionValue(rec, "Max Power(W)", "Max Power")), + Status: status, + Description: cold, + } + if strings.TrimSpace(psu.Slot) == "" && strings.TrimSpace(psu.SerialNumber) == "" && strings.TrimSpace(psu.Model) == "" { + continue + } + out = append(out, psu) + } + + return out +} + +func parseBoardCfgINI(content []byte, result *models.AnalysisResult) { + sections := parseLooseINI(string(content)) + if len(sections) == 0 { + return + } + + board := &result.Hardware.BoardInfo + boardType := getSectionValue(findINISection(sections, "Board Type"), "Board Type") + boardVersion := getSectionValue(findINISection(sections, "Board Version"), "Board Version") + customerID := getSectionValue(findINISection(sections, "Customer ID"), "CustomerID", "Customer ID") + oemFlag := getSectionValue(findINISection(sections, "OEM ID"), "OEM Flag", "OEMID") + + setIfEmpty(&board.ProductName, boardType) + setIfEmpty(&board.Version, boardVersion) + + extras := make([]string, 0, 2) + if customerID != "" { + extras = append(extras, "CustomerID: "+customerID) + } + if oemFlag != "" { + extras = append(extras, "OEM Flag: "+oemFlag) + } + if len(extras) > 0 { + if strings.TrimSpace(board.Description) == "" { + board.Description = strings.Join(extras, "; ") + } else if !strings.Contains(strings.ToLower(board.Description), strings.ToLower(extras[0])) { + board.Description = strings.TrimSpace(board.Description) + "; " + strings.Join(extras, "; ") + } + } +} + +func findINISection(sections map[string]map[string]string, name string) map[string]string { + target := normalizeKey(name) + for sectionName, section := range sections { + if normalizeKey(sectionName) == target { + return section + } + } + return nil +} + +func parseRaidBPConfDevices(content []byte) []models.HardwareDevice { + lines := strings.Split(string(content), "\n") + section := "" + devices := make([]models.HardwareDevice, 0) + backplaneIndex := 0 + + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) + continue + } + + switch section { + case "bp information": + if strings.Contains(strings.ToLower(line), "description") && strings.Contains(line, "|") { + continue + } + if !strings.Contains(line, "|") { + continue + } + cols := parsePipeColumns(line) + if len(cols) < 3 { + continue + } + backplaneIndex++ + desc := cols[0] + bpType := cols[1] + slot := cols[2] + if slot == "" || slot == "~" { + slot = fmt.Sprintf("BP-%d", backplaneIndex) + } + dev := models.HardwareDevice{ + ID: fmt.Sprintf("h3c-bp-%d-%s", backplaneIndex, strings.ToLower(strings.ReplaceAll(slot, " ", "-"))), + Kind: models.DeviceKindStorage, + Slot: slot, + Model: bpType, + DeviceClass: "backplane", + Status: "ok", + Details: map[string]any{ + "source": "Raid_BP_Conf_Info.ini", + "description": desc, + }, + } + if len(cols) > 4 && cols[4] != "" && cols[4] != "~" { + dev.Location = cols[4] + } + devices = append(devices, dev) + + case "raid information": + if strings.Contains(strings.ToLower(line), "pcie slot") && strings.Contains(line, "|") { + continue + } + if !strings.Contains(line, "|") { + continue + } + cols := parsePipeColumns(line) + if len(cols) < 2 { + continue + } + slot := strings.TrimSpace(cols[0]) + if slot == "" || slot == "~" { + continue + } + sasNum := strings.TrimSpace(cols[1]) + desc := "RAID Controller" + if sasNum != "" && sasNum != "~" { + desc = "SAS ports: " + sasNum + } + dev := models.HardwareDevice{ + ID: "h3c-raid-slot-" + slot, + Kind: models.DeviceKindPCIe, + Slot: "PCIe " + slot, + Model: "RAID Controller", + DeviceClass: "raid_controller", + Status: "ok", + Details: map[string]any{ + "source": "Raid_BP_Conf_Info.ini", + "description": desc, + "pcie_slot": slot, + "raid_sasnum": sasNum, + }, + } + devices = append(devices, dev) + } + } + + return dedupeHardwareDevices(devices) +} + +func parsePipeColumns(line string) []string { + parts := strings.Split(line, "|") + out := make([]string, 0, len(parts)) + for _, part := range parts { + v := strings.TrimSpace(part) + if v == "" { + continue + } + out = append(out, v) + } + return out +} + +func dedupeHardwareDevices(items []models.HardwareDevice) []models.HardwareDevice { + out := make([]models.HardwareDevice, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + key := strings.ToLower(strings.TrimSpace(item.ID)) + if key == "" { + key = strings.ToLower(strings.TrimSpace(item.Kind + "|" + item.Slot + "|" + item.Model)) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func parseFirmwareJSON(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) { + type fwEntry struct { + FirmwareName string `json:"Firmware Name"` + FirmwareVersion string `json:"Firmware Version"` + Location string `json:"Location"` + PartModel string `json:"Part Model"` + } + + var payload map[string]fwEntry + if err := json.Unmarshal(content, &payload); err != nil { + return + } + + keys := make([]string, 0, len(payload)) + for key := range payload { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + entry := payload[key] + version := strings.TrimSpace(entry.FirmwareVersion) + if version == "" || strings.EqualFold(version, "N/A") { + continue + } + + name := strings.TrimSpace(entry.FirmwareName) + if name == "" { + name = key + } + descParts := make([]string, 0, 2) + if loc := strings.TrimSpace(entry.Location); loc != "" && loc != "-" { + descParts = append(descParts, "location: "+loc) + } + if part := strings.TrimSpace(entry.PartModel); part != "" && part != "-" { + descParts = append(descParts, "model: "+part) + } + + appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{ + DeviceName: name, + Version: version, + Description: strings.Join(descParts, "; "), + }) + } +} + +func parseCPUXML(content []byte, result *models.AnalysisResult) { + root, ok := parseXMLRoot(content) + if !ok { + return + } + + for _, node := range root.Nodes { + if !strings.HasPrefix(strings.ToLower(node.XMLName.Local), "cpu") { + continue + } + + fields := xmlNodeFields(node) + model := firstNonEmpty(fields["Model"], fields["CPUName"]) + if model == "" { + continue + } + + socket := parseSocketID(node.XMLName.Local) + cpu := models.CPU{ + Socket: socket, + Model: model, + Description: strings.TrimSpace(fields["Manufacturer"]), + Cores: parseMaybeInt(fields["TotalCores"]), + Threads: parseMaybeInt(fields["TotalThreads"]), + FrequencyMHz: parseMaybeInt(fields["ProcessorSpeed"]), + MaxFreqMHz: parseMaybeInt(fields["ProcessorMaxSpeed"]), + SerialNumber: strings.TrimSpace(fields["SerialNumber"]), + PPIN: strings.TrimSpace(fields["PPIN"]), + Status: normalizePresenceStatus(fields["Status"]), + } + + result.Hardware.CPUs = append(result.Hardware.CPUs, cpu) + } +} + +func parseMemoryXML(content []byte, result *models.AnalysisResult) { + root, ok := parseXMLRoot(content) + if !ok { + return + } + + for _, node := range root.Nodes { + name := strings.ToLower(node.XMLName.Local) + if !strings.HasPrefix(name, "dimm") { + continue + } + + fields := xmlNodeFields(node) + slot := strings.TrimSpace(fields["Name"]) + if slot == "" { + slot = node.XMLName.Local + } + + statusRaw := strings.TrimSpace(fields["Status"]) + present := strings.Contains(strings.ToLower(statusRaw), "presence") + status := "absent" + if present { + status = "ok" + } + + dimm := models.MemoryDIMM{ + Slot: slot, + Location: strings.TrimSpace(fields["DIMMSilk"]), + Present: present, + SizeMB: parseMaybeInt(fields["DIMMSize"]), + Type: strings.TrimSpace(fields["DIMMTech"]), + MaxSpeedMHz: parseMaybeInt(fields["MaxFreq"]), + CurrentSpeedMHz: parseMaybeInt(fields["CurFreq"]), + SerialNumber: strings.TrimSpace(fields["SerialNumber"]), + PartNumber: strings.TrimSpace(fields["PartNumber"]), + Status: status, + Ranks: parseMaybeInt(fields["DIMMRanks"]), + } + if dimm.Type == "" { + dimm.Type = "DIMM" + } + + result.Hardware.Memory = append(result.Hardware.Memory, dimm) + } +} + +var diskSectionRE = regexp.MustCompile(`(?i)^disk_\d+$`) + +func parseStorageINI(content []byte) []models.Storage { + sections := parseLooseINI(string(content)) + names := sortedSectionNames(sections) + out := make([]models.Storage, 0) + + for _, sectionName := range names { + if !diskSectionRE.MatchString(strings.TrimSpace(sectionName)) { + continue + } + + section := sections[sectionName] + slot := getSectionValue(section, "DiskSlotDesc") + if slot == "" { + slot = sectionName + } + + present := isTruthy(getSectionValue(section, "Present")) + status := "absent" + if present { + status = "ok" + } + + storage := models.Storage{ + Slot: slot, + SerialNumber: getSectionValue(section, "SerialNumber"), + Present: present, + Status: status, + Type: inferStorageType(slot), + } + if storage.Type == "" { + storage.Type = "disk" + } + + out = append(out, storage) + } + return out +} + +func parseRAIDJSONVolumes(content []byte) []models.StorageVolume { + var root map[string]any + if err := json.Unmarshal(content, &root); err != nil { + return nil + } + + raidCfg := toAnyMap(lookupAnyCase(root, "RaidConfig")) + ctrlInfo := toAnySlice(lookupAnyCase(raidCfg, "CtrlInfo", "ControllerInfo")) + volumes := make([]models.StorageVolume, 0) + + for ctrlIdx, ctrlAny := range ctrlInfo { + ctrl := toAnyMap(ctrlAny) + ctrlSlot := firstNonEmpty( + toStringAny(lookupAnyCase(ctrl, "CtrlSlot", "CtrlDevice Slot", "Slot")), + toStringAny(lookupAnyByPrefix(ctrl, "ctrldeviceslot")), + ) + ctrlName := firstNonEmpty( + toStringAny(lookupAnyCase(ctrl, "CtrlName", "CtrlDevice Name", "Name", "ControllerName")), + toStringAny(lookupAnyByPrefix(ctrl, "ctrldevicename")), + fmt.Sprintf("RAID Controller %d", ctrlIdx+1), + ) + controller := ctrlName + if ctrlSlot != "" { + controller = fmt.Sprintf("%s (slot %s)", ctrlName, ctrlSlot) + } + + ldInfo := toAnySlice(lookupAnyCase(ctrl, "LDInfo", "LogicalDeviceInfo", "LogicalDiskInfo", "VirtualDriveInfo")) + for ldIdx, ldAny := range ldInfo { + ld := toAnyMap(ldAny) + vol := parseVolumeFromAnyMap(ld, controller, ldIdx+1) + if isVolumeEmpty(vol) { + continue + } + volumes = append(volumes, vol) + } + } + + return dedupeVolumes(volumes) +} + +func parseRAIDDetailTXT(content []byte) ([]models.Storage, []models.StorageVolume) { + lines := strings.Split(string(content), "\n") + + controllerInfo := make(map[string]string) + logicalRecords := make([]map[string]string, 0) + physicalRecords := make([]map[string]string, 0) + + section := "" + currentLogical := make(map[string]string) + currentPhysical := make(map[string]string) + + flushLogical := func() { + if len(currentLogical) == 0 { + return + } + logicalRecords = append(logicalRecords, currentLogical) + currentLogical = make(map[string]string) + } + flushPhysical := func() { + if len(currentPhysical) == 0 { + return + } + physicalRecords = append(physicalRecords, currentPhysical) + currentPhysical = make(map[string]string) + } + + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + lower := strings.ToLower(line) + switch { + case strings.Contains(lower, "controller information"): + flushLogical() + flushPhysical() + section = "controller" + continue + case strings.Contains(lower, "logical device information"): + flushLogical() + flushPhysical() + section = "logical" + continue + case strings.Contains(lower, "physical device information"): + flushLogical() + flushPhysical() + section = "physical" + continue + case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"): + continue + case strings.HasPrefix(line, "---"): + continue + } + + key, value, ok := parseColonKVLine(line) + if !ok { + continue + } + normKey := normalizeKey(key) + + switch section { + case "controller": + controllerInfo[key] = value + case "logical": + if len(currentLogical) > 0 && startsNewLogicalRecord(currentLogical, normKey) { + flushLogical() + } + currentLogical[key] = value + case "physical": + if len(currentPhysical) > 0 && normKey == "connectionid" { + flushPhysical() + } + currentPhysical[key] = value + } + } + + flushLogical() + flushPhysical() + + controller := firstNonEmpty( + getSectionValue(controllerInfo, "AssetTag", "CtrlName", "ControllerName", "ChipModel"), + "RAID Controller", + ) + + storages := make([]models.Storage, 0, len(physicalRecords)) + for _, rec := range physicalRecords { + slot := firstNonEmpty(getSectionValue(rec, "Position", "Slot", "Location")) + if slot == "" { + slot = "Connection " + getSectionValue(rec, "ConnectionID") + } + mediaType := getSectionValue(rec, "MediaType", "Type") + protocol := getSectionValue(rec, "Protocol") + model := getSectionValue(rec, "Model") + statusRaw := firstNonEmpty(getSectionValue(rec, "StatusIndicator", "FirmwareStatus", "Status"), "ok") + + capacityBytes, sizeGB := parseCapacityFields(getSectionValue(rec, "CapacityBytes"), getSectionValue(rec, "Capacity"), "") + if sizeGB == 0 && capacityBytes > 0 { + sizeGB = int(capacityBytes / 1_000_000_000) + } + + present := true + statusLower := strings.ToLower(statusRaw) + if containsAny(statusLower, "missing", "absent", "removed", "not present") { + present = false + } + + storage := models.Storage{ + Slot: slot, + Type: inferStorageTypeFromValues(slot, mediaType, protocol, model), + Model: strings.TrimSpace(model), + SizeGB: sizeGB, + SerialNumber: getSectionValue(rec, "SerialNumber"), + Manufacturer: getSectionValue(rec, "Manufacturer", "Vendor"), + Firmware: getSectionValue(rec, "Revision", "FirmwareVersion"), + Interface: protocol, + Present: present, + Location: firstNonEmpty(getSectionValue(rec, "Position"), controller), + Status: normalizeStorageStatus(statusRaw, present), + } + if storage.Type == "" { + storage.Type = "disk" + } + storages = append(storages, storage) + } + + volumes := make([]models.StorageVolume, 0, len(logicalRecords)) + for idx, rec := range logicalRecords { + vol := parseVolumeFromStringMap(rec, controller, idx+1) + if isVolumeEmpty(vol) { + continue + } + volumes = append(volumes, vol) + } + + return dedupeStorage(storages), dedupeVolumes(volumes) +} + +func parseNVMeInfo(content []byte) []models.Storage { + text := strings.TrimSpace(string(content)) + if text == "" { + return nil + } + lower := strings.ToLower(text) + if strings.Contains(lower, "note: no nvme info") { + return nil + } + + storages := make([]models.Storage, 0) + sections := parseLooseINI(text) + for _, sectionName := range sortedSectionNames(sections) { + section := sections[sectionName] + storage := parseStorageFromSection(section, sectionName, "nvme") + if isStorageEmpty(storage) { + continue + } + storages = append(storages, storage) + } + if len(storages) > 0 { + return dedupeStorage(storages) + } + + for _, block := range parseKeyValueBlocks(text) { + storage := parseStorageFromSection(block, "NVMe", "nvme") + if isStorageEmpty(storage) { + continue + } + storages = append(storages, storage) + } + + return dedupeStorage(storages) +} + +func parseStorageFromSection(section map[string]string, fallbackSlot, defaultType string) models.Storage { + slot := firstNonEmpty(getSectionValue(section, "DiskSlotDesc", "Position", "Slot", "Name"), fallbackSlot) + model := firstNonEmpty(getSectionValue(section, "Model", "ProductName", "DeviceModel", "PartNumber"), "") + media := firstNonEmpty(getSectionValue(section, "MediaType", "Type"), defaultType) + protocol := firstNonEmpty(getSectionValue(section, "Protocol", "Interface"), "") + statusRaw := firstNonEmpty(getSectionValue(section, "Status", "State", "Health"), "") + presentRaw := firstNonEmpty(getSectionValue(section, "Present"), "") + + present := true + if presentRaw != "" { + present = isTruthy(presentRaw) + if !present && !containsAny(strings.ToLower(presentRaw), "absent", "missing", "no") { + present = true + } + } + if statusRaw != "" && containsAny(strings.ToLower(statusRaw), "absent", "missing", "removed") { + present = false + } + + capacityBytes, sizeGB := parseCapacityFields( + getSectionValue(section, "CapacityBytes", "SizeBytes"), + getSectionValue(section, "Capacity", "Size"), + getSectionValue(section, "SizeGB", "CapacityGB"), + ) + if sizeGB == 0 && capacityBytes > 0 { + sizeGB = int(capacityBytes / 1_000_000_000) + } + + storage := models.Storage{ + Slot: slot, + Type: inferStorageTypeFromValues(slot, media, protocol, model), + Model: model, + SizeGB: sizeGB, + SerialNumber: getSectionValue(section, "SerialNumber"), + Manufacturer: getSectionValue(section, "Manufacturer", "Vendor"), + Firmware: getSectionValue(section, "Revision", "FirmwareVersion", "Firmware"), + Interface: protocol, + Present: present, + Location: firstNonEmpty(getSectionValue(section, "Position", "Location"), slot), + Status: normalizeStorageStatus(statusRaw, present), + } + if storage.Type == "" { + storage.Type = defaultType + } + + return storage +} + +func parseVolumeFromAnyMap(rec map[string]any, controller string, idx int) models.StorageVolume { + id := firstNonEmpty( + toStringAny(lookupAnyCase(rec, "LDID", "LD ID", "Id", "ID", "VDID", "LogicalDeviceID", "VirtualDriveID")), + fmt.Sprintf("ld-%d", idx), + ) + name := firstNonEmpty( + toStringAny(lookupAnyCase(rec, "LDName", "LD_name", "Name", "VolumeName", "LogicalDeviceName")), + id, + ) + + capacityBytes := parseLDCapacity(rec) + sizeGB := parseMaybeInt(toStringAny(lookupAnyCase(rec, "SizeGB", "CapacityGB"))) + if sizeGB == 0 && capacityBytes > 0 { + sizeGB = int(capacityBytes / 1_000_000_000) + } + + bootable, _ := toBoolAny(lookupAnyCase(rec, "Bootable", "IsBootable")) + encrypted, _ := toBoolAny(lookupAnyCase(rec, "Encrypted", "IsEncrypted", "EncryptionEnabled")) + + return models.StorageVolume{ + ID: id, + Name: name, + Controller: controller, + RAIDLevel: normalizeRAIDLevel(firstNonEmpty(toStringAny(lookupAnyCase(rec, "RAIDLevel", "RaidLevel", "RAID", "Level")), toStringAny(lookupAnyByPrefix(rec, "raidlevel")))), + SizeGB: sizeGB, + CapacityBytes: capacityBytes, + Status: normalizeVolumeStatus(toStringAny(lookupAnyCase(rec, "Status", "State", "HealthStatus"))), + Bootable: bootable, + Encrypted: encrypted, + } +} + +func parseVolumeFromStringMap(rec map[string]string, controller string, idx int) models.StorageVolume { + id := firstNonEmpty( + getSectionValue(rec, "LDID", "Id", "ID", "VDID", "LogicalDeviceID", "VirtualDriveID"), + fmt.Sprintf("ld-%d", idx), + ) + name := firstNonEmpty( + getSectionValue(rec, "LDName", "Name", "VolumeName", "LogicalDeviceName"), + id, + ) + + capacityBytes, sizeGB := parseCapacityFields( + getSectionValue(rec, "CapacityBytes", "SizeBytes"), + getSectionValue(rec, "Capacity", "Size"), + getSectionValue(rec, "SizeGB", "CapacityGB"), + ) + if sizeGB == 0 && capacityBytes > 0 { + sizeGB = int(capacityBytes / 1_000_000_000) + } + + bootable, _ := parseBoolString(getSectionValue(rec, "Bootable", "IsBootable")) + encrypted, _ := parseBoolString(getSectionValue(rec, "Encrypted", "IsEncrypted", "EncryptionEnabled")) + + return models.StorageVolume{ + ID: id, + Name: name, + Controller: controller, + RAIDLevel: normalizeRAIDLevel(getSectionValue(rec, "RAIDLevel", "RaidLevel", "RAID", "Level")), + SizeGB: sizeGB, + CapacityBytes: capacityBytes, + Status: normalizeVolumeStatus(getSectionValue(rec, "Status", "State", "HealthStatus")), + Bootable: bootable, + Encrypted: encrypted, + } +} + +func parseSensorInfoINI(content []byte) []models.SensorReading { + sensors := make([]models.SensorReading, 0) + scanner := bufio.NewScanner(strings.NewReader(string(content))) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.Contains(line, "|") { + continue + } + if strings.Contains(strings.ToLower(line), "sensor name") && strings.Contains(strings.ToLower(line), "reading") { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 4 { + continue + } + + name := strings.TrimSpace(parts[0]) + reading := strings.TrimSpace(parts[1]) + unit := strings.TrimSpace(parts[2]) + status := strings.TrimSpace(parts[3]) + if name == "" { + continue + } + + sensor := models.SensorReading{ + Name: name, + Type: inferSensorType(unit, reading), + Unit: unit, + RawValue: reading, + Status: normalizeSensorStatus(status), + } + if v, ok := parseMaybeFloat(reading); ok { + sensor.Value = v + } + + sensors = append(sensors, sensor) + } + + return sensors +} + +type selEntry struct { + Created string `json:"Created"` + Description string `json:"Description"` + Severity string `json:"Severity"` + EntryCode string `json:"EntryCode"` + EntryType string `json:"EntryType"` + ID int `json:"Id"` + Level string `json:"Level"` + Message string `json:"Message"` + SensorName string `json:"SensorName"` + SensorType string `json:"SensorType"` +} + +func parseSELJSON(content []byte) []models.Event { + text := strings.TrimSpace(string(content)) + if text == "" { + return nil + } + + if !strings.HasPrefix(text, "[") { + text = strings.TrimRight(text, ", \n\r\t") + text = "[" + text + "]" + } + + var entries []selEntry + if err := json.Unmarshal([]byte(text), &entries); err != nil { + return nil + } + + events := make([]models.Event, 0, len(entries)) + for _, entry := range entries { + timestamp := parseH3CTimestamp(entry.Created) + if timestamp.IsZero() { + timestamp = time.Now() + } + + description := firstNonEmpty(entry.Message, entry.Description) + eventType := firstNonEmpty(entry.SensorType, entry.EntryType, "SEL") + + events = append(events, models.Event{ + ID: strconv.Itoa(entry.ID), + Timestamp: timestamp, + Source: "SEL", + SensorType: strings.TrimSpace(entry.SensorType), + SensorName: strings.TrimSpace(entry.SensorName), + EventType: strings.TrimSpace(eventType), + Severity: mapH3CSeverity(entry.Severity, entry.Level, entry.Message), + Description: strings.TrimSpace(description), + RawData: strings.TrimSpace(entry.EntryCode), + }) + } + + return events +} + +func parseSELListTXT(content []byte) []models.Event { + lines := strings.Split(string(content), "\n") + events := make([]models.Event, 0, len(lines)) + location := parser.DefaultArchiveLocation() + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "|") { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 6 { + continue + } + + id := strings.TrimSpace(parts[0]) + date := strings.TrimSpace(parts[1]) + tod := strings.TrimSpace(parts[2]) + sensor := strings.TrimSpace(parts[3]) + message := strings.TrimSpace(parts[4]) + state := strings.TrimSpace(parts[5]) + + if strings.EqualFold(date, "Pre-Init") { + continue + } + + ts, err := time.ParseInLocation("01/02/2006 15:04:05", date+" "+tod, location) + if err != nil { + continue + } + + sensorType := strings.TrimSpace(sensor) + sensorName := "" + if hashIdx := strings.Index(sensor, "#"); hashIdx != -1 { + sensorType = strings.TrimSpace(sensor[:hashIdx]) + sensorName = strings.TrimSpace(sensor[hashIdx+1:]) + } + + events = append(events, models.Event{ + ID: id, + Timestamp: ts, + Source: "SEL", + SensorType: sensorType, + SensorName: sensorName, + EventType: message, + Severity: mapH3CSeverity("", "", sensor+" "+message+" "+state), + Description: buildSELDescription(message, state), + RawData: line, + }) + } + + return events +} + +func parseSELCSVFiles(files []parser.ExtractedFile) []models.Event { + events := make([]models.Event, 0) + for _, f := range files { + path := strings.ToLower(strings.TrimSpace(f.Path)) + if !strings.HasSuffix(path, ".csv") { + continue + } + if !(strings.Contains(path, "/user/") || strings.HasPrefix(path, "user/")) { + continue + } + events = append(events, parseSELCSV(f.Content)...) + } + return events +} + +func parseSELCSV(content []byte) []models.Event { + csvText := strings.ReplaceAll(string(content), "\x00", "") + reader := csv.NewReader(strings.NewReader(csvText)) + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + + header, err := reader.Read() + if err != nil { + return nil + } + index := make(map[string]int, len(header)) + for i, col := range header { + index[normalizeKey(col)] = i + } + + required := []string{"descinfo"} + if !hasAllKeys(index, required...) { + return nil + } + if !hasAnyKey(index, "recordtimestamp", "eventoccurredtime") { + return nil + } + + events := make([]models.Event, 0, 128) + + for { + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + continue + } + + ts := parseSELCSVTimestamp( + readCSVField(row, index, "eventoccurredtime"), + readCSVField(row, index, "recordtimestamp"), + ) + if ts.IsZero() { + continue + } + + desc := strings.TrimSpace(readCSVField(row, index, "descinfo")) + explanation := strings.TrimSpace(readCSVField(row, index, "explanation")) + if desc == "" && explanation == "" { + continue + } + + events = append(events, models.Event{ + ID: strconv.Itoa(len(events) + 1), + Timestamp: ts, + Source: "SEL", + SensorType: strings.TrimSpace(readCSVField(row, index, "sensortypestr")), + SensorName: strings.TrimSpace(readCSVField(row, index, "sensorname")), + EventType: strings.TrimSpace(readCSVField(row, index, "eventdir")), + Severity: mapH3CSeverity(readCSVField(row, index, "severitylevel"), readCSVField(row, index, "severitylevelid"), desc+" "+explanation), + Description: composeSELCSVDescription(desc, explanation), + RawData: strings.TrimSpace(readCSVField(row, index, "severitylevelid")), + }) + } + + return events +} + +func parseSELCSVTimestamp(values ...string) time.Time { + location := parser.DefaultArchiveLocation() + for _, raw := range values { + clean := strings.TrimSpace(strings.ReplaceAll(raw, "\x00", "")) + if clean == "" || strings.EqualFold(clean, "Pre-Init") { + continue + } + if ts := parseH3CTimestamp(clean); !ts.IsZero() { + return ts + } + if ts, err := time.ParseInLocation("2006-01-02 15:04:05", clean, location); err == nil { + return ts + } + } + return time.Time{} +} + +func composeSELCSVDescription(desc, explanation string) string { + desc = strings.TrimSpace(desc) + explanation = strings.TrimSpace(explanation) + if explanation == "" || explanation == desc { + return desc + } + if desc == "" { + return explanation + } + return desc + " | " + explanation +} + +func readCSVField(row []string, index map[string]int, key string) string { + i, ok := index[normalizeKey(key)] + if !ok || i < 0 || i >= len(row) { + return "" + } + return strings.TrimSpace(strings.ReplaceAll(row[i], "\x00", "")) +} + +func hasAnyKey(index map[string]int, keys ...string) bool { + for _, key := range keys { + if _, ok := index[normalizeKey(key)]; ok { + return true + } + } + return false +} + +func hasAllKeys(index map[string]int, keys ...string) bool { + for _, key := range keys { + if _, ok := index[normalizeKey(key)]; !ok { + return false + } + } + return true +} + +func parseH3CTimestamp(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + + layouts := []string{ + "2006-01-02 15:04:05 MST-07:00", + "2006-01-02 15:04:05 -07:00", + time.RFC3339, + } + for _, layout := range layouts { + if ts, err := time.Parse(layout, value); err == nil { + return ts + } + } + + cleaned := strings.ReplaceAll(value, "UTC", "") + cleaned = strings.Join(strings.Fields(cleaned), " ") + if ts, err := time.Parse("2006-01-02 15:04:05 -07:00", cleaned); err == nil { + return ts + } + + return time.Time{} +} + +func mapH3CSeverity(severity, level, text string) models.Severity { + all := strings.ToLower(strings.TrimSpace(severity + " " + level + " " + text)) + if all == "" { + return models.SeverityInfo + } + + if containsAny(all, "critical", "major", "fatal", "unrecoverable", "off-line", "offline", "redundancy lost", "failure", "fault", "error") { + return models.SeverityCritical + } + if containsAny(all, "warning", "warn", "minor", "non-critical", "lost", "degraded", "disabled", "abnormal") { + return models.SeverityWarning + } + return models.SeverityInfo +} + +func buildSELDescription(message, state string) string { + message = strings.TrimSpace(message) + state = strings.TrimSpace(state) + if state == "" || strings.EqualFold(state, "Asserted") || strings.EqualFold(state, "Deasserted") { + return message + } + if message == "" { + return state + } + return message + " (" + state + ")" +} + +func appendFirmwareUnique(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) { + fw.DeviceName = strings.TrimSpace(fw.DeviceName) + fw.Description = strings.TrimSpace(fw.Description) + fw.Version = strings.TrimSpace(fw.Version) + if fw.DeviceName == "" || fw.Version == "" { + return + } + + key := strings.ToLower(fw.DeviceName + "|" + fw.Version + "|" + fw.Description) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + result.Hardware.Firmware = append(result.Hardware.Firmware, fw) +} + +func parseLooseINI(content string) map[string]map[string]string { + sections := make(map[string]map[string]string) + current := "" + scanner := bufio.NewScanner(strings.NewReader(content)) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + current = strings.TrimSpace(line[1 : len(line)-1]) + if current != "" { + if _, ok := sections[current]; !ok { + sections[current] = make(map[string]string) + } + } + continue + } + + if current == "" { + continue + } + + sep := strings.IndexAny(line, "=:") + if sep <= 0 { + continue + } + key := strings.TrimSpace(line[:sep]) + value := strings.TrimSpace(line[sep+1:]) + if key == "" { + continue + } + sections[current][key] = value + } + + return sections +} + +func getSectionValue(section map[string]string, keys ...string) string { + for _, k := range keys { + normK := normalizeKey(k) + for rawKey, rawVal := range section { + if normalizeKey(rawKey) == normK { + val := strings.TrimSpace(rawVal) + if val != "" { + return val + } + } + } + } + return "" +} + +func normalizeKey(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +func sortedSectionNames(sections map[string]map[string]string) []string { + names := make([]string, 0, len(sections)) + for name := range sections { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func isEmptyFRU(f models.FRUInfo) bool { + return strings.TrimSpace(f.Manufacturer) == "" && + strings.TrimSpace(f.ProductName) == "" && + strings.TrimSpace(f.SerialNumber) == "" && + strings.TrimSpace(f.PartNumber) == "" && + strings.TrimSpace(f.Version) == "" +} + +func applyBoardFromFRU(board *models.BoardInfo, fru models.FRUInfo) { + setIfEmpty(&board.Manufacturer, fru.Manufacturer) + setIfEmpty(&board.ProductName, fru.ProductName) + setIfEmpty(&board.SerialNumber, fru.SerialNumber) + setIfEmpty(&board.PartNumber, fru.PartNumber) + setIfEmpty(&board.Version, fru.Version) +} + +func setIfEmpty(dst *string, value string) { + if strings.TrimSpace(*dst) != "" { + return + } + *dst = strings.TrimSpace(value) +} + +type xmlNode struct { + XMLName xml.Name + Text string `xml:",chardata"` + Nodes []xmlNode `xml:",any"` +} + +func parseXMLRoot(content []byte) (xmlNode, bool) { + var root xmlNode + if err := xml.Unmarshal(content, &root); err != nil { + return xmlNode{}, false + } + return root, true +} + +func xmlNodeFields(node xmlNode) map[string]string { + fields := make(map[string]string, len(node.Nodes)) + for _, child := range node.Nodes { + fields[child.XMLName.Local] = strings.TrimSpace(child.Text) + } + return fields +} + +var socketSuffixRE = regexp.MustCompile(`(?i)cpu(\d+)$`) + +func parseSocketID(name string) int { + m := socketSuffixRE.FindStringSubmatch(strings.TrimSpace(name)) + if len(m) != 2 { + return 0 + } + v, _ := strconv.Atoi(m[1]) + if v <= 0 { + return 0 + } + return v +} + +func parseSectionIndex(sectionName, expr string) int { + re, err := regexp.Compile(expr) + if err != nil { + return 0 + } + match := re.FindStringSubmatch(sectionName) + if len(match) < 2 { + return 0 + } + v, _ := strconv.Atoi(match[1]) + if v <= 0 { + return 0 + } + return v +} + +func formatSlot(frontRear, slotNum string) string { + frontRear = strings.TrimSpace(frontRear) + slotNum = strings.TrimSpace(slotNum) + switch { + case frontRear == "" && slotNum == "": + return "" + case frontRear == "": + return slotNum + case slotNum == "": + return frontRear + default: + return frontRear + " slot " + slotNum + } +} + +func parseMaybeInt(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + + if strings.HasPrefix(strings.ToLower(raw), "0x") { + if v, err := strconv.ParseInt(raw[2:], 16, 64); err == nil { + return int(v) + } + } + + if v, err := strconv.Atoi(raw); err == nil { + return v + } + if fv, err := strconv.ParseFloat(raw, 64); err == nil { + return int(fv) + } + return 0 +} + +var firstNumberRE = regexp.MustCompile(`[-+]?[0-9]+(?:\.[0-9]+)?`) + +func parseMaybeIntLoose(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + if v := parseMaybeInt(raw); v > 0 { + return v + } + + number := firstNumberRE.FindString(raw) + if number == "" { + return 0 + } + if fv, err := strconv.ParseFloat(number, 64); err == nil { + return int(fv) + } + return 0 +} + +func parseMaybeFloat(raw string) (float64, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "na") { + return 0, false + } + if strings.HasPrefix(strings.ToLower(raw), "0x") { + return 0, false + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, false + } + return v, true +} + +func normalizePresenceStatus(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.Contains(strings.ToLower(raw), "presence") { + return "ok" + } + return strings.ToLower(raw) +} + +func normalizeComponentStatus(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + switch { + case s == "": + return "" + case containsAny(s, "ok", "normal", "presence", "present", "healthy", "running"): + return "ok" + case containsAny(s, "absent", "missing", "not present", "removed"): + return "absent" + case containsAny(s, "warn", "degrad", "minor"): + return "warning" + case containsAny(s, "critical", "major", "fault", "fail", "error"): + return "critical" + default: + return s + } +} + +func inferStorageType(slot string) string { + return inferStorageTypeFromValues(slot, "", "", "") +} + +func inferStorageTypeFromValues(slot, mediaType, protocol, model string) string { + lower := strings.ToLower(strings.TrimSpace(slot + " " + mediaType + " " + protocol + " " + model)) + switch { + case strings.Contains(lower, "nvme"): + return "nvme" + case strings.Contains(lower, "ssd"): + return "ssd" + case strings.Contains(lower, "hdd"), strings.Contains(lower, "sas"), strings.Contains(lower, "sata"): + return "disk" + case strings.Contains(lower, "disk"), strings.Contains(lower, "front"), strings.Contains(lower, "rear"): + return "disk" + default: + return "" + } +} + +func inferSensorType(unit, reading string) string { + lowerUnit := strings.ToLower(strings.TrimSpace(unit)) + switch { + case strings.Contains(lowerUnit, "degrees"): + return "temperature" + case strings.Contains(lowerUnit, "volts"): + return "voltage" + case strings.Contains(lowerUnit, "amps"): + return "current" + case strings.Contains(lowerUnit, "watts"): + return "power" + case strings.Contains(lowerUnit, "rpm"): + return "fan" + case strings.Contains(lowerUnit, "discrete"): + return "discrete" + } + + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(reading)), "0x") { + return "discrete" + } + return "sensor" +} + +func normalizeSensorStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "unknown" + } + if strings.EqualFold(status, "na") { + return "unknown" + } + return strings.ToLower(status) +} + +func isTruthy(raw string) bool { + raw = strings.ToLower(strings.TrimSpace(raw)) + return raw == "yes" || raw == "y" || raw == "true" || raw == "1" || raw == "present" +} + +func containsAny(s string, items ...string) bool { + for _, item := range items { + if strings.Contains(s, item) { + return true + } + } + return false +} + +func normalizeStorageStatus(raw string, present bool) string { + s := strings.ToLower(strings.TrimSpace(raw)) + switch { + case s == "" && present: + return "ok" + case s == "": + return "absent" + case containsAny(s, "ok", "healthy", "optimal", "normal"): + return "ok" + case containsAny(s, "missing", "absent", "removed", "not present"): + return "absent" + case containsAny(s, "predictive", "degrad", "warn"): + return "warning" + default: + return s + } +} + +func normalizeVolumeStatus(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + switch { + case s == "": + return "" + case containsAny(s, "ok", "healthy", "optimal", "normal"): + return "ok" + case containsAny(s, "degrad", "rebuild", "warn"): + return "warning" + case containsAny(s, "fail", "offline", "critical"): + return "critical" + default: + return s + } +} + +func normalizeRAIDLevel(raw string) string { + s := strings.ToUpper(strings.TrimSpace(raw)) + s = strings.ReplaceAll(s, " ", "") + if s == "" { + return "" + } + if strings.HasPrefix(s, "RAID") { + return s + } + if raidNumericRE.MatchString(s) { + return "RAID" + s + } + return s +} + +var raidNumericRE = regexp.MustCompile(`^\d+(?:\+\d+)?$`) +var sizeValueRE = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*([KMGTPE]?)(I?B)?$`) + +func parseCapacityFields(bytesField, sizeField, sizeGBField string) (int64, int) { + if b := parseCapacityFromString(bytesField); b > 0 { + return b, int(b / 1_000_000_000) + } + if b := parseCapacityFromString(sizeField); b > 0 { + return b, int(b / 1_000_000_000) + } + sizeGB := parseMaybeInt(sizeGBField) + if sizeGB > 0 { + return int64(sizeGB) * 1_000_000_000, sizeGB + } + return 0, 0 +} + +func parseCapacityFromString(raw string) int64 { + clean := strings.ReplaceAll(strings.TrimSpace(raw), ",", "") + if clean == "" { + return 0 + } + if strings.EqualFold(clean, "na") { + return 0 + } + if v, err := strconv.ParseInt(clean, 10, 64); err == nil { + return v + } + m := sizeValueRE.FindStringSubmatch(clean) + if len(m) != 4 { + return 0 + } + value, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0 + } + unit := strings.ToUpper(m[2]) + suffix := strings.ToUpper(m[3]) + base := float64(1000) + if strings.HasPrefix(suffix, "I") { + base = 1024 + } + power := 0 + switch unit { + case "K": + power = 1 + case "M": + power = 2 + case "G": + power = 3 + case "T": + power = 4 + case "P": + power = 5 + case "E": + power = 6 + } + mult := float64(1) + for i := 0; i < power; i++ { + mult *= base + } + return int64(value * mult) +} + +func parseCapacityFromAny(v any) int64 { + switch t := v.(type) { + case nil: + return 0 + case float64: + return int64(t) + case int: + return int64(t) + case int64: + return t + case json.Number: + if i, err := t.Int64(); err == nil { + return i + } + if f, err := t.Float64(); err == nil { + return int64(f) + } + return 0 + case string: + return parseCapacityFromString(t) + case map[string]any: + return parseCapacityFromAny(lookupAnyCase(t, "value", "Value", "CapacityBytes", "SizeBytes")) + default: + return parseCapacityFromString(fmt.Sprintf("%v", t)) + } +} + +func parseLDCapacity(rec map[string]any) int64 { + base := parseCapacityFromAny( + lookupAnyCase(rec, "CapacityBytes", "SizeBytes", "Capacity", "Size", "LogicalCapacity", "LogicalCapacityBytes"), + ) + if base > 0 { + return base + } + + for rawKey, rawVal := range rec { + norm := normalizeKey(rawKey) + if strings.HasPrefix(norm, "logicalcapacity") || strings.HasPrefix(norm, "logicalcapicity") { + v := parseCapacityFromAny(rawVal) + if v <= 0 { + continue + } + if strings.Contains(norm, "512") { + return v * 512 + } + return v + } + } + return 0 +} + +func parseColonKVLine(line string) (string, string, bool) { + idx := strings.Index(line, ":") + if idx <= 0 { + return "", "", false + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key == "" { + return "", "", false + } + return key, value, true +} + +func parseFlexibleKVLine(line string) (string, string, bool) { + if key, val, ok := parseColonKVLine(line); ok { + return key, val, true + } + idx := strings.Index(line, "=") + if idx <= 0 { + return "", "", false + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key == "" { + return "", "", false + } + return key, value, true +} + +func startsNewLogicalRecord(current map[string]string, normalizedKey string) bool { + switch normalizedKey { + case "ldid", "logicaldeviceid", "virtualdriveid": + return true + case "id": + return hasAnyNormalizedKey(current, "ldid", "logicaldeviceid", "virtualdriveid", "id") + case "name": + return hasAnyNormalizedKey(current, "name", "ldname", "logicaldevicename") + default: + return false + } +} + +func hasAnyNormalizedKey(m map[string]string, keys ...string) bool { + for rawKey := range m { + n := normalizeKey(rawKey) + for _, key := range keys { + if n == key { + return true + } + } + } + return false +} + +func parseKeyValueBlocks(content string) []map[string]string { + blocks := make([]map[string]string, 0) + current := make(map[string]string) + + flush := func() { + if len(current) == 0 { + return + } + blocks = append(blocks, current) + current = make(map[string]string) + } + + lines := strings.Split(content, "\n") + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + flush() + continue + } + key, value, ok := parseFlexibleKVLine(line) + if !ok { + continue + } + nk := normalizeKey(key) + if nk == "serialnumber" && hasAnyNormalizedKey(current, "serialnumber") { + flush() + } + current[key] = value + } + flush() + return blocks +} + +func findCPUIndex(items []models.CPU, target models.CPU) int { + targetSocket := target.Socket + targetPPIN := strings.ToLower(strings.TrimSpace(target.PPIN)) + targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) + targetModel := strings.ToLower(strings.TrimSpace(target.Model)) + + for i := range items { + cpu := items[i] + if targetSocket > 0 && cpu.Socket > 0 && targetSocket == cpu.Socket { + return i + } + if targetSocket > 0 && cpu.Socket > 0 && targetSocket != cpu.Socket { + continue + } + + ppin := strings.ToLower(strings.TrimSpace(cpu.PPIN)) + if targetPPIN != "" && ppin != "" && targetPPIN == ppin { + return i + } + + serial := strings.ToLower(strings.TrimSpace(cpu.SerialNumber)) + if targetSerial != "" && serial != "" && targetSerial == serial { + return i + } + + model := strings.ToLower(strings.TrimSpace(cpu.Model)) + if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && model == targetModel { + return i + } + } + return -1 +} + +func mergeCPU(dst *models.CPU, src models.CPU) { + if dst.Socket == 0 && src.Socket > 0 { + dst.Socket = src.Socket + } + setStorageString(&dst.Model, src.Model) + setStorageString(&dst.Description, src.Description) + if dst.Cores == 0 && src.Cores > 0 { + dst.Cores = src.Cores + } + if dst.Threads == 0 && src.Threads > 0 { + dst.Threads = src.Threads + } + if dst.FrequencyMHz == 0 && src.FrequencyMHz > 0 { + dst.FrequencyMHz = src.FrequencyMHz + } + if dst.MaxFreqMHz == 0 && src.MaxFreqMHz > 0 { + dst.MaxFreqMHz = src.MaxFreqMHz + } + if dst.L1CacheKB == 0 && src.L1CacheKB > 0 { + dst.L1CacheKB = src.L1CacheKB + } + if dst.L2CacheKB == 0 && src.L2CacheKB > 0 { + dst.L2CacheKB = src.L2CacheKB + } + if dst.L3CacheKB == 0 && src.L3CacheKB > 0 { + dst.L3CacheKB = src.L3CacheKB + } + setStorageString(&dst.PPIN, src.PPIN) + setStorageString(&dst.SerialNumber, src.SerialNumber) + setStorageString(&dst.Status, src.Status) +} + +func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int { + targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) + targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + for i := range items { + serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) + slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) + if targetSerial != "" && serial != "" && targetSerial == serial { + return i + } + if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + return i + } + } + return -1 +} + +func mergeMemoryDIMM(dst *models.MemoryDIMM, src models.MemoryDIMM) { + setStorageString(&dst.Slot, src.Slot) + setStorageString(&dst.Location, src.Location) + if src.Present { + dst.Present = true + } + if dst.SizeMB == 0 && src.SizeMB > 0 { + dst.SizeMB = src.SizeMB + } + setStorageString(&dst.Type, src.Type) + setStorageString(&dst.Technology, src.Technology) + if dst.MaxSpeedMHz == 0 && src.MaxSpeedMHz > 0 { + dst.MaxSpeedMHz = src.MaxSpeedMHz + } + if dst.CurrentSpeedMHz == 0 && src.CurrentSpeedMHz > 0 { + dst.CurrentSpeedMHz = src.CurrentSpeedMHz + } + setStorageString(&dst.Manufacturer, src.Manufacturer) + setStorageString(&dst.SerialNumber, src.SerialNumber) + setStorageString(&dst.PartNumber, src.PartNumber) + setStorageString(&dst.Status, src.Status) + if dst.Ranks == 0 && src.Ranks > 0 { + dst.Ranks = src.Ranks + } +} + +func appendUniqueStorages(dst *[]models.Storage, additions []models.Storage) { + for _, add := range additions { + if isStorageEmpty(add) { + continue + } + idx := findStorageIndex(*dst, add) + if idx < 0 { + *dst = append(*dst, add) + continue + } + mergeStorage(&(*dst)[idx], add) + } +} + +func dedupeStorage(items []models.Storage) []models.Storage { + out := make([]models.Storage, 0, len(items)) + appendUniqueStorages(&out, items) + return out +} + +func findStorageIndex(items []models.Storage, target models.Storage) int { + targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) + targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + for i := range items { + serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) + slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) + if targetSerial != "" && serial != "" && targetSerial == serial { + return i + } + if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + return i + } + } + return -1 +} + +func mergeStorage(dst *models.Storage, src models.Storage) { + setStorageString(&dst.Slot, src.Slot) + if mergedType := strings.TrimSpace(pickStorageType(dst.Type, src.Type)); mergedType != "" { + dst.Type = mergedType + } + setStorageString(&dst.Model, src.Model) + if dst.SizeGB == 0 && src.SizeGB > 0 { + dst.SizeGB = src.SizeGB + } + setStorageString(&dst.SerialNumber, src.SerialNumber) + setStorageString(&dst.Manufacturer, src.Manufacturer) + setStorageString(&dst.Firmware, src.Firmware) + setStorageString(&dst.Interface, src.Interface) + if src.Present { + dst.Present = true + } + setStorageString(&dst.Location, src.Location) + setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present)) +} + +func setStorageString(dst *string, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if strings.TrimSpace(*dst) == "" { + *dst = value + return + } + if strings.EqualFold(*dst, "unknown") || strings.EqualFold(*dst, "n/a") || strings.EqualFold(*dst, "absent") { + *dst = value + } +} + +func pickStorageType(current, next string) string { + if storageTypeRank(next) > storageTypeRank(current) { + return next + } + if strings.TrimSpace(current) == "" { + return next + } + return current +} + +func storageTypeRank(t string) int { + switch strings.ToLower(strings.TrimSpace(t)) { + case "nvme": + return 4 + case "ssd": + return 3 + case "hdd", "disk": + return 2 + case "unknown": + return 0 + default: + if strings.TrimSpace(t) == "" { + return 0 + } + return 1 + } +} + +func isStorageEmpty(s models.Storage) bool { + return strings.TrimSpace(s.Slot) == "" && + strings.TrimSpace(s.SerialNumber) == "" && + strings.TrimSpace(s.Model) == "" +} + +func appendUniqueNetworkAdapters(dst *[]models.NetworkAdapter, additions []models.NetworkAdapter) { + for _, add := range additions { + if isNetworkAdapterEmpty(add) { + continue + } + idx := findNetworkAdapterIndex(*dst, add) + if idx < 0 { + *dst = append(*dst, add) + continue + } + mergeNetworkAdapter(&(*dst)[idx], add) + } +} + +func isNetworkAdapterEmpty(n models.NetworkAdapter) bool { + return strings.TrimSpace(n.Slot) == "" && + len(n.MACAddresses) == 0 && + strings.TrimSpace(n.Model) == "" +} + +func findNetworkAdapterIndex(items []models.NetworkAdapter, target models.NetworkAdapter) int { + targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + for i := range items { + if hasSharedMAC(items[i].MACAddresses, target.MACAddresses) { + return i + } + slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) + if targetSlot != "" && slot != "" && targetSlot == slot { + return i + } + } + return -1 +} + +func hasSharedMAC(a, b []string) bool { + if len(a) == 0 || len(b) == 0 { + return false + } + set := make(map[string]struct{}, len(a)) + for _, item := range a { + norm := strings.ToLower(strings.TrimSpace(item)) + if norm != "" { + set[norm] = struct{}{} + } + } + for _, item := range b { + norm := strings.ToLower(strings.TrimSpace(item)) + if norm == "" { + continue + } + if _, ok := set[norm]; ok { + return true + } + } + return false +} + +func mergeNetworkAdapter(dst *models.NetworkAdapter, src models.NetworkAdapter) { + setStorageString(&dst.Slot, src.Slot) + setStorageString(&dst.Location, src.Location) + if src.Present { + dst.Present = true + } + setStorageString(&dst.Model, src.Model) + dst.Description = mergeTextUnique(dst.Description, src.Description) + setStorageString(&dst.Vendor, src.Vendor) + if dst.VendorID == 0 && src.VendorID != 0 { + dst.VendorID = src.VendorID + } + if dst.DeviceID == 0 && src.DeviceID != 0 { + dst.DeviceID = src.DeviceID + } + setStorageString(&dst.SerialNumber, src.SerialNumber) + setStorageString(&dst.PartNumber, src.PartNumber) + setStorageString(&dst.Firmware, src.Firmware) + dst.PortCount = maxInt(dst.PortCount, src.PortCount) + setStorageString(&dst.PortType, src.PortType) + for _, mac := range src.MACAddresses { + appendStringUnique(&dst.MACAddresses, mac) + } + dst.PortCount = maxInt(dst.PortCount, len(dst.MACAddresses)) + setStorageString(&dst.Status, src.Status) +} + +func appendUniqueNICs(dst *[]models.NIC, additions []models.NIC) { + for _, add := range additions { + if isNICEmpty(add) { + continue + } + idx := findNICIndex(*dst, add) + if idx < 0 { + *dst = append(*dst, add) + continue + } + mergeNIC(&(*dst)[idx], add) + } +} + +func isNICEmpty(n models.NIC) bool { + return strings.TrimSpace(n.Name) == "" && + strings.TrimSpace(n.Model) == "" && + strings.TrimSpace(n.MACAddress) == "" +} + +func findNICIndex(items []models.NIC, target models.NIC) int { + targetMAC := strings.ToLower(strings.TrimSpace(target.MACAddress)) + targetName := strings.ToLower(strings.TrimSpace(target.Name)) + for i := range items { + mac := strings.ToLower(strings.TrimSpace(items[i].MACAddress)) + if targetMAC != "" && mac != "" && targetMAC == mac { + return i + } + name := strings.ToLower(strings.TrimSpace(items[i].Name)) + if targetMAC == "" && targetName != "" && name == targetName { + return i + } + } + return -1 +} + +func mergeNIC(dst *models.NIC, src models.NIC) { + setStorageString(&dst.Name, src.Name) + setStorageString(&dst.Model, src.Model) + dst.Description = mergeTextUnique(dst.Description, src.Description) + setStorageString(&dst.MACAddress, src.MACAddress) + if dst.SpeedMbps == 0 && src.SpeedMbps > 0 { + dst.SpeedMbps = src.SpeedMbps + } + setStorageString(&dst.SerialNumber, src.SerialNumber) +} + +func mergeTextUnique(dst, src string) string { + dst = strings.TrimSpace(dst) + src = strings.TrimSpace(src) + switch { + case dst == "": + return src + case src == "": + return dst + case strings.EqualFold(dst, src): + return dst + case strings.Contains(strings.ToLower(dst), strings.ToLower(src)): + return dst + case strings.Contains(strings.ToLower(src), strings.ToLower(dst)): + return src + default: + return dst + "; " + src + } +} + +func appendUniquePSUs(dst *[]models.PSU, additions []models.PSU) { + for _, add := range additions { + if isPSUEmpty(add) { + continue + } + idx := findPSUIndex(*dst, add) + if idx < 0 { + *dst = append(*dst, add) + continue + } + mergePSU(&(*dst)[idx], add) + } +} + +func isPSUEmpty(p models.PSU) bool { + return strings.TrimSpace(p.Slot) == "" && + strings.TrimSpace(p.SerialNumber) == "" && + strings.TrimSpace(p.Model) == "" +} + +func findPSUIndex(items []models.PSU, target models.PSU) int { + targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) + targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) + for i := range items { + serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) + slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) + if targetSerial != "" && serial != "" && targetSerial == serial { + return i + } + if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { + return i + } + } + return -1 +} + +func mergePSU(dst *models.PSU, src models.PSU) { + setStorageString(&dst.Slot, src.Slot) + if src.Present { + dst.Present = true + } + setStorageString(&dst.Model, src.Model) + setStorageString(&dst.Description, src.Description) + setStorageString(&dst.Vendor, src.Vendor) + if dst.WattageW == 0 && src.WattageW > 0 { + dst.WattageW = src.WattageW + } + setStorageString(&dst.SerialNumber, src.SerialNumber) + setStorageString(&dst.PartNumber, src.PartNumber) + setStorageString(&dst.Firmware, src.Firmware) + setStorageString(&dst.Status, src.Status) +} + +func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume { + out := make([]models.StorageVolume, 0, len(items)) + for _, item := range items { + if isVolumeEmpty(item) { + continue + } + idx := findVolumeIndex(out, item) + if idx < 0 { + out = append(out, item) + continue + } + mergeVolume(&out[idx], item) + } + return out +} + +func findVolumeIndex(items []models.StorageVolume, target models.StorageVolume) int { + targetID := strings.ToLower(strings.TrimSpace(target.ID)) + targetName := strings.ToLower(strings.TrimSpace(target.Name)) + targetController := strings.ToLower(strings.TrimSpace(target.Controller)) + for i := range items { + id := strings.ToLower(strings.TrimSpace(items[i].ID)) + name := strings.ToLower(strings.TrimSpace(items[i].Name)) + controller := strings.ToLower(strings.TrimSpace(items[i].Controller)) + if targetID != "" && id != "" && targetID == id { + if !controllersCompatible(targetController, controller) { + continue + } + return i + } + if targetName != "" && name == targetName { + if controllersCompatible(targetController, controller) { + return i + } + } + if targetID == "" && targetName != "" && name == targetName && controller == targetController { + return i + } + } + return -1 +} + +func controllersCompatible(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == "" || b == "" { + return true + } + if a == b { + return true + } + if strings.Contains(a, b) || strings.Contains(b, a) { + return true + } + return false +} + +func mergeVolume(dst *models.StorageVolume, src models.StorageVolume) { + setStorageString(&dst.ID, src.ID) + setStorageString(&dst.Name, src.Name) + setStorageString(&dst.Controller, src.Controller) + setStorageString(&dst.RAIDLevel, src.RAIDLevel) + if dst.SizeGB == 0 && src.SizeGB > 0 { + dst.SizeGB = src.SizeGB + } + if dst.CapacityBytes == 0 && src.CapacityBytes > 0 { + dst.CapacityBytes = src.CapacityBytes + } + setStorageString(&dst.Status, src.Status) + if src.Bootable { + dst.Bootable = true + } + if src.Encrypted { + dst.Encrypted = true + } +} + +func isVolumeEmpty(v models.StorageVolume) bool { + return strings.TrimSpace(v.ID) == "" && + strings.TrimSpace(v.Name) == "" && + strings.TrimSpace(v.RAIDLevel) == "" && + v.CapacityBytes == 0 && + v.SizeGB == 0 +} + +func lookupAnyCase(m map[string]any, keys ...string) any { + if len(m) == 0 { + return nil + } + for _, key := range keys { + norm := normalizeKey(key) + for rawKey, value := range m { + if normalizeKey(rawKey) == norm { + return value + } + } + } + return nil +} + +func lookupAnyByPrefix(m map[string]any, prefixes ...string) any { + if len(m) == 0 { + return nil + } + normPrefixes := make([]string, 0, len(prefixes)) + for _, prefix := range prefixes { + n := normalizeKey(prefix) + if n != "" { + normPrefixes = append(normPrefixes, n) + } + } + for rawKey, value := range m { + normKey := normalizeKey(rawKey) + for _, prefix := range normPrefixes { + if strings.HasPrefix(normKey, prefix) { + return value + } + } + } + return nil +} + +func toAnyMap(v any) map[string]any { + if v == nil { + return nil + } + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +func toAnySlice(v any) []any { + if v == nil { + return nil + } + switch t := v.(type) { + case []any: + return t + case []map[string]any: + out := make([]any, 0, len(t)) + for _, item := range t { + out = append(out, item) + } + return out + default: + return nil + } +} + +func toStringAny(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(t) + case fmt.Stringer: + return strings.TrimSpace(t.String()) + case float64: + return strconv.FormatInt(int64(t), 10) + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case bool: + if t { + return "true" + } + return "false" + case json.Number: + return t.String() + default: + return strings.TrimSpace(fmt.Sprintf("%v", t)) + } +} + +func toBoolAny(v any) (bool, bool) { + switch t := v.(type) { + case nil: + return false, false + case bool: + return t, true + case string: + return parseBoolString(t) + case float64: + return t != 0, true + case int: + return t != 0, true + case int64: + return t != 0, true + case json.Number: + if i, err := t.Int64(); err == nil { + return i != 0, true + } + if f, err := t.Float64(); err == nil { + return f != 0, true + } + return false, false + default: + return parseBoolString(fmt.Sprintf("%v", t)) + } +} + +func parseBoolString(raw string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y", "enabled", "on": + return true, true + case "0", "false", "no", "n", "disabled", "off": + return false, true + default: + return false, false + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/parser/vendors/h3c/parser_test.go b/internal/parser/vendors/h3c/parser_test.go new file mode 100644 index 0000000..228000a --- /dev/null +++ b/internal/parser/vendors/h3c/parser_test.go @@ -0,0 +1,962 @@ +package h3c + +import ( + "strings" + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +func TestDetectH3C_GenerationRouting(t *testing.T) { + g5 := &G5Parser{} + g6 := &G6Parser{} + + g5Files := []parser.ExtractedFile{ + {Path: "bmc/pack.info", Content: []byte("STARTTIME:0")}, + {Path: "static/FRUInfo.ini", Content: []byte("[Baseboard]\nBoard Manufacturer=H3C\n")}, + {Path: "static/hardware_info.ini", Content: []byte("[Processors: Processor 1]\nModel: Intel Xeon\n")}, + {Path: "static/hardware.info", Content: []byte("[Disk_0_Front_NA]\nSerialNumber=DISK-0\n")}, + {Path: "static/firmware_version.ini", Content: []byte("[System board]\nBIOS Version: 5.59\n")}, + {Path: "user/test1.csv", Content: []byte("Record Time Stamp,DescInfo\n2025-01-01 00:00:00,foo\n")}, + } + if gotG5, gotG6 := g5.Detect(g5Files), g6.Detect(g5Files); gotG5 <= gotG6 { + t.Fatalf("expected G5 confidence > G6 for G5 sample, got g5=%d g6=%d", gotG5, gotG6) + } + + g6Files := []parser.ExtractedFile{ + {Path: "bmc/pack.info", Content: []byte("STARTTIME:0")}, + {Path: "static/FRUInfo.ini", Content: []byte("[Baseboard]\nBoard Manufacturer=H3C\n")}, + {Path: "static/board_info.ini", Content: []byte("[System board]\nBoardMfr=H3C\n")}, + {Path: "static/firmware_version.json", Content: []byte(`{"BIOS":{"Firmware Name":"BIOS","Firmware Version":"6.10"}}`)}, + {Path: "static/CPUDetailInfo.xml", Content: []byte("X")}, + {Path: "static/MemoryDetailInfo.xml", Content: []byte("A0")}, + {Path: "user/Sel.json", Content: []byte(`{"Id":1}`)}, + } + if gotG5, gotG6 := g5.Detect(g6Files), g6.Detect(g6Files); gotG6 <= gotG5 { + t.Fatalf("expected G6 confidence > G5 for G6 sample, got g5=%d g6=%d", gotG5, gotG6) + } +} + +func TestParseH3CG6_RaidAndNVMeEnrichment(t *testing.T) { + p := &G6Parser{} + files := []parser.ExtractedFile{ + { + Path: "static/storage_disk.ini", + Content: []byte(`[Disk_000] +DiskSlotDesc=Front0 +Present=YES +SerialNumber=SER-0 +`), + }, + { + Path: "static/raid.json", + Content: []byte(`{ + "RaidConfig": { + "CtrlInfo": [ + { + "CtrlSlot": 1, + "CtrlName": "RAID-LSI-9560", + "LDInfo": [ + { + "LDID": "0", + "LDName": "VD0", + "RAIDLevel": "1", + "CapacityBytes": 1000000000, + "Status": "Optimal" + } + ] + } + ] + } +}`), + }, + { + Path: "static/Storage_RAID-LSI-9560-LP-8i-4GB[1].txt", + Content: []byte(`Controller Information +------------------------------------------------------------------------ +AssetTag : RAID-LSI-9560 + +Logical Device Information +------------------------------------------------------------------------ +LDID : 0 +Name : VD0 +RAID Level : 1 +CapacityBytes : 1000000000 +Status : Optimal + +Physical Device Information +------------------------------------------------------------------------ +ConnectionID : 0 +Position : Front0 +StatusIndicator : OK +Protocol : SATA +MediaType : SSD +Manufacturer : Samsung +Model : PM893 +Revision : GDC1 +SerialNumber : SER-0 +CapacityBytes : 480000000000 + +ConnectionID : 1 +Position : Front1 +StatusIndicator : OK +Protocol : SATA +MediaType : SSD +Manufacturer : Samsung +Model : PM893 +Revision : GDC1 +SerialNumber : SER-1 +CapacityBytes : 480000000000 +`), + }, + { + Path: "static/NVMe_info.txt", + Content: []byte(`[NVMe_0] +Present=YES +DiskSlotDesc=Front2 +Model=INTEL SSDPE2KX010T8 +SerialNumber=NVME-1 +Firmware=V100 +CapacityBytes=1000204886016 +Interface=NVMe +Status=OK +`), + }, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if result.Hardware == nil { + t.Fatalf("expected hardware section") + } + + if len(result.Hardware.Volumes) != 1 { + t.Fatalf("expected 1 volume, got %d", len(result.Hardware.Volumes)) + } + vol := result.Hardware.Volumes[0] + if vol.RAIDLevel != "RAID1" { + t.Fatalf("expected RAID1 level, got %q", vol.RAIDLevel) + } + if vol.SizeGB != 1 { + t.Fatalf("expected 1GB logical volume, got %d", vol.SizeGB) + } + + if len(result.Hardware.Storage) != 3 { + t.Fatalf("expected 3 unique storage devices, got %d", len(result.Hardware.Storage)) + } + + var front0 *models.Storage + var nvme *models.Storage + for i := range result.Hardware.Storage { + s := &result.Hardware.Storage[i] + if strings.EqualFold(s.SerialNumber, "SER-0") { + front0 = s + } + if strings.EqualFold(s.SerialNumber, "NVME-1") { + nvme = s + } + } + if front0 == nil { + t.Fatalf("expected merged Front0 disk by serial SER-0") + } + if front0.Model != "PM893" { + t.Fatalf("expected Front0 model PM893, got %q", front0.Model) + } + if front0.SizeGB != 480 { + t.Fatalf("expected Front0 size 480GB, got %d", front0.SizeGB) + } + if nvme == nil { + t.Fatalf("expected NVMe disk by serial NVME-1") + } + if nvme.Type != "nvme" { + t.Fatalf("expected nvme type, got %q", nvme.Type) + } +} + +func TestParseH3CG6(t *testing.T) { + p := &G6Parser{} + + files := []parser.ExtractedFile{ + { + Path: "static/FRUInfo.ini", + Content: []byte(`[Baseboard] +Board Manufacturer=H3C +Board Product Name=RS36M2C6SB +Product Product Name=H3C UniServer R4700 G6 +Product Serial Number=210235A4FYH257000010 +Product Part Number=0235A4FY +`), + }, + { + Path: "static/firmware_version.json", + Content: []byte(`{ + "BMCP": {"Firmware Name":"HDM","Firmware Version":"1.83","Location":"bmc card","Part Model":"-"}, + "BIOS": {"Firmware Name":"BIOS","Firmware Version":"6.10.53","Location":"system board","Part Model":"-"} +}`), + }, + { + Path: "static/CPUDetailInfo.xml", + Content: []byte(` + + Presence + INTEL(R) XEON(R) GOLD 6542Y + 0xb54 + 0x1004 + 0x18 + 0x30 + 68-5C-81-C1-0E-A3-4E-40 + 68-5C-81-C1-0E-A3-4E-40 + +`), + }, + { + Path: "static/MemoryDetailInfo.xml", + Content: []byte(` + + Presence + CPU1_CH1_D0 (A0) + M321R8GA0PB0-CWMXJ + RDIMM + 80CE032519135C82ED + 0x2 + 0x10000 + 0x1130 + 0x15e0 + A0 + +`), + }, + { + Path: "static/storage_disk.ini", + Content: []byte(`[Disk_000] +SerialNumber=S6KLNN0Y516813 +DiskSlotDesc=Front0 +Present=YES +`), + }, + { + Path: "static/net_cfg.ini", + Content: []byte(`[Network Configuration] +eth0 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F6 + UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + +eth0.2 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F6 + inet6 addr: fe80::32c6:d7ff:fe94:54f6/64 Scope:Link + UP BROADCAST RUNNING MULTICAST MTU:1496 Metric:1 + +eth1 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F5 + inet addr:10.201.129.0 Bcast:10.201.143.255 Mask:255.255.240.0 + inet6 addr: fe80::32c6:d7ff:fe94:54f5/64 Scope:Link + UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + +lo Link encap:Local Loopback + inet addr:127.0.0.1 Mask:255.0.0.0 + UP LOOPBACK RUNNING MTU:65536 Metric:1 +`), + }, + { + Path: "static/psu_cfg.ini", + Content: []byte(`[Psu0] +SN=210231AGUNH257001569 +Max_Power(W)=1600 +Manufacturer=Great Wall +Power Status=Input Normal, Output Normal +Present_Status=Present +Power_ID=1 +Model=GW-CRPS1600D2 +Version=03.02.00 + +[Psu1] +Manufacturer=Great Wall +Power_ID=2 +Version=03.02.00 +Power Status=Input Normal, Output Normal +SN=210231AGUNH257001570 +Model=GW-CRPS1600D2 +Present_Status=Present +Max_Power(W)=1600 +`), + }, + { + Path: "static/hardware_info.ini", + Content: []byte(`[Ethernet adapters: Port 1] +Device Type : NIC +Network Port : Port 1 +Location : PCIE-[1] +MAC Address : E4:3D:1A:6F:B0:30 +Speed : 8.0GT/s +Product Name : NIC-BCM957414-F-B-25Gb-2P +[Ethernet adapters: Port 2] +Device Type : NIC +Network Port : Port 2 +Location : PCIE-[1] +MAC Address : E4:3D:1A:6F:B0:31 +Speed : 8.0GT/s +Product Name : NIC-BCM957414-F-B-25Gb-2P + +[PCIe Card: PCIe 1] +Location : 1 +Product Name : NIC-BCM957414-F-B-25Gb-2P +Status : Normal +Vendor ID : 0x14E4 +Device ID : 0x16D7 +Serial Number : NICSN-G6-001 +Part Number : NICPN-G6-001 +Firmware Version : 22.35.1010 +`), + }, + { + Path: "static/sensor_info.ini", + Content: []byte(`Sensor Name | Reading | Unit | Status| Crit low +Inlet_Temp | 20.000 | degrees C | ok | na +CPU1_Status | 0x0 | discrete | 0x8080| na +`), + }, + { + Path: "user/Sel.json", + Content: []byte(` +{ + "Created": "2025-07-14 03:34:18 UTC+08:00", + "Severity": "Info", + "EntryCode": "Asserted", + "EntryType": "Event", + "Id": 1, + "Level": "Info", + "Message": "Processor Presence detected", + "SensorName": "CPU1_Status", + "SensorType": "Processor" +}, +{ + "Created": "2025-07-14 20:56:45 UTC+08:00", + "Severity": "Critical", + "EntryCode": "Asserted", + "EntryType": "Event", + "Id": 2, + "Level": "Critical", + "Message": "Power Supply AC lost", + "SensorName": "PSU1_Status", + "SensorType": "Power Supply" +} +`), + }, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + if result.Hardware == nil { + t.Fatalf("expected hardware section") + } + if result.Hardware.BoardInfo.Manufacturer != "H3C" { + t.Fatalf("unexpected board manufacturer: %q", result.Hardware.BoardInfo.Manufacturer) + } + if result.Hardware.BoardInfo.ProductName != "H3C UniServer R4700 G6" { + t.Fatalf("unexpected board product: %q", result.Hardware.BoardInfo.ProductName) + } + if result.Hardware.BoardInfo.SerialNumber != "210235A4FYH257000010" { + t.Fatalf("unexpected board serial: %q", result.Hardware.BoardInfo.SerialNumber) + } + + if len(result.Hardware.Firmware) < 2 { + t.Fatalf("expected firmware entries, got %d", len(result.Hardware.Firmware)) + } + if len(result.Hardware.CPUs) != 1 { + t.Fatalf("expected 1 cpu, got %d", len(result.Hardware.CPUs)) + } + if result.Hardware.CPUs[0].Cores != 24 { + t.Fatalf("expected 24 cores, got %d", result.Hardware.CPUs[0].Cores) + } + + if len(result.Hardware.Memory) != 1 { + t.Fatalf("expected 1 dimm, got %d", len(result.Hardware.Memory)) + } + if result.Hardware.Memory[0].SizeMB != 65536 { + t.Fatalf("expected 65536MB, got %d", result.Hardware.Memory[0].SizeMB) + } + + if len(result.Hardware.Storage) != 1 { + t.Fatalf("expected 1 disk, got %d", len(result.Hardware.Storage)) + } + if result.Hardware.Storage[0].SerialNumber != "S6KLNN0Y516813" { + t.Fatalf("unexpected disk serial: %q", result.Hardware.Storage[0].SerialNumber) + } + if len(result.Hardware.PowerSupply) != 2 { + t.Fatalf("expected 2 PSUs from psu_cfg.ini, got %d", len(result.Hardware.PowerSupply)) + } + if result.Hardware.PowerSupply[0].WattageW == 0 { + t.Fatalf("expected PSU wattage parsed, got 0") + } + + if len(result.Hardware.NetworkAdapters) != 1 { + t.Fatalf("expected 1 host network adapter from hardware_info.ini, got %d", len(result.Hardware.NetworkAdapters)) + } + macs := make(map[string]struct{}) + var hostNIC models.NetworkAdapter + var hostNICFound bool + for _, nic := range result.Hardware.NetworkAdapters { + if len(nic.MACAddresses) == 0 { + t.Fatalf("expected MAC on network adapter %+v", nic) + } + for _, mac := range nic.MACAddresses { + macs[strings.ToLower(mac)] = struct{}{} + } + if strings.EqualFold(nic.Slot, "PCIe 1") && strings.Contains(strings.ToLower(nic.Model), "bcm957414") { + hostNIC = nic + hostNICFound = true + } + } + if !hostNICFound { + t.Fatalf("expected host NIC from hardware_info.ini, got %+v", result.Hardware.NetworkAdapters) + } + if _, ok := macs["e4:3d:1a:6f:b0:30"]; !ok { + t.Fatalf("expected host NIC MAC e4:3d:1a:6f:b0:30 in adapters, got %+v", result.Hardware.NetworkAdapters) + } + if _, ok := macs["e4:3d:1a:6f:b0:31"]; !ok { + t.Fatalf("expected host NIC MAC e4:3d:1a:6f:b0:31 in adapters, got %+v", result.Hardware.NetworkAdapters) + } + if !strings.Contains(strings.ToLower(hostNIC.Vendor), "broadcom") { + t.Fatalf("expected host NIC vendor enrichment from Vendor ID, got %q", hostNIC.Vendor) + } + if hostNIC.SerialNumber != "NICSN-G6-001" { + t.Fatalf("expected host NIC serial from PCIe card section, got %q", hostNIC.SerialNumber) + } + if hostNIC.PartNumber != "NICPN-G6-001" { + t.Fatalf("expected host NIC part number from PCIe card section, got %q", hostNIC.PartNumber) + } + if hostNIC.Firmware != "22.35.1010" { + t.Fatalf("expected host NIC firmware from PCIe card section, got %q", hostNIC.Firmware) + } + + if len(result.Sensors) != 2 { + t.Fatalf("expected 2 sensors, got %d", len(result.Sensors)) + } + if result.Sensors[0].Name != "Inlet_Temp" { + t.Fatalf("unexpected first sensor: %q", result.Sensors[0].Name) + } + + if len(result.Events) != 2 { + t.Fatalf("expected 2 events, got %d", len(result.Events)) + } + if result.Events[0].Timestamp.Year() != 2025 || result.Events[0].Timestamp.Month() != 7 { + t.Fatalf("expected SEL timestamp from payload, got %s", result.Events[0].Timestamp) + } + if result.Events[1].Severity != models.SeverityCritical { + t.Fatalf("expected critical severity for AC lost event, got %q", result.Events[1].Severity) + } +} + +func TestParseH3CG5_PCIeArgumentsEnrichesNonNVMeStorage(t *testing.T) { + p := &G5Parser{} + files := []parser.ExtractedFile{ + { + Path: "static/storage_disk.ini", + Content: []byte(`[Disk_000] +DiskSlotDesc=Front slot 3 +Present=YES +SerialNumber=SAT-03 +`), + }, + { + Path: "static/NVMe_info.txt", + Content: []byte(`[NVMe_0] +Present=YES +DiskSlotDesc=Front slot 108 +SerialNumber=NVME-108 +`), + }, + { + Path: "static/PCIe_arguments_table.xml", + Content: []byte(` + + + SSD + SSD-SATA-960G + + + + 0x144D + + + + + + SSD + SSD-3.84T-NVMe-SFF + + + + 0x144D + + + +`), + }, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if result.Hardware == nil { + t.Fatalf("expected hardware section") + } + + if len(result.Hardware.Storage) != 2 { + t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage)) + } + + var sata *models.Storage + var nvme *models.Storage + for i := range result.Hardware.Storage { + s := &result.Hardware.Storage[i] + switch s.SerialNumber { + case "SAT-03": + sata = s + case "NVME-108": + nvme = s + } + } + + if sata == nil { + t.Fatalf("expected SATA storage SAT-03") + } + if sata.Model != "SSD-SATA-960G" { + t.Fatalf("expected SATA model enrichment from PCIe table, got %q", sata.Model) + } + if !strings.Contains(strings.ToLower(sata.Manufacturer), "samsung") { + t.Fatalf("expected SATA vendor enrichment to Samsung, got %q", sata.Manufacturer) + } + + if nvme == nil { + t.Fatalf("expected NVMe storage NVME-108") + } + if nvme.Model != "SSD-3.84T-NVMe-SFF" { + t.Fatalf("expected NVMe model enrichment from PCIe table, got %q", nvme.Model) + } + if !strings.Contains(strings.ToLower(nvme.Manufacturer), "samsung") { + t.Fatalf("expected NVMe vendor enrichment to Samsung, got %q", nvme.Manufacturer) + } +} + +func TestParseH3CG5_VariantLayout(t *testing.T) { + p := &G5Parser{} + + files := []parser.ExtractedFile{ + { + Path: "static/FRUInfo.ini", + Content: []byte(`[Baseboard] +Board Manufacturer=H3C +Product Product Name=H3C UniServer R4900 G5 +Product Serial Number=02A6AX5231C003VM +`), + }, + { + Path: "static/firmware_version.ini", + Content: []byte(`[System board] +BIOS Version : 5.59 V100R001B05D078 +ME Version : 4.4.4.202 +HDM Version : 3.34.01 HDM V100R001B05D078SP01 +CPLD Version : V00C +`), + }, + { + Path: "static/board_cfg.ini", + Content: []byte(`[Board Type] +Board Type : R4900 G5 + +[Board Version] +Board Version : VER.D + +[Customer ID] +CustomerID : 255 + +[OEM ID] +OEM Flag : 1 +`), + }, + { + Path: "static/hardware_info.ini", + Content: []byte(`[Processors: Processor 1] +Model : Intel(R) Xeon(R) Gold 6342 CPU @ 2.80GHz +Status : Normal +Frequency : 2800 MHz +Cores : 24 +Threads : 48 +L1 Cache : 1920 KB +L2 Cache : 30720 KB +L3 Cache : 36864 KB +CPU PPIN : 49-A9-50-C0-15-9F-2D-DC + +[Processors: Processor 2] +Model : Intel(R) Xeon(R) Gold 6342 CPU @ 2.80GHz +Status : Normal +Frequency : 2800 MHz +Cores : 24 +Threads : 48 +CPU PPIN : 49-AC-3D-BF-85-7F-17-58 + +[Memory Details: Dimm Index 0] +Location : Processor 1 +Channel : 1 +Socket ID : A0 +Status : Normal +Size : 65536 MB +Maximum Frequency : 3200 MHz +Type : DDR4 +Ranks : 2R DIMM +Technology : RDIMM +Part Number : M393A8G40AB2-CWE +Manufacture : Samsung +Serial Number : S02K0D0243351D7079 + +[Memory Details: Dimm Index 16] +Location : Processor 2 +Channel : 1 +Socket ID : A0 +Status : Normal +Size : 65536 MB +Maximum Frequency : 3200 MHz +Type : DDR4 +Ranks : 2R DIMM +Technology : RDIMM +Part Number : M393A8G40AB2-CWE +Manufacture : Samsung +Serial Number : S02K0D0243351D73F0 + +[Ethernet adapters: Port 1] +Device Type : NIC +Network Port : Port 1 +Location : PCIE-[1] +MAC Address : E4:3D:1A:6F:B0:30 +Speed : 8.0GT/s +Product Name : NIC-BCM957414-F-B-25Gb-2P +[Ethernet adapters: Port 2] +Device Type : NIC +Network Port : Port 2 +Location : PCIE-[1] +MAC Address : E4:3D:1A:6F:B0:31 +Speed : 8.0GT/s +Product Name : NIC-BCM957414-F-B-25Gb-2P + +[Ethernet adapters: Port 1] +Device Type : NIC +Network Port : Port 1 +Location : PCIE-[4] +MAC Address : E8:EB:D3:4F:2E:90 +Speed : 8.0GT/s +Product Name : NIC-MCX512A-ACAT-2*25Gb-F +[Ethernet adapters: Port 2] +Device Type : NIC +Network Port : Port 2 +Location : PCIE-[4] +MAC Address : E8:EB:D3:4F:2E:91 +Speed : 8.0GT/s +Product Name : NIC-MCX512A-ACAT-2*25Gb-F + +[PCIe Card: PCIe 1] +Location : 1 +Product Name : NIC-BCM957414-F-B-25Gb-2P +Status : Normal +Vendor ID : 0x14E4 +Device ID : 0x16D7 +Serial Number : NICSN-G5-001 +Part Number : NICPN-G5-001 +Firmware Version : 21.80.1 + +[PCIe Card: PCIe 4] +Location : 4 +Product Name : NIC-MCX512A-ACAT-2*25Gb-F +Status : Normal +Vendor ID : 0x15B3 +Device ID : 0x1017 +Serial Number : NICSN-G5-004 +Part Number : NICPN-G5-004 +Firmware Version : 28.33.15 +`), + }, + { + Path: "static/hardware.info", + Content: []byte(`[Disk_0_Front_NA] +Present=YES +SlotNum=0 +FrontOrRear=Front +SerialNumber=22443C4EE184 + +[Nvme_Front slot 21] +Present=YES +NvmePhySlot=Front slot 21 +SlotNum=121 +SerialNumber=NVME-21 + +[Nvme_255_121] +Present=YES +SlotNum=121 +SerialNumber=NVME-21 +`), + }, + { + Path: "static/raid.json", + Content: []byte(`{ + "RAIDCONFIG": { + "Ctrl info": [ + { + "CtrlDevice Slot": 3, + "CtrlDevice Name": "AVAGO MegaRAID SAS 9460-8i", + "LDInfo": [ + { + "LD ID": 0, + "LD_name": "SystemRAID", + "RAID_level(RAID 0,RAID 1,RAID 5,RAID 6,RAID 00,RAID 10,RAID 50,RAID 60)": "RAID1", + "Logical_capicity(per 512byte)": 936640512 + } + ] + }, + { + "CtrlDevice Slot": 6, + "CtrlDevice Name": "MegaRAID 9560-16i 8GB", + "LDInfo": [ + { + "LD ID": 0, + "LD_name": "DataRAID", + "RAID_level(RAID 0,RAID 1,RAID 5,RAID 6,RAID 00,RAID 10,RAID 50,RAID 60)": "RAID50", + "Logical_capicity(per 512byte)": 90004783104 + } + ] + } + ] + } +}`), + }, + { + Path: "static/Raid_BP_Conf_Info.ini", + Content: []byte(`[BP Information] +Description | BP TYPE | I2cPort | BpConnectorNum | FrontOrRear | Node Num | DiskSlotRange | +8SFF SAS/SATA | BP_G5_8SFF | AUX_1 | ~ | ~ | ~ | ~ | +8SFF SAS/SATA | BP_G5_8SFF | AUX_2 | ~ | ~ | ~ | ~ | +8SFF SAS/SATA | BP_G5_8SFF | AUX_3 | ~ | ~ | ~ | ~ | + +[RAID Information] +PCIE SLOT | RAID SAS_NUM | +3 | 2 | +6 | 4 | +`), + }, + { + Path: "static/PCIe_arguments_table.xml", + Content: []byte(` + + + SSD + SSD-1.92T/3.84T-NVMe-EV-SFF-sa + + + + 0x144D + + + +`), + }, + { + Path: "static/psu_cfg.ini", + Content: []byte(`[Active / Standby configuration] +Power ID : 1 +Present Status : Present +Cold Status : Active Power +Model : DPS-1300AB-6 R +SN : 210231ACT9H232000080 +Max Power(W) : 1300 + +Power ID : 2 +Present Status : Present +Cold Status : Active Power +Model : DPS-1300AB-6 R +SN : 210231ACT9H232000079 +Max Power(W) : 1300 +`), + }, + { + Path: "static/net_cfg.ini", + Content: []byte(`[Network Configuration] +eth0 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F6 + UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + +eth0.2 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F6 + inet6 addr: fe80::32c6:d7ff:fe94:54f6/64 Scope:Link + UP BROADCAST RUNNING MULTICAST MTU:1496 Metric:1 + +eth1 Link encap:Ethernet HWaddr 30:C6:D7:94:54:F5 + inet addr:10.201.129.0 Bcast:10.201.143.255 Mask:255.255.240.0 + inet6 addr: fe80::32c6:d7ff:fe94:54f5/64 Scope:Link + UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + +lo Link encap:Local Loopback + inet addr:127.0.0.1 Mask:255.0.0.0 + UP LOOPBACK RUNNING MTU:65536 Metric:1 +`), + }, + { + Path: "static/smartdata/Front0/first_date_analysis.txt", + Content: []byte(`The Current System Time Is 2023_09_22_14_19_39 +Model Info: ATA Micron_5300_MTFD +Serial Number: 22443C4EE184 +`), + }, + { + Path: "user/test1.csv", + Content: []byte(`Record Time Stamp,Severity Level,Severity Level ID,SensorTypeStr,SensorName,Event Dir,Event Occurred Time,DescInfo,Explanation,Suggestion +2025-04-01 08:50:13,Minor,0x1,NA,NA,NA,2025-04-01 08:50:13,"SSH login failed from IP: 10.200.10.121 user: admin"," "," " +Pre-Init,Info,0x0,Management Subsystem Health,Health,Assertion event,Pre-Init,"Management controller off-line"," "," " +2025-04-01 08:51:10,Major,0x2,Power Supply,PSU1_Status,Assertion event,2025-04-01 08:51:10,"Power Supply AC lost"," "," " +`), + }, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if result.Hardware == nil { + t.Fatalf("expected hardware section") + } + + if len(result.Hardware.CPUs) != 2 { + t.Fatalf("expected 2 CPUs from hardware_info.ini, got %d", len(result.Hardware.CPUs)) + } + if result.Hardware.CPUs[0].FrequencyMHz != 2800 { + t.Fatalf("expected CPU frequency 2800MHz, got %d", result.Hardware.CPUs[0].FrequencyMHz) + } + + if len(result.Hardware.Memory) != 2 { + t.Fatalf("expected 2 DIMMs from hardware_info.ini, got %d", len(result.Hardware.Memory)) + } + if result.Hardware.Memory[0].SizeMB != 65536 { + t.Fatalf("expected DIMM size 65536MB, got %d", result.Hardware.Memory[0].SizeMB) + } + + if len(result.Hardware.Firmware) < 4 { + t.Fatalf("expected firmware entries from firmware_version.ini, got %d", len(result.Hardware.Firmware)) + } + if result.Hardware.BoardInfo.Version == "" { + t.Fatalf("expected board version from board_cfg.ini") + } + if !strings.Contains(result.Hardware.BoardInfo.Description, "CustomerID: 255") { + t.Fatalf("expected board description enrichment from board_cfg.ini, got %q", result.Hardware.BoardInfo.Description) + } + + if len(result.Hardware.Storage) != 2 { + t.Fatalf("expected 2 unique storage devices from hardware.info, got %d", len(result.Hardware.Storage)) + } + var nvmeFound bool + var diskModelEnriched bool + for _, s := range result.Hardware.Storage { + if s.SerialNumber == "NVME-21" { + nvmeFound = true + if s.Type != "nvme" { + t.Fatalf("expected NVME-21 type nvme, got %q", s.Type) + } + if !strings.Contains(strings.ToLower(s.Manufacturer), "samsung") { + t.Fatalf("expected NVME vendor enrichment to Samsung, got %q", s.Manufacturer) + } + if s.Model != "SSD-1.92T/3.84T-NVMe-EV-SFF-sa" { + t.Fatalf("expected NVME model enrichment from PCIe table, got %q", s.Model) + } + } + if s.SerialNumber == "22443C4EE184" && strings.Contains(s.Model, "Micron") { + diskModelEnriched = true + } + } + if !nvmeFound { + t.Fatalf("expected deduped NVME storage by serial NVME-21") + } + if !diskModelEnriched { + t.Fatalf("expected disk model enrichment from smartdata by serial") + } + + if len(result.Hardware.PowerSupply) != 2 { + t.Fatalf("expected 2 PSUs from psu_cfg.ini, got %d", len(result.Hardware.PowerSupply)) + } + if result.Hardware.PowerSupply[0].WattageW == 0 { + t.Fatalf("expected PSU wattage parsed, got 0") + } + if len(result.Hardware.NetworkAdapters) != 2 { + t.Fatalf("expected 2 host network adapters from hardware_info.ini, got %d", len(result.Hardware.NetworkAdapters)) + } + if len(result.Hardware.NetworkCards) != 2 { + t.Fatalf("expected 2 network cards synthesized from adapters, got %d", len(result.Hardware.NetworkCards)) + } + var g5NIC models.NetworkAdapter + var g5NICFound bool + for _, nic := range result.Hardware.NetworkAdapters { + if strings.EqualFold(nic.Slot, "PCIe 1") && strings.Contains(strings.ToLower(nic.Model), "bcm957414") { + g5NIC = nic + g5NICFound = true + break + } + } + if !g5NICFound { + t.Fatalf("expected host NIC PCIe 1 from hardware_info.ini, got %+v", result.Hardware.NetworkAdapters) + } + if !strings.Contains(strings.ToLower(g5NIC.Vendor), "broadcom") { + t.Fatalf("expected G5 NIC vendor from Vendor ID, got %q", g5NIC.Vendor) + } + if g5NIC.SerialNumber != "NICSN-G5-001" { + t.Fatalf("expected G5 NIC serial from PCIe card section, got %q", g5NIC.SerialNumber) + } + if g5NIC.PartNumber != "NICPN-G5-001" { + t.Fatalf("expected G5 NIC part number from PCIe card section, got %q", g5NIC.PartNumber) + } + if g5NIC.Firmware != "21.80.1" { + t.Fatalf("expected G5 NIC firmware from PCIe card section, got %q", g5NIC.Firmware) + } + + if len(result.Hardware.Devices) != 5 { + t.Fatalf("expected 5 topology devices from Raid_BP_Conf_Info.ini (3 BP + 2 RAID), got %d", len(result.Hardware.Devices)) + } + var bpFound bool + var raidFound bool + for _, d := range result.Hardware.Devices { + if strings.Contains(d.ID, "h3c-bp-") && strings.Contains(d.Model, "BP_G5_8SFF") { + bpFound = true + } + desc, _ := d.Details["description"].(string) + if strings.Contains(d.ID, "h3c-raid-slot-3") && strings.Contains(desc, "SAS ports: 2") { + raidFound = true + } + } + if !bpFound || !raidFound { + t.Fatalf("expected parsed backplane and RAID topology devices, got %+v", result.Hardware.Devices) + } + + if len(result.Hardware.Volumes) != 2 { + t.Fatalf("expected 2 RAID volumes (same LD ID on different controllers), got %d", len(result.Hardware.Volumes)) + } + var raid1Found bool + var raid50Found bool + for _, v := range result.Hardware.Volumes { + if strings.Contains(v.Controller, "slot 3") { + raid1Found = v.RAIDLevel == "RAID1" && v.CapacityBytes > 0 + } + if strings.Contains(v.Controller, "slot 6") { + raid50Found = v.RAIDLevel == "RAID50" && v.CapacityBytes > 0 + } + } + if !raid1Found || !raid50Found { + t.Fatalf("expected RAID1 and RAID50 volumes with parsed capacities, got %+v", result.Hardware.Volumes) + } + + if len(result.Events) != 2 { + t.Fatalf("expected 2 CSV events (Pre-Init skipped), got %d", len(result.Events)) + } + if result.Events[0].Severity != models.SeverityWarning { + t.Fatalf("expected Minor CSV severity mapped to warning, got %q", result.Events[0].Severity) + } + if result.Events[1].Severity != models.SeverityCritical { + t.Fatalf("expected Major CSV severity mapped to critical, got %q", result.Events[1].Severity) + } +} diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index 7252402..aedcea2 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -4,6 +4,7 @@ package vendors import ( // Import vendor modules to trigger their init() registration + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report" @@ -13,7 +14,6 @@ import ( // Generic fallback parser (must be last for lowest priority) _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic" - // Future vendors: // _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell" // _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/hpe"