//go:build linux package system import ( "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" ) // IsMountPoint checks if a path is on a different device than its parent. // Returns true if the path is a mount point (different device ID from parent). func IsMountPoint(path string) bool { var pathStat, parentStat syscall.Stat_t if err := syscall.Stat(path, &pathStat); err != nil { return false } parent := filepath.Dir(path) if err := syscall.Stat(parent, &parentStat); err != nil { return false } return pathStat.Dev != parentStat.Dev } // IsWritable checks if the given path is writable by attempting to create+remove a temp file. func IsWritable(path string) bool { testFile := filepath.Join(path, ".felhom-write-test") f, err := os.Create(testFile) if err != nil { return false } f.Close() os.Remove(testFile) return true } // PathsOverlap returns true if one path is a parent or child of the other. func PathsOverlap(a, b string) bool { a = filepath.Clean(a) b = filepath.Clean(b) if a == b { return true } aSep := a + string(os.PathSeparator) bSep := b + string(os.PathSeparator) return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep) } // DiskUsageInfo holds disk usage statistics for a path. type DiskUsageInfo struct { TotalGB float64 UsedGB float64 AvailGB float64 UsedPercent float64 TotalHuman string UsedHuman string } // GetDiskUsage returns disk usage info for a path, or nil on error. func GetDiskUsage(path string) *DiskUsageInfo { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { return nil } bsize := uint64(stat.Bsize) total := stat.Blocks * bsize avail := stat.Bavail * bsize used := total - (stat.Bfree * bsize) const gb = 1024 * 1024 * 1024 info := &DiskUsageInfo{ TotalGB: float64(total) / float64(gb), UsedGB: float64(used) / float64(gb), AvailGB: float64(avail) / float64(gb), } if total > 0 { info.UsedPercent = float64(used) / float64(total) * 100 } info.TotalHuman = formatGB(info.TotalGB) info.UsedHuman = formatGB(info.UsedGB) return info } func formatGB(gb float64) string { if gb >= 1000 { return fmt.Sprintf("%.1f TB", gb/1024) } return fmt.Sprintf("%.1f GB", gb) } // FSInfo holds filesystem type, device, and disk model info. type FSInfo struct { FSType string // "ext4", "btrfs" Device string // "/dev/sda1" Model string // "WD Elements 25A2" (best-effort from sysfs) } // GetFSInfo returns filesystem info for a path using findmnt, or nil on error. func GetFSInfo(path string) *FSInfo { out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output() if err != nil { return nil } fields := strings.Fields(strings.TrimSpace(string(out))) if len(fields) < 2 { return nil } info := &FSInfo{ Device: fields[0], FSType: fields[1], } // Try to get disk model from sysfs info.Model = diskModel(info.Device) return info } // DestinationHealth holds the result of a tiered backup destination check. type DestinationHealth struct { Exists bool Writable bool MountPoint bool // true if path is on a different device from its parent SystemDrive bool // true if path is on the same device as / UsedPercent float64 // disk usage percentage (0 if unknown) FreeGB float64 Warning string // human-readable warning message in Hungarian (empty = ok) Blocked bool // if true, backup must not run Severity string // "ok", "warning", "critical" } // CheckBackupDestination performs tiered validation of a cross-drive backup destination. // Returns a DestinationHealth describing any issues found. func CheckBackupDestination(path string) DestinationHealth { h := DestinationHealth{Severity: "ok"} // Tier 1: path must exist if _, err := os.Stat(path); os.IsNotExist(err) { h.Warning = "A cél tárhely (" + path + ") nem létezik!" h.Blocked = true h.Severity = "critical" return h } h.Exists = true // Tier 2: path must be writable if !IsWritable(path) { h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat." h.Blocked = true h.Severity = "critical" return h } h.Writable = true // Tier 3: detect if source and destination are on the same block device // (stronger than IsMountPoint — catches e.g. bind mounts within same device) if isSameBlockDevice(path, "/") { h.SystemDrive = true // This is a warning, not a block — user data still protected against software errors h.Warning = "A cél tárhely (" + path + ") a rendszermeghajtón van. " + "Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " + "Külső meghajtó használata javasolt." h.Severity = "warning" // Don't return early — also check disk usage } else { h.MountPoint = true } // Tier 4: disk usage checks if di := GetDiskUsage(path); di != nil { h.UsedPercent = di.UsedPercent h.FreeGB = di.AvailGB if di.UsedPercent >= 95 { h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent) h.Blocked = true h.Severity = "critical" } else if di.UsedPercent >= 90 && h.Severity == "ok" { h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent) h.Severity = "warning" } } return h } // isSameBlockDevice returns true if pathA and pathB are on the same block device. func isSameBlockDevice(pathA, pathB string) bool { var statA, statB syscall.Stat_t if err := syscall.Stat(pathA, &statA); err != nil { return false } if err := syscall.Stat(pathB, &statB); err != nil { return false } return statA.Dev == statB.Dev } // diskModel reads the disk model from /sys/block//device/model. func diskModel(device string) string { // /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1 base := filepath.Base(device) // Strip partition number: sda1 → sda, nvme0n1p1 → nvme0n1 disk := base if strings.HasPrefix(base, "nvme") { // nvme0n1p1 → find last 'p' followed by digits if idx := strings.LastIndex(base, "p"); idx > 4 { disk = base[:idx] } } else { // sda1 → sda: strip trailing digits disk = strings.TrimRight(base, "0123456789") } modelPath := "/sys/block/" + disk + "/device/model" data, err := os.ReadFile(modelPath) if err != nil { return "" } return strings.TrimSpace(string(data)) }