c5daa9480d
gates / gates (push) Successful in 31s
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
258 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|