From d2f732a2d656fe761b12f99877efa4337ab6431c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Rousseau?= Date: Tue, 5 May 2026 07:12:15 +0200 Subject: [PATCH] Add support for parsing VPD Page 80 serials VPD Page 80 usually contains the serial number of a storage device when the `device/serial` file is missing. Also adding this method just before the WWN fallback --- internal/blockdev/blockdev.go | 17 ++++++ internal/blockdev/blockdev_test.go | 85 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/internal/blockdev/blockdev.go b/internal/blockdev/blockdev.go index 634e4b8..6e1a94f 100644 --- a/internal/blockdev/blockdev.go +++ b/internal/blockdev/blockdev.go @@ -97,6 +97,11 @@ func readOne(fsys FS, sysBlock, name string) (Device, error) { vendor := readStr("device/vendor") model := readStr("device/model") serial := readStr("device/serial") + if serial == "" { + if raw, err := fsys.ReadFile(filepath.Join(base, "device/vpd_pg80")); err == nil { + serial = parseVPDPage80(raw) + } + } if serial == "" { serial = readStr("device/wwid") } @@ -127,6 +132,18 @@ func readOne(fsys FS, sysBlock, name string) (Device, error) { }, nil } +// Extracts the serial number from a SCSI VPD page 80 blob +func parseVPDPage80(data []byte) string { + if len(data) < 4 || data[1] != 0x80 { + return "" + } + pageLen := int(data[2])<<8 | int(data[3]) + if len(data) < 4+pageLen { + return "" + } + return strings.TrimSpace(string(data[4 : 4+pageLen])) +} + func (d Device) CapacityGB() uint64 { return d.CapacityBytes / 1_000_000_000 } diff --git a/internal/blockdev/blockdev_test.go b/internal/blockdev/blockdev_test.go index 0517e4f..8eb975b 100644 --- a/internal/blockdev/blockdev_test.go +++ b/internal/blockdev/blockdev_test.go @@ -587,3 +587,88 @@ func TestDiscover_MixedFilteredAndValid(t *testing.T) { t.Error("expected 'sda' in result") } } + +// TestParseVPDPage80 tests the VPD page 80 serial number parsing +func TestParseVPDPage80(t *testing.T) { + tests := []struct { + name string + data []byte + want string + }{ + { + name: "valid real data with leading spaces", + data: []byte{0x00, 0x80, 0x00, 0x14, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 'S', 'E', 'R', 'I', 'A', 'L', 'N', 'U', 'M', 'B', 'E', 'R'}, + want: "SERIALNUMBER", + }, + { + name: "too short", + data: []byte{0x00, 0x80, 0x00}, + want: "", + }, + { + name: "wrong page code", + data: []byte{0x00, 0x83, 0x00, 0x04, 'A', 'B', 'C', 'D'}, + want: "", + }, + { + name: "truncated payload", + data: []byte{0x00, 0x80, 0x00, 0x10, 'A', 'B'}, + want: "", + }, + { + name: "no padding", + data: []byte{0x00, 0x80, 0x00, 0x04, 'A', 'B', 'C', 'D'}, + want: "ABCD", + }, + { + name: "empty data", + data: []byte{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseVPDPage80(tt.data) + if got != tt.want { + t.Errorf("parseVPDPage80() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestDiscover_FallsBackToVPDPage80 verifies that when device/serial is absent, +// the code parses device/vpd_pg80 before falling back to wwid. +func TestDiscover_FallsBackToVPDPage80(t *testing.T) { + fsys := &mockFS{ + dirs: map[string][]fakeEntry{ + "/sys/block": {{name: "sda"}}, + }, + files: map[string]string{ + "/sys/block/sda/queue/rotational": "0\n", + "/sys/block/sda/device/vendor": "ATA", + "/sys/block/sda/device/model": "MYLITTLEHDD", + "/sys/block/sda/size": "1000", + "/sys/block/sda/queue/logical_block_size": "512", + // device/serial absent; vpd_pg80 present with binary data + "/sys/block/sda/device/vpd_pg80": string([]byte{ + 0x00, 0x80, 0x00, 0x14, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 'S', 'E', 'R', 'I', 'A', 'L', 'N', 'U', 'M', 'B', 'E', 'R', + }), + }, + symlinks: map[string]string{}, + } + devs, err := Discover(fsys, "/sys/block") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + dev, ok := devs["sda"] + if !ok { + t.Fatal("expected device 'sda' to be present") + } + if dev.Serial != "SERIALNUMBER" { + t.Errorf("expected serial from vpd_pg80, got %q", dev.Serial) + } +}