package storage import ( "context" "encoding/json" "fmt" "os" "path" "path/filepath" "strings" ) // The "unclaimed-disk" filter (Impl-1, SPIKE-drive-enrollment-2026-07-01 §SQ2). It answers a single // safety-critical question: is a block device provably FREE for Felhom to format, i.e. NOT claimed by // the OS or by anything but Felhom's own drives? It is the mandatory guard `Format` runs before mkfs // (the sudoers permits `mkfs /dev/*`, so the agent's code — this filter — is the real destructive-op // guard; the pool-scoped token ACL does not touch a sudo mkfs). // // FAIL-SAFE is the rule: the filter returns unclaimed ONLY when it can positively read every required // signal AND none indicates a claim. Any read error, undeterminable topology, or any claim signal → // CLAIMED → refuse. It never offers a disk it cannot prove is free. // felhomDrivesPrefix — a mount under here is one of Felhom's OWN managed drives, which is NOT a foreign // claim: re-initialising our own drive stays allowed (the DataBearing wipe-confirm still gates the data // loss). A mount anywhere else is a foreign claim → refuse. const felhomDrivesPrefix = "/mnt/felhom-drives" // memberFSTypes are lsblk/blkid FSTYPE values meaning the device is a MEMBER of a higher-level // construct (LVM/ZFS/mdraid/LUKS) or active swap — always a claim, never a plain formattable data disk. var memberFSTypes = map[string]bool{ "LVM2_member": true, "zfs_member": true, "linux_raid_member": true, "crypto_LUKS": true, "swap": true, } // claimNode is one block node — the whole disk or a partition/child — with the signals we classify on. type claimNode struct { name string fstype string mountpoint string } // claimFacts is the gathered evidence for one candidate device. classifyClaim is a PURE function of it // (so the decision logic is fully unit-testable from fixtures, with no host). type claimFacts struct { device string wholeDisk string wholeDiskOK bool isSystem bool readonly bool nodes []claimNode // the whole disk + its children (partitions) lvmPV bool // pvs (authoritative): the disk / a partition is an LVM physical volume zfsMember bool // zpool (authoritative): the disk / a partition is a ZFS pool member // felhomOwnedMounts (R-220) — mountpoints OUTSIDE /mnt/felhom-drives that are nevertheless Felhom's // OWN, corroborated from the host mount table: the same device is also mounted at the managed path. // Empty means "nothing corroborated", which is the fail-safe direction. felhomOwnedMounts map[string]bool gatherErr string // non-empty ⇒ a REQUIRED read failed ⇒ fail-safe CLAIMED } // classifyClaim is the pure guard verdict. unclaimed=true ONLY when the device is provably free for // Felhom to format; every claim signal / error / ambiguity ⇒ unclaimed=false with a human reason. func classifyClaim(f claimFacts) (unclaimed bool, reason string) { if f.gatherErr != "" { return false, "could not determine device claims: " + f.gatherErr } if !f.wholeDiskOK { return false, "undeterminable device topology (not a recognizable raw disk)" } if f.isSystem { return false, "system/OS disk" } if f.readonly { return false, "read-only device" } if f.lvmPV { return false, "device holds an LVM physical volume" } if f.zfsMember { return false, "device is a ZFS pool member" } for _, n := range f.nodes { if memberFSTypes[n.fstype] { return false, "device holds a " + n.fstype + " (" + n.name + ")" } // ── R-220 — A MOUNT FELHOM ITSELF MADE IS NOT "SOMETHING ELSE". ─────────────────────── // // Enrolment mounts a drive TWICE: at the managed path `/mnt/felhom-drives/` and at the // raw `/mnt/` it creates on the host. The host — and therefore that raw mount — survives // a guest rebuild, while the controller's registry does not. So after a rebuild the customer's // own drives looked foreign, `attach` returned an empty list, and the refusal told them to // choose from it. Measured live three times (CAMPAIGN-11 Phase 1, and the R-201 re-walk twice); // unmounting only the raw mounts flipped `attach: []` to both drives every time. // // The fence this must NOT breach: a disk genuinely in use by something else stays refused. So // the exemption is not "any /mnt/* path" — it is CORROBORATED: the same device must ALSO be // mounted at Felhom's managed path, which is a state only Felhom's own enrolment produces. // A foreign disk at /srv/data or /media/x has no such counterpart and is still refused. if n.mountpoint != "" && !underFelhomDrives(n.mountpoint) && !f.felhomOwnedMounts[n.mountpoint] { return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")" } } // Fail-safe backstop (audit D2): a successful-but-EMPTY lsblk (or a tree that does not even contain // the target whole-disk) means the member/mount loop above inspected nothing — that is undeterminable // topology, not proof of freedom. Without this, "unclaimed" rested on the untested assumption that // lsblk always ERRORS (non-zero exit) on a bad device rather than emitting empty success. if len(f.nodes) == 0 { return false, "empty block topology (undeterminable) — refusing" } base := path.Base(f.wholeDisk) found := false for _, n := range f.nodes { if n.name == base { found = true break } } if !found { return false, "target disk " + base + " absent from block topology (undeterminable) — refusing" } return true, "unclaimed" } // underFelhomDrives reports whether a mountpoint is one of Felhom's own managed drive mounts. func underFelhomDrives(mp string) bool { mp = path.Clean(mp) // mountpoints are always unix paths — path.Clean, not filepath.Clean (Windows tests) return mp == felhomDrivesPrefix || strings.HasPrefix(mp, felhomDrivesPrefix+"/") } // deviceUnclaimed gathers the claim evidence for device and returns the pure verdict. func (h *SudoHostOps) deviceUnclaimed(ctx context.Context, device string) (bool, string) { return classifyClaim(h.gatherClaimFacts(ctx, device)) } // gatherClaimFacts reads every claim signal via host-visible reads (NOT the PVE token): the OS-disk set // (mount table), the whole-disk lsblk tree (member FSTYPE + mountpoints — the authoritative backbone), // the /sys read-only flag, and — when their tools are installed — the authoritative LVM (pvs) and ZFS // (zpool) sources. A tool that is genuinely ABSENT contributes "no claim of that kind" (lsblk still // detects the member FSTYPE); a tool that is present but ERRORS is a fail-safe CLAIMED. func (h *SudoHostOps) gatherClaimFacts(ctx context.Context, device string) claimFacts { f := claimFacts{device: device} wd, ok := wholeDiskOf(device) f.wholeDisk, f.wholeDiskOK = wd, ok // OS/system disk — fail-safe inside (unknown topology / unknown system set ⇒ system). sys, sysKnown := SystemDisks(h.host) f.isSystem = isSystemBacked(device, sys, sysKnown) if !ok { return f // classifyClaim refuses on !wholeDiskOK } // Read-only flag (/sys/block//ro is world-readable). if ro, rerr := readWholeDiskRO(wd); rerr != nil { f.gatherErr = "read-only flag unreadable" return f } else { f.readonly = ro } // lsblk tree on the WHOLE disk — REQUIRED (error ⇒ claimed). Reuses the allowlisted command. lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", wd) if lerr != nil { f.gatherErr = "lsblk failed" return f } nodes, perr := parseLsblkNodes(lout) if perr != nil { f.gatherErr = "lsblk parse failed" return f } f.nodes = nodes // R-220: corroborate which non-managed mountpoints are nevertheless Felhom's own. f.felhomOwnedMounts = felhomOwnedMounts(device, nodes, h.mountTable) // LVM PV (authoritative). pvs installed but erroring ⇒ fail-safe claimed; absent ⇒ rely on lsblk's // LVM2_member FSTYPE (already in nodes). if h.binaryPresent(h.bins.Pvs) { pvset, err := h.lvmPVSet(ctx) if err != nil { f.gatherErr = "pvs failed" return f } for _, n := range nodes { if pvset["/dev/"+n.name] { f.lvmPV = true } } if pvset[wd] { f.lvmPV = true } } // ZFS members (authoritative). Absent ⇒ no ZFS on this host; present but erroring ⇒ claimed. if h.binaryPresent(h.bins.Zpool) { member, err := h.zfsMembers(ctx, nodes, wd) if err != nil { f.gatherErr = "zpool failed" return f } f.zfsMember = member } return f } // binaryPresent reports whether an absolute binary path exists (distinguishes "tool not installed" // from "tool present but errored" — only the latter is a fail-safe CLAIMED). func (h *SudoHostOps) binaryPresent(path string) bool { if path == "" { return false } _, err := os.Stat(path) return err == nil } // readWholeDiskRO reads /sys/block//ro ("1" ⇒ read-only). Whole-disk path e.g. /dev/sdd. A // package var so tests can stub the /sys read (the only host read in the gather that isn't a stubbable // runner/HostReader call). var readWholeDiskRO = func(wholeDisk string) (bool, error) { name := filepath.Base(wholeDisk) b, err := os.ReadFile(filepath.Join("/sys/block", name, "ro")) if err != nil { return false, err } return strings.TrimSpace(string(b)) == "1", nil } // --- lsblk tree parsing (per-node FSTYPE + mountpoint for the disk and every child) --- type lsblkDev struct { Name string `json:"name"` FSType string `json:"fstype"` MountPoint string `json:"mountpoint"` Children []lsblkDev `json:"children"` } // parseLsblkNodes flattens `lsblk -J` output into every node (the disk + all descendants). func parseLsblkNodes(out []byte) ([]claimNode, error) { var doc struct { BlockDevices []lsblkDev `json:"blockdevices"` } if err := json.Unmarshal(out, &doc); err != nil { return nil, err } var nodes []claimNode var walk func(d lsblkDev) walk = func(d lsblkDev) { nodes = append(nodes, claimNode{name: d.Name, fstype: d.FSType, mountpoint: d.MountPoint}) for _, c := range d.Children { walk(c) } } for _, d := range doc.BlockDevices { walk(d) } return nodes, nil } // lvmPVSet returns the set of PV device paths (canonicalized to whole-disk where possible is done by // the caller; here we return the raw pv_name paths as pvs reports them, e.g. /dev/sda3 or /dev/sdb). func (h *SudoHostOps) lvmPVSet(ctx context.Context) (map[string]bool, error) { out, stderr, err := h.runner.Run(ctx, h.bins.Pvs, "--reportformat", "json", "--noheadings", "-o", "pv_name") if err != nil { return nil, fmt.Errorf("pvs: %w: %s", err, trim(stderr)) } var doc struct { Report []struct { PV []struct { PVName string `json:"pv_name"` } `json:"pv"` } `json:"report"` } if jerr := json.Unmarshal(out, &doc); jerr != nil { return nil, jerr } set := map[string]bool{} for _, r := range doc.Report { for _, pv := range r.PV { if n := strings.TrimSpace(pv.PVName); n != "" { set[n] = true } } } return set, nil } // zfsMembers reports whether any of the device's nodes (or the whole disk) is a ZFS pool member, by // scanning `zpool status -P` (which prints full /dev paths). Conservative substring match on the node // device paths — a false positive only ever REFUSES (safe direction). func (h *SudoHostOps) zfsMembers(ctx context.Context, nodes []claimNode, wholeDisk string) (bool, error) { out, stderr, err := h.runner.Run(ctx, h.bins.Zpool, "status", "-P") if err != nil { return false, fmt.Errorf("zpool: %w: %s", err, trim(stderr)) } text := string(out) if strings.Contains(text, wholeDisk) { return true, nil } for _, n := range nodes { if n.name != "" && strings.Contains(text, "/dev/"+n.name) { return true, nil } } return false, nil } // mountTableSource yields the host mount table as (device, mountpoint) pairs. A seam so the R-220 // corroboration is unit-testable without a host. nil ⇒ the real /proc/mounts. type mountTableSource func() ([][2]string, error) // procMounts reads /proc/mounts — WORLD-READABLE, so this needs no sudo and no allowlisted command. // That matters: the lsblk invocation is pinned verbatim in the sudoers file // (`lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT /dev/*`), so switching it to the plural MOUNTPOINTS // would have meant shipping a sudoers change with the binary — a far larger blast radius than this // finding warrants. Reading the mount table directly sidesteps that entirely. func procMounts() ([][2]string, error) { data, err := os.ReadFile("/proc/mounts") if err != nil { return nil, err } var out [][2]string for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } // /proc/mounts escapes spaces as \040; unescape so a path with a space still compares. out = append(out, [2]string{fields[0], strings.ReplaceAll(fields[1], `\040`, " ")}) } return out, nil } // felhomOwnedMounts returns the mountpoints of `device` (and its children) that sit OUTSIDE // /mnt/felhom-drives but are still Felhom's own, corroborated by the same device also being mounted // UNDER /mnt/felhom-drives. That pairing is what enrolment produces and nothing else does. // // ⚠ FAIL-SAFE: an unreadable mount table returns an EMPTY set, never a permissive one. The device then // classifies exactly as it did before R-220 — refused — because "we could not corroborate" must never // read as "it is ours". func felhomOwnedMounts(device string, nodes []claimNode, src mountTableSource) map[string]bool { if src == nil { src = procMounts } table, err := src() if err != nil { return nil // unreadable ⇒ corroborate nothing } // Every device name this disk answers to: the whole disk and each child node. devs := map[string]bool{device: true} if wd, ok := wholeDiskOf(device); ok { devs[wd] = true } for _, n := range nodes { devs["/dev/"+n.name] = true } // A device is Felhom-managed only if it is mounted under the managed prefix. managed := map[string]bool{} for _, row := range table { if devs[row[0]] && underFelhomDrives(row[1]) { managed[row[0]] = true } } if len(managed) == 0 { return nil } owned := map[string]bool{} for _, row := range table { if managed[row[0]] && !underFelhomDrives(row[1]) { owned[path.Clean(row[1])] = true } } return owned }