package storage import ( "bufio" "os" "path/filepath" "strings" ) // HostReader is the non-privileged host-read seam the observer and watchdog need. All of // it is read-only and root-free: the active mount table, fs-UUID resolution via the // /dev/disk/by-uuid symlinks, block-device presence, and the rotational/removable sysfs // flags. Production is *ProcHostReader; tests inject a fake. // // Anything that needs root (smartctl, lvs, blkid) is NOT here — it lands on Phase B's // privileged HostOps surface. Keep this seam root-free. type HostReader interface { // Mounts parses the active mount table (/proc/mounts). Mounts() ([]Mount, error) // ResolveUUID returns the filesystem UUID of a block device, derived from the // /dev/disk/by-uuid symlinks. ok=false when the device has no by-uuid entry. ResolveUUID(device string) (uuid string, ok bool) // DeviceExists reports whether a block-device node is present (the fast USB-drop // signal for the watchdog). DeviceExists(device string) bool // Rotational reads the backing disk's rotational flag (true=HDD/slow, false=SSD/fast). // ok=false when it cannot be determined (network fs, missing sysfs, device-mapper). Rotational(device string) (rotational bool, ok bool) // Removable reads the backing disk's removable flag (true => a USB/hot-plug device). // ok=false when it cannot be determined. Removable(device string) (removable bool, ok bool) } // Mount is one active-mount-table entry. type Mount struct { Device string // e.g. "/dev/sdb1", "server:/export", "//server/share" MountPoint string FSType string } // ProcHostReader is the production HostReader: it reads the host's /proc, /dev, and /sys. // The paths are fields so tests CAN point it at fixtures, though most tests use a fully // fake HostReader instead. type ProcHostReader struct { ProcMounts string // default "/proc/mounts" ByUUIDDir string // default "/dev/disk/by-uuid" SysClass string // default "/sys/class/block" } // NewProcHostReader builds a ProcHostReader with the standard host paths. func NewProcHostReader() *ProcHostReader { return &ProcHostReader{ ProcMounts: "/proc/mounts", ByUUIDDir: "/dev/disk/by-uuid", SysClass: "/sys/class/block", } } // Mounts parses /proc/mounts. The format is space-separated, octal-escaped fields: // device mountpoint fstype options dump pass. We unescape the first three. func (r *ProcHostReader) Mounts() ([]Mount, error) { f, err := os.Open(r.procMounts()) if err != nil { return nil, err } defer f.Close() var out []Mount sc := bufio.NewScanner(f) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) < 3 { continue } out = append(out, Mount{ Device: unescapeMount(fields[0]), MountPoint: unescapeMount(fields[1]), FSType: fields[2], }) } return out, sc.Err() } // ResolveUUID reverse-maps a device path to its fs-UUID by reading the /dev/disk/by-uuid // symlinks and matching the canonical target of each against the device. func (r *ProcHostReader) ResolveUUID(device string) (string, bool) { if device == "" { return "", false } want := canonPath(device) entries, err := os.ReadDir(r.byUUIDDir()) if err != nil { return "", false } for _, e := range entries { link := filepath.Join(r.byUUIDDir(), e.Name()) target, err := os.Readlink(link) if err != nil { continue } if !filepath.IsAbs(target) { target = filepath.Join(r.byUUIDDir(), target) } if canonPath(target) == want { return e.Name(), true } } return "", false } // DeviceExists stats the device node (after resolving symlinks like /dev/disk/by-uuid/X). func (r *ProcHostReader) DeviceExists(device string) bool { if device == "" { return false } _, err := os.Stat(device) return err == nil } // Rotational reads /sys/block//queue/rotational for the device's backing // disk. "1" => rotational (HDD/slow), "0" => SSD/fast. func (r *ProcHostReader) Rotational(device string) (bool, bool) { disk, ok := r.parentDisk(device) if !ok { return false, false } b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "queue", "rotational")) if err != nil { return false, false } switch strings.TrimSpace(string(b)) { case "1": return true, true case "0": return false, true } return false, false } // Removable reads /sys/block//removable. "1" => removable (USB/hot-plug). func (r *ProcHostReader) Removable(device string) (bool, bool) { disk, ok := r.parentDisk(device) if !ok { return false, false } b, err := os.ReadFile(filepath.Join(r.sysBlockDir(), disk, "removable")) if err != nil { return false, false } switch strings.TrimSpace(string(b)) { case "1": return true, true case "0": return false, true } return false, false } // parentDisk maps a device path (possibly a partition like /dev/sdb1 or /dev/nvme0n1p2) // to its parent disk's sysfs name (sdb / nvme0n1). It uses /sys/class/block/, whose // real path ends in ...// for a partition and .../ for a whole disk. func (r *ProcHostReader) parentDisk(device string) (string, bool) { name := filepath.Base(strings.TrimSpace(device)) if name == "" || name == "." || name == "/" { return "", false } real, err := filepath.EvalSymlinks(filepath.Join(r.sysClass(), name)) if err != nil { return "", false } // If is a partition, /sys/class/block//partition exists and its parent // directory is the disk. Otherwise IS the disk. if _, err := os.Stat(filepath.Join(real, "partition")); err == nil { return filepath.Base(filepath.Dir(real)), true } return filepath.Base(real), true } // sysBlockDir derives /sys/block from the configured /sys/class/block. func (r *ProcHostReader) sysBlockDir() string { return filepath.Join(filepath.Dir(filepath.Dir(r.sysClass())), "block") } func (r *ProcHostReader) procMounts() string { if r.ProcMounts != "" { return r.ProcMounts } return "/proc/mounts" } func (r *ProcHostReader) byUUIDDir() string { if r.ByUUIDDir != "" { return r.ByUUIDDir } return "/dev/disk/by-uuid" } func (r *ProcHostReader) sysClass() string { if r.SysClass != "" { return r.SysClass } return "/sys/class/block" } // canonPath resolves symlinks for a best-effort canonical comparison, falling back to the // cleaned path when the target can't be resolved (e.g. the device just disappeared). func canonPath(p string) string { if real, err := filepath.EvalSymlinks(p); err == nil { return real } return filepath.Clean(p) } // unescapeMount decodes the octal \040-style escapes /proc/mounts uses for spaces, tabs, // newlines and backslashes in the device/mountpoint fields. func unescapeMount(s string) string { if !strings.Contains(s, `\`) { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1]) && isOctal(s[i+2]) && isOctal(s[i+3]) { v := (int(s[i+1]-'0') << 6) | (int(s[i+2]-'0') << 3) | int(s[i+3]-'0') b.WriteByte(byte(v)) i += 3 continue } b.WriteByte(s[i]) } return b.String() } func isOctal(c byte) bool { return c >= '0' && c <= '7' }