154 lines
4.6 KiB
Go
154 lines
4.6 KiB
Go
// 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
|
|
}
|
|
}
|