0963bae3c3
PART_ENTRY_* comes from the parent's table, so it proves nothing about a partition's contents unless blkid also completed: lsblk's PTTYPE is now set aside only on a blkid exit 0 (exit 2, an I/O error or a missing binary keep "device has a partition table"). An lsblk that exits 0 with output that does not parse into a device, or lists none, leaves the device unprobed instead of blank (a hole that predates R-1093). Six more TestInspect_R1093_PartitionVerdicts rows, each red-proved.
336 lines
14 KiB
Go
336 lines
14 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// scriptedRunner returns canned stdout/stderr/err per command name (last arg = device).
|
|
type scriptedRunner struct {
|
|
calls [][]string
|
|
outputs map[string][]byte // keyed by binary basename
|
|
errs map[string]error
|
|
}
|
|
|
|
func (r *scriptedRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
|
return r.Run(ctx, name, args...)
|
|
}
|
|
|
|
func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
|
r.calls = append(r.calls, append([]string{name}, args...))
|
|
base := name
|
|
if i := strings.LastIndexByte(name, '/'); i >= 0 {
|
|
base = name[i+1:]
|
|
}
|
|
return r.outputs[base], nil, r.errs[base]
|
|
}
|
|
|
|
func (r *scriptedRunner) ran(substr string) bool {
|
|
for _, c := range r.calls {
|
|
if strings.Contains(strings.Join(c, " "), substr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func newSudo(r *scriptedRunner) *SudoHostOps {
|
|
return NewSudoHostOps(SudoHostOpsConfig{Runner: r})
|
|
}
|
|
|
|
// ---- validators -------------------------------------------------------------------------
|
|
|
|
func TestValidateBlockDevice(t *testing.T) {
|
|
ok := []string{"/dev/sdb", "/dev/sdb1", "/dev/nvme0n1", "/dev/nvme0n1p2", "/dev/vdb3"}
|
|
for _, d := range ok {
|
|
if err := ValidateBlockDevice(d); err != nil {
|
|
t.Errorf("expected %q valid: %v", d, err)
|
|
}
|
|
}
|
|
bad := []string{"/dev/disk/by-uuid/x", "/dev/../etc/passwd", "/dev/sdb; rm -rf /", "/etc/shadow", "/dev/mapper/x", "sdb", ""}
|
|
for _, d := range bad {
|
|
if err := ValidateBlockDevice(d); err == nil {
|
|
t.Errorf("expected %q rejected", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateFSType(t *testing.T) {
|
|
for _, f := range []string{"ext4", "xfs"} {
|
|
if err := ValidateFSType(f); err != nil {
|
|
t.Errorf("expected %q valid", f)
|
|
}
|
|
}
|
|
for _, f := range []string{"ntfs", "vfat", "ext4 ", "", "ext4;ls"} {
|
|
if err := ValidateFSType(f); err == nil {
|
|
t.Errorf("expected %q rejected", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- InspectDevice (data-bearing detection) ---------------------------------------------
|
|
|
|
func TestInspect_Blank(t *testing.T) {
|
|
// blkid finds nothing (empty output); lsblk reads cleanly and shows a bare disk.
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{
|
|
"blkid": nil,
|
|
"lsblk": []byte(`{"blockdevices":[{"name":"sdb","fstype":null,"pttype":null,"mountpoint":null}]}`),
|
|
},
|
|
errs: map[string]error{"blkid": errors.New("exit status 2")}, // blkid exits non-zero on blank
|
|
}
|
|
p, err := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !p.Probed {
|
|
t.Fatal("expected a clean probe (lsblk read cleanly)")
|
|
}
|
|
if p.DataBearing() {
|
|
t.Fatalf("blank device classified data-bearing: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestInspect_HasFilesystem(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{
|
|
"blkid": []byte("DEVNAME=/dev/sdb\nTYPE=ext4\nUSAGE=filesystem\n"),
|
|
"lsblk": []byte(`{"blockdevices":[{"name":"sdb","fstype":"ext4","pttype":null,"mountpoint":null}]}`),
|
|
},
|
|
}
|
|
p, _ := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if !p.DataBearing() || !p.HasFilesystem || p.FSType != "ext4" {
|
|
t.Fatalf("filesystem not detected: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestInspect_HasPartitionTable(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{
|
|
"blkid": []byte("DEVNAME=/dev/sdb\nPTTYPE=gpt\n"),
|
|
"lsblk": []byte(`{"blockdevices":[{"name":"sdb","pttype":"gpt","children":[{"name":"sdb1","fstype":"ext4"}]}]}`),
|
|
},
|
|
}
|
|
p, _ := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if !p.DataBearing() || !p.HasPartitionTable || !p.HasPartitions {
|
|
t.Fatalf("partition table/children not detected: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestInspect_Mounted(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{
|
|
"blkid": []byte("TYPE=xfs\n"),
|
|
"lsblk": []byte(`{"blockdevices":[{"name":"sdb","fstype":"xfs","mountpoint":"/mnt/data"}]}`),
|
|
},
|
|
}
|
|
p, _ := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if !p.Mounted || !p.DataBearing() {
|
|
t.Fatalf("mounted not detected: %+v", p)
|
|
}
|
|
}
|
|
|
|
// A probe that fails to read cleanly must be conservative (data-bearing).
|
|
func TestInspect_FailedProbe_FailSafe(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{"blkid": nil, "lsblk": nil},
|
|
errs: map[string]error{"blkid": errors.New("blkid broke"), "lsblk": errors.New("lsblk broke")},
|
|
}
|
|
p, _ := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if p.Probed {
|
|
t.Fatal("a broken probe must not be 'Probed'")
|
|
}
|
|
if !p.DataBearing() {
|
|
t.Fatal("a broken probe must be treated as data-bearing (fail-safe)")
|
|
}
|
|
}
|
|
|
|
func TestInspect_RejectsBadDevice(t *testing.T) {
|
|
if _, err := newSudo(&scriptedRunner{}).InspectDevice(context.Background(), "/dev/../etc"); err == nil {
|
|
t.Fatal("expected a bad device to be rejected before any exec")
|
|
}
|
|
}
|
|
|
|
// ---- Format (mkfs) ----------------------------------------------------------------------
|
|
|
|
// Format now (Impl-1) runs the unclaimed guard then execs the guarded wrapper (<device> <fstype>),
|
|
// not raw mkfs. These use the stubbed guard (unclaimed /dev/sdb; system disk = /dev/sda).
|
|
func TestFormat_Ext4(t *testing.T) {
|
|
defer stubRO(false)()
|
|
sr := &scriptRunner{out: map[string][]byte{
|
|
"/usr/bin/lsblk": []byte(`{"blockdevices":[{"name":"sdb"}]}`),
|
|
}}
|
|
ops := newGuardOps(sr, []Mount{{Device: "/dev/sda2", MountPoint: "/"}})
|
|
if err := ops.Format(context.Background(), "/dev/sdb", "ext4"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !srRan(sr, "/usr/local/sbin/felhom-mkfs-guarded /dev/sdb ext4") {
|
|
t.Fatalf("guarded wrapper not invoked correctly: %v", sr.calls)
|
|
}
|
|
}
|
|
|
|
func TestFormat_Xfs(t *testing.T) {
|
|
defer stubRO(false)()
|
|
sr := &scriptRunner{out: map[string][]byte{
|
|
"/usr/bin/lsblk": []byte(`{"blockdevices":[{"name":"nvme0n1","children":[{"name":"nvme0n1p1"}]}]}`),
|
|
}}
|
|
ops := newGuardOps(sr, []Mount{{Device: "/dev/sda2", MountPoint: "/"}})
|
|
if err := ops.Format(context.Background(), "/dev/nvme0n1p1", "xfs"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !srRan(sr, "/usr/local/sbin/felhom-mkfs-guarded /dev/nvme0n1p1 xfs") {
|
|
t.Fatalf("guarded wrapper not invoked correctly: %v", sr.calls)
|
|
}
|
|
}
|
|
|
|
// srRan reports whether the scriptRunner recorded a call whose joined form contains substr.
|
|
func srRan(sr *scriptRunner, substr string) bool {
|
|
for _, c := range sr.calls {
|
|
if strings.Contains(strings.Join(c, " "), substr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestFormat_RejectsBadArgs(t *testing.T) {
|
|
r := &scriptedRunner{}
|
|
if err := newSudo(r).Format(context.Background(), "/dev/disk/by-uuid/x", "ext4"); err == nil {
|
|
t.Fatal("expected bad device rejected")
|
|
}
|
|
if err := newSudo(r).Format(context.Background(), "/dev/sdb", "ntfs"); err == nil {
|
|
t.Fatal("expected bad fstype rejected")
|
|
}
|
|
if len(r.calls) != 0 {
|
|
t.Fatalf("mkfs ran despite invalid input: %v", r.calls)
|
|
}
|
|
}
|
|
|
|
// R-25: the probe carries the superblock's filesystem UUID so the format path can report the new one.
|
|
func TestInspect_ReadsFilesystemUUID(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{
|
|
"blkid": []byte("DEVNAME=/dev/sdb\nUUID=0fc63daf-8483-4772-8e79-3d69d8477de4\nTYPE=ext4\nUSAGE=filesystem\n"),
|
|
"lsblk": []byte(`{"blockdevices":[{"name":"sdb","fstype":"ext4","pttype":null,"mountpoint":null}]}`),
|
|
},
|
|
}
|
|
p, _ := newSudo(r).InspectDevice(context.Background(), "/dev/sdb")
|
|
if p.FSUUID != "0fc63daf-8483-4772-8e79-3d69d8477de4" {
|
|
t.Fatalf("FSUUID = %q, want the blkid UUID", p.FSUUID)
|
|
}
|
|
}
|
|
|
|
// ---- R-1093: an EMPTY partition is blank; lsblk's PTTYPE on a partition is the PARENT disk's ----------
|
|
|
|
// blkidEmptyGPTPart is `blkid -p -o export /dev/sdb1` for an empty GPT partition: only the partition's
|
|
// own entry in the parent table (PART_ENTRY_*), no TYPE, no PTTYPE — nothing inside the partition.
|
|
const blkidEmptyGPTPart = "DEVNAME=/dev/sdb1\nPART_ENTRY_SCHEME=gpt\nPART_ENTRY_UUID=3b0c1a52-55a4-4c4e-9d0b-7a1f0e0f7c11\n" +
|
|
"PART_ENTRY_TYPE=0fc63daf-8483-4772-8e79-3d69d8477de4\nPART_ENTRY_NUMBER=1\nPART_ENTRY_OFFSET=2048\n" +
|
|
"PART_ENTRY_SIZE=2097152\nPART_ENTRY_DISK=8:16\n"
|
|
|
|
// lsblkPartInheritsGPT is `lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT /dev/sdb1`: lsblk prints the DISK's
|
|
// table type on the partition — the input that made the agent read an empty partition as data-bearing.
|
|
const lsblkPartInheritsGPT = `{"blockdevices":[{"name":"sdb1","fstype":null,"pttype":"gpt","mountpoint":null}]}`
|
|
|
|
// TestInspect_R1093_PartitionVerdicts pins the CONSEQUENCE (DataBearing + Reason, what decides whether
|
|
// the typed erase confirmation is asked) for a partition, in both directions.
|
|
//
|
|
// COMPANION RED-PROOFS (observed 2026-10-18): (1) restore the pre-fix `if pttype != "" {` (drop the
|
|
// inheritedPartitionTable call) → BOTH empty-partition rows (GPT and dos) FAIL with
|
|
// `DataBearing=true reason="device has a partition table", want blank`. (2) drop the `blkidErr != nil`
|
|
// check → the "exit 2" and "I/O error" rows FAIL with `DataBearing=false reason="device is blank"`.
|
|
// (3) drop `&& parsed` → both lsblk-exit-0 rows FAIL with `Probed=true … reason="device is blank"`
|
|
// (the unparsable-output hole predates R-1093; closed here). Restored.
|
|
func TestInspect_R1093_PartitionVerdicts(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
dev string
|
|
blkid string
|
|
blkidErr error
|
|
lsblk string
|
|
lsblkErr error
|
|
wantBear bool
|
|
wantReason string
|
|
unprobed bool // the verdict must be "could not be reliably inspected"
|
|
}{
|
|
{name: "empty GPT partition is blank (the R-1093 case)", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, lsblk: lsblkPartInheritsGPT,
|
|
wantBear: false, wantReason: "device is blank"},
|
|
{name: "empty MBR (dos) partition is blank", dev: "/dev/sdb2",
|
|
blkid: "DEVNAME=/dev/sdb2\nPART_ENTRY_SCHEME=dos\nPART_ENTRY_TYPE=0x83\nPART_ENTRY_NUMBER=2\nPART_ENTRY_DISK=8:16\n",
|
|
lsblk: `{"blockdevices":[{"name":"sdb2","fstype":null,"pttype":"dos","mountpoint":null}]}`,
|
|
wantBear: false, wantReason: "device is blank"},
|
|
{name: "partition with a filesystem stays data-bearing", dev: "/dev/sdb1",
|
|
blkid: "DEVNAME=/dev/sdb1\nUUID=8c1e\nTYPE=ext4\nUSAGE=filesystem\n" + blkidEmptyGPTPart[len("DEVNAME=/dev/sdb1\n"):],
|
|
lsblk: `{"blockdevices":[{"name":"sdb1","fstype":"ext4","pttype":"gpt","mountpoint":null}]}`,
|
|
wantBear: true, wantReason: "device has a ext4 filesystem"},
|
|
{name: "partition holding its OWN (nested) table stays data-bearing", dev: "/dev/sdb1",
|
|
blkid: "DEVNAME=/dev/sdb1\nPTUUID=0b8d4358\nPTTYPE=dos\n" + blkidEmptyGPTPart[len("DEVNAME=/dev/sdb1\n"):],
|
|
lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "blkid printed nothing: lsblk's PTTYPE stands (fail-safe)", dev: "/dev/sdb1",
|
|
blkid: "", blkidErr: errors.New("sudo: a password is required"), lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
// Fail-closed paths (the 2026-10-18 review): PART_ENTRY_* is read from the PARENT's table, so it
|
|
// proves nothing about the partition's contents unless blkid also completed (exit 0).
|
|
{name: "blkid exit 2 with only PART_ENTRY output: lsblk stands", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, blkidErr: errors.New("exit status 2"), lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "blkid I/O error after PART_ENTRY output: lsblk stands", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, blkidErr: errors.New("exit status 1"), lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "blkid binary missing: lsblk stands", dev: "/dev/sdb1",
|
|
blkid: "", blkidErr: errors.New(`exec: "/usr/sbin/blkid": file does not exist`), lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "lsblk exit 0 with unparsable output: unprobed", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, lsblk: `lsblk: something odd`,
|
|
wantBear: true, unprobed: true},
|
|
{name: "lsblk exit 0 with no device listed: unprobed", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, lsblk: `{"blockdevices":[]}`,
|
|
wantBear: true, unprobed: true},
|
|
{name: "lsblk failed: unprobed", dev: "/dev/sdb1",
|
|
blkid: blkidEmptyGPTPart, lsblk: "", lsblkErr: errors.New("exit status 32"),
|
|
wantBear: true, unprobed: true},
|
|
{name: "scheme differs from lsblk's PTTYPE: lsblk stands", dev: "/dev/sdb1",
|
|
blkid: strings.Replace(blkidEmptyGPTPart, "PART_ENTRY_SCHEME=gpt", "PART_ENTRY_SCHEME=dos", 1),
|
|
lsblk: lsblkPartInheritsGPT,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "whole disk with a GPT stays data-bearing", dev: "/dev/sdb",
|
|
blkid: "DEVNAME=/dev/sdb\nPTUUID=a07f88ff\nPTTYPE=gpt\n",
|
|
lsblk: `{"blockdevices":[{"name":"sdb","fstype":null,"pttype":"gpt","mountpoint":null}]}`,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
{name: "whole disk: blkid silent, lsblk GPT stands", dev: "/dev/sdb",
|
|
blkid: "", lsblk: `{"blockdevices":[{"name":"sdb","fstype":null,"pttype":"gpt","mountpoint":null}]}`,
|
|
wantBear: true, wantReason: "device has a partition table"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
r := &scriptedRunner{
|
|
outputs: map[string][]byte{"blkid": []byte(c.blkid), "lsblk": []byte(c.lsblk)},
|
|
errs: map[string]error{"blkid": c.blkidErr, "lsblk": c.lsblkErr},
|
|
}
|
|
p, err := newSudo(r).InspectDevice(context.Background(), c.dev)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.unprobed {
|
|
if p.Probed || !p.DataBearing() || p.Reason() != "device could not be reliably inspected" {
|
|
t.Fatalf("Probed=%v DataBearing=%v reason=%q, want unprobed + data-bearing (probe %+v)",
|
|
p.Probed, p.DataBearing(), p.Reason(), p)
|
|
}
|
|
return
|
|
}
|
|
if !p.Probed {
|
|
t.Fatal("expected a clean probe (lsblk read cleanly)")
|
|
}
|
|
if p.DataBearing() != c.wantBear || p.Reason() != c.wantReason {
|
|
t.Fatalf("DataBearing=%v reason=%q, want DataBearing=%v reason=%q (probe %+v)",
|
|
p.DataBearing(), p.Reason(), c.wantBear, c.wantReason, p)
|
|
}
|
|
})
|
|
}
|
|
}
|