package storage import ( "bufio" "context" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" ) // Physical-disk identity of a storage place — felhom.eu architecture/16-storage-physical-identity.md §3. // // A storage place (a PVE storage, an enrolled drive, the guest's data volume, a network share) is // reduced to the PHYSICAL whole disks under it — partition → parent disk, device-mapper / LVM thin → // sysfs slaves (recursive), loop → its backing file → the filesystem holding that file — and each disk // is named by a key the agent trusts: `eui:` for NVMe, `serial:::` from udev, // `wwn:` only when no serial exists and the WWN carries a vendor (OUI ≠ 000000). // // The verdict is AGENT-COMPUTED evidence (`16` §10): it is read from /sys, /run/udev/data and // /proc/mounts only, never from a caller's claim, so a compromised controller cannot make two places // "different disks". `proven` means every disk under the place has a key the agent trusts; anything // else is `unknown` with ONE reason, chosen by precedence (vm > network > usb_bridge > weak_wwn > // no_serial). Nothing is cached: every call reads the current topology, so a disk swapped in the same // bay is re-judged on the next read (the "right before each Tier-2 run" rule is the caller calling). // // Pinned by identity_test.go (fixture /sys + udev trees, one per §3 case) — the spike that proved the // walk on real devices: felhom.eu documentation/audits/storage-identity-2026-10-15/. const ( IdentityProven = "proven" IdentityUnknown = "unknown" ReasonVM = "vm" ReasonNetwork = "network" ReasonUSBBridge = "usb_bridge" ReasonWeakWWN = "weak_wwn" ReasonNoSerial = "no_serial" PlaceKindSystem = "system" PlaceKindInternal = "internal" PlaceKindDisk = "disk" PlaceKindPartition = "partition" PlaceKindNetwork = "network" ) // reasonRank orders the unknown reasons: the first one a place hits, by this precedence, is reported. var reasonRank = map[string]int{ReasonVM: 0, ReasonNetwork: 1, ReasonUSBBridge: 2, ReasonWeakWWN: 3, ReasonNoSerial: 4} // PlaceIdentity is what a storage place reports. type PlaceIdentity struct { PhysicalDisks []string // disk keys, sorted, deduplicated Identity string // proven | unknown | "" (not computed — e.g. the device is absent) Reason string // set only when Identity == unknown } // Computed reports whether a verdict was reached at all. func (p PlaceIdentity) Computed() bool { return p.Identity != "" } // IdentityHost is the read surface the resolver needs. SysfsIdentityHost is production; tests use the // same struct pointed at fixture trees. type IdentityHost interface { Mounts() ([]Mount, error) // KernelName maps a /dev path (symlinks allowed: /dev/mapper/x, /dev/pve/y) to its kernel block name. KernelName(dev string) (string, bool) // Parent returns the parent disk's kernel name when name is a partition. Parent(name string) (string, bool) // Slaves lists /sys/class/block//slaves. Slaves(name string) []string // LoopBackingFile returns a loop device's backing file. LoopBackingFile(name string) (string, bool) // UdevProps returns the udev E: properties of a block device. UdevProps(name string) map[string]string // Virt returns the VM technology ("none" on bare metal). Virt() string } // SysfsIdentityHost reads the real host. Every path is a field so tests can point it at fixtures. type SysfsIdentityHost struct { SysClass string // default /sys/class/block UdevData string // default /run/udev/data ProcMounts string // default /proc/mounts DevResolve func(string) (string, error) // default filepath.EvalSymlinks VirtFn func() string // default `systemd-detect-virt --vm`, read once virtOnce sync.Once virt string } // NewSysfsIdentityHost returns the production reader. func NewSysfsIdentityHost() *SysfsIdentityHost { return &SysfsIdentityHost{} } func (h *SysfsIdentityHost) sysClass() string { if h.SysClass != "" { return h.SysClass } return "/sys/class/block" } func (h *SysfsIdentityHost) Mounts() ([]Mount, error) { return (&ProcHostReader{ProcMounts: h.ProcMounts}).Mounts() } func (h *SysfsIdentityHost) KernelName(dev string) (string, bool) { if !strings.HasPrefix(dev, "/dev/") { return "", false } resolve := h.DevResolve if resolve == nil { resolve = filepath.EvalSymlinks } real := dev if r, err := resolve(dev); err == nil { real = r } name := filepath.Base(real) if _, err := os.Stat(filepath.Join(h.sysClass(), name)); err != nil { return "", false } return name, true } func (h *SysfsIdentityHost) Parent(name string) (string, bool) { p := filepath.Join(h.sysClass(), name) if _, err := os.Stat(filepath.Join(p, "partition")); err != nil { return "", false } real, err := filepath.EvalSymlinks(p) if err != nil { return "", false } return filepath.Base(filepath.Dir(real)), true } func (h *SysfsIdentityHost) Slaves(name string) []string { entries, err := os.ReadDir(filepath.Join(h.sysClass(), name, "slaves")) if err != nil { return nil } out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Name()) } sort.Strings(out) return out } func (h *SysfsIdentityHost) LoopBackingFile(name string) (string, bool) { if !strings.HasPrefix(name, "loop") { return "", false } b, err := os.ReadFile(filepath.Join(h.sysClass(), name, "loop", "backing_file")) if err != nil { return "", false } f := strings.TrimSpace(string(b)) return f, f != "" } func (h *SysfsIdentityHost) UdevProps(name string) map[string]string { out := map[string]string{} devB, err := os.ReadFile(filepath.Join(h.sysClass(), name, "dev")) if err != nil { return out } dir := h.UdevData if dir == "" { dir = "/run/udev/data" } f, err := os.Open(filepath.Join(dir, "b"+strings.TrimSpace(string(devB)))) if err != nil { return out } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := sc.Text() if !strings.HasPrefix(line, "E:") { continue } k, v, ok := strings.Cut(line[2:], "=") if ok { out[k] = v } } return out } func (h *SysfsIdentityHost) Virt() string { h.virtOnce.Do(func() { if h.VirtFn != nil { h.virt = h.VirtFn() return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // --vm: a container is not a reason (the agent runs on the host); exit 1 + "none" on metal. out, _ := exec.CommandContext(ctx, "systemd-detect-virt", "--vm").Output() h.virt = strings.TrimSpace(string(out)) if h.virt == "" { h.virt = "unknown" } }) return h.virt } // IdentityResolver turns devices and host paths into PlaceIdentity. type IdentityResolver struct { Host IdentityHost } // NewIdentityResolver returns a resolver over the real host. func NewIdentityResolver() *IdentityResolver { return &IdentityResolver{Host: NewSysfsIdentityHost()} } var networkFSTypes = map[string]bool{ "nfs": true, "nfs4": true, "cifs": true, "smb3": true, "fuse.sshfs": true, "9p": true, "autofs": true, "ceph": true, "glusterfs": true, } // NetworkPlace is the fixed verdict for a network share or a remote store. func NetworkPlace() PlaceIdentity { return PlaceIdentity{PhysicalDisks: []string{}, Identity: IdentityUnknown, Reason: ReasonNetwork} } // MountFor returns the mount that holds path (longest mountpoint prefix; the LAST entry wins on an equal // mountpoint, which is the one stacked on top — an automount's real filesystem over its autofs trigger). func MountFor(mounts []Mount, path string) (Mount, bool) { p := filepath.Clean(path) var best Mount found := false for _, m := range mounts { mp := filepath.Clean(m.MountPoint) if p != mp && !strings.HasPrefix(p, strings.TrimSuffix(mp, "/")+"/") { continue } if !found || len(mp) >= len(filepath.Clean(best.MountPoint)) { best, found = m, true } } return best, found } // HostPath resolves the filesystem holding a HOST path. func (r *IdentityResolver) HostPath(path string) PlaceIdentity { return r.hostPath(path, 0) } func (r *IdentityResolver) hostPath(path string, depth int) PlaceIdentity { if r == nil || r.Host == nil || depth > 4 { return PlaceIdentity{} } mounts, err := r.Host.Mounts() if err != nil { return PlaceIdentity{} } m, ok := MountFor(mounts, path) if !ok { return PlaceIdentity{} } if networkFSTypes[m.FSType] || !strings.HasPrefix(m.Device, "/dev/") { return NetworkPlace() } return r.device(m.Device, depth) } // Device resolves a block device (a /dev path, symlinks allowed). func (r *IdentityResolver) Device(dev string) PlaceIdentity { return r.device(dev, 0) } func (r *IdentityResolver) device(dev string, depth int) PlaceIdentity { if r == nil || r.Host == nil || dev == "" { return PlaceIdentity{} } name, ok := r.Host.KernelName(dev) if !ok { return PlaceIdentity{} } keys := map[string]bool{} reason := "" if !r.walk(name, depth, map[string]bool{}, keys, &reason) { return PlaceIdentity{} } out := PlaceIdentity{PhysicalDisks: sortedKeys(keys), Identity: IdentityProven} if reason != "" { out.Identity, out.Reason = IdentityUnknown, reason } return out } // walk adds the keys of every physical disk under name; ok=false when a branch cannot be grounded. func (r *IdentityResolver) walk(name string, depth int, seen, keys map[string]bool, reason *string) bool { if seen[name] { return true // a diamond (a thin pool's tmeta and tdata on one partition): already counted } if len(seen) > 64 { return false } seen[name] = true if parent, ok := r.Host.Parent(name); ok { return r.walk(parent, depth, seen, keys, reason) } if file, ok := r.Host.LoopBackingFile(name); ok { p := r.hostPath(file, depth+1) if !p.Computed() { return false } for _, k := range p.PhysicalDisks { keys[k] = true } noteReason(reason, p.Reason) return true } if slaves := r.Host.Slaves(name); len(slaves) > 0 { for _, s := range slaves { if !r.walk(s, depth, seen, keys, reason) { return false } } return true } if strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "loop") { return false // a virtual device with nothing beneath it cannot be grounded } key, why := r.diskKey(name) keys[key] = true noteReason(reason, why) return true } func noteReason(cur *string, why string) { if why == "" { return } if *cur == "" || reasonRank[why] < reasonRank[*cur] { *cur = why } } // diskKey names one whole disk and says why it is not trusted ("" when it is). func (r *IdentityResolver) diskKey(name string) (key, reason string) { p := r.Host.UdevProps(name) serial := strings.TrimSpace(p["ID_SERIAL_SHORT"]) model := strings.TrimSpace(p["ID_MODEL"]) bus := strings.TrimSpace(p["ID_BUS"]) wwn := strings.ToLower(strings.TrimSpace(p["ID_WWN"])) if bus == "" && strings.HasPrefix(name, "nvme") { bus = "nvme" } usb := bus == "usb" || strings.Contains(p["DEVPATH"], "/usb") switch { case strings.HasPrefix(name, "nvme") && (strings.HasPrefix(wwn, "eui.") || strings.HasPrefix(wwn, "nvme.")): key = "eui:" + strings.TrimPrefix(strings.TrimPrefix(wwn, "eui."), "nvme.") case serial != "": key = "serial:" + bus + ":" + model + ":" + serial if usb && FakeBridgeSerial(serial) { reason = ReasonUSBBridge } case wwn != "" && WWNHasVendor(wwn): key = "wwn:" + wwn case wwn != "": key, reason = "wwn:"+wwn, ReasonWeakWWN default: key, reason = "dev:"+name, ReasonNoSerial } if v := r.Host.Virt(); v != "none" { reason = ReasonVM // inside a VM a virtual disk says nothing about the host disk (spike cases 5–6) } return key, reason } // WWNHasVendor reports whether an NAA-5/6 WWN carries a registered vendor (OUI ≠ 000000). Other NAA // formats are taken as they are. Measured: the N100's AirDisk SSD reports 0x5000000123456f1f. func WWNHasVendor(wwn string) bool { h := strings.TrimPrefix(strings.ToLower(wwn), "0x") if len(h) < 7 { return false } if h[0] == '5' || h[0] == '6' { return h[1:7] != "000000" } return true } // FakeBridgeSerial reports a serial that a USB bridge reports for any disk: too short, one repeated // character, or a known placeholder. UNPROVEN on a real enclosure (R-1077) — the rule is the design's. func FakeBridgeSerial(s string) bool { s = strings.ToUpper(strings.TrimSpace(s)) if len(s) < 6 { return true } if strings.Count(s, s[:1]) == len(s) { return true } switch s { case "123456789", "1234567890", "123456789ABC", "0123456789ABCDEF", "1234567890ABCDEF", "DEADBEEF": return true } return false } func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // Relation compares two places (`16` §4). A SHARED disk key is a positive fact, so two places on one disk // are "same, proven" even when that disk is unknown (inside a VM, two parts of one virtual disk). Otherwise // proven is true only when both verdicts are proven; different then means "no disk in common". When proven // is false, different only says the known keys do not overlap — callers must act on proven first. // reason: "" (proven, different), "same_disk", "unmapped" (a side has no verdict), or the unknown reason. func Relation(a, b PlaceIdentity) (different, proven bool, reason string) { if !a.Computed() || !b.Computed() { return false, false, "unmapped" } set := map[string]bool{} for _, k := range a.PhysicalDisks { set[k] = true } shared := false for _, k := range b.PhysicalDisks { if set[k] { shared = true } } if shared { return false, true, "same_disk" } if a.Identity == IdentityProven && b.Identity == IdentityProven { return true, true, "" } why := a.Reason if why == "" || (b.Reason != "" && reasonRank[b.Reason] < reasonRank[why]) { why = b.Reason } return true, false, why }