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 while another holds the folder is a **move**: what is // at the path now is not what was kept (a disk mounted over a folder first seen empty below it, a mount made // deeper), so the folder is judged afresh — kept again only if it holds nothing, else it waits for the operator's // word as any folder holding data does (ADR 0325 §8). // // **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 `Where=` of a systemd .mount unit, every mount point a ZFS dataset names // (`zfs list`, when the machine has zfs), every mount point this engine has seen mounted since it started, 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 pool not imported, whose datasets `zfs list` // does not show; a hand `mount` never seen) is not known to be missing on first sight: then a folder first seen // empty below it is kept as empty, and the move above takes it back the moment the disk is there. // 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 { return readMountPoints() } // mountHistory is every mount point this engine has seen mounted since it started (§9's "mountinfo history"). var mountHistory = struct { sync.Mutex seen map[string]bool }{seen: map[string]bool{}} // sawMounted adds points to the history, and is the history: every mount point seen, never "/". func sawMounted(points []string) []string { mountHistory.Lock() defer mountHistory.Unlock() for _, p := range points { if p != "/" { mountHistory.seen[p] = true } } out := make([]string, 0, len(mountHistory.seen)) for p := range mountHistory.seen { out = append(out, p) } return out } func readMountPoints() []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))...) } out = append(out, mountUnitTargets(systemdUnitDirs)...) 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 } // systemdUnitDirs are where systemd keeps the units a machine mounts by. var systemdUnitDirs = []string{"/etc/systemd/system", "/run/systemd/system", "/run/systemd/generator", "/usr/lib/systemd/system", "/lib/systemd/system"} // mountUnitTargets reads the `Where=` of every .mount unit in dirs. func mountUnitTargets(dirs []string) []string { var out []string for _, dir := range dirs { names, err := filepath.Glob(filepath.Join(dir, "*.mount")) if err != nil { continue } for _, name := range names { raw, err := os.ReadFile(name) if err != nil { continue } for _, line := range strings.Split(string(raw), "\n") { if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Where="); ok && strings.HasPrefix(v, "/") { out = append(out, filepath.Clean(strings.TrimSpace(v))) } } } } 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 // Moved is that the recorded mount point is mounted and another holds the folder: what is there is not what // was kept. Moved 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) seen := sawMounted(points) if recorded != "" { return placement{Mount: have, Away: have != recorded && !mounted[recorded], Moved: have != recorded && mounted[recorded]} } for _, t := range append(append(MountTargets(ctx), others...), seen...) { if atOrBelow(path, t) && t != have && atOrBelow(t, have) && !mounted[t] { return placement{Mount: have, Away: true} } } return placement{Mount: have} }