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) require.NoError(t, os.Remove(link)) time.Sleep(60 * 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) }