// Package symlinkguard is the hyperguild#157 tripwire. The brain directory is // a Syncthing sendreceive folder and Syncthing syncs symlinks; several brain // readers (search, vectorstore, context, graph) follow a symlink out of the // brain and would index or return whatever it points to. Until every reader // reads through an os.Root, a symlink inside the brain, or a directory the // scan cannot read (which could hide one), is an incident: the server refuses // to start on one (main), and Watch reports one that appears while it runs. package symlinkguard import ( "context" "fmt" "io/fs" "log/slog" "os" "path/filepath" "sort" "strconv" "strings" "time" ) // DefaultInterval is used when Watch is given a non-positive interval. const DefaultInterval = 5 * time.Minute // Notifier is the out-of-band alert sink (audit.Notifier satisfies it). type Notifier interface { Notify(ctx context.Context, msg string) error } // Result is what a scan found: symlinks, and directories it could not read // (each of which could hide a symlink). Paths are brain-relative. type Result struct { Symlinks []string Unreadable []string } // Clean reports whether the scan found nothing to act on. func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 } // String describes the findings for a log line or alert. Paths are quoted, so // a name containing a newline cannot blur two findings together. func (r Result) String() string { q := func(xs []string) string { out := make([]string, len(xs)) for i, x := range xs { out[i] = strconv.Quote(x) } return strings.Join(out, ", ") } var parts []string if len(r.Symlinks) > 0 { parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks))) } if len(r.Unreadable) > 0 { parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable))) } return strings.Join(parts, "; ") } // Scan walks the brain dir and reports every symlink in it and every // directory it could not read, continuing past both. Only the ROOT .git is // skipped (it is brain-sync's, and no reader walks it); a nested .git is // scanned like any other directory. brainDir itself may be a symlink (a // volume mount); it is resolved first. The error is non-nil when the root // cannot be resolved (wrapping os.ErrNotExist when it is missing) or cannot be // listed. func Scan(brainDir string) (Result, error) { root, err := filepath.EvalSymlinks(brainDir) if err != nil { return Result{}, fmt.Errorf("resolve brain dir: %w", err) } var r Result rootGit := filepath.Join(root, ".git") walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { rel, _ := filepath.Rel(root, p) rel = filepath.ToSlash(rel) if err != nil { if p == root { return err } r.Unreadable = append(r.Unreadable, rel) if d != nil && d.IsDir() { return filepath.SkipDir } return nil } if d.IsDir() && p == rootGit { return filepath.SkipDir } if d.Type()&os.ModeSymlink != 0 { r.Symlinks = append(r.Symlinks, rel) } return nil }) sort.Strings(r.Symlinks) sort.Strings(r.Unreadable) if walkErr != nil { // Only the root's own error reaches here (deeper errors are recorded // as Unreadable). A root that cannot be listed cannot be shown clean // (PR #159 round 2: discarding this reported clean while search // served a planted symlink). return r, fmt.Errorf("scan brain dir: %w", walkErr) } return r, nil } // Watch rescans every interval (DefaultInterval if non-positive) until ctx // ends, and notifies once per distinct non-clean result. A result counts as // reported only after Notify succeeds, so an alert lost to a notifier outage // is retried on the next tick. func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) { if interval <= 0 { interval = DefaultInterval } t := time.NewTicker(interval) defer t.Stop() reported := "" for { select { case <-ctx.Done(): return case <-t.C: } r, err := Scan(brainDir) var key, msg string switch { case err != nil: key = "error: " + err.Error() msg = "brain symlink tripwire (hyperguild#157): cannot scan the brain dir: " + err.Error() case r.Clean(): reported = "" continue default: key = r.String() msg = "brain symlink tripwire (hyperguild#157): " + key + ". Readers may follow symlinks out of the brain; remove them." } if key == reported { continue } slog.Error("brain symlink tripwire", "finding", key) if n == nil { reported = key continue } if err := n.Notify(ctx, msg); err != nil { slog.Warn("symlink tripwire notify failed; will retry", "err", err) continue } reported = key } }