package apply import ( "bufio" "context" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) // Whether a shared folder's disk is there (novox/hq ADR 0325 ยง9). // // **The rule the engine keeps.** A folder is placed on the filesystem it is first seen on: the mount point that // holds it โ€” the longest mount point in /proc/self/mountinfo that is the folder or above it โ€” is recorded beside the // kept folder. On every later apply the folder (or, when it is absent, its nearest existing parent) is judged // against that record: held by another mount point than the one recorded while the recorded one is not mounted is // the disk not there. A recorded mount point that is mounted is the disk there, whatever now holds the folder (a // mount made deeper is a placement, not a missing disk), and the record follows it. // // **On first sight, with no record,** the engine knows a disk is missing only from where disks are meant to be: // every mount target in /etc/fstab, every mount point a ZFS dataset names (`zfs list`, when the machine has zfs), // and every mount point recorded for another kept folder. A target that is the folder or above it, lies below the // mount point that holds what is there now, and is not mounted, is the disk not there. Otherwise the filesystem that // holds what is there is the placed one. A mount nothing names in those places (a hand `mount` of a disk that is not // in fstab, then unmounted before the engine ever saw the folder) is not known to be missing: said here, because the // folder would then be made on the filesystem below it. // MountPoints is every mount point this machine has mounted now, from /proc/self/mountinfo. A variable so a test // can stand in for mounts it cannot make. var MountPoints = func() []string { raw, err := os.ReadFile("/proc/self/mountinfo") if err != nil { return []string{"/"} } return mountinfoPoints(string(raw)) } // MountTargets is every place a disk is meant to be mounted: /etc/fstab's targets and ZFS datasets' mount points, // read at most once a minute โ€” every look judges every access by them. A variable so a test can stand in. var MountTargets = func(ctx context.Context) []string { targets.Lock() defer targets.Unlock() if targets.read.IsZero() || time.Since(targets.read) > time.Minute { targets.list, targets.read = readMountTargets(ctx), time.Now() } return append([]string(nil), targets.list...) } var targets struct { sync.Mutex list []string read time.Time } func readMountTargets(ctx context.Context) []string { var out []string if raw, err := os.ReadFile("/etc/fstab"); err == nil { out = append(out, fstabTargets(string(raw))...) } if _, err := exec.LookPath("zfs"); err == nil { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if raw, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-o", "mountpoint").Output(); err == nil { for _, line := range strings.Split(string(raw), "\n") { if p := strings.TrimSpace(line); strings.HasPrefix(p, "/") { out = append(out, filepath.Clean(p)) } } } } return out } // mountinfoPoints reads the mount point of each line of mountinfo, its fifth field, with the kernel's octal escapes // undone. func mountinfoPoints(raw string) []string { var out []string for _, line := range strings.Split(raw, "\n") { fields := strings.Fields(line) if len(fields) < 5 { continue } out = append(out, filepath.Clean(unescapeMount(fields[4]))) } return out } // mountSourceOf is the source and filesystem type of the mount at point, from mountinfo: for a ZFS dataset, its // name. Empty when the machine says nothing of it. func mountSourceOf(point string) (source, fstype string) { raw, err := os.ReadFile("/proc/self/mountinfo") if err != nil { return "", "" } for _, line := range strings.Split(string(raw), "\n") { before, after, ok := strings.Cut(line, " - ") fields, rest := strings.Fields(before), strings.Fields(after) if !ok || len(fields) < 5 || len(rest) < 2 { continue } if filepath.Clean(unescapeMount(fields[4])) == point { source, fstype = unescapeMount(rest[1]), rest[0] } } return source, fstype } // fstabTargets reads the second field of each fstab line that mounts somewhere: not swap, not a comment. func fstabTargets(raw string) []string { var out []string sc := bufio.NewScanner(strings.NewReader(raw)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) < 2 || strings.HasPrefix(fields[0], "#") || !strings.HasPrefix(fields[1], "/") { continue } out = append(out, filepath.Clean(unescapeMount(fields[1]))) } return out } // unescapeMount undoes the kernel's and fstab's octal escapes: \040 is a space. 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+4 <= len(s) { if v, ok := octal3(s[i+1 : i+4]); ok { b.WriteByte(v) i += 3 continue } } b.WriteByte(s[i]) } return b.String() } func octal3(s string) (byte, bool) { if len(s) != 3 { return 0, false } v := 0 for _, c := range s { if c < '0' || c > '7' { return 0, false } v = v*8 + int(c-'0') } return byte(v), v < 256 } // mountHolding is the longest of points that is path or above it: the mount point that holds it. func mountHolding(path string, points []string) string { best := "/" for _, p := range points { if atOrBelow(path, p) && len(p) > len(best) { best = p } } return best } // placement is the judgement of where a folder's data lives. type placement struct { // Mount is the mount point that holds what is there: the folder, or its nearest existing parent. Mount string // Away is that the disk the folder was placed on is not there. Away bool } // placedOn judges path, of which existing is what is there (the folder itself, or its nearest existing parent), // against what was recorded and where disks are meant to be. func placedOn(ctx context.Context, path, existing, recorded string, others []string) placement { points := MountPoints() mounted := map[string]bool{} for _, p := range points { mounted[p] = true } have := mountHolding(existing, points) if recorded != "" { return placement{Mount: have, Away: have != recorded && !mounted[recorded]} } for _, t := range append(MountTargets(ctx), others...) { if atOrBelow(path, t) && t != have && atOrBelow(t, have) && !mounted[t] { return placement{Mount: have, Away: true} } } return placement{Mount: have} }