diff --git a/docs/collector.dmi.md b/docs/collector.dmi.md index 8fdc36ba..f26bc5af 100644 --- a/docs/collector.dmi.md +++ b/docs/collector.dmi.md @@ -16,9 +16,26 @@ None ## Metrics -| Name | Description | Type | Labels | -|---------------------|-----------------------------------------------------------------------------------------------------|-------|----------------------------------------------------------------------------| -| `windows_dmi_info` | A metric with a constant '1' value labeled by SMBIOS Type 1 (System Information) fields. | gauge | `product_name`, `product_serial`, `product_uuid`, `product_version`, `system_vendor` | +| Name | Description | Type | Labels | +|--------------------|-----------------------------------------------------------------------------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `windows_dmi_info` | A metric with a constant '1' value labeled by SMBIOS identification fields. | gauge | `bios_date`, `bios_release`, `bios_vendor`, `bios_version`, `board_asset_tag`, `board_name`, `board_serial`, `board_vendor`, `board_version`, `chassis_asset_tag`, `chassis_serial`, `chassis_vendor`, `chassis_version`, `product_family`, `product_name`, `product_serial`, `product_sku`, `product_uuid`, `product_version`, `system_vendor` | + +The labels come from these SMBIOS structures, using the first structure of +each type like the Linux kernel's `/sys/class/dmi/id` attributes that +node_exporter reads: + +| SMBIOS structure | Labels | +|--------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| Type 0 (BIOS Information) | `bios_vendor`, `bios_version`, `bios_date`, `bios_release` (2.4+) | +| Type 1 (System Information) | `system_vendor`, `product_name`, `product_version`, `product_serial`, `product_uuid`, `product_sku` (2.4+), `product_family` (2.4+) | +| Type 2 (Baseboard Information) | `board_vendor`, `board_name`, `board_version`, `board_serial`, `board_asset_tag` | +| Type 3 (System Enclosure) | `chassis_vendor`, `chassis_version`, `chassis_serial`, `chassis_asset_tag` | + +Fields the firmware does not provide are exposed as empty labels, which +Prometheus drops on ingestion. `bios_release` is the system +BIOS major.minor release, e.g. `2.17`; some firmware, such as QEMU's SeaBIOS, +reports `0.0`. Firmware placeholders like `Not Specified` or +`Default string` are passed through unchanged, as on Linux. On VMs the labels reflect the hypervisor-assigned identity; on physical hosts they reflect the hardware SMBIOS data. If SMBIOS data cannot be read (e.g. in @@ -27,11 +44,13 @@ some container environments), the collector will fail to build. ### Example metric ``` -# HELP windows_dmi_info A metric with a constant '1' value labeled by product_name, product_serial, product_uuid, product_version, system_vendor from SMBIOS Type 1 (System Information). +# HELP windows_dmi_info A metric with a constant '1' value labeled by bios_date, bios_release, bios_vendor, bios_version, board_asset_tag, board_name, board_serial, board_vendor, board_version, chassis_asset_tag, chassis_serial, chassis_vendor, chassis_version, product_family, product_name, product_serial, product_sku, product_uuid, product_version, system_vendor from SMBIOS. # TYPE windows_dmi_info gauge -windows_dmi_info{product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="Not Specified",product_uuid="12345678-1234-1234-1234-123456789abc",product_version="pc-q35-9.2",system_vendor="QEMU"} 1 +windows_dmi_info{bios_date="04/01/2014",bios_release="0.0",bios_vendor="SeaBIOS",bios_version="rel-1.16.3-0-ga6ed6b701f0a-prebuilt.qemu.org",chassis_vendor="QEMU",chassis_version="pc-q35-9.2",product_name="Standard PC (Q35 + ICH9, 2009)",product_uuid="12345678-1234-1234-1234-123456789abc",product_version="pc-q35-9.2",system_vendor="QEMU"} 1 ``` +Empty labels are left out of this example for brevity. + ## Useful queries ### Cross-exporter compatibility diff --git a/internal/collector/dmi/dmi.go b/internal/collector/dmi/dmi.go index 617a6cf7..933d6110 100644 --- a/internal/collector/dmi/dmi.go +++ b/internal/collector/dmi/dmi.go @@ -69,9 +69,9 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { - info, err := sysinfoapi.GetSMBIOSSystemInfo() + info, err := sysinfoapi.GetSMBIOSInfo() if err != nil { - return fmt.Errorf("SMBIOS system info unavailable: %w", err) + return fmt.Errorf("SMBIOS info unavailable: %w", err) } // Sanitize label values: firmware strings may contain invalid UTF-8 bytes. @@ -80,17 +80,36 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { return strings.ToValidUTF8(strings.TrimSpace(s), "�") } + // Label names match node_exporter's node_dmi_info. Fields the firmware + // does not provide stay empty; Prometheus drops empty labels on ingestion. desc := prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "info"), - "A metric with a constant '1' value labeled by product_name, product_serial, "+ - "product_uuid, product_version, system_vendor from SMBIOS Type 1 (System Information).", + "A metric with a constant '1' value labeled by bios_date, bios_release, bios_vendor, bios_version, "+ + "board_asset_tag, board_name, board_serial, board_vendor, board_version, chassis_asset_tag, "+ + "chassis_serial, chassis_vendor, chassis_version, product_family, product_name, product_serial, "+ + "product_sku, product_uuid, product_version, system_vendor from SMBIOS.", nil, prometheus.Labels{ - "product_name": cleanLabel(info.ProductName), - "product_serial": cleanLabel(info.SerialNumber), - "product_uuid": strings.ToLower(cleanLabel(info.UUID)), - "product_version": cleanLabel(info.Version), - "system_vendor": cleanLabel(info.Manufacturer), + "bios_date": cleanLabel(info.BIOS.ReleaseDate), + "bios_release": cleanLabel(info.BIOS.Release), + "bios_vendor": cleanLabel(info.BIOS.Vendor), + "bios_version": cleanLabel(info.BIOS.Version), + "board_asset_tag": cleanLabel(info.Board.AssetTag), + "board_name": cleanLabel(info.Board.Product), + "board_serial": cleanLabel(info.Board.SerialNumber), + "board_vendor": cleanLabel(info.Board.Manufacturer), + "board_version": cleanLabel(info.Board.Version), + "chassis_asset_tag": cleanLabel(info.Chassis.AssetTag), + "chassis_serial": cleanLabel(info.Chassis.SerialNumber), + "chassis_vendor": cleanLabel(info.Chassis.Manufacturer), + "chassis_version": cleanLabel(info.Chassis.Version), + "product_family": cleanLabel(info.System.Family), + "product_name": cleanLabel(info.System.ProductName), + "product_serial": cleanLabel(info.System.SerialNumber), + "product_sku": cleanLabel(info.System.SKUNumber), + "product_uuid": strings.ToLower(cleanLabel(info.System.UUID)), + "product_version": cleanLabel(info.System.Version), + "system_vendor": cleanLabel(info.System.Manufacturer), }, ) diff --git a/internal/collector/dmi/dmi_test.go b/internal/collector/dmi/dmi_test.go index 80272a9a..732ec367 100644 --- a/internal/collector/dmi/dmi_test.go +++ b/internal/collector/dmi/dmi_test.go @@ -18,6 +18,8 @@ package dmi_test import ( + "maps" + "slices" "testing" "github.com/prometheus-community/windows_exporter/internal/collector/dmi" @@ -37,13 +39,18 @@ func TestCollector(t *testing.T) { return } + labels := make(map[string]string, len(metric.GetLabel())) for _, label := range metric.GetLabel() { - if label.GetName() == "product_uuid" { - require.NotEmpty(t, label.GetValue(), "SMBIOS system UUID is empty") - - return - } + labels[label.GetName()] = label.GetValue() } - t.Fatalf("windows_dmi_info has no product_uuid label: %s", metric) + require.ElementsMatch(t, []string{ + "bios_date", "bios_release", "bios_vendor", "bios_version", + "board_asset_tag", "board_name", "board_serial", "board_vendor", "board_version", + "chassis_asset_tag", "chassis_serial", "chassis_vendor", "chassis_version", + "product_family", "product_name", "product_serial", "product_sku", + "product_uuid", "product_version", "system_vendor", + }, slices.Collect(maps.Keys(labels)), "windows_dmi_info must carry node_exporter's node_dmi_info label names") + require.NotEmpty(t, labels["product_uuid"], "SMBIOS system UUID is empty: %s", metric) + require.NotEmpty(t, labels["bios_vendor"], "SMBIOS BIOS vendor is empty: %s", metric) } diff --git a/internal/headers/sysinfoapi/smbios.go b/internal/headers/sysinfoapi/smbios.go index 3d0497cf..3d919b3d 100644 --- a/internal/headers/sysinfoapi/smbios.go +++ b/internal/headers/sysinfoapi/smbios.go @@ -28,15 +28,15 @@ var procGetSystemFirmwareTable = kernel32.NewProc("GetSystemFirmwareTable") // SMBIOS table provider signature ('RSMB'). const firmwareTableProviderSigRSMB uint32 = 0x52534D42 -// GetSMBIOSSystemInfo retrieves the SMBIOS Type 1 (System Information) data -// using the GetSystemFirmwareTable Win32 API. No WMI dependency. -func GetSMBIOSSystemInfo() (*SMBIOSSystemInfo, error) { +// GetSMBIOSInfo retrieves the SMBIOS BIOS, System, Baseboard and Chassis +// information using the GetSystemFirmwareTable Win32 API. No WMI dependency. +func GetSMBIOSInfo() (*SMBIOSInfo, error) { data, err := getSystemFirmwareTable(firmwareTableProviderSigRSMB, 0) if err != nil { return nil, fmt.Errorf("GetSystemFirmwareTable: %w", err) } - return parseSMBIOSSystemInfo(data) + return parseSMBIOSInfo(data) } // getSystemFirmwareTable wraps the GetSystemFirmwareTable Win32 API. diff --git a/internal/headers/sysinfoapi/smbios_parse.go b/internal/headers/sysinfoapi/smbios_parse.go index b5a90e62..ba37fbaa 100644 --- a/internal/headers/sysinfoapi/smbios_parse.go +++ b/internal/headers/sysinfoapi/smbios_parse.go @@ -21,6 +21,26 @@ import ( "fmt" ) +// SMBIOSInfo holds the identification strings of the SMBIOS structures that +// node_exporter exposes through /sys/class/dmi/id on Linux. +type SMBIOSInfo struct { + BIOS SMBIOSBIOSInfo + System SMBIOSSystemInfo + Board SMBIOSBoardInfo + Chassis SMBIOSChassisInfo +} + +// SMBIOSBIOSInfo holds the fields extracted from the SMBIOS Type 0 +// (BIOS Information) structure. +type SMBIOSBIOSInfo struct { + Vendor string + Version string + ReleaseDate string + // Release is the system BIOS major.minor release (SMBIOS 2.4+), + // formatted like the Linux kernel's bios_release attribute. + Release string +} + // SMBIOSSystemInfo holds the fields extracted from the SMBIOS Type 1 // (System Information) structure. type SMBIOSSystemInfo struct { @@ -29,6 +49,27 @@ type SMBIOSSystemInfo struct { Version string SerialNumber string UUID string + SKUNumber string + Family string +} + +// SMBIOSBoardInfo holds the fields extracted from the first SMBIOS Type 2 +// (Baseboard Information) structure. +type SMBIOSBoardInfo struct { + Manufacturer string + Product string + Version string + SerialNumber string + AssetTag string +} + +// SMBIOSChassisInfo holds the fields extracted from the first SMBIOS Type 3 +// (System Enclosure or Chassis) structure. +type SMBIOSChassisInfo struct { + Manufacturer string + Version string + SerialNumber string + AssetTag string } // rawSMBIOSDataHeaderSize is the size of the RawSMBIOSData header returned @@ -37,9 +78,48 @@ type SMBIOSSystemInfo struct { // DMARevision (1) + Length (4) = 8 bytes. const rawSMBIOSDataHeaderSize = 8 -// parseSMBIOSSystemInfo parses the raw RSMB firmware table and extracts -// the Type 1 (System Information) structure. -func parseSMBIOSSystemInfo(data []byte) (*SMBIOSSystemInfo, error) { +const ( + smbiosTypeBIOS = 0 + smbiosTypeSystem = 1 + smbiosTypeBaseboard = 2 + smbiosTypeChassis = 3 +) + +// smbiosStructure is one structure of the SMBIOS table: its formatted area +// and the strings of its unformatted area. +type smbiosStructure struct { + formatted []byte + strs []string +} + +// byteAt returns the byte at offset in the formatted area and whether the +// structure is long enough to contain it. +func (s smbiosStructure) byteAt(offset int) (byte, bool) { + if offset >= len(s.formatted) { + return 0, false + } + + return s.formatted[offset], true +} + +// stringAt resolves the string index stored at offset in the formatted area. +// It returns an empty string when the structure is too short for the field. +func (s smbiosStructure) stringAt(offset int) string { + index, ok := s.byteAt(offset) + if !ok { + return "" + } + + return getStringByIndex(s.strs, index) +} + +// parseSMBIOSInfo parses the raw RSMB firmware table and extracts the +// Type 0 (BIOS), Type 1 (System), Type 2 (Baseboard) and Type 3 (Chassis) +// structures. Only the first structure of each type is used, matching the +// Linux kernel. Type 1 is required; the others are optional. The walk +// continues past the Type 127 end-of-table marker, as the Linux kernel does +// for SMBIOS 2.x tables, since some firmware places structures after it. +func parseSMBIOSInfo(data []byte) (*SMBIOSInfo, error) { if len(data) < rawSMBIOSDataHeaderSize { return nil, errors.New("SMBIOS data too short for header") } @@ -56,7 +136,11 @@ func parseSMBIOSSystemInfo(data []byte) (*SMBIOSSystemInfo, error) { tableData = tableData[:tableLen] - // Walk structures looking for Type 1. + info := &SMBIOSInfo{} + seen := map[byte]bool{} + + var walkErr error + offset := 0 for offset < len(tableData) { if offset+4 > len(tableData) { @@ -88,41 +172,115 @@ func parseSMBIOSSystemInfo(data []byte) (*SMBIOSSystemInfo, error) { } if !foundTerminator { - return nil, errors.New("SMBIOS string area not properly terminated (missing double-NUL)") + walkErr = errors.New("SMBIOS string area not properly terminated (missing double-NUL)") + + break } - if structType == 1 && structLen >= 0x08 { - // Type 1: System Information - // Offsets (within the formatted area): - // 04h: Manufacturer (string index) - // 05h: Product Name (string index) - // 06h: Version (string index) - // 07h: Serial Number (string index) - // 08h-17h: UUID (16 bytes) — present only when structLen >= 0x19 - strs := extractStrings(tableData[stringsStart:stringsEnd]) - - info := &SMBIOSSystemInfo{ - Manufacturer: getStringByIndex(strs, tableData[offset+0x04]), - ProductName: getStringByIndex(strs, tableData[offset+0x05]), - Version: getStringByIndex(strs, tableData[offset+0x06]), - SerialNumber: getStringByIndex(strs, tableData[offset+0x07]), + if structType <= smbiosTypeChassis && !seen[structType] { + s := smbiosStructure{ + formatted: tableData[offset : offset+structLen], + strs: extractStrings(tableData[stringsStart:stringsEnd]), } - // UUID is only available in SMBIOS 2.1+ (structLen >= 0x19). - if structLen >= 0x19 { - // SMBIOS 2.6+ uses little-endian byte order for the first three UUID fields; - // earlier versions use network (big-endian) order throughout. - smbios26Plus := smbiosMajor > 2 || (smbiosMajor == 2 && smbiosMinor >= 6) - info.UUID = formatUUID(tableData[offset+0x08:offset+0x18], smbios26Plus) + switch structType { + case smbiosTypeBIOS: + seen[structType] = true + info.BIOS = parseSMBIOSBIOSInfo(s) + case smbiosTypeSystem: + // The Type 1 fields this parser reads require at least 8 bytes. + if structLen >= 0x08 { + seen[structType] = true + info.System = parseSMBIOSSystemInfo(s, smbiosMajor, smbiosMinor) + } + case smbiosTypeBaseboard: + seen[structType] = true + info.Board = SMBIOSBoardInfo{ + Manufacturer: s.stringAt(0x04), + Product: s.stringAt(0x05), + Version: s.stringAt(0x06), + SerialNumber: s.stringAt(0x07), + AssetTag: s.stringAt(0x08), + } + case smbiosTypeChassis: + seen[structType] = true + info.Chassis = SMBIOSChassisInfo{ + Manufacturer: s.stringAt(0x04), + Version: s.stringAt(0x06), + SerialNumber: s.stringAt(0x07), + AssetTag: s.stringAt(0x08), + } } - - return info, nil } offset = stringsEnd } - return nil, errors.New("SMBIOS Type 1 (System Information) structure not found") + // A malformed structure after Type 1 only loses the structures that follow it. + if !seen[smbiosTypeSystem] { + if walkErr != nil { + return nil, walkErr + } + + return nil, errors.New("SMBIOS Type 1 (System Information) structure not found") + } + + return info, nil +} + +// parseSMBIOSBIOSInfo decodes a Type 0 (BIOS Information) structure. +// +// 04h: Vendor (string index) +// 05h: BIOS Version (string index) +// 08h: BIOS Release Date (string index) +// 14h: System BIOS Major Release (2.4+) +// 15h: System BIOS Minor Release (2.4+) +func parseSMBIOSBIOSInfo(s smbiosStructure) SMBIOSBIOSInfo { + bios := SMBIOSBIOSInfo{ + Vendor: s.stringAt(0x04), + Version: s.stringAt(0x05), + ReleaseDate: s.stringAt(0x08), + } + + major, okMajor := s.byteAt(0x14) + minor, okMinor := s.byteAt(0x15) + + // 0xFF in both fields means the system does not support them. + if okMajor && okMinor && (major != 0xFF || minor != 0xFF) { + bios.Release = fmt.Sprintf("%d.%d", major, minor) + } + + return bios +} + +// parseSMBIOSSystemInfo decodes a Type 1 (System Information) structure. +// +// 04h: Manufacturer (string index) +// 05h: Product Name (string index) +// 06h: Version (string index) +// 07h: Serial Number (string index) +// 08h-17h: UUID (16 bytes, 2.1+) +// 19h: SKU Number (string index, 2.4+) +// 1Ah: Family (string index, 2.4+) +func parseSMBIOSSystemInfo(s smbiosStructure, smbiosMajor, smbiosMinor byte) SMBIOSSystemInfo { + system := SMBIOSSystemInfo{ + Manufacturer: s.stringAt(0x04), + ProductName: s.stringAt(0x05), + Version: s.stringAt(0x06), + SerialNumber: s.stringAt(0x07), + SKUNumber: s.stringAt(0x19), + Family: s.stringAt(0x1A), + } + + // UUID is only available in SMBIOS 2.1+ (structLen >= 0x19). + if len(s.formatted) >= 0x19 { + // SMBIOS 2.6+ uses little-endian byte order for the first three UUID fields; + // earlier versions use network (big-endian) order throughout. + smbios26Plus := smbiosMajor > 2 || (smbiosMajor == 2 && smbiosMinor >= 6) + system.UUID = formatUUID(s.formatted[0x08:0x18], smbios26Plus) + } + + return system } // extractStrings splits the unformatted string area into individual strings. diff --git a/internal/headers/sysinfoapi/smbios_test.go b/internal/headers/sysinfoapi/smbios_test.go index 322bae1d..bff4fe79 100644 --- a/internal/headers/sysinfoapi/smbios_test.go +++ b/internal/headers/sysinfoapi/smbios_test.go @@ -153,13 +153,13 @@ func TestParseSMBIOSSystemInfo(t *testing.T) { data := slices.Concat(header, tableData) - info, err := parseSMBIOSSystemInfo(data) + info, err := parseSMBIOSInfo(data) require.NoError(t, err) - assert.Equal(t, "QEMU", info.Manufacturer) - assert.Equal(t, "Standard PC (Q35 + ICH9, 2009)", info.ProductName) - assert.Equal(t, "pc-q35-9.2", info.Version) - assert.Equal(t, "SN-12345", info.SerialNumber) - assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) + assert.Equal(t, "QEMU", info.System.Manufacturer) + assert.Equal(t, "Standard PC (Q35 + ICH9, 2009)", info.System.ProductName) + assert.Equal(t, "pc-q35-9.2", info.System.Version) + assert.Equal(t, "SN-12345", info.System.SerialNumber) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.System.UUID) } func TestParseSMBIOSSystemInfoPre26(t *testing.T) { @@ -196,10 +196,10 @@ func TestParseSMBIOSSystemInfoPre26(t *testing.T) { data := slices.Concat(header, tableData) - info, err := parseSMBIOSSystemInfo(data) + info, err := parseSMBIOSInfo(data) require.NoError(t, err) - assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) - assert.Equal(t, "QEMU", info.Manufacturer) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.System.UUID) + assert.Equal(t, "QEMU", info.System.Manufacturer) } func TestParseSMBIOSSystemInfoSMBIOS20(t *testing.T) { @@ -233,13 +233,13 @@ func TestParseSMBIOSSystemInfoSMBIOS20(t *testing.T) { data := slices.Concat(header, tableData) - info, err := parseSMBIOSSystemInfo(data) + info, err := parseSMBIOSInfo(data) require.NoError(t, err) - assert.Equal(t, "OldBIOS", info.Manufacturer) - assert.Equal(t, "Legacy PC", info.ProductName) - assert.Equal(t, "v0.1", info.Version) - assert.Equal(t, "SN-OLD", info.SerialNumber) - assert.Empty(t, info.UUID, "UUID should be empty for 8-byte SMBIOS 2.0 Type 1 structures") + assert.Equal(t, "OldBIOS", info.System.Manufacturer) + assert.Equal(t, "Legacy PC", info.System.ProductName) + assert.Equal(t, "v0.1", info.System.Version) + assert.Equal(t, "SN-OLD", info.System.SerialNumber) + assert.Empty(t, info.System.UUID, "UUID should be empty for 8-byte SMBIOS 2.0 Type 1 structures") } func TestParseSMBIOSSystemInfoWithType0Preceding(t *testing.T) { @@ -286,13 +286,13 @@ func TestParseSMBIOSSystemInfoWithType0Preceding(t *testing.T) { data := slices.Concat(header, tableData) - info, err := parseSMBIOSSystemInfo(data) + info, err := parseSMBIOSInfo(data) require.NoError(t, err) - assert.Equal(t, "Dell Inc.", info.Manufacturer) - assert.Equal(t, "PowerEdge R740", info.ProductName) - assert.Equal(t, "v2.0", info.Version) - assert.Equal(t, "SN-ABCDE", info.SerialNumber) - assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) + assert.Equal(t, "Dell Inc.", info.System.Manufacturer) + assert.Equal(t, "PowerEdge R740", info.System.ProductName) + assert.Equal(t, "v2.0", info.System.Version) + assert.Equal(t, "SN-ABCDE", info.System.SerialNumber) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.System.UUID) } func TestParseSMBIOSSystemInfoUnterminatedStrings(t *testing.T) { @@ -324,12 +324,184 @@ func TestParseSMBIOSSystemInfoUnterminatedStrings(t *testing.T) { data := slices.Concat(header, tableData) - _, err := parseSMBIOSSystemInfo(data) + _, err := parseSMBIOSInfo(data) require.Error(t, err) assert.Contains(t, err.Error(), "not properly terminated") } -func FuzzParseSMBIOSSystemInfo(f *testing.F) { +// buildRSMB wraps SMBIOS structures in a RawSMBIOSData header for SMBIOS 3.2. +func buildRSMB(structures ...[]byte) []byte { + tableData := slices.Concat(structures...) + tableLen := len(tableData) + + header := []byte{ + 0x00, // Used20CallingMethod + 0x03, // SMBIOSMajorVersion + 0x02, // SMBIOSMinorVersion + 0x00, // DMARevision + byte(tableLen), byte(tableLen >> 8), byte(tableLen >> 16), byte(tableLen >> 24), + } + + return slices.Concat(header, tableData) +} + +// smbiosStruct builds one SMBIOS structure with a formatted area of length +// bytes, the given fields set and the given strings. +func smbiosStruct(structType byte, length int, fields map[int]byte, strs ...string) []byte { + formatted := make([]byte, length) + formatted[0] = structType + formatted[1] = byte(length) + + for offset, value := range fields { + formatted[offset] = value + } + + var stringArea []byte + + for _, str := range strs { + stringArea = append(stringArea, str...) + stringArea = append(stringArea, 0) + } + + if len(stringArea) == 0 { + stringArea = []byte{0} + } + + return slices.Concat(formatted, stringArea, []byte{0}) +} + +func TestParseSMBIOSInfoAllTypes(t *testing.T) { + t.Parallel() + + type0 := smbiosStruct(0, 0x1A, + map[int]byte{0x04: 1, 0x05: 2, 0x08: 3, 0x14: 2, 0x15: 17}, + "American Megatrends Inc.", "1.4.2", "05/23/2024", + ) + type1 := smbiosStruct(1, 0x1B, + map[int]byte{0x04: 1, 0x05: 2, 0x06: 3, 0x07: 4, 0x19: 5, 0x1A: 6}, + "Dell Inc.", "PowerEdge R740", "v2.0", "SN-SYS", "SKU=0715", "PowerEdge", + ) + type2 := smbiosStruct(2, 0x0F, + map[int]byte{0x04: 1, 0x05: 2, 0x06: 3, 0x07: 4, 0x08: 5}, + "Dell Inc.", "0WXD1Y", "A05", "SN-BOARD", "BOARD-TAG", + ) + // A second baseboard must not override the first. + type2b := smbiosStruct(2, 0x0F, map[int]byte{0x04: 1, 0x05: 2}, "Other", "Second") + type3 := smbiosStruct(3, 0x15, + map[int]byte{0x04: 1, 0x05: 0x17, 0x06: 2, 0x07: 3, 0x08: 4}, + "Dell Inc.", "C1", "SN-CHASSIS", "CHASSIS-TAG", + ) + end := smbiosStruct(127, 4, nil) + + info, err := parseSMBIOSInfo(buildRSMB(type0, end, type1, type2, type2b, type3)) + require.NoError(t, err) + + assert.Equal(t, SMBIOSBIOSInfo{ + Vendor: "American Megatrends Inc.", + Version: "1.4.2", + ReleaseDate: "05/23/2024", + Release: "2.17", + }, info.BIOS) + assert.Equal(t, "SKU=0715", info.System.SKUNumber) + assert.Equal(t, "PowerEdge", info.System.Family) + assert.Equal(t, "PowerEdge R740", info.System.ProductName) + assert.Equal(t, SMBIOSBoardInfo{ + Manufacturer: "Dell Inc.", + Product: "0WXD1Y", + Version: "A05", + SerialNumber: "SN-BOARD", + AssetTag: "BOARD-TAG", + }, info.Board) + assert.Equal(t, SMBIOSChassisInfo{ + Manufacturer: "Dell Inc.", + Version: "C1", + SerialNumber: "SN-CHASSIS", + AssetTag: "CHASSIS-TAG", + }, info.Chassis) +} + +func TestParseSMBIOSInfoShortStructures(t *testing.T) { + t.Parallel() + + // SMBIOS 2.0-sized structures: no BIOS release, SKU, family or asset tags. + type0 := smbiosStruct(0, 0x12, map[int]byte{0x04: 1, 0x05: 2, 0x08: 3}, "Vendor", "1.0", "01/01/2000") + type1 := smbiosStruct(1, 0x08, map[int]byte{0x04: 1}, "Maker") + type2 := smbiosStruct(2, 0x08, map[int]byte{0x04: 1, 0x05: 2, 0x06: 3, 0x07: 4}, "BV", "BN", "BVer", "BS") + type3 := smbiosStruct(3, 0x08, map[int]byte{0x04: 1, 0x06: 2, 0x07: 3}, "CV", "CVer", "CS") + + info, err := parseSMBIOSInfo(buildRSMB(type0, type1, type2, type3)) + require.NoError(t, err) + + assert.Equal(t, "Vendor", info.BIOS.Vendor) + assert.Equal(t, "01/01/2000", info.BIOS.ReleaseDate) + assert.Empty(t, info.BIOS.Release) + assert.Empty(t, info.System.SKUNumber) + assert.Empty(t, info.System.Family) + assert.Equal(t, "BS", info.Board.SerialNumber) + assert.Empty(t, info.Board.AssetTag) + assert.Equal(t, "CS", info.Chassis.SerialNumber) + assert.Empty(t, info.Chassis.AssetTag) +} + +func TestParseSMBIOSInfoBIOSReleaseNotSupported(t *testing.T) { + t.Parallel() + + type0 := smbiosStruct(0, 0x18, map[int]byte{0x04: 1, 0x14: 0xFF, 0x15: 0xFF}, "Vendor") + type1 := smbiosStruct(1, 0x08, map[int]byte{0x04: 1}, "Maker") + + info, err := parseSMBIOSInfo(buildRSMB(type0, type1)) + require.NoError(t, err) + assert.Empty(t, info.BIOS.Release) +} + +func TestParseSMBIOSInfoBIOSReleaseMinorMissing(t *testing.T) { + t.Parallel() + + // Length 0x15 covers the major release byte but not the minor one. + type0 := smbiosStruct(0, 0x15, map[int]byte{0x04: 1, 0x14: 2}, "Vendor") + type1 := smbiosStruct(1, 0x08, map[int]byte{0x04: 1}, "Maker") + + info, err := parseSMBIOSInfo(buildRSMB(type0, type1)) + require.NoError(t, err) + assert.Empty(t, info.BIOS.Release) +} + +func TestParseSMBIOSInfoMalformedBeforeSystem(t *testing.T) { + t.Parallel() + + type0 := smbiosStruct(0, 0x12, map[int]byte{0x04: 1}, "Vendor") + broken := slices.Concat(smbiosStruct(2, 0x08, nil)[:0x08], []byte("Board\x00")) + + _, err := parseSMBIOSInfo(buildRSMB(type0, broken)) + require.Error(t, err) + assert.Contains(t, err.Error(), "not properly terminated") +} + +func TestParseSMBIOSInfoMalformedAfterSystem(t *testing.T) { + t.Parallel() + + // A structure with an unterminated string area after Type 1 must not + // discard the System Information that was already parsed. + type1 := smbiosStruct(1, 0x08, map[int]byte{0x04: 1}, "Maker") + broken := slices.Concat(smbiosStruct(2, 0x08, map[int]byte{0x04: 1})[:0x08], []byte("Board\x00")) + + info, err := parseSMBIOSInfo(buildRSMB(type1, broken)) + require.NoError(t, err) + assert.Equal(t, "Maker", info.System.Manufacturer) + assert.Empty(t, info.Board.Manufacturer) +} + +func TestParseSMBIOSInfoMissingSystem(t *testing.T) { + t.Parallel() + + type0 := smbiosStruct(0, 0x12, map[int]byte{0x04: 1}, "Vendor") + + _, err := parseSMBIOSInfo(buildRSMB(type0)) + require.Error(t, err) + assert.Contains(t, err.Error(), "Type 1") +} + +func FuzzParseSMBIOSInfo(f *testing.F) { // Seed with a valid RSMB blob. header := []byte{ 0x00, 0x03, 0x02, 0x00, @@ -356,12 +528,25 @@ func FuzzParseSMBIOSSystemInfo(f *testing.F) { f.Add(slices.Concat(header, tableData)) + // Seed with every parsed structure type and a partial BIOS release. + f.Add(buildRSMB( + smbiosStruct(0, 0x1A, map[int]byte{0x04: 1, 0x05: 2, 0x08: 3, 0x14: 2, 0x15: 17}, "Vendor", "1.0", "01/01/2024"), + smbiosStruct(127, 4, nil), + smbiosStruct(1, 0x1B, map[int]byte{0x04: 1, 0x19: 2, 0x1A: 3}, "Maker", "SKU", "Family"), + smbiosStruct(2, 0x0F, map[int]byte{0x04: 1, 0x08: 2}, "Board", "Tag"), + smbiosStruct(3, 0x15, map[int]byte{0x04: 1, 0x05: 0x17}, "Chassis"), + )) + f.Add(buildRSMB( + smbiosStruct(0, 0x15, map[int]byte{0x04: 1, 0x14: 2}, "Vendor"), + smbiosStruct(1, 0x08, map[int]byte{0x04: 1}, "Maker"), + )) + // Also seed with an empty input and a truncated one. f.Add([]byte{}) f.Add(header[:4]) f.Fuzz(func(t *testing.T, data []byte) { // Must not panic on any input. - _, _ = parseSMBIOSSystemInfo(data) + _, _ = parseSMBIOSInfo(data) }) }