feat(ingestion): the runtime symlink tripwire removes what it finds
CI / Lint / Test / Vet (pull_request) Failing after 33s
CI / Mirror to GitHub (pull_request) Skipped

Decision (#157): the tripwire only alerted, so a symlink planted in the
Syncthing-shared brain dir stayed readable until a human removed it.
Watch now unlinks every symlink it finds through an os.Root on the brain
dir (re-checking it is still a symlink; the target is never touched),
and the default scan interval drops from 5 min to 30 s, so a planted
link is readable for at most one interval.

Each removal is an alert naming what was removed; a persistent finding
(a link it could not remove, an unreadable dir, a scan error) alerts
once per distinct state. Alerts queue until Notify succeeds, so an ntfy
outage delays them without losing them, even though the next scan of
the cleaned brain is clean.

The startup check still refuses to start on any symlink.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:16:43 +02:00
co-authored by Claude Opus 5.5
parent 687afa0145
commit 504feb372f
2 changed files with 117 additions and 34 deletions
+76 -32
View File
@@ -21,7 +21,7 @@ import (
) )
// DefaultInterval is used when Watch is given a non-positive interval. // DefaultInterval is used when Watch is given a non-positive interval.
const DefaultInterval = 5 * time.Minute const DefaultInterval = 30 * time.Second
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it). // Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
type Notifier interface { type Notifier interface {
@@ -41,13 +41,7 @@ func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable)
// String describes the findings for a log line or alert. Paths are quoted, so // String describes the findings for a log line or alert. Paths are quoted, so
// a name containing a newline cannot blur two findings together. // a name containing a newline cannot blur two findings together.
func (r Result) String() string { func (r Result) String() string {
q := func(xs []string) string { q := r.quoted
out := make([]string, len(xs))
for i, x := range xs {
out[i] = strconv.Quote(x)
}
return strings.Join(out, ", ")
}
var parts []string var parts []string
if len(r.Symlinks) > 0 { if len(r.Symlinks) > 0 {
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks))) parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
@@ -58,6 +52,15 @@ func (r Result) String() string {
return strings.Join(parts, "; ") return strings.Join(parts, "; ")
} }
// quoted joins paths with each one strconv-quoted.
func (Result) quoted(xs []string) string {
out := make([]string, len(xs))
for i, x := range xs {
out[i] = strconv.Quote(x)
}
return strings.Join(out, ", ")
}
// Scan walks the brain dir and reports every symlink in it and every // 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 // 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 // skipped (it is brain-sync's, and no reader walks it); a nested .git is
@@ -106,15 +109,19 @@ func Scan(brainDir string) (Result, error) {
} }
// Watch rescans every interval (DefaultInterval if non-positive) until ctx // Watch rescans every interval (DefaultInterval if non-positive) until ctx
// ends, and notifies once per distinct non-clean result. A result counts as // ends. Every symlink found is removed (the link, never its target:
// reported only after Notify succeeds, so an alert lost to a notifier outage // hyperguild#157 decision), so one is readable for at most one interval.
// is retried on the next tick. // Each removal is an alert; a persistent finding (a link it could not remove,
// an unreadable dir, a scan error) alerts once per distinct state. Alerts
// queue until Notify succeeds, so an outage delays them but loses none, even
// though the next scan of a cleaned brain is clean.
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) { func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
if interval <= 0 { if interval <= 0 {
interval = DefaultInterval interval = DefaultInterval
} }
t := time.NewTicker(interval) t := time.NewTicker(interval)
defer t.Stop() defer t.Stop()
var pending []string
reported := "" reported := ""
for { for {
select { select {
@@ -123,31 +130,68 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
case <-t.C: case <-t.C:
} }
r, err := Scan(brainDir) r, err := Scan(brainDir)
var key, msg string key := ""
switch { if err != nil {
case err != nil: key = "cannot scan the brain dir: " + err.Error()
key = "error: " + err.Error() } else {
msg = "brain symlink tripwire (hyperguild#157): cannot scan the brain dir: " + err.Error() removed, failed := removeSymlinks(brainDir, r.Symlinks)
case r.Clean(): if len(removed) > 0 {
reported = "" msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
continue len(removed), Result{Symlinks: removed}.quoted(removed))
default: slog.Error("brain symlink tripwire removed symlinks", "paths", removed)
key = r.String() pending = append(pending, msg)
msg = "brain symlink tripwire (hyperguild#157): " + key + }
". Readers may follow symlinks out of the brain; remove them." key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
} }
if key == reported { if key != reported {
continue
}
slog.Error("brain symlink tripwire", "finding", key)
if n == nil {
reported = key reported = key
if key != "" {
slog.Error("brain symlink tripwire", "finding", key)
pending = append(pending, "brain symlink tripwire (hyperguild#157): "+key+
". Readers may follow symlinks out of the brain; fix them.")
}
}
if n == nil {
pending = nil
continue continue
} }
if err := n.Notify(ctx, msg); err != nil { for len(pending) > 0 {
slog.Warn("symlink tripwire notify failed; will retry", "err", err) if err := n.Notify(ctx, pending[0]); err != nil {
continue slog.Warn("symlink tripwire notify failed; will retry", "err", err)
break
}
pending = pending[1:]
} }
reported = key
} }
} }
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
// the brain dir, after re-checking it is still a symlink. Removing a symlink
// never touches its target. It returns what was removed and what was not.
func removeSymlinks(brainDir string, rels []string) (removed, failed []string) {
if len(rels) == 0 {
return nil, nil
}
resolved, err := filepath.EvalSymlinks(brainDir)
if err != nil {
return nil, rels
}
root, err := os.OpenRoot(resolved)
if err != nil {
return nil, rels
}
defer func() { _ = root.Close() }()
for _, rel := range rels {
fi, err := root.Lstat(rel)
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
failed = append(failed, rel)
continue
}
if err := root.Remove(rel); err != nil {
failed = append(failed, rel)
continue
}
removed = append(removed, rel)
}
return removed, failed
}
@@ -182,8 +182,47 @@ func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) {
go Watch(ctx, brain, 10*time.Millisecond, n) go Watch(ctx, brain, 10*time.Millisecond, n)
require.NoError(t, os.Symlink(t.TempDir(), link)) require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond) require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
require.NoError(t, os.Remove(link)) // The tripwire removed it; planting it again is a new event.
time.Sleep(60 * time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 10*time.Millisecond)
require.NoError(t, os.Symlink(t.TempDir(), link)) require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond) require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
} }
// hyperguild#157 decision: the runtime tripwire removes a symlink it finds
// (the link, never its target), so it is readable for at most one interval
// instead of until a human acts, and the alert says what was removed.
func TestWatchRemovesSymlinksButNeverTheirTargetsOrRegularFiles(t *testing.T) {
brain := t.TempDir()
outside := filepath.Join(t.TempDir(), "target.md")
require.NoError(t, os.WriteFile(outside, []byte("keep me"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
link := filepath.Join(brain, "wiki", "leak.md")
require.NoError(t, os.Symlink(outside, link))
regular := filepath.Join(brain, "wiki", "note.md")
require.NoError(t, os.WriteFile(regular, []byte("note"), 0o644))
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool {
_, err := os.Lstat(link)
return os.IsNotExist(err)
}, 2*time.Second, 10*time.Millisecond, "the symlink is removed")
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
b, err := os.ReadFile(outside)
require.NoError(t, err)
assert.Equal(t, "keep me", string(b), "the target is untouched")
b, err = os.ReadFile(regular)
require.NoError(t, err)
assert.Equal(t, "note", string(b), "regular files are untouched")
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], "removed")
assert.Contains(t, n.msgs[0], "wiki/leak.md")
}
func TestDefaultIntervalIsThirtySeconds(t *testing.T) {
assert.Equal(t, 30*time.Second, DefaultInterval)
}