Files
felhom-agent/internal/storage/identity_test.go
T
admin c5daa9480d
gates / gates (push) Successful in 31s
Physical-disk identity of every storage place + POST /disks/same-disk (felhom.eu architecture/16 §3/§4)
The agent names the physical disk(s) under each storage place from /sys, udev and /proc/mounts only
(partition -> disk, dm/LVM thin -> slaves, loop -> backing file), keys them (eui / serial:<bus>:<model>
:<serial> / vendor WWN) and says proven or unknown (vm, network, usb_bridge, weak_wwn, no_serial).
GET /disks and the hub report's storage_targets gain kind, physical_disks, identity,
identity_reason (golden updated; the hub copy changes in the same session). POST /disks/same-disk
maps two guest paths to places the agent itself made and answers {different, proven, reason};
an absent drive is unmapped, never the root disk. Tests on fixture trees for every spike case,
red-proofed. CHANGELOG entry follows the release (release-complete gate).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 21:09:18 +02:00

258 lines
9.4 KiB
Go

package storage
import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// Fixture /sys/class/block + /run/udev/data + /proc/mounts trees, one per `16` §3 case. The tree mimics the
// kernel: /sys/devices/.../<disk>/<part> real dirs (a partition carries a "partition" file), /sys/class/block
// symlinks, a "dev" file per node, udev "E:" lines keyed by major:minor. The cases are the ones the spike
// measured on real devices (felhom.eu documentation/audits/storage-identity-2026-10-15/).
type fx struct {
t *testing.T
root string
minor int
mounts []string
links map[string]string // /dev path -> kernel name
virt string
}
func newFx(t *testing.T) *fx {
t.Helper()
r := t.TempDir()
for _, d := range []string{"sys/class/block", "sys/devices", "udev"} {
if err := os.MkdirAll(filepath.Join(r, d), 0o755); err != nil {
t.Fatal(err)
}
}
return &fx{t: t, root: r, links: map[string]string{}, virt: "none"}
}
func (f *fx) write(p, s string) {
f.t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
f.t.Fatal(err)
}
}
// node makes a block node. parent "" → a top-level device. udev lines are "KEY=VAL".
func (f *fx) node(name, parent string, udev ...string) {
f.t.Helper()
real := filepath.Join(f.root, "sys/devices", name)
if parent != "" {
pr, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", parent))
real = filepath.Join(pr, name)
f.write(filepath.Join(real, "partition"), "1\n")
}
f.minor++
dev := "8:" + strconv.Itoa(f.minor)
f.write(filepath.Join(real, "dev"), dev+"\n")
if err := os.Symlink(real, filepath.Join(f.root, "sys/class/block", name)); err != nil {
f.t.Fatal(err)
}
var b strings.Builder
for _, u := range udev {
b.WriteString("E:" + u + "\n")
}
f.write(filepath.Join(f.root, "udev", "b"+dev), b.String())
f.links["/dev/"+name] = name
}
func (f *fx) slaves(name string, slaves ...string) {
f.t.Helper()
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
for _, s := range slaves {
if err := os.MkdirAll(filepath.Join(real, "slaves", s), 0o755); err != nil {
f.t.Fatal(err)
}
}
}
func (f *fx) loop(name, file string) {
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
f.write(filepath.Join(real, "loop", "backing_file"), file+"\n")
}
func (f *fx) mount(dev, mp, fstype string) {
f.mounts = append(f.mounts, dev+" "+mp+" "+fstype+" rw 0 0")
}
func (f *fx) resolver() *IdentityResolver {
f.write(filepath.Join(f.root, "mounts"), strings.Join(f.mounts, "\n")+"\n")
return &IdentityResolver{Host: &SysfsIdentityHost{
SysClass: filepath.Join(f.root, "sys/class/block"),
UdevData: filepath.Join(f.root, "udev"),
ProcMounts: filepath.Join(f.root, "mounts"),
DevResolve: func(p string) (string, error) {
if n, ok := f.links[p]; ok {
return "/dev/" + n, nil
}
return p, nil
},
VirtFn: func() string { return f.virt },
}}
}
var sanDisk = []string{"ID_BUS=ata", "ID_MODEL=SanDisk_X600", "ID_SERIAL_SHORT=182195804614", "ID_WWN=0x5001b448b60a9d6e"}
// oneDiskLVM builds the PVE default layout on one SATA SSD: sda3 → pve-root, pve-data tmeta/tdata → tpool →
// a guest volume (the shape of all three test boxes).
func oneDiskLVM(f *fx) {
f.node("sda", "", sanDisk...)
f.node("sda3", "sda")
for _, d := range []string{"dm-1", "dm-2", "dm-3", "dm-4", "dm-6"} {
f.node(d, "")
}
f.slaves("dm-1", "sda3")
f.slaves("dm-2", "sda3")
f.slaves("dm-3", "sda3")
f.slaves("dm-4", "dm-2", "dm-3")
f.slaves("dm-6", "dm-4")
f.links["/dev/mapper/pve-root"] = "dm-1"
f.links["/dev/mapper/pve-data-tpool"] = "dm-4"
f.links["/dev/pve/vm-9201-disk-1"] = "dm-6"
f.mount("/dev/mapper/pve-root", "/", "ext4")
}
func TestIdentity_OneDiskLVM_AllPlacesSameProvenDisk(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
r := f.resolver()
want := []string{"serial:ata:SanDisk_X600:182195804614"}
for _, p := range []PlaceIdentity{r.HostPath("/var/lib/vz"), r.Device("/dev/mapper/pve-data-tpool"), r.Device("/dev/pve/vm-9201-disk-1")} {
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, want) {
t.Fatalf("want proven %v, got %+v", want, p)
}
}
diff, proven, why := Relation(r.HostPath("/var/lib/vz"), r.Device("/dev/pve/vm-9201-disk-1"))
if diff || !proven || why != "same_disk" {
t.Fatalf("root vs guest volume: want same+proven, got diff=%v proven=%v why=%q", diff, proven, why)
}
}
func TestIdentity_TwoDisks_ProvenDifferent(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=TOSHIBA_MQ04ABF100", "ID_SERIAL_SHORT=65NOP3HDT", "ID_WWN=0x5000039ddb108568", "DEVPATH=/devices/pci0000:00/0000:00:14.0/usb1/1-3/block/sdb")
f.mount("/dev/sdb", "/mnt/hdd_1", "ext4")
f.mount("/dev/sdb", "/mnt/felhom-drives/hdd_1", "ext4") // the felhom-data bind shows the source device
r := f.resolver()
drive := r.HostPath("/mnt/felhom-drives/hdd_1/userdata")
if drive.Identity != IdentityProven || drive.PhysicalDisks[0] != "serial:ata:TOSHIBA_MQ04ABF100:65NOP3HDT" {
t.Fatalf("USB drive passing its identity: want proven, got %+v", drive)
}
diff, proven, why := Relation(r.Device("/dev/pve/vm-9201-disk-1"), drive)
if !diff || !proven || why != "" {
t.Fatalf("system vs drive: want different+proven, got %v %v %q", diff, proven, why)
}
}
func TestIdentity_TwoPartitionsOneDisk_Same(t *testing.T) {
f := newFx(t)
f.node("sdc", "", "ID_BUS=usb", "ID_MODEL=Portable", "ID_SERIAL_SHORT=NAAB7C21")
f.node("sdc1", "sdc")
f.node("sdc2", "sdc")
f.mount("/dev/sdc1", "/mnt/a", "ext4")
f.mount("/dev/sdc2", "/mnt/b", "ext4")
r := f.resolver()
diff, proven, why := Relation(r.HostPath("/mnt/a"), r.HostPath("/mnt/b"))
if diff || !proven || why != "same_disk" {
t.Fatalf("two partitions of one disk: want same+proven, got %v %v %q", diff, proven, why)
}
}
func TestIdentity_LoopPartitionsOnAFile_ResolveToTheFilesDisk(t *testing.T) {
f := newFx(t)
f.node("nvme0n1", "", "ID_MODEL=KXG50PNV1T02", "ID_SERIAL_SHORT=58BS11AFT8MQ", "ID_WWN=eui.000000000000001000080d020046c07b")
f.mount("/dev/nvme0n1", "/mnt/hdd_1", "ext4")
f.node("loop2", "")
f.loop("loop2", "/mnt/hdd_1/scratch.img")
f.node("loop2p1", "loop2") // the partition step must run BEFORE the loop step (the spike's probe bug)
f.mount("/dev/loop2p1", "/tmp/scrA", "ext4")
r := f.resolver()
p := r.HostPath("/tmp/scrA")
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, []string{"eui:000000000000001000080d020046c07b"}) {
t.Fatalf("loop partition: want the NVMe's eui, got %+v", p)
}
}
func TestIdentity_WWNWithoutVendor(t *testing.T) {
f := newFx(t)
f.node("sda", "", "ID_BUS=ata", "ID_MODEL=AirDisk_512GB_SSD", "ID_SERIAL_SHORT=QDF922W009654S30EX", "ID_WWN=0x5000000123456f1f")
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=Cheap", "ID_WWN=0x5000000123456f1f")
r := f.resolver()
if p := r.Device("/dev/sda"); p.Identity != IdentityProven || p.PhysicalDisks[0] != "serial:ata:AirDisk_512GB_SSD:QDF922W009654S30EX" {
t.Fatalf("a real serial wins over a vendor-less WWN: got %+v", p)
}
if p := r.Device("/dev/sdb"); p.Identity != IdentityUnknown || p.Reason != ReasonWeakWWN {
t.Fatalf("no serial + vendor-less WWN: want unknown/weak_wwn, got %+v", p)
}
}
func TestIdentity_NoSerial(t *testing.T) {
f := newFx(t)
f.node("vdb", "", "ID_MODEL=")
if p := f.resolver().Device("/dev/vdb"); p.Identity != IdentityUnknown || p.Reason != ReasonNoSerial {
t.Fatalf("want unknown/no_serial, got %+v", p)
}
}
func TestIdentity_InsideAVM_EverythingUnknown(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.virt = "kvm"
f.node("sdb", "", "ID_BUS=scsi", "ID_MODEL=QEMU_HARDDISK", "ID_SERIAL_SHORT=drive-scsi1")
f.mount("/dev/sdb", "/mnt/felhom-drives/adatlemez", "ext4")
r := f.resolver()
a, b := r.Device("/dev/pve/vm-9201-disk-1"), r.HostPath("/mnt/felhom-drives/adatlemez")
for _, p := range []PlaceIdentity{a, b} {
if p.Identity != IdentityUnknown || p.Reason != ReasonVM {
t.Fatalf("inside a VM: want unknown/vm, got %+v", p)
}
}
diff, proven, why := Relation(a, b)
if proven || why != ReasonVM || !diff {
t.Fatalf("two virtual disks: want not proven (vm), got %v %v %q", diff, proven, why)
}
}
func TestIdentity_USBBridgeSerial(t *testing.T) {
f := newFx(t)
f.node("sdd", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=000000000000")
f.node("sde", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=20231106A1B2")
r := f.resolver()
if p := r.Device("/dev/sdd"); p.Identity != IdentityUnknown || p.Reason != ReasonUSBBridge {
t.Fatalf("a bridge placeholder serial: want unknown/usb_bridge, got %+v", p)
}
if p := r.Device("/dev/sde"); p.Identity != IdentityProven {
t.Fatalf("a USB disk with a plausible serial: want proven, got %+v", p)
}
}
func TestIdentity_NetworkShareAndUnmapped(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.mount("//192.168.0.123/TesztFilmek", "/mnt/felhom-drives/omv-filmek", "cifs")
r := f.resolver()
n := r.HostPath("/mnt/felhom-drives/omv-filmek/x")
if n.Identity != IdentityUnknown || n.Reason != ReasonNetwork || len(n.PhysicalDisks) != 0 {
t.Fatalf("network share: want unknown/network with no disks, got %+v", n)
}
_, proven, why := Relation(r.HostPath("/"), n)
if proven || why != ReasonNetwork {
t.Fatalf("system vs network: want not proven (network), got %v %q", proven, why)
}
if _, proven, why := Relation(PlaceIdentity{}, n); proven || why != "unmapped" {
t.Fatalf("an uncomputed side: want unmapped, got %v %q", proven, why)
}
}