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) }