//go:build linux package storage import ( "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "golang.org/x/sys/unix" ) // IsSystemDisk checks if the given device path overlaps with the root filesystem device. // Returns true if the device is (or is the parent of) the system disk. func IsSystemDisk(devicePath string) (bool, error) { // Get the block device major number of the root filesystem var rootStat syscall.Stat_t if err := syscall.Stat("/", &rootStat); err != nil { return false, fmt.Errorf("cannot stat /: %w", err) } // Get block device info of the target device (via /host-dev — Docker overrides /dev) var devStat syscall.Stat_t if err := syscall.Stat(HostDevicePath(devicePath), &devStat); err != nil { return false, fmt.Errorf("cannot stat %s: %w", devicePath, err) } // C5: Use unix.Major/Minor for correct 12-bit extraction (old 0xff mask truncated high bits). // Also compare the disk portion of the minor to distinguish separate physical disks of the // same type (e.g., sda and sdb both have major 8, but different disk-minor groups of 16). rootMajor := unix.Major(rootStat.Dev) rootMinor := unix.Minor(rootStat.Dev) devMajor := unix.Major(devStat.Rdev) devMinor := unix.Minor(devStat.Rdev) if rootMajor != devMajor { return false, nil } // Same major — compare disk groups (each disk gets 16 minor numbers on SCSI/SATA, // e.g., sda=0-15, sdb=16-31; NVMe uses similar grouping). rootDiskGroup := rootMinor / 16 devDiskGroup := devMinor / 16 return rootDiskGroup == devDiskGroup, nil } // IsSystemPartition checks if a specific partition is a system partition // (/, /boot, /boot/efi, swap) or is currently mounted. // Unlike IsSystemDisk() which blocks the entire disk, this checks only the // individual partition — allowing non-system partitions on system disks to be formatted. func IsSystemPartition(partitionPath string) (bool, error) { fstabPath := "/host-fstab" if _, err := os.Stat(fstabPath); err != nil { fstabPath = "/etc/fstab" } data, err := os.ReadFile(fstabPath) if err != nil { // If we can't read fstab, err on the side of caution return true, fmt.Errorf("cannot read fstab: %w", err) } systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true} for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } source := fields[0] mountPoint := fields[1] fsType := fields[2] if !systemMounts[mountPoint] && fsType != "swap" { continue } var devPath string if strings.HasPrefix(source, "UUID=") { uuid := strings.TrimPrefix(source, "UUID=") if out, err := exec.Command("blkid", "-U", uuid).Output(); err == nil { devPath = strings.TrimSpace(string(out)) } } else if strings.HasPrefix(source, "/dev/") { devPath = source } if devPath == partitionPath { return true, nil } } // Also check if the partition is currently mounted mounted, err := IsDeviceMounted(partitionPath) if err != nil { return false, err } if mounted { return true, nil } return false, nil } // IsDeviceMounted checks if a device or any of its partitions is currently mounted. func IsDeviceMounted(devicePath string) (bool, error) { data, err := os.ReadFile("/proc/mounts") if err != nil { return false, fmt.Errorf("cannot read /proc/mounts: %w", err) } base := filepath.Base(devicePath) for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } dev := fields[0] devBase := filepath.Base(dev) // H9: Require exact match or that the suffix after base is a digit or 'p' (partition marker). // Prevents /dev/sdb matching /dev/sdba (hypothetical device) or /dev/sdb_backup (bind). if devBase == base { return true, nil } if strings.HasPrefix(devBase, base) { next := devBase[len(base)] if next >= '0' && next <= '9' || next == 'p' { return true, nil } } } return false, nil } // IsMountPathInUse checks if a path is already used as a mount point. func IsMountPathInUse(mountPath string) (bool, error) { data, err := os.ReadFile("/proc/mounts") if err != nil { return false, fmt.Errorf("cannot read /proc/mounts: %w", err) } mountPath = filepath.Clean(mountPath) for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } if filepath.Clean(fields[1]) == mountPath { return true, nil } } return false, nil } // BackupFstab creates a dated backup of the fstab file. func BackupFstab(fstabPath string) error { data, err := os.ReadFile(fstabPath) if err != nil { return fmt.Errorf("cannot read %s: %w", fstabPath, err) } backupPath := fstabPath + ".bak." + time.Now().Format("20060102") return os.WriteFile(backupPath, data, 0644) } // AppendFstabEntry appends a UUID-based fstab entry. // Uses atomic rename when possible, falls back to direct overwrite for bind-mounted files // (Docker mounts /etc/fstab as /host-fstab — rename fails with EBUSY on bind mounts). func AppendFstabEntry(fstabPath, uuid, mountPoint, fsType, options string) error { existing, err := os.ReadFile(fstabPath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("cannot read fstab: %w", err) } entry := fmt.Sprintf("\nUUID=%s\t%s\t%s\t%s\t0 2\n", uuid, mountPoint, fsType, options) newContent := append(existing, []byte(entry)...) return safeWriteFile(fstabPath, newContent, 0644) } // RemoveFstabEntry removes any line containing the given UUID from fstab. // H19: Called as rollback if mount fails after fstab was written. func RemoveFstabEntry(fstabPath, uuid string) error { data, err := os.ReadFile(fstabPath) if err != nil { return fmt.Errorf("cannot read fstab: %w", err) } var kept []string for _, line := range strings.Split(string(data), "\n") { if !strings.Contains(line, "UUID="+uuid) { kept = append(kept, line) } } newContent := strings.Join(kept, "\n") return safeWriteFile(fstabPath, []byte(newContent), 0644) } // safeWriteFile writes content to a file. It tries atomic rename first (write to .tmp, // then rename). If rename fails (e.g., bind-mounted files where rename returns EBUSY), // it falls back to a direct truncate-and-write to the target file. func safeWriteFile(path string, content []byte, perm os.FileMode) error { tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, content, perm); err != nil { return fmt.Errorf("cannot write tmp file %s: %w", tmpPath, err) } if err := os.Rename(tmpPath, path); err == nil { return nil // atomic rename succeeded } // Rename failed (likely bind mount) — fall back to direct write os.Remove(tmpPath) if err := os.WriteFile(path, content, perm); err != nil { return fmt.Errorf("cannot write %s: %w", path, err) } return nil }