//go:build linux package system import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "time" ) // 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 { debugf("[DEBUG] [system] GetDiskUsage: statfs(%q) failed: %v", path, err) 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) debugf("[DEBUG] [system] GetDiskUsage: path=%q total=%s used=%s avail=%.1fGB (%.1f%%)", path, info.TotalHuman, info.UsedHuman, info.AvailGB, info.UsedPercent) 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 { debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) failed: %v", path, err) return nil } fields := strings.Fields(strings.TrimSpace(string(out))) if len(fields) < 2 { debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) returned unexpected output: %q", path, strings.TrimSpace(string(out))) return nil } info := &FSInfo{ Device: fields[0], FSType: fields[1], } // Try to get disk model from sysfs info.Model = diskModel(info.Device) debugf("[DEBUG] [system] GetFSInfo: path=%q device=%s fstype=%s model=%q", path, info.Device, info.FSType, info.Model) 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 { debugf("[DEBUG] [system] CheckBackupDestination: path=%q", path) 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" debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier1 FAIL (not exists)", path) 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" debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier2 FAIL (not writable)", path) 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" debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 WARN (same block device as /)", path) // Don't return early — also check disk usage } else { h.MountPoint = true debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 OK (different block device)", path) } // Tier 4: disk usage checks if di := GetDiskUsage(path); di != nil { h.UsedPercent = di.UsedPercent h.FreeGB = di.AvailGB if h.SystemDrive { // System drive: stricter limits to protect OS stability if di.AvailGB < 10 { h.Warning = fmt.Sprintf("A rendszermeghajtón csak %.1f GB szabad — legalább 10 GB szükséges a rendszer stabilitásához!", di.AvailGB) h.Blocked = true h.Severity = "critical" } else if di.UsedPercent >= 90 { h.Warning = fmt.Sprintf("A rendszermeghajtó %.0f%%-ban megtelt — maximum 90%% megengedett.", di.UsedPercent) h.Blocked = true h.Severity = "critical" } // If neither triggers, keep the Tier 3 system-drive warning } else { // External drive: original thresholds 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.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent) h.Severity = "warning" } } } debugf("[DEBUG] [system] CheckBackupDestination: path=%q — result: severity=%s blocked=%v freeGB=%.1f usedPct=%.1f%%", path, h.Severity, h.Blocked, h.FreeGB, h.UsedPercent) 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 } // stripPartition strips the partition suffix from a device name. // e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0". func stripPartition(base string) string { if strings.HasPrefix(base, "nvme") || strings.HasPrefix(base, "mmcblk") { if idx := strings.LastIndex(base, "p"); idx > 4 { return base[:idx] } } else { return strings.TrimRight(base, "0123456789") } return base } // diskModel reads the disk model from /sys/block//device/model. func diskModel(device string) string { disk := stripPartition(filepath.Base(device)) modelPath := "/sys/block/" + disk + "/device/model" data, err := os.ReadFile(modelPath) if err != nil { return "" } return strings.TrimSpace(string(data)) } // ProbeStatus represents the result of a storage path probe. type ProbeStatus int const ( ProbeConnected ProbeStatus = iota ProbeDisconnected ProbeTimeout ) // ProbeResult holds the outcome of a storage path probe. type ProbeResult struct { Status ProbeStatus Err error } // ProbeStoragePath checks if a storage path is responsive. // Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts. func ProbeStoragePath(path string) ProbeResult { start := time.Now() // Quick check: does the path exist at all? if _, err := os.Lstat(path); os.IsNotExist(err) { debugf("[DEBUG] [system] ProbeStoragePath: path=%q — not exists (%s)", path, time.Since(start).Round(time.Millisecond)) return ProbeResult{Status: ProbeDisconnected, Err: err} } type statResult struct { err error } ch := make(chan statResult, 1) go func() { var stat syscall.Statfs_t err := syscall.Statfs(path, &stat) ch <- statResult{err: err} }() select { case res := <-ch: elapsed := time.Since(start).Round(time.Millisecond) if res.err == nil { debugf("[DEBUG] [system] ProbeStoragePath: path=%q — connected (%s)", path, elapsed) return ProbeResult{Status: ProbeConnected} } errStr := res.err.Error() if strings.Contains(errStr, "transport endpoint") || strings.Contains(errStr, "input/output error") || strings.Contains(errStr, "no such device") { debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected: %v (%s)", path, res.err, elapsed) return ProbeResult{Status: ProbeDisconnected, Err: res.err} } debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected (other error): %v (%s)", path, res.err, elapsed) return ProbeResult{Status: ProbeDisconnected, Err: res.err} case <-time.After(3 * time.Second): debugf("[DEBUG] [system] ProbeStoragePath: path=%q — TIMEOUT (3s)", path) return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")} } } // IsUSBDevice checks if a block device is connected via USB. // devicePath should be like "/dev/sdb" or "/dev/sdb1". // Checks the sysfs symlink for the disk — if the path contains "/usb", it's a USB device. func IsUSBDevice(devicePath string) bool { disk := stripPartition(filepath.Base(devicePath)) if disk == "" { return false } // Try /host/sys first (Docker mount), then /sys (native) for _, prefix := range []string{"/host/sys", "/sys"} { link, err := os.Readlink(prefix + "/block/" + disk) if err != nil { continue } isUSB := strings.Contains(link, "/usb") debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q sysfs=%s → usb=%v", devicePath, disk, link, isUSB) return isUSB } debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q — no sysfs entry found", devicePath, disk) return false } // ParseFstabUUID extracts the UUID for a given mount point from an fstab file. // Returns empty string if the mount point is not found or has no UUID. func ParseFstabUUID(fstabPath, mountPath string) string { f, err := os.Open(fstabPath) if err != nil { return "" } defer f.Close() cleanMount := filepath.Clean(mountPath) scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Fields(line) if len(fields) < 2 { continue } if filepath.Clean(fields[1]) != cleanMount { continue } source := fields[0] if strings.HasPrefix(source, "UUID=") { return strings.TrimPrefix(source, "UUID=") } } return "" } // HasFelhomRawMount checks if a mount path was set up via the attach wizard // (which uses a raw mount at /mnt/.felhom-raw/ + a bind mount). // Returns the raw mount path if found, e.g., "/mnt/.felhom-raw/hdd_1". func HasFelhomRawMount(fstabPath, mountPath string) (rawPath string, ok bool) { f, err := os.Open(fstabPath) if err != nil { return "", false } defer f.Close() cleanMount := filepath.Clean(mountPath) scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Fields(line) if len(fields) < 4 { continue } // Look for a bind mount line targeting our mount path // Format: /mnt/.felhom-raw/hdd_1/subfolder /mnt/hdd_1 none bind,... target := filepath.Clean(fields[1]) if target != cleanMount { continue } source := fields[0] if !strings.Contains(source, ".felhom-raw") { continue } // Extract the raw mount path: /mnt/.felhom-raw/hdd_1/subfolder → /mnt/.felhom-raw/hdd_1 // The raw mount is the first two components after /mnt/.felhom-raw/ parts := strings.Split(filepath.Clean(source), string(os.PathSeparator)) // parts: ["", "mnt", ".felhom-raw", "hdd_1", "subfolder"] for i, p := range parts { if p == ".felhom-raw" && i+1 < len(parts) { rawPath = string(os.PathSeparator) + filepath.Join(parts[1:i+2]...) return rawPath, true } } } return "", false }