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>
229 lines
9.0 KiB
Go
229 lines
9.0 KiB
Go
package symlinkguard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// hyperguild#157: the brain dir is a Syncthing sendreceive folder and
|
|
// Syncthing syncs symlinks; several readers follow them out of the brain.
|
|
// Until every reader goes through an os.Root, a symlink anywhere in the brain,
|
|
// or a directory the scan cannot read (which could hide one), is an incident.
|
|
|
|
func TestScanReportsSymlinksAtEveryDepthIncludingNestedGit(t *testing.T) {
|
|
brain := t.TempDir()
|
|
outside := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts", ".git"), 0o755))
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, ".git", "objects"), 0o755))
|
|
require.NoError(t, os.Symlink(filepath.Join(outside, "f"), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
|
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "dirlink")))
|
|
// PR #159 review: only the ROOT .git is skipped; a nested .git is not
|
|
// special to the brain readers, which walk into it.
|
|
require.NoError(t, os.Symlink(filepath.Join(outside, "g"), filepath.Join(brain, "wiki", "w", "facts", ".git", "leak.md")))
|
|
require.NoError(t, os.Symlink(outside, filepath.Join(brain, ".git", "objects", "alt")))
|
|
|
|
r, err := Scan(brain)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"wiki/w/facts/leak.md", "wiki/dirlink", "wiki/w/facts/.git/leak.md"}, r.Symlinks)
|
|
assert.Empty(t, r.Unreadable)
|
|
}
|
|
|
|
// PR #159 review: WalkDir stopped at the first unreadable directory and the
|
|
// startup check passed, while search (which keeps walking) served a planted
|
|
// symlink. Scan now records the unreadable dir and keeps going.
|
|
func TestScanReportsUnreadableDirsAndKeepsWalkingPastThem(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root reads mode-000 directories")
|
|
}
|
|
brain := t.TempDir()
|
|
locked := filepath.Join(brain, "aaa")
|
|
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
require.NoError(t, os.Chmod(locked, 0o000))
|
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "zzz-planted")))
|
|
|
|
r, err := Scan(brain)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"aaa"}, r.Unreadable)
|
|
assert.Equal(t, []string{"zzz-planted"}, r.Symlinks, "the walk continues past the unreadable dir")
|
|
}
|
|
|
|
func TestScanResolvesASymlinkedRootAndReportsSymlinkedEntriesInsideIt(t *testing.T) {
|
|
real := t.TempDir()
|
|
link := filepath.Join(t.TempDir(), "brain")
|
|
require.NoError(t, os.Symlink(real, link))
|
|
require.NoError(t, os.WriteFile(filepath.Join(real, "n.md"), []byte("x"), 0o644))
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(real, "inner")))
|
|
r, err := Scan(link)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"inner"}, r.Symlinks)
|
|
}
|
|
|
|
func TestScanOfAMissingRootIsErrNotExist(t *testing.T) {
|
|
_, err := Scan(filepath.Join(t.TempDir(), "nope"))
|
|
assert.True(t, errors.Is(err, os.ErrNotExist), "got %v", err)
|
|
}
|
|
|
|
type recNotifier struct {
|
|
mu sync.Mutex
|
|
msgs []string
|
|
fail int // fail the first N calls
|
|
}
|
|
|
|
func (r *recNotifier) Notify(_ context.Context, m string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.msgs = append(r.msgs, m)
|
|
if r.fail > 0 {
|
|
r.fail--
|
|
return errors.New("ntfy down")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *recNotifier) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.msgs) }
|
|
|
|
func TestWatchNotifiesOncePerDistinctSetAndAgainForANewSet(t *testing.T) {
|
|
brain := t.TempDir()
|
|
n := &recNotifier{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
assert.Zero(t, n.count(), "a clean brain raises nothing")
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "first")))
|
|
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
time.Sleep(60 * time.Millisecond)
|
|
assert.Equal(t, 1, n.count(), "the same set is reported once, not every tick")
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "second")))
|
|
require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond)
|
|
}
|
|
|
|
// PR #159 review: last was recorded before Notify, so an alert lost to an
|
|
// ntfy outage was never retried while the symlink stayed.
|
|
func TestWatchRetriesAnAlertThatFailedToSend(t *testing.T) {
|
|
brain := t.TempDir()
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "planted")))
|
|
n := &recNotifier{fail: 2}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 10*time.Millisecond)
|
|
time.Sleep(60 * time.Millisecond)
|
|
assert.Equal(t, 3, n.count(), "after one successful send, no more")
|
|
}
|
|
|
|
func TestWatchAlertsOnAnUnreadableDir(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root reads mode-000 directories")
|
|
}
|
|
brain := t.TempDir()
|
|
locked := filepath.Join(brain, "locked")
|
|
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
require.NoError(t, os.Chmod(locked, 0o000))
|
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
n := &recNotifier{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
assert.Contains(t, n.msgs[0], "locked")
|
|
}
|
|
|
|
// PR #159 review: a non-positive interval panicked time.NewTicker inside the
|
|
// goroutine and crashlooped the server.
|
|
func TestWatchToleratesANonPositiveInterval(t *testing.T) {
|
|
brain := t.TempDir()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { Watch(ctx, brain, 0, nil); close(done) }()
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Watch did not return on cancel")
|
|
}
|
|
}
|
|
|
|
// PR #159 round 2: a brain root that can be entered but not listed (mode
|
|
// 0100) made WalkDir fail at the root; the error was discarded and Scan
|
|
// reported clean, while search (which goes straight into wiki/) still served
|
|
// a planted symlink. A root that cannot be listed is now an error.
|
|
func TestScanOfAnUnlistableRootIsAnError(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root lists mode-0100 directories")
|
|
}
|
|
brain := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
|
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "wiki", "leak.md")))
|
|
require.NoError(t, os.Chmod(brain, 0o100))
|
|
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
|
|
_, err := Scan(brain)
|
|
require.Error(t, err)
|
|
assert.False(t, errors.Is(err, os.ErrNotExist), "an unlistable root is not a missing root")
|
|
}
|
|
|
|
// The same set that disappears and comes back is reported again.
|
|
func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) {
|
|
brain := t.TempDir()
|
|
link := filepath.Join(brain, "planted")
|
|
n := &recNotifier{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond)
|
|
// The tripwire removed it; planting it again is a new event.
|
|
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.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)
|
|
}
|