Physical-disk identity of every storage place + POST /disks/same-disk (felhom.eu architecture/16 §3/§4)
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
This commit is contained in:
admin committed 2026-10-15 21:09:18 +02:00
1 parent d2053e7d47
commit c5daa9480d
13 files changed
+1219 -1

No files matched your search

+445
View File
@@ -0,0 +1,445 @@
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:<bus>:<model>:<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/<name>/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
}