Files
admin 3f382bf762 v0.61.0: audit fixes B1 (random temp staging) + D1 (mkfs wrapper member/RO re-checks) + D2 (empty-lsblk fail-safe) + D3 (blank-format anti-retarget)
From AUDIT-blast-radius-hostroot-localapi-2026-07-02.md. Each fix ships with a
non-hollow test + a companion red-proof (shown failing on the pre-fix impl).
Sudoers install-source grants became globs — deploy the sudoers drop-in with
the binary. A1 (stale-lock pool-membership) deliberately excluded (spike).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-03 07:25:50 +02:00

186 lines
7.9 KiB
Go

package storage
import (
"context"
"strings"
"testing"
)
// classifyClaim is the pure guard verdict — the load-bearing safety logic. Table-driven over every
// claim signal + the fail-safe, plus the two ALLOW cases (clean disk, and re-init of our own drive).
func TestClassifyClaim(t *testing.T) {
base := func() claimFacts {
return claimFacts{device: "/dev/sdd", wholeDisk: "/dev/sdd", wholeDiskOK: true,
nodes: []claimNode{{name: "sdd", fstype: "", mountpoint: ""}, {name: "sdd1", fstype: "ntfs", mountpoint: ""}}}
}
cases := []struct {
name string
mutate func(f *claimFacts)
wantUnclaimed bool
reasonHas string
}{
{"clean unclaimed disk (ntfs, unmounted)", func(f *claimFacts) {}, true, "unclaimed"},
{"blank disk", func(f *claimFacts) { f.nodes = []claimNode{{name: "sdd"}} }, true, "unclaimed"},
{"our own drive re-init (mounted under /mnt/felhom-drives)", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd"}, {name: "sdd1", fstype: "ext4", mountpoint: "/mnt/felhom-drives/felhom-flash"}}
}, true, "unclaimed"},
{"system/OS disk (non-data-bearing) — RED-PROOF vs DataBearing", func(f *claimFacts) {
f.isSystem = true
f.nodes = []claimNode{{name: "sda"}} // blank: DataBearing would say benign; the guard must still refuse
}, false, "system/OS disk"},
{"LVM PV (pvs)", func(f *claimFacts) { f.lvmPV = true }, false, "LVM physical volume"},
{"LVM2_member via lsblk fstype", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd"}, {name: "sdd1", fstype: "LVM2_member"}}
}, false, "LVM2_member"},
{"ZFS pool member (zpool)", func(f *claimFacts) { f.zfsMember = true }, false, "ZFS pool member"},
{"zfs_member via lsblk fstype", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd", fstype: "zfs_member"}}
}, false, "zfs_member"},
{"mdraid member", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd1", fstype: "linux_raid_member"}}
}, false, "linux_raid_member"},
{"active swap signature", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd1", fstype: "swap"}}
}, false, "swap"},
{"foreign mount (outside /mnt/felhom-drives)", func(f *claimFacts) {
f.nodes = []claimNode{{name: "sdd1", fstype: "ext4", mountpoint: "/srv/data"}}
}, false, "mounted at /srv/data"},
{"read-only device", func(f *claimFacts) { f.readonly = true }, false, "read-only"},
{"undeterminable topology", func(f *claimFacts) { f.wholeDiskOK = false }, false, "undeterminable"},
{"fail-safe: gather error", func(f *claimFacts) { f.gatherErr = "lsblk failed" }, false, "could not determine"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := base()
tc.mutate(&f)
gotUnclaimed, reason := classifyClaim(f)
if gotUnclaimed != tc.wantUnclaimed {
t.Fatalf("unclaimed=%v want %v (reason %q)", gotUnclaimed, tc.wantUnclaimed, reason)
}
if !strings.Contains(reason, tc.reasonHas) {
t.Errorf("reason %q missing %q", reason, tc.reasonHas)
}
})
}
}
// TestClassifyClaim_EmptyNodesRefused is the audit-D2 negative test: a successful-but-EMPTY lsblk
// (`{"blockdevices":[]}` → zero nodes) previously skipped the member/mount loop entirely and returned
// (true,"unclaimed") — the one hole in the "undeterminable ⇒ claimed" fail-safe. It must refuse.
func TestClassifyClaim_EmptyNodesRefused(t *testing.T) {
for _, nodes := range [][]claimNode{nil, {}} {
f := claimFacts{device: "/dev/sdd", wholeDisk: "/dev/sdd", wholeDiskOK: true, nodes: nodes}
unclaimed, reason := classifyClaim(f)
if unclaimed {
t.Fatalf("nodes=%v: empty topology classified UNCLAIMED (reason %q) — fail-safe hole", nodes, reason)
}
if !strings.Contains(reason, "empty block topology") {
t.Errorf("nodes=%v: reason %q missing the empty-topology explanation", nodes, reason)
}
}
}
// TestClassifyClaim_TargetAbsentFromTree (audit D2): lsblk returned SOME tree, but the target
// whole-disk is not in it — the loop inspected the wrong device's signals. Undeterminable → refuse.
func TestClassifyClaim_TargetAbsentFromTree(t *testing.T) {
f := claimFacts{device: "/dev/sdd", wholeDisk: "/dev/sdd", wholeDiskOK: true,
nodes: []claimNode{{name: "sdc"}, {name: "sdc1", fstype: "ntfs"}}} // benign signals, wrong disk
unclaimed, reason := classifyClaim(f)
if unclaimed {
t.Fatalf("target-absent tree classified UNCLAIMED (reason %q)", reason)
}
if !strings.Contains(reason, "absent from block topology") {
t.Errorf("reason %q missing the target-absent explanation", reason)
}
}
func TestParseLsblkNodes(t *testing.T) {
out := []byte(`{"blockdevices":[{"name":"sdd","fstype":null,"mountpoint":null,"children":[{"name":"sdd1","fstype":"ntfs","mountpoint":null}]}]}`)
nodes, err := parseLsblkNodes(out)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(nodes) != 2 || nodes[0].name != "sdd" || nodes[1].name != "sdd1" || nodes[1].fstype != "ntfs" {
t.Fatalf("nodes = %+v", nodes)
}
if _, err := parseLsblkNodes([]byte("not json")); err == nil {
t.Error("expected a parse error on garbage")
}
}
// --- Format guard integration: the guard is wired into Format and actually gates mkfs. ---
// newGuardOps builds a SudoHostOps with stubbed reads: a scriptRunner for lsblk/mkfs, a fakeHostReader
// for SystemDisks, Pvs/Zpool empty (→ binaryPresent=false → skipped), and readWholeDiskRO stubbed.
func newGuardOps(sr *scriptRunner, mounts []Mount) *SudoHostOps {
return &SudoHostOps{
runner: sr,
// Pvs/Zpool empty ⇒ binaryPresent=false ⇒ skipped (deterministic across OSes; lsblk carries the signals).
bins: Binaries{Lsblk: "/usr/bin/lsblk", MkfsGuarded: "/usr/local/sbin/felhom-mkfs-guarded"},
host: &fakeHostReader{mounts: mounts},
logger: quietLogger(),
}
}
func mkfsCalled(sr *scriptRunner) bool {
for _, c := range sr.calls {
if strings.Contains(c[0], "mkfs") {
return true
}
}
return false
}
func TestFormatGuard_RefusesSystemDisk(t *testing.T) {
defer stubRO(false)()
sr := &scriptRunner{out: map[string][]byte{
"/usr/bin/lsblk": []byte(`{"blockdevices":[{"name":"sda","children":[{"name":"sda2","mountpoint":"/"}]}]}`),
}}
// / on /dev/sda2 → SystemDisks resolves {/dev/sda}; formatting /dev/sda must be refused.
ops := newGuardOps(sr, []Mount{{Device: "/dev/sda2", MountPoint: "/"}})
err := ops.Format(context.Background(), "/dev/sda", "ext4")
if err == nil || !strings.Contains(err.Error(), "system/OS disk") {
t.Fatalf("want system-disk refusal, got %v", err)
}
if mkfsCalled(sr) {
t.Fatal("mkfs was invoked on a claimed device — guard failed")
}
}
func TestFormatGuard_RefusesLVMMember(t *testing.T) {
defer stubRO(false)()
sr := &scriptRunner{out: map[string][]byte{
"/usr/bin/lsblk": []byte(`{"blockdevices":[{"name":"sdd","children":[{"name":"sdd1","fstype":"LVM2_member"}]}]}`),
}}
ops := newGuardOps(sr, []Mount{{Device: "/dev/sda2", MountPoint: "/"}})
err := ops.Format(context.Background(), "/dev/sdd", "ext4")
if err == nil || !strings.Contains(err.Error(), "LVM2_member") {
t.Fatalf("want LVM-member refusal, got %v", err)
}
if mkfsCalled(sr) {
t.Fatal("mkfs invoked on an LVM member — guard failed")
}
}
func TestFormatGuard_AllowsUnclaimed(t *testing.T) {
defer stubRO(false)()
sr := &scriptRunner{out: map[string][]byte{
"/usr/bin/lsblk": []byte(`{"blockdevices":[{"name":"sdd","children":[{"name":"sdd1","fstype":"ntfs"}]}]}`),
}}
// /dev/sdd is not the system disk, not a member, not mounted → guard passes → mkfs runs.
ops := newGuardOps(sr, []Mount{{Device: "/dev/sda2", MountPoint: "/"}})
if err := ops.Format(context.Background(), "/dev/sdd", "ext4"); err != nil {
t.Fatalf("unclaimed disk must format, got %v", err)
}
if !mkfsCalled(sr) {
t.Fatal("mkfs was NOT invoked on an unclaimed disk — guard over-refused")
}
}
// stubRO overrides the /sys read-only probe for the duration of a test; the returned func restores it.
func stubRO(ro bool) func() {
orig := readWholeDiskRO
readWholeDiskRO = func(string) (bool, error) { return ro, nil }
return func() { readWholeDiskRO = orig }
}