Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5.5 4ba9d20b4e fix(ingestion): tripwire alerts survive outages; drop only permanent rejections
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped
PR #160 round 2: dropping a message after 5 failures could not tell a
poison message from an ntfy outage. A 15-minute outage dropped the only
incident with nothing reported, and drops were not counted (74 of 300
removals were neither delivered nor counted).

- audit.NtfyNotifier returns *RejectedError for non-2xx; Permanent() is
  true for 4xx other than 429.
- The tripwire queue drops a message only on a permanent rejection and
  counts it; every other failure is an outage and is retried, however
  long. Each flush tries every queued message, so an undeliverable one
  cannot block the rest.
- Overflow and drops go into one counter, sent as a summary retried
  every tick and never dropped. Every removal is delivered or counted.

Tests (red first): an outage of ~100 ticks still delivers the only
incident; delivered + counted equals the number of removals; a
permanently rejected message is dropped and counted. The short-outage
test is renamed to say it is short. The Watch doc no longer claims
'loses none' unconditionally.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:30:46 +02:00
mathiasandClaude Opus 5.5 01de163452 fix(ingestion): bound the tripwire's alerts; test removal guards; deflake race tests
CI / Lint / Test / Vet (pull_request) Successful in 47s
CI / Mirror to GitHub (pull_request) Skipped
PR #160 review:
- The planter chooses the paths, and one alert listed them all, while the
  queue retried oldest-first forever: a message ntfy refuses (too large)
  blocked every later alert (2000 links: 0 alerts delivered), and an
  outage queued one message per removal, unbounded. Alerts now name at
  most 10 paths and count the rest; the queue holds at most 5, further
  alerts become one suppression summary; a message failing 5 times is
  dropped so it cannot block the rest.
- removeSymlinks' Lstat re-check had no test: a direct test passes a
  regular file, a path through an in-brain dir link, a ../ escape and a
  missing path, and asserts all fail with nothing deleted. A Watch test
  covers dir and in-brain targets. root.Remove (not RemoveAll) is
  commented: it refuses a dir swapped in after the Lstat.
- Every queued alert is delivered after an outage (truncating the queue
  after the first send is now red).
- hyperguild#162: race tests did not wait for their swapper goroutines
  before t.TempDir cleanup ("directory not empty" in CI run 8912). Each
  swapper is now stopped and awaited in a t.Cleanup.

Fixes #162
Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:24:13 +02:00
mathiasandClaude Opus 5.5 504feb372f 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>
2026-10-03 18:16:43 +02:00
10 changed files with 529 additions and 430 deletions
+17 -11
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"testing" "testing"
@@ -25,6 +26,18 @@ import (
const raceSentinel = "SENTINEL-153-RACE" const raceSentinel = "SENTINEL-153-RACE"
// startSwapper runs fn until the test ends, and waits for it to exit before
// t.TempDir's cleanup removes the directory it writes into (hyperguild#162:
// an unawaited swapper raced the cleanup, "directory not empty").
func startSwapper(t *testing.T, fn func(stop *atomic.Bool)) {
t.Helper()
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); fn(&stop) }()
t.Cleanup(func() { stop.Store(true); wg.Wait() })
}
func swap(raw, outside string, fifo bool, stop *atomic.Bool) { func swap(raw, outside string, fifo bool, stop *atomic.Bool) {
tgt := filepath.Join(raw, "r.md") tgt := filepath.Join(raw, "r.md")
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
@@ -76,9 +89,7 @@ func readers(brain, raw string) string {
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) { func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t) brain, raw, outside := raceBrain(t)
var stop atomic.Bool startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, false, stop) })
go swap(raw, outside, false, &stop)
defer stop.Store(true)
leaks, iters := 0, 0 leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
leaks += strings.Count(readers(brain, raw), raceSentinel) leaks += strings.Count(readers(brain, raw), raceSentinel)
@@ -91,8 +102,7 @@ func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) { func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t) brain, raw, outside := raceBrain(t)
var stop atomic.Bool startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, true, stop) })
go swap(raw, outside, true, &stop)
var iters atomic.Int64 var iters atomic.Int64
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@@ -104,9 +114,7 @@ func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
}() }()
select { select {
case <-done: case <-done:
stop.Store(true)
case <-time.After(6 * time.Second): case <-time.After(6 * time.Second):
stop.Store(true)
// Unblock the stuck open so the test process can exit. // Unblock the stuck open so the test process can exit.
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close() _ = f.Close()
@@ -147,8 +155,7 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil { if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var stop atomic.Bool startSwapper(t, func(stop *atomic.Bool) {
go func() {
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swapdocx") tmp := filepath.Join(raw, ".swapdocx")
_ = os.Remove(tmp) _ = os.Remove(tmp)
@@ -159,8 +166,7 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
} }
_ = os.Rename(tmp, tgt) _ = os.Rename(tmp, tgt)
} }
}() })
defer stop.Store(true)
iters := 0 iters := 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
_, _ = api.ReadIngestFile(brain, tgt) _, _ = api.ReadIngestFile(brain, tgt)
+13 -1
View File
@@ -49,7 +49,19 @@ func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode) return &RejectedError{Status: resp.StatusCode}
} }
return nil return nil
} }
// RejectedError is a non-2xx ntfy response.
type RejectedError struct{ Status int }
func (e *RejectedError) Error() string { return fmt.Sprintf("ntfy notify: status %d", e.Status) }
// Permanent reports whether resending the same message cannot succeed: a 4xx
// other than 429 (rate limited). Callers may drop such a message; anything
// else (5xx, 429, transport errors) is an outage and must be retried.
func (e *RejectedError) Permanent() bool {
return e.Status >= 400 && e.Status < 500 && e.Status != http.StatusTooManyRequests
}
@@ -0,0 +1,27 @@
package audit
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A non-2xx ntfy response is a *RejectedError, and only a client-side refusal
// other than rate limiting is permanent: callers may drop a message on a
// permanent rejection and must retry the rest (hyperguild#157, PR #160).
func TestNotifyClassifiesRejectionsAsPermanentOrNot(t *testing.T) {
for status, permanent := range map[int]bool{413: true, 400: true, 403: true, 429: false, 500: false, 503: false} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) }))
err := NewNtfyNotifier(srv.URL, "").Notify(context.Background(), "m")
srv.Close()
var rej *RejectedError
require.True(t, errors.As(err, &rej), "status %d: %v", status, err)
assert.Equal(t, status, rej.Status)
assert.Equal(t, permanent, rej.Permanent(), "status %d", status)
}
}
-113
View File
@@ -1,113 +0,0 @@
// Package brainfs reads the brain directory through an os.Root (hyperguild#157).
// The brain dir is a Syncthing sendreceive folder and Syncthing syncs
// symlinks; a reader that walks and reads by path follows a planted symlink
// out of the brain, and blocks forever on a planted FIFO. Readers that go
// through a Brain only ever read regular files inside the brain: os.Root
// refuses to leave the root even if an entry is swapped mid-read, and every
// open is non-blocking with the type checked on the opened handle.
package brainfs
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// ErrNotRegular is returned for an entry that is not a regular file.
var ErrNotRegular = errors.New("not a regular file")
// Brain is an open brain directory.
type Brain struct {
root *os.Root
dir string
}
// Open opens brainDir (which may itself be a mount symlink) as a root.
func Open(brainDir string) (*Brain, error) {
r, err := os.OpenRoot(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain root: %w", err)
}
return &Brain{root: r, dir: brainDir}, nil
}
// Close releases the root.
func (b *Brain) Close() error { return b.root.Close() }
// Rel converts a path under the brain dir to a root-relative path.
func (b *Brain) Rel(path string) (string, error) {
rel, err := filepath.Rel(b.dir, path)
if err != nil {
return "", err
}
if rel == ".." || len(rel) > 2 && rel[:3] == ".."+string(filepath.Separator) {
return "", fmt.Errorf("%s is outside the brain dir", path)
}
return filepath.ToSlash(rel), nil
}
// ReadRegular reads a brain-relative path, refusing anything that is not a
// regular file, both before the open and on the opened handle.
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
fi, err := b.root.Lstat(rel)
if err != nil {
return nil, err
}
if !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
}
return b.readOpened(rel)
}
// readOpened opens rel non-blocking and reads it only if the OPENED handle is
// a regular file. This is the check that matters under a race: an entry
// swapped for a FIFO after the Lstat, with a writer holding it open, would
// otherwise park io.ReadAll forever (PR #161 review: removing it hung search
// after 16 iterations).
//
// Accepted residual, as in api/rawroot.go: a HARD link to an outside file is
// a regular file and is read. Syncthing syncs contents, not links, so a peer
// cannot create one; fs.protected_hardlinks=1 and the pod's secrets are on a
// different filesystem from the brain volume, so a local caller cannot link
// them in. Refusing Nlink > 1 is the next step if either changes.
func (b *Brain) readOpened(rel string) ([]byte, error) {
f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
hfi, err := f.Stat()
if err != nil {
return nil, err
}
if !hfi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
}
return io.ReadAll(f)
}
// WalkRegular walks a brain-relative directory through the root and calls fn
// for each regular file (directories are descended; symlinks, FIFOs and
// devices are skipped, never followed). An entry error is passed to onErr and
// the walk continues; a nil onErr ignores it.
func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func(rel string, err error)) error {
return fs.WalkDir(b.root.FS(), filepath.ToSlash(relDir), func(p string, d fs.DirEntry, err error) error {
if err != nil {
if onErr != nil {
onErr(p, err)
}
if d != nil && d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
return fn(p)
})
}
@@ -1,92 +0,0 @@
package brainfs
import (
"errors"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The handle check is what stops a FIFO swapped in after the Lstat. A FIFO
// with no writer reads EOF at once, so a race test could not see the check
// missing; here a writer holds the FIFO open, as any local process can, and
// readOpened must refuse it promptly (PR #161 review).
func TestReadOpenedRefusesAFIFOHeldOpenByAWriter(t *testing.T) {
dir := t.TempDir()
fifo := filepath.Join(dir, "p.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
require.NoError(t, err)
defer func() { _ = w.Close() }()
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
done := make(chan error, 1)
go func() { _, err := b.readOpened("p.md"); done <- err }()
select {
case err := <-done:
assert.True(t, errors.Is(err, ErrNotRegular), "got %v", err)
case <-time.After(2 * time.Second):
t.Fatal("readOpened blocked on a FIFO with a writer")
}
}
func TestReadRegularRefusesSymlinksAndReadsRegularFiles(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ok.md"), []byte("ok"), 0o644))
outside := filepath.Join(t.TempDir(), "s.md")
require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link.md")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
got, err := b.ReadRegular("ok.md")
require.NoError(t, err)
assert.Equal(t, "ok", string(got))
_, err = b.ReadRegular("link.md")
assert.True(t, errors.Is(err, ErrNotRegular), "got %v", err)
_, err = b.ReadRegular("../" + filepath.Base(outside))
assert.Error(t, err)
}
func TestRelAcceptsPathsInsideAndRefusesEscapes(t *testing.T) {
dir := t.TempDir()
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for in, want := range map[string]string{
filepath.Join(dir, "wiki", "x.md"): "wiki/x.md",
filepath.Join(dir, "..foo", "x"): "..foo/x",
dir: ".",
} {
got, err := b.Rel(in)
require.NoError(t, err, in)
assert.Equal(t, want, got, in)
}
for _, bad := range []string{filepath.Join(dir, "..", "x"), dir + "-evil/x", "/etc/passwd", filepath.Join(dir, "wiki", "..", "..", "x")} {
_, err := b.Rel(bad)
assert.Error(t, err, bad)
}
}
func TestWalkRegularYieldsOnlyRegularFiles(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "n.md"), []byte("n"), 0o644))
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(dir, "a", "dirlink")))
require.NoError(t, os.Symlink(filepath.Join(dir, "a", "n.md"), filepath.Join(dir, "a", "filelink.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "a", "p.md"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
var got []string
require.NoError(t, b.WalkRegular(".", func(rel string) error { got = append(got, rel); return nil }, nil))
assert.Equal(t, []string{"a/n.md"}, got)
}
-149
View File
@@ -1,149 +0,0 @@
package search
import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157 option 1: search walked and read the brain by path, so a
// symlink anywhere in the Syncthing-shared brain dir (wiki/<w>/<h>/leak.md ->
// /any/file) put the target's contents in brain_query results (reproduced in
// the PR #156 review). Search now walks and reads through an os.Root on the
// brain dir and only reads regular files.
const sentinel157 = "zqsentinelonefiftyseven"
func searchBrain(t *testing.T) (brain, facts, outside string) {
t.Helper()
brain = t.TempDir()
facts = filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "knowledge"), 0o755))
outside = filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\n"+sentinel157), 0o644))
return brain, facts, outside
}
func leaked(rs []Result) int {
n := 0
for _, r := range rs {
n += strings.Count(r.Excerpt+r.Title, sentinel157)
}
return n
}
func TestQueryDoesNotFollowASymlinkInTheWiki(t *testing.T) {
brain, facts, outside := searchBrain(t)
require.NoError(t, os.Symlink(outside, filepath.Join(facts, "leak.md")))
require.NoError(t, os.WriteFile(filepath.Join(facts, "real.md"), []byte("# real\n\nzqrealnote"), 0o644))
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
assert.Zero(t, leaked(rs))
rs, err = Query(brain, QueryOptions{Query: "zqrealnote", Limit: 10})
require.NoError(t, err)
assert.Len(t, rs, 1, "regular notes are still found")
}
func TestQueryDoesNotFollowASymlinkedDirectory(t *testing.T) {
brain, _, outside := searchBrain(t)
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(brain, "wiki", "linked")))
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
assert.Zero(t, leaked(rs))
}
// swapInto keeps renaming a regular note, and either a symlink to outside or
// a FIFO, into path. Each FIFO has a writer held open (as any local process
// can): a FIFO with no writer reads EOF at once, which would let a reader
// that skips the handle check pass (PR #161 round 2: with a held writer, a
// bypass of readOpened hung Query after 5 iterations).
// startSwap runs swapInto until the test ends and waits for it before
// t.TempDir's cleanup (hyperguild#162).
func startSwap(t *testing.T, path, outside string, fifo bool) {
t.Helper()
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop) }()
t.Cleanup(func() { stop.Store(true); wg.Wait() })
}
func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
tmp := path + ".swap"
// Writers stay open until the swapper exits, so a reader parked on a
// FIFO stays parked (closing one would hand the reader EOF and hide the
// hang). Past the fd limit, new FIFOs simply have no writer.
var writers []*os.File
defer func() {
for _, w := range writers {
_ = w.Close()
}
}()
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
switch {
case i%2 == 0:
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
case fifo:
if err := syscall.Mkfifo(tmp, 0o644); err == nil {
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
writers = append(writers, w)
}
}
default:
_ = os.Symlink(outside, tmp)
}
_ = os.Rename(tmp, path)
}
}
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t)
startSwap(t, filepath.Join(facts, "r.md"), outside, false)
leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
rs, _ := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
leaks += leaked(rs)
}
if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
}
}
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t)
startSwap(t, filepath.Join(facts, "r.md"), outside, true)
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
_, _ = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
}
close(done)
}()
select {
case <-done:
case <-time.After(6 * time.Second):
if f, err := os.OpenFile(filepath.Join(facts, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close()
}
t.Fatal("search hung on a swapped-in FIFO")
}
}
// hydrate (vector-only hits) reads by relative path; it must refuse a symlink.
func TestHydrateRefusesASymlink(t *testing.T) {
brain, facts, outside := searchBrain(t)
require.NoError(t, os.Symlink(outside, filepath.Join(facts, "leak.md")))
r, err := hydrate(brain, "wiki/w/facts/leak.md")
assert.Error(t, err)
assert.NotContains(t, r.Excerpt, sentinel157)
}
+10 -28
View File
@@ -5,7 +5,6 @@ import (
"bufio" "bufio"
"context" "context"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter" sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
"log/slog" "log/slog"
"os" "os"
@@ -120,30 +119,20 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
return nil, err return nil, err
} }
// Walk and read through an os.Root on the brain dir, regular files only
// (hyperguild#157): a symlink planted in the Syncthing-shared brain must
// not put an outside file in results, nor a FIFO hang the query.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
defer func() { _ = bfs.Close() }()
var results []Result var results []Result
for _, dir := range roots { for _, dir := range roots {
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
continue continue
} }
relDir, relErr := bfs.Rel(dir) err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if relErr != nil { if err != nil {
return nil, fmt.Errorf("search: %w", relErr) slog.Warn("search: skipping path", "path", path, "err", err)
}
err := bfs.WalkRegular(relDir, func(relPath string) error {
if !strings.HasSuffix(relPath, ".md") {
return nil return nil
} }
path := filepath.Join(brainDir, filepath.FromSlash(relPath)) if d.IsDir() || !strings.HasSuffix(path, ".md") {
content, err := bfs.ReadRegular(relPath) return nil
}
content, err := os.ReadFile(path)
if err != nil { if err != nil {
slog.Warn("search: skipping unreadable file", "path", path, "err", err) slog.Warn("search: skipping unreadable file", "path", path, "err", err)
return nil return nil
@@ -166,7 +155,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
origin, writtenBy := extractProvenance(string(content)) origin, writtenBy := extractProvenance(string(content))
results = append(results, Result{ results = append(results, Result{
Path: rel, Path: rel,
Title: extractTitle(string(content), filepath.Base(relPath)), Title: extractTitle(string(content), d.Name()),
Excerpt: excerpt(string(content), 300), Excerpt: excerpt(string(content), 300),
Score: score, Score: score,
Wing: wing, Wing: wing,
@@ -177,8 +166,6 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
WrittenBy: writtenBy, WrittenBy: writtenBy,
}) })
return nil return nil
}, func(p string, err error) {
slog.Warn("search: skipping path", "path", p, "err", err)
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -288,13 +275,8 @@ func pathInScope(relPath, wing, hall string) bool {
// excerpt, wing, and hall populated. Used for paths that surface only // excerpt, wing, and hall populated. Used for paths that surface only
// via vector search. // via vector search.
func hydrate(brainDir, relPath string) (Result, error) { func hydrate(brainDir, relPath string) (Result, error) {
// Through the brain root, regular files only (hyperguild#157). full := filepath.Join(brainDir, filepath.FromSlash(relPath))
bfs, err := brainfs.Open(brainDir) content, err := os.ReadFile(full)
if err != nil {
return Result{}, err
}
defer func() { _ = bfs.Close() }()
content, err := bfs.ReadRegular(filepath.ToSlash(relPath))
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
+145 -34
View File
@@ -9,6 +9,7 @@ package symlinkguard
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
@@ -21,7 +22,14 @@ 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
// Alert bounds (PR #160 review): the planter chooses the paths, so neither an
// alert's size nor the queue may grow with what was planted.
const (
maxPathsPerAlert = 10 // further paths are counted, not listed
maxPending = 5 // queued alerts; the rest become one summary
)
// 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 +49,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 +60,24 @@ func (r Result) String() string {
return strings.Join(parts, "; ") return strings.Join(parts, "; ")
} }
// quoted joins at most maxPathsPerAlert paths, each strconv-quoted, and
// counts the rest.
func (Result) quoted(xs []string) string {
shown := xs
if len(shown) > maxPathsPerAlert {
shown = shown[:maxPathsPerAlert]
}
out := make([]string, len(shown))
for i, x := range shown {
out[i] = strconv.Quote(x)
}
s := strings.Join(out, ", ")
if extra := len(xs) - len(shown); extra > 0 {
s += fmt.Sprintf(" and %d more", extra)
}
return s
}
// 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 +126,20 @@ 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 (see alertQueue): an outage delays them, and
// any that do not fit the bounded queue, or are undeliverable, are counted in
// a summary that is never dropped, so every incident is delivered or counted.
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()
q := &alertQueue{}
reported := "" reported := ""
for { for {
select { select {
@@ -123,31 +148,117 @@ 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 = "" slog.Error("brain symlink tripwire removed symlinks", "count", len(removed))
continue q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
default: len(removed), Result{}.quoted(removed)))
key = r.String() }
msg = "brain symlink tripwire (hyperguild#157): " + key + key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
". Readers may follow symlinks out of the brain; remove them."
} }
if key == reported { if key != reported {
continue
}
slog.Error("brain symlink tripwire", "finding", key)
if n == nil {
reported = key reported = key
continue if key != "" {
slog.Error("brain symlink tripwire", "finding", key)
q.push("brain symlink tripwire (hyperguild#157): " + key +
". Readers may follow symlinks out of the brain; fix them.")
}
} }
if err := n.Notify(ctx, msg); err != nil { q.flush(ctx, n)
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
continue
}
reported = key
} }
} }
// alertQueue holds alerts until Notify accepts them. It is bounded (at most
// maxPending queued) and loses nothing from the record: every overflow and
// every dropped message increments suppressed, sent as a summary that is
// retried every tick and never dropped.
//
// A message is dropped only when the notifier rejects it permanently (an
// error with Permanent() == true, e.g. ntfy 413). Every other failure is an
// outage, and nothing is dropped however long it lasts (PR #160 round 2:
// dropping after N raw failures lost the only incident of a long outage).
// Each flush tries every queued message, so one undeliverable message cannot
// block the others.
type alertQueue struct {
items []string
suppressed int
}
func (q *alertQueue) push(msg string) {
if len(q.items) >= maxPending {
q.suppressed++
return
}
q.items = append(q.items, msg)
}
func (q *alertQueue) flush(ctx context.Context, n Notifier) {
if n == nil {
q.items, q.suppressed = nil, 0
return
}
kept := q.items[:0]
for _, msg := range q.items {
err := n.Notify(ctx, msg)
switch {
case err == nil:
case permanent(err):
slog.Error("symlink tripwire alert rejected permanently; counted in the summary", "err", err)
q.suppressed++
default:
slog.Warn("symlink tripwire notify failed; will retry", "err", err)
kept = append(kept, msg)
}
}
q.items = kept
if q.suppressed > 0 {
msg := fmt.Sprintf("brain symlink tripwire (hyperguild#157): %d further alert(s) were suppressed or undeliverable; check the ingestion logs", q.suppressed)
if err := n.Notify(ctx, msg); err == nil {
q.suppressed = 0
}
}
}
// permanent reports whether err says the message itself can never be sent.
func permanent(err error) bool {
var p interface{ Permanent() bool }
return errors.As(err, &p) && p.Permanent()
}
// 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
}
// root.Remove, not os.RemoveAll: if the link is swapped for a
// non-empty directory after the Lstat, Remove refuses rather than
// deleting it recursively, and it cannot act outside the root.
if err := root.Remove(rel); err != nil {
failed = append(failed, rel)
continue
}
removed = append(removed, rel)
}
return removed, failed
}
@@ -3,9 +3,13 @@ package symlinkguard
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -182,8 +186,313 @@ 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 TestWatchRemovesAnOutsideFileLinkButNotItsTargetOrRegularFiles(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)
}
// PR #160 review: the Lstat re-check and the os.Root confinement in
// removeSymlinks had no test. Anything that is not a symlink inside the brain
// is reported failed and nothing is deleted.
func TestRemoveSymlinksRefusesNonSymlinksEscapesAndPathsThroughLinks(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "real"), 0o755))
regular := filepath.Join(brain, "wiki", "note.md")
require.NoError(t, os.WriteFile(regular, []byte("note"), 0o644))
inLinked := filepath.Join(brain, "wiki", "real", "x.md")
require.NoError(t, os.WriteFile(inLinked, []byte("x"), 0o644))
require.NoError(t, os.Symlink(filepath.Join(brain, "wiki", "real"), filepath.Join(brain, "wiki", "linkdir")))
outside := filepath.Join(filepath.Dir(brain), "outside-157.md")
require.NoError(t, os.WriteFile(outside, []byte("o"), 0o644))
t.Cleanup(func() { _ = os.Remove(outside) })
removed, failed := removeSymlinks(brain, []string{
"wiki/note.md", "wiki/linkdir/x.md", "../outside-157.md", "wiki/missing.md",
})
assert.Empty(t, removed)
assert.ElementsMatch(t, []string{"wiki/note.md", "wiki/linkdir/x.md", "../outside-157.md", "wiki/missing.md"}, failed)
for _, p := range []string{regular, inLinked, outside} {
_, err := os.Stat(p)
assert.NoError(t, err, "%s must not be deleted", p)
}
}
// Dir targets and in-brain targets survive too, not only an outside file.
func TestWatchRemovesDirAndInBrainLinksButNotTheirTargets(t *testing.T) {
brain := t.TempDir()
outsideDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "keep.md"), []byte("keep"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
inBrain := filepath.Join(brain, "wiki", "target.md")
require.NoError(t, os.WriteFile(inBrain, []byte("t"), 0o644))
require.NoError(t, os.Symlink(outsideDir, filepath.Join(brain, "wiki", "dirlink")))
require.NoError(t, os.Symlink(inBrain, filepath.Join(brain, "wiki", "inlink.md")))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, nil)
require.Eventually(t, func() bool {
r, err := Scan(brain)
return err == nil && len(r.Symlinks) == 0
}, 2*time.Second, 10*time.Millisecond)
for _, p := range []string{filepath.Join(outsideDir, "keep.md"), inBrain} {
_, err := os.Stat(p)
assert.NoError(t, err, "%s must survive", p)
}
}
// PR #160 review: the planter chooses the paths. An alert names at most
// maxPathsPerAlert of them and counts the rest, so its size stays bounded.
func TestWatchAlertNamesBoundedPathsAndCountsTheRest(t *testing.T) {
brain := t.TempDir()
for i := 0; i < 50; i++ {
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, strings.Repeat("n", 100)+strconv.Itoa(i))))
}
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)
n.mu.Lock()
msg := n.msgs[0]
n.mu.Unlock()
assert.Contains(t, msg, "removed 50 symlink(s)")
assert.Contains(t, msg, "and 40 more")
assert.Less(t, len(msg), 2500, "alert size is bounded regardless of how many links were planted")
}
// rejectNotifier refuses every message containing a marker, forever.
type rejectNotifier struct {
recNotifier
reject string
}
// permanentErr is a rejection the notifier says can never succeed (like ntfy 413).
type permanentErr struct{}
func (permanentErr) Error() string { return "413 payload too large" }
func (permanentErr) Permanent() bool { return true }
func (r *rejectNotifier) Notify(ctx context.Context, m string) error {
if strings.Contains(m, r.reject) {
r.mu.Lock()
r.msgs = append(r.msgs, "REJECTED")
r.mu.Unlock()
return permanentErr{}
}
return r.recNotifier.Notify(ctx, m)
}
// PR #160 review: one message the server will never accept blocked every
// later alert forever (head-of-line). It is dropped after maxAttempts.
func TestWatchDropsAMessageThatNeverSendsSoLaterAlertsFlow(t *testing.T) {
brain := t.TempDir()
n := &rejectNotifier{reject: "POISON"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "POISON")))
time.Sleep(30 * time.Millisecond)
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "later")))
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "later") {
return true
}
}
return false
}, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message")
}
// toggleNotifier fails while down is set.
type toggleNotifier struct {
recNotifier
down atomic.Bool
}
func (r *toggleNotifier) Notify(ctx context.Context, m string) error {
if r.down.Load() {
return errors.New("ntfy down")
}
return r.recNotifier.Notify(ctx, m)
}
// PR #160 review: an outage queued one message per removal, unbounded. The
// queue holds at most maxPending messages; the rest become one summary.
func TestWatchQueueIsBoundedDuringAnOutage(t *testing.T) {
brain := t.TempDir()
n := &toggleNotifier{}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 5*time.Millisecond, n)
for i := 0; i < 30; i++ {
link := filepath.Join(brain, "p"+strconv.Itoa(i))
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond)
time.Sleep(100 * time.Millisecond)
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
n.mu.Lock()
defer n.mu.Unlock()
found := false
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
found = true
}
}
assert.True(t, found, "suppressed alerts are counted in a summary")
}
// Within the bound, every alert queued during an outage is delivered after
// it, each naming its own removal (PR #160 review: truncating the queue after
// the first send stayed green).
func TestWatchDeliversEveryQueuedAlertAfterAShortOutage(t *testing.T) {
brain := t.TempDir()
n := &toggleNotifier{}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 5*time.Millisecond, n)
for _, name := range []string{"qa", "qb", "qc"} {
link := filepath.Join(brain, name)
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
for i, name := range []string{"qa", "qb", "qc"} {
assert.Contains(t, n.msgs[i], strconv.Quote(name))
}
}
// PR #160 round 2: dropping after N failures could not tell a poison message
// from an outage, so a 15-minute outage silently lost the only incident. A
// message is only dropped when it keeps failing while others send; through
// an outage nothing is dropped, however long.
func TestWatchLosesNoAlertThroughALongOutage(t *testing.T) {
brain := t.TempDir()
n := &toggleNotifier{}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 2*time.Millisecond, n)
link := filepath.Join(brain, "only-incident")
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
time.Sleep(200 * time.Millisecond) // ~100 ticks of outage, far past any attempt limit
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], strconv.Quote("only-incident"))
}
// Every removal is either delivered or counted in the suppression summary:
// none disappears from the record (round 2: 74 of 300 were in neither).
func TestWatchAccountsForEveryRemovalDuringAnOutage(t *testing.T) {
brain := t.TempDir()
n := &toggleNotifier{}
n.down.Store(true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 2*time.Millisecond, n)
const plants = 25
for i := 0; i < plants; i++ {
link := filepath.Join(brain, "acct"+strconv.Itoa(i))
require.NoError(t, os.Symlink(t.TempDir(), link))
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
return true
}
}
return false
}, 2*time.Second, 5*time.Millisecond)
time.Sleep(50 * time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
delivered, counted := 0, 0
for _, m := range n.msgs {
if strings.Contains(m, "removed 1 symlink(s)") {
delivered++
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted)
}
}
assert.Equal(t, plants, delivered+counted, "delivered %d + counted %d must equal %d removals", delivered, counted, plants)
}
// A poison message is still dropped (others send while it fails), and the
// drop is counted in the summary.
func TestWatchDropsAPoisonMessageAndCountsIt(t *testing.T) {
brain := t.TempDir()
n := &rejectNotifier{reject: "POISON"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 5*time.Millisecond, n)
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "POISON")))
time.Sleep(20 * time.Millisecond)
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "fine")))
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "1 further alert(s)") {
return true
}
}
return false
}, 3*time.Second, 5*time.Millisecond, "the dropped poison alert is counted in a summary")
}
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"testing" "testing"
@@ -343,7 +344,12 @@ func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brainDir := setupBrainDir(t) brainDir := setupBrainDir(t)
raw := filepath.Join(brainDir, "raw") raw := filepath.Join(brainDir, "raw")
var stop atomic.Bool var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
// Awaited before t.TempDir's cleanup (hyperguild#162).
t.Cleanup(func() { stop.Store(true); wg.Wait() })
go func() { go func() {
defer wg.Done()
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swap") tmp := filepath.Join(raw, ".swap")
_ = os.Remove(tmp) _ = os.Remove(tmp)