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 } }