diff --git a/CHANGELOG.md b/CHANGELOG.md index df583d9..0099ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased — three fixes from the 2026-10-12 security review and the storage build (R-1093, R-978, R-973) (2026-10-18) + +Committed, not released — the release and its vouch are the operator's. + +- **R-1093 — an EMPTY partition no longer asks the typed „data will be erased" confirmation.** `lsblk` prints the + parent disk's table type (PTTYPE) on every partition, so `SudoHostOps.InspectDevice` read a blank partition as + „device has a partition table". Now lsblk's PTTYPE is set aside only when `blkid -p` on the partition itself proved + it read the device and that it is a partition of a table of exactly that type (`PART_ENTRY_NUMBER` + + `PART_ENTRY_SCHEME` equal to lsblk's PTTYPE). The partition's own signatures still come from `blkid -p`: a + filesystem or a nested table inside it stays data-bearing; a blkid that printed nothing, a whole disk, or a scheme + that differs keeps the old, safe reading. **No new command, no sudoers or config-bundle change** (the two probes are + the same allow-listed argv). Test `TestInspect_R1093_PartitionVerdicts` (8 rows), red-proved: with the old + `if pttype != ""` both empty-partition rows fail `DataBearing=true reason="device has a partition table"`. + ## v0.159.2 — the orphan thin check runs only after a failed backup (R-1012 follow-up) (2026-10-18) **Released 2026-10-18 (committed 2026-10-17): sha256 `4d98868e922d6c10665a6c0da9323e8627ea9c589094e81d27d741e625d9d8f9`, bundle `477704ea2980948433ffc34bb59f008cc11410fd9683d386bfd3e2f085d55bc9`; the vouch is the operator's.** No `configs/` change since v0.159.1. The independent review of 2026-10-18 found nothing in it. v0.159.0/v0.159.1 ran the R-1012 orphan check diff --git a/internal/storage/hostops.go b/internal/storage/hostops.go index b4bf282..c3b6f71 100644 --- a/internal/storage/hostops.go +++ b/internal/storage/hostops.go @@ -420,7 +420,8 @@ func (h *SudoHostOps) InspectDevice(ctx context.Context, device string) (DeviceP // on (blkid exits 2 on a blank device) — output presence is what matters. A broken/empty // blkid simply adds no positive evidence; lsblk (below) is the read-success authority. bout, _, _ := h.runner.Run(ctx, h.bins.Blkid, "-p", "-o", "export", device) - for k, v := range parseBlkidExport(bout) { + blk := parseBlkidExport(bout) + for k, v := range blk { switch k { case "TYPE": probe.HasFilesystem = true @@ -456,13 +457,32 @@ func (h *SudoHostOps) InspectDevice(ctx context.Context, device string) (DeviceP probe.FSType = fstype } } - if pttype != "" { + if pttype != "" && !inheritedPartitionTable(blk, pttype) { probe.HasPartitionTable = true } } return probe, nil } +// inheritedPartitionTable reports whether lsblk's PTTYPE for the probed node is only the PARENT disk's +// table (R-1093). lsblk prints the disk's table type on every one of its partitions, so an EMPTY +// partition read as "device has a partition table" and asked for the typed erase confirmation. +// +// The partition's OWN signatures come from `blkid -p` on the partition itself (above): a nested table +// inside it is its PTTYPE, a filesystem is its TYPE. So lsblk's value is set aside ONLY when blkid +// positively proved two things — that it read the device (it printed output), and that the device is a +// partition of a table of exactly that type (PART_ENTRY_NUMBER + PART_ENTRY_SCHEME == lsblk's PTTYPE). +// Anything else keeps the old, safe reading: a blkid that failed or printed nothing, a whole disk, or a +// scheme that differs from lsblk's (then lsblk sees a table blkid did not attribute to the parent). +// Pinned by TestInspect_R1093_* (hostops_disk_test.go). +func inheritedPartitionTable(blkid map[string]string, lsblkPTType string) bool { + if blkid["PART_ENTRY_NUMBER"] == "" { + return false + } + scheme := blkid["PART_ENTRY_SCHEME"] + return scheme != "" && strings.EqualFold(scheme, lsblkPTType) +} + // Format runs mkfs. on a validated device. The caller is responsible for authorization // (8C: only after classifying the device non-data-bearing, or via a slice-10 operator signature). func (h *SudoHostOps) Format(ctx context.Context, device, fstype string) error { diff --git a/internal/storage/hostops_disk_test.go b/internal/storage/hostops_disk_test.go index 1b7b37e..42b9e58 100644 --- a/internal/storage/hostops_disk_test.go +++ b/internal/storage/hostops_disk_test.go @@ -222,3 +222,82 @@ func TestInspect_ReadsFilesystemUUID(t *testing.T) { 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-PROOF (observed 2026-10-18): 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`. Restored. +func TestInspect_R1093_PartitionVerdicts(t *testing.T) { + cases := []struct { + name string + dev string + blkid string + blkidErr error + lsblk string + wantBear bool + wantReason string + }{ + {name: "empty GPT partition is blank (the R-1093 case)", dev: "/dev/sdb1", + blkid: blkidEmptyGPTPart, blkidErr: errors.New("exit status 2"), 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"}, + {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}, + } + p, err := newSudo(r).InspectDevice(context.Background(), c.dev) + if err != nil { + t.Fatal(err) + } + 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) + } + }) + } +}