package storage import ( "fmt" "regexp" "strings" ) // This file is the security boundary for the privileged host surface (slice 5 Phase B). // EVERY argument that will reach a root shell-out is validated HERE, before any command // is constructed — the SudoHostOps methods refuse on a validation error and never build an // arg vector, let alone exec. The adversarial matrix in validate_test.go is the proof that // the "aggressive write side" is not a loose one: shell metacharacters, path traversal, and // malformed inputs are rejected up front. Combined with arg-vector exec (never a shell // string), a validated input cannot inject. var ( // fs-UUIDs: ext/xfs are 8-4-4-4-12 lowercase hex; FAT/vFAT are "XXXX-XXXX" (upper // hex); others vary. Accept hex groups joined by single hyphens, length-bounded. // This rejects '/', '.', whitespace, and every shell metacharacter by construction. reUUID = regexp.MustCompile(`^[A-Fa-f0-9]{4,}(-[A-Fa-f0-9]+){0,4}$`) // SMART device: a strict whitelist of real block-disk patterns under /dev. No // /dev/disk/by-* symlinks, no device-mapper, no traversal — just the raw disks // smartctl is run against. Anything else is refused. reSMARTDevice = regexp.MustCompile(`^/dev/(sd[a-z]+|nvme[0-9]+n[0-9]+|hd[a-z]+|vd[a-z]+)$`) // Block device for inspection / mkfs: a raw disk OR a partition under /dev. Like the SMART // whitelist but also allows the trailing partition number (sda1, nvme0n1p2, vdb3). No // by-* symlinks, no device-mapper, no traversal. This is the mkfs/inspect target — it is // validated AND the agent device-inspects it before any destructive decision (8C). reBlockDevice = regexp.MustCompile(`^/dev/(sd[a-z]+[0-9]*|nvme[0-9]+n[0-9]+(p[0-9]+)?|hd[a-z]+[0-9]*|vd[a-z]+[0-9]*)$`) // Filesystem types the agent will mkfs. Deliberately tiny — the sudoers mkfs entries are // per-fstype binaries (mkfs.ext4 / mkfs.xfs), so this set MUST match those entries. reFSType = regexp.MustCompile(`^(ext4|xfs)$`) // LVM VG / pool names: LVM permits [A-Za-z0-9._+-]; we forbid leading '-' (would look // like a flag) and cap the length. reLVMName = regexp.MustCompile(`^[A-Za-z0-9_+.][A-Za-z0-9_+.-]*$`) // A single safe path segment (for mountpoint validation). No metacharacters; "." and // ".." are rejected separately as traversal. rePathSegment = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) ) const ( maxUUIDLen = 40 maxPathLen = 255 maxLVMLen = 128 byUUIDDir = "/dev/disk/by-uuid" maxMountSeg = 32 // a sane cap on mountpoint depth ) // ValidateUUID accepts a filesystem UUID for use in a by-uuid device path. It is the // load-bearing check (the UUID is the DR re-attach key AND a shell-out argument). func ValidateUUID(uuid string) error { if uuid == "" { return fmt.Errorf("storage: empty UUID") } if len(uuid) > maxUUIDLen { return fmt.Errorf("storage: UUID too long (%d > %d)", len(uuid), maxUUIDLen) } if !reUUID.MatchString(uuid) { return fmt.Errorf("storage: invalid UUID %q (want hex groups, no metacharacters)", uuid) } return nil } // ByUUIDDevicePath returns the validated /dev/disk/by-uuid/ path for a mount unit's // What=. Device paths for mounting are ALWAYS confined to this directory — we never accept // an arbitrary device path from any source. func ByUUIDDevicePath(uuid string) (string, error) { if err := ValidateUUID(uuid); err != nil { return "", err } return byUUIDDir + "/" + uuid, nil } // ValidateMountPath accepts an absolute mountpoint with no traversal and no metacharacters. // Each segment must be a safe token; "." / ".." segments are rejected; the bare root "/" // is rejected (we never manage a mount at root). func ValidateMountPath(path string) error { if path == "" || path[0] != '/' { return fmt.Errorf("storage: mount path must be absolute, got %q", path) } if len(path) > maxPathLen { return fmt.Errorf("storage: mount path too long (%d > %d)", len(path), maxPathLen) } if strings.ContainsAny(path, "\x00\n\r\t") { return fmt.Errorf("storage: mount path contains control characters") } segs := nonEmptySegments(path) if len(segs) == 0 { return fmt.Errorf("storage: refusing to manage a mount at %q", path) } if len(segs) > maxMountSeg { return fmt.Errorf("storage: mount path too deep") } for _, s := range segs { if s == "." || s == ".." { return fmt.Errorf("storage: mount path traversal segment %q in %q", s, path) } if !rePathSegment.MatchString(s) { return fmt.Errorf("storage: invalid mount path segment %q in %q", s, path) } } return nil } // ValidateSMARTDevice accepts only a raw block-disk path (sdX/nvmeXnY/hdX/vdX) under /dev. func ValidateSMARTDevice(device string) error { if !reSMARTDevice.MatchString(device) { return fmt.Errorf("storage: refusing smartctl on non-whitelisted device %q", device) } return nil } // ValidateBlockDevice accepts only a raw disk or partition path under /dev (the mkfs / inspect // target). The same strict-whitelist discipline as ValidateSMARTDevice: no symlinks, no // device-mapper, no traversal — refused before any command is built. func ValidateBlockDevice(device string) error { if !reBlockDevice.MatchString(device) { return fmt.Errorf("storage: refusing to operate on non-whitelisted block device %q", device) } return nil } // ValidateFSType accepts only a filesystem type the agent is configured to mkfs (ext4|xfs). The // set MUST match the per-fstype sudoers entries. func ValidateFSType(fstype string) error { if !reFSType.MatchString(fstype) { return fmt.Errorf("storage: unsupported filesystem type %q (want ext4|xfs)", fstype) } return nil } // ValidateLVMName accepts an LVM VG or LV (pool) name. func ValidateLVMName(name string) error { if name == "" { return fmt.Errorf("storage: empty LVM name") } if len(name) > maxLVMLen { return fmt.Errorf("storage: LVM name too long") } if !reLVMName.MatchString(name) { return fmt.Errorf("storage: invalid LVM name %q", name) } return nil } // UnitNameForMount returns the systemd .mount unit name for a (validated) mountpoint. A // .mount unit's name MUST be the systemd-escaped mountpoint — this is computed // deterministically from the already-validated path, so the result is inherently safe to // pass in an arg vector (no shell). func UnitNameForMount(where string) (string, error) { if err := ValidateMountPath(where); err != nil { return "", err } return systemdEscapePath(where) + ".mount", nil } // nonEmptySegments splits a path on '/', dropping empties (so "//a///b/" → [a b]). func nonEmptySegments(path string) []string { parts := strings.Split(path, "/") out := parts[:0] for _, p := range parts { if p != "" { out = append(out, p) } } return out } // systemdEscapePath replicates `systemd-escape --path`: strip leading/trailing slashes and // collapse internal repeats, then escape each char — '/' → '-', alnum/'_' kept, '.' kept // (except a leading '.'), everything else (including a literal '-') → '\xNN'. The empty // path / "/" escapes to "-". Computed in-process so no `systemd-escape` shell-out / sudoers // entry is needed. func systemdEscapePath(path string) string { segs := nonEmptySegments(path) if len(segs) == 0 { return "-" } joined := strings.Join(segs, "/") var b strings.Builder for i := 0; i < len(joined); i++ { c := joined[i] switch { case c == '/': b.WriteByte('-') case i == 0 && c == '.': b.WriteString(`\x2e`) case isAlnum(c) || c == '_': b.WriteByte(c) case c == '.': b.WriteByte('.') default: fmt.Fprintf(&b, `\x%02x`, c) } } return b.String() } func isAlnum(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') }