Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5.5 fd33e04467 test(search): hold a writer on swapped-in FIFOs; await the swapper
CI / Mirror to GitHub (pull_request) Skipped
CI / Lint / Test / Vet (pull_request) Successful in 39s
PR #161 round 2: ReadRegular bypassing readOpened (non-blocking open,
no handle check) stayed green, because the search FIFO race used FIFOs
with no writer, which read EOF at once. The swapper now opens each FIFO
O_RDWR and keeps every writer open until it exits, as any local process
could, so a reader that skips the handle check parks forever; the bypass
flip is red. The swapper is stopped and awaited in a t.Cleanup before
t.TempDir removal (hyperguild#162).

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:32:40 +02:00
mathiasandClaude Opus 5.5 ffe46c7077 test(brainfs): pin the handle check with a FIFO held open by a writer
CI / Lint / Test / Vet (pull_request) Successful in 47s
CI / Mirror to GitHub (pull_request) Skipped
PR #161 review: removing ReadRegular's type check on the opened handle
stayed green, because the race test's FIFOs had no writer and read EOF
at once; with a writer holding the FIFO open, search hung after 16
iterations. The open-and-check step is split into readOpened, and
TestReadOpenedRefusesAFIFOHeldOpenByAWriter reaches it past the Lstat
with a writer open: it must return ErrNotRegular promptly (the flip is
red).

Also unit tests for ReadRegular (symlink, escape), Rel (inside incl.
..foo, escapes) and WalkRegular (regular files only), and the hard-link
residual documented where it is accepted, as in api/rawroot.go.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:26:27 +02:00
mathiasandClaude Opus 5.5 9c9ba31770 fix(search): walk and read the brain through an os.Root (#157, slice 1)
CI / Lint / Test / Vet (pull_request) Successful in 48s
CI / Mirror to GitHub (pull_request) Skipped
Option 1 of #157, first slice. Search walked and read the brain by
path, so a symlink planted in the Syncthing-shared brain dir put an
outside file's contents in brain_query results, and a FIFO swapped into
the wiki hung the query forever (reproduced: the FIFO race test blocked
for the full 6s timeout before this change).

New package internal/brainfs: Open (os.Root on the brain dir),
ReadRegular (Lstat + non-blocking open + type check on the handle) and
WalkRegular (walks the root's FS, yields regular files only, never
follows a link). search.Query and hydrate use it.

Tests (red first): a wiki symlink is not followed, a symlinked dir is not
descended, hydrate refuses a symlink, and race tests swap a symlink or a
FIFO into a wiki note while Query loops; any leaked byte or a hang fails.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:18:54 +02:00
35 changed files with 173 additions and 1936 deletions
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"context"
"log/slog"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
)
// buildJWTValidator runs OIDC discovery under policy, so a 502 from an auth
// upstream that is not serving yet on a cold boot is retried instead of
// exiting (#83). It honours ctx between attempts.
func buildJWTValidator(
ctx context.Context, logger *slog.Logger, issuer, audience string, policy startupretry.Policy,
) (*chassisauth.JWTValidator, error) {
return startupretry.Do(ctx, policy, func(ctx context.Context) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, issuer, audience)
if err != nil {
logger.Warn("oidc discovery not ready, retrying", "err", err)
}
return v, err
})
}
@@ -1,86 +0,0 @@
package main
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
)
// fakeIssuer answers OIDC discovery with 502 for the first fail requests, as
// ingress does while dex is not yet serving on a cold boot (#83), then with a
// real discovery document and an empty JWKS.
func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) {
t.Helper()
var discoveries atomic.Int32
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
if discoveries.Add(1) <= fail {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"issuer":"`+srv.URL+`","jwks_uri":"`+srv.URL+`/keys"}`)
})
mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"keys":[]}`)
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &discoveries
}
var fastPolicy = startupretry.Policy{Attempts: 8, Base: time.Millisecond, Max: 5 * time.Millisecond}
func TestBuildJWTValidatorRetriesDiscovery502sThenSucceeds(t *testing.T) {
srv, discoveries := fakeIssuer(t, 3)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
v, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
require.NoError(t, err)
assert.NotNil(t, v)
assert.Equal(t, int32(4), discoveries.Load(), "three 502s, then the successful discovery")
}
func TestBuildJWTValidatorGivesUpAfterTheBudgetWithTheLastError(t *testing.T) {
srv, discoveries := fakeIssuer(t, 1000)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
require.Error(t, err)
assert.Contains(t, err.Error(), "status 502")
assert.Equal(t, int32(8), discoveries.Load(), "bounded: exactly the attempt budget")
}
func TestBuildJWTValidatorStopsRetryingWhenTheContextIsCancelled(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
ctx, cancel := context.WithCancel(context.Background())
slow := startupretry.Policy{Attempts: 8, Base: time.Hour, Max: time.Hour}
done := make(chan error, 1)
go func() {
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", slow)
done <- err
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(3 * time.Second):
t.Fatal("did not return after the context was cancelled")
}
}
+9 -3
View File
@@ -381,9 +381,15 @@ func main() {
//
// The budget stays bounded: retrying forever would turn a misconfiguration
// into a process that looks alive and never serves.
jwtValidator, err := buildJWTValidator(ctx, logger,
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"),
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second})
jwtValidator, err := startupretry.Do(ctx,
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
if err != nil {
logger.Warn("oidc discovery not ready, retrying", "err", err)
}
return v, err
})
if err != nil {
logger.Error("build jwt validator", "err", err)
os.Exit(1)
+6 -6
View File
@@ -34,9 +34,9 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
@@ -66,9 +66,9 @@ require (
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1
)
+14 -14
View File
@@ -117,42 +117,42 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+11 -17
View File
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
@@ -26,18 +25,6 @@ import (
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) {
tgt := filepath.Join(raw, "r.md")
for i := 0; !stop.Load(); i++ {
@@ -89,7 +76,9 @@ func readers(brain, raw string) string {
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, false, stop) })
var stop atomic.Bool
go swap(raw, outside, false, &stop)
defer stop.Store(true)
leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
leaks += strings.Count(readers(brain, raw), raceSentinel)
@@ -102,7 +91,8 @@ func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, raw, outside := raceBrain(t)
startSwapper(t, func(stop *atomic.Bool) { swap(raw, outside, true, stop) })
var stop atomic.Bool
go swap(raw, outside, true, &stop)
var iters atomic.Int64
done := make(chan struct{})
go func() {
@@ -114,7 +104,9 @@ func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
}()
select {
case <-done:
stop.Store(true)
case <-time.After(6 * time.Second):
stop.Store(true)
// 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 {
_ = f.Close()
@@ -155,7 +147,8 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
t.Fatal(err)
}
startSwapper(t, func(stop *atomic.Bool) {
var stop atomic.Bool
go func() {
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swapdocx")
_ = os.Remove(tmp)
@@ -166,7 +159,8 @@ func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
}
_ = os.Rename(tmp, tgt)
}
})
}()
defer stop.Store(true)
iters := 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
_, _ = api.ReadIngestFile(brain, tgt)
@@ -1,43 +0,0 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by
// path; it now reads through brainfs.
func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
assert.Error(t, err)
assert.NotContains(t, body, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
fm, body, _, err := api.ReadNote(brain, "wiki/w/facts/r.md")
require.NoError(t, err)
assert.Equal(t, "y", fm["x"])
assert.Contains(t, body, "inside")
read := func() string {
fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md")
s := body
for k, v := range fm {
s += k + v
}
return s
}
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
}
+2 -9
View File
@@ -4,7 +4,6 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"os"
"path/filepath"
"strings"
@@ -175,17 +174,11 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
// write primitive behind brain_get: the hash it returns equals the hash
// brain_write / brain_update returned for the same bytes.
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
rel, _, err := resolveWithin(brainDir, relPath)
_, abs, err := resolveWithin(brainDir, relPath)
if err != nil {
return nil, "", "", err
}
// Through the brain root, regular files only (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, "", "", fmt.Errorf("read note: %w", err)
}
defer func() { _ = bfs.Close() }()
raw, err := bfs.ReadRegular(rel)
raw, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
+1 -13
View File
@@ -49,19 +49,7 @@ func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &RejectedError{Status: resp.StatusCode}
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode)
}
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
}
@@ -1,27 +0,0 @@
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)
}
}
+7 -124
View File
@@ -13,18 +13,13 @@ import (
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"syscall"
)
// ErrNotRegular is returned for an entry that is not a regular file.
var ErrNotRegular = errors.New("not a regular file")
// ErrTooLarge is returned by ReadRegularMax for a file over the limit.
var ErrTooLarge = errors.New("file too large")
// Brain is an open brain directory.
type Brain struct {
root *os.Root
@@ -55,55 +50,6 @@ func (b *Brain) Rel(path string) (string, error) {
return filepath.ToSlash(rel), nil
}
// Lstat stats a brain-relative path through the root without following a
// final symlink.
func (b *Brain) Lstat(rel string) (fs.FileInfo, error) {
return b.root.Lstat(filepath.ToSlash(rel))
}
// realDir returns ErrNotRegular unless every component of rel is a real
// directory. A swap after the check is not caught (accepted residual,
// hyperguild#157).
func (b *Brain) realDir(rel string) error {
if rel == "." {
return nil
}
prefix := ""
for _, part := range strings.Split(rel, "/") {
prefix = path.Join(prefix, part)
fi, err := b.root.Lstat(prefix)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%s: walk root component %s is not a directory: %w", rel, prefix, ErrNotRegular)
}
}
return nil
}
// ReadDirRegular lists the regular files directly in a brain-relative
// directory, by name. Every component of relDir must be a real directory
// (ErrNotRegular otherwise); symlinks, FIFOs, devices and subdirectories in
// it are left out.
func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
relDir = filepath.ToSlash(relDir)
if err := b.realDir(relDir); err != nil {
return nil, err
}
ents, err := fs.ReadDir(b.root.FS(), relDir)
if err != nil {
return nil, err
}
var names []string
for _, e := range ents {
if e.Type().IsRegular() {
names = append(names, e.Name())
}
}
return names, 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) {
@@ -114,7 +60,7 @@ func (b *Brain) ReadRegular(rel string) ([]byte, error) {
if !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
}
return b.readOpenedMax(rel, -1, fi)
return b.readOpened(rel)
}
// readOpened opens rel non-blocking and reads it only if the OPENED handle is
@@ -128,26 +74,7 @@ func (b *Brain) ReadRegular(rel string) ([]byte, error) {
// 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) { return b.readOpenedMax(rel, -1, nil) }
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
// handle before reading (max < 0 means no limit).
func (b *Brain) ReadRegularMax(rel string, max int64) ([]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.readOpenedMax(rel, max, fi)
}
// readOpenedMax opens rel and reads it if the opened handle is a regular file
// and, when lfi is set, the same file the caller Lstat-ed: os.Root follows an
// in-root final symlink even with O_NOFOLLOW, so a symlink to another brain
// file swapped in between the Lstat and the open is refused only here.
func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]byte, error) {
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
@@ -160,59 +87,15 @@ func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]byte, e
if !hfi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
}
if lfi != nil && !os.SameFile(lfi, hfi) {
return nil, fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
}
if max >= 0 && hfi.Size() > max {
return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge)
}
var r io.Reader = f
if wrapAfterStat != nil {
r = wrapAfterStat(r)
}
if max < 0 {
return io.ReadAll(r)
}
// The handle's size can grow after the check; bound the read itself.
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(data)) > max {
return nil, fmt.Errorf("%s grew past %d bytes while read: %w", rel, max, ErrTooLarge)
}
return data, nil
return io.ReadAll(f)
}
// wrapAfterStat is a test seam: it runs between the size check and the read,
// and wraps the reader the read consumes. A package global: brainfs tests
// must not use t.Parallel.
var wrapAfterStat func(io.Reader) io.Reader
// WalkRegular walks a brain-relative directory through the root and calls fn
// for each regular file. Directories are descended; symlinks, FIFOs and
// devices found by the walk are skipped. A walk root with any component that
// is not a real directory goes to onErr as ErrNotRegular and nothing is
// yielded. An entry error is passed to onErr and the walk continues; a nil
// onErr ignores it.
//
// Accepted residual (hyperguild#157): a directory swapped for a symlink
// between the listing and the read is followed. os.Root still keeps the read
// inside the brain, but not inside relDir.
// 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 {
relDir = filepath.ToSlash(relDir)
// fs.WalkDir stats its root with a follow, and os.Root allows a symlink
// that stays inside the brain: knowledge -> . would alias the whole
// brain. Require every component of the root to be a real directory
// (Lstat checks only the last one).
err := b.realDir(relDir)
if err != nil {
if onErr != nil {
onErr(relDir, err)
}
return nil
}
return fs.WalkDir(b.root.FS(), relDir, func(p string, d fs.DirEntry, 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)
-172
View File
@@ -2,12 +2,8 @@ package brainfs
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -94,171 +90,3 @@ func TestWalkRegularYieldsOnlyRegularFiles(t *testing.T) {
require.NoError(t, b.WalkRegular(".", func(rel string) error { got = append(got, rel); return nil }, nil))
assert.Equal(t, []string{"a/n.md"}, got)
}
func TestReadRegularMaxRefusesAFileOverTheLimit(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), make([]byte, 100), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
_, err = b.ReadRegularMax("big.md", 99)
assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err)
got, err := b.ReadRegularMax("big.md", 100)
require.NoError(t, err)
assert.Len(t, got, 100)
}
func TestReadRegularMaxRefusesAndStopsReadingAFileThatGrowsPastTheLimit(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "grow.md")
require.NoError(t, os.WriteFile(p, make([]byte, 10), 0o644))
consumed := &countingReader{}
wrapAfterStat = func(r io.Reader) io.Reader {
f, err := os.OpenFile(p, os.O_WRONLY|os.O_APPEND, 0)
require.NoError(t, err)
_, err = f.Write(make([]byte, 1000))
require.NoError(t, err)
require.NoError(t, f.Close())
consumed.r = r
return consumed
}
t.Cleanup(func() { wrapAfterStat = nil })
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
got, err := b.ReadRegularMax("grow.md", 100)
assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err)
assert.Nil(t, got)
assert.LessOrEqual(t, consumed.n, 101, "read past max+1 bytes")
}
type countingReader struct {
r io.Reader
n int
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}
func TestLstatDoesNotFollowSymlinksAndRefusesEscapes(t *testing.T) {
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "o.md")
require.NoError(t, os.WriteFile(outside, []byte("o"), 0o644))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "l.md")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
fi, err := b.Lstat("l.md")
require.NoError(t, err)
assert.Equal(t, fs.ModeSymlink, fi.Mode().Type())
_, err = b.Lstat("../" + filepath.Base(filepath.Dir(outside)) + "/o.md")
assert.Error(t, err)
}
func TestWalkRegularReportsASymlinkedWalkRootAndYieldsNothing(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "real"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "n.md"), []byte("n"), 0o644))
require.NoError(t, os.Symlink("real", filepath.Join(dir, "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
var got []string
var errs []error
require.NoError(t, b.WalkRegular("alias", func(rel string) error { got = append(got, rel); return nil },
func(_ string, err error) { errs = append(errs, err) }))
assert.Empty(t, got)
require.Len(t, errs, 1)
assert.ErrorIs(t, errs[0], ErrNotRegular)
}
func TestWalkRegularReportsASymlinkedIntermediateComponentOfTheWalkRoot(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "real", "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "sub", "n.md"), []byte("n"), 0o644))
require.NoError(t, os.Symlink("real", filepath.Join(dir, "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
var got []string
var errs []error
require.NoError(t, b.WalkRegular("alias/sub", func(rel string) error { got = append(got, rel); return nil },
func(_ string, err error) { errs = append(errs, err) }))
assert.Empty(t, got)
require.Len(t, errs, 1)
assert.ErrorIs(t, errs[0], ErrNotRegular)
}
func TestReadDirRegularListsOnlyRegularFilesAndRefusesSymlinkedComponents(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b", "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "b", "n.md"), []byte("n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "other.md"), []byte("o"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "other.md"), filepath.Join(dir, "a", "b", "l.md")))
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "a", "b", "f.md"), 0o644))
require.NoError(t, os.Symlink("a", filepath.Join(dir, "alias")))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "x"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "a"), filepath.Join(dir, "x", "mid")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
names, err := b.ReadDirRegular("a/b")
require.NoError(t, err)
assert.Equal(t, []string{"n.md"}, names)
for _, rel := range []string{"alias/b", "x/mid/b"} {
_, err := b.ReadDirRegular(rel)
assert.ErrorIs(t, err, ErrNotRegular, rel)
}
}
// A final component swapped for a symlink to another regular file in the
// brain between the Lstat and the open: os.Root follows in-root links, so
// only comparing the two (os.SameFile) refuses it.
func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
p := filepath.Join(dir, "r.md")
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
tmp := p + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, []byte("inside"), 0o644)
} else {
_ = os.Symlink("s.md", tmp)
}
_ = os.Rename(tmp, p)
}
}()
leaks, ok := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
got, err := b.ReadRegular("r.md")
if err == nil {
ok++
}
if string(got) == "zqsamefilesentinel" {
leaks++
}
got, _ = b.ReadRegularMax("r.md", 1<<20)
if string(got) == "zqsamefilesentinel" {
leaks++
}
}
stop.Store(true)
wg.Wait()
assert.Zero(t, leaks)
assert.Positive(t, ok, "positive control: the regular file is read")
}
@@ -1,141 +0,0 @@
// Package brainfstest is the shared harness for hyperguild#157's swap-race
// tests: it keeps swapping a regular note, a symlink to an outside file, or a
// FIFO (with a writer held open) into a path while a reader under test loops.
// It observes two things: no sentinel byte from the outside file reaches the
// reader's output, and the reader returns within a timeout. It does not cover
// hard links, other paths into the reader, or races it does not generate.
package brainfstest
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
// Sentinel marks the outside file's contents.
const Sentinel = "zqbrainfssentinelonefiftyseven"
// outsideContent carries Sentinel in every field a reader might extract: the
// frontmatter title and aliases, a heading, a wikilink and the body.
const outsideContent = "---\ntitle: " + Sentinel + "\naliases:\n - " + Sentinel + "\n---\n# " + Sentinel +
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
// Outside writes a file outside any brain dir containing Sentinel.
func Outside(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "secret.md")
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
t.Fatal(err)
}
return p
}
var swapSeq atomic.Int64
// StartSwap renames, in a loop, a regular note (content inside) and either
// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a
// writer held open until the swapper exits, so a reader that skips the
// handle check parks forever instead of reading EOF. The swapper is stopped
// and awaited before t.TempDir cleanup.
func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
t.Helper()
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// A name of its own: swappers on one path share a test, and one
// opening another's FIFO for write would block forever.
tmp := fmt.Sprintf("%s.swap%d", path, swapSeq.Add(1))
// A relative link: os.Root refuses every absolute link on its own, so
// an absolute in-brain target would test nothing.
link := outside
if rel, err := filepath.Rel(filepath.Dir(path), outside); err == nil {
link = rel
}
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(inside), 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(link, tmp)
}
_ = os.Rename(tmp, path)
}
}()
t.Cleanup(func() { stop.Store(true); wg.Wait() })
}
// WriteSentinelAt writes a file carrying Sentinel at p (creating its
// directory), for an in-brain symlink target.
func WriteSentinelAt(t *testing.T, p string) string {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// AssertNoLeak loops read for d while a symlink swapper runs on path, and
// fails if any output contains Sentinel. The symlink points outside the
// brain; os.Root refuses that on its own, so pair it with AssertNoLeakFrom
// and an in-brain target.
func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) {
t.Helper()
AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
}
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
// a file elsewhere in the brain written with WriteSentinelAt.
func AssertNoLeakFrom(t *testing.T, path, inside, target string, d time.Duration, read func() string) {
t.Helper()
StartSwap(t, path, inside, target, false)
leaks, iters := 0, 0
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
leaks += strings.Count(read(), Sentinel)
}
if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
}
}
// AssertNoHang loops read for d while a FIFO swapper (held writers) runs on
// path, and fails if a read does not return within timeout.
func AssertNoHang(t *testing.T, path, inside string, d, timeout time.Duration, read func()) {
t.Helper()
StartSwap(t, path, inside, Outside(t), true)
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(d); time.Now().Before(deadline); {
read()
}
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
t.Fatal("reader hung on a swapped-in FIFO with a writer held open")
}
}
@@ -16,10 +16,9 @@
package classification
import (
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
@@ -111,20 +110,9 @@ type rawConfig struct {
func Load(brainDir string) (*Config, error) {
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
// Read through the brain root, regular files only (hyperguild#157): a
// symlinked or special file is not trusted as the config, which is a hard
// error like a malformed one.
bfs, err := brainfs.Open(brainDir)
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, fmt.Errorf("read classification.yaml: %w", err)
@@ -1,46 +0,0 @@
package classification
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157 slice 6: Load read classification.yaml with os.ReadFile. It
// sets the I1 sovereignty levels; a symlinked file must not be trusted as the
// config (a hard error, as for a malformed file), and a FIFO must not hang
// startup.
func TestLoadRefusesASymlinkedOrFIFOClassificationFile(t *testing.T) {
ok := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(ok, "classification.yaml"), []byte("wings:\n w: confidential\n"), 0o644))
_, err := Load(ok)
require.NoError(t, err, "positive control")
linked := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(linked, "real.yaml"), []byte("wings:\n w: public\n"), 0o644))
require.NoError(t, os.Symlink("real.yaml", filepath.Join(linked, "classification.yaml")))
_, err = Load(linked)
assert.Error(t, err, "an in-brain symlink is not trusted as the config")
fifo := t.TempDir()
require.NoError(t, syscall.Mkfifo(filepath.Join(fifo, "classification.yaml"), 0o644))
w, err := os.OpenFile(filepath.Join(fifo, "classification.yaml"), os.O_RDWR, 0)
require.NoError(t, err)
defer func() { _ = w.Close() }()
done := make(chan error, 1)
go func() { _, err := Load(fifo); done <- err }()
select {
case err := <-done:
assert.Error(t, err)
case <-time.After(3 * time.Second):
t.Fatal("Load hung on a FIFO")
}
_, err = Load(t.TempDir())
assert.NoError(t, err, "an absent file is still not an error")
}
+21 -43
View File
@@ -12,14 +12,10 @@ package graphsync
import (
"context"
"errors"
"fmt"
"io/fs"
"path"
"os"
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
)
@@ -50,25 +46,8 @@ func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error
if relPath == "" {
return nil
}
// Callers pass caller-supplied paths (brain_tunnel, brain_update): index
// only a clean path under wiki/ or knowledge/.
if path.Clean(relPath) != relPath || !filepath.IsLocal(relPath) ||
(!strings.HasPrefix(relPath, "wiki/") && !strings.HasPrefix(relPath, "knowledge/")) {
return fmt.Errorf("index %q: not a clean wiki/ or knowledge/ path", relPath)
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
return indexDoc(ctx, store, bfs, relPath)
}
// indexDoc reads relPath through the brain root, regular files only
// (hyperguild#157): the brain is a Syncthing folder, and os.ReadFile
// followed a synced symlink into the graph and blocked on a FIFO.
func indexDoc(ctx context.Context, store Store, bfs *brainfs.Brain, relPath string) error {
content, err := bfs.ReadRegular(relPath)
abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
content, err := os.ReadFile(abs)
if err != nil {
return fmt.Errorf("read %q: %w", relPath, err)
}
@@ -98,34 +77,33 @@ func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (in
if store == nil {
return 0, nil
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return 0, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
for _, root := range []string{"wiki", "knowledge"} {
if _, err := bfs.Lstat(root); errors.Is(err, fs.ErrNotExist) {
roots := []string{"wiki", "knowledge"}
for _, root := range roots {
base := filepath.Join(brainDir, root)
if _, err := os.Stat(base); os.IsNotExist(err) {
continue
}
// Any walk error, including a root that is a symlink, aborts.
var walkErr error
err := bfs.WalkRegular(root, func(rel string) error {
if path.Ext(rel) != ".md" {
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if err := indexDoc(ctx, store, bfs, rel); err != nil {
if filepath.Ext(path) != ".md" {
return nil
}
rel, relErr := filepath.Rel(brainDir, path)
if relErr != nil {
return fmt.Errorf("rel %q: %w", path, relErr)
}
rel = filepath.ToSlash(rel)
if err := IndexDoc(ctx, store, brainDir, rel); err != nil {
return fmt.Errorf("index %q: %w", rel, err)
}
indexed++
return nil
}, func(rel string, err error) {
if walkErr == nil {
walkErr = fmt.Errorf("%s: %w", rel, err)
}
})
if err == nil {
err = walkErr
}
if err != nil {
return indexed, fmt.Errorf("walk %s: %w", root, err)
}
@@ -1,92 +0,0 @@
package graphsync
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
func (f *fakeStore) dump() string {
f.mu.Lock()
defer f.mu.Unlock()
return fmt.Sprintf("%+v %+v", f.upserts, f.replaces)
}
// hyperguild#157 slice 4: IndexDoc and BackfillFromBrainDir read notes with
// os.ReadFile, so a symlinked note put the target's title and wikilinks into
// the graph, and a FIFO hung the read.
func TestIndexDocRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: zqinside\n---\nbody [[other]]\n"), 0o644))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
store := newFakeStore()
require.NoError(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/ok.md"))
assert.Contains(t, store.dump(), "zqinside", "positive control: a regular note is indexed")
assert.Error(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/leak.md"))
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file check refuses it.
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
assert.Error(t, IndexDoc(context.Background(), store, brain, "wiki/w/facts/inlink.md"))
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
read := func() string {
s := newFakeStore()
_ = IndexDoc(context.Background(), s, brain, "wiki/w/facts/r.md")
return s.dump()
}
brainfstest.AssertNoLeak(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: zqinside\n---\n", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
}
func TestBackfillSkipsSymlinkedNotesAndAbortsOnASymlinkedRoot(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: zqinside\n---\n"), 0o644))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "r.md"), []byte("---\ntitle: zqrawonly\n---\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "r.md"), filepath.Join(facts, "inlink.md")))
require.NoError(t, os.Symlink(".", filepath.Join(brain, "knowledge")))
store := newFakeStore()
_, err := BackfillFromBrainDir(context.Background(), store, brain)
assert.Error(t, err, "a symlinked root aborts the backfill")
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
assert.NotContains(t, store.dump(), "zqrawonly")
require.NoError(t, os.Remove(filepath.Join(brain, "knowledge")))
store = newFakeStore()
n, err := BackfillFromBrainDir(context.Background(), store, brain)
require.NoError(t, err)
assert.Equal(t, 1, n, "only the regular note is indexed")
assert.Contains(t, store.dump(), "zqinside")
assert.NotContains(t, store.dump(), brainfstest.Sentinel)
}
// IndexDoc gets caller-supplied paths (brain_tunnel, brain_update): only a
// clean path under wiki/ or knowledge/ is indexed.
func TestIndexDocRefusesPathsOutsideWikiAndKnowledgeAndUncleanPaths(t *testing.T) {
brain := t.TempDir()
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "secret.md"))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
for _, p := range []string{"raw/secret.md", "wiki/w/../../raw/secret.md", "./raw/secret.md", "/raw/secret.md"} {
store := newFakeStore()
assert.Error(t, IndexDoc(context.Background(), store, brain, p), p)
assert.NotContains(t, store.dump(), brainfstest.Sentinel, p)
}
}
@@ -1,34 +0,0 @@
package mcp
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#157 slice 2: readExcerpt (brain_context) read by path, so a
// symlink in the Syncthing-shared brain put an outside file in context, and
// a FIFO hung it. It now reads through brainfs.
func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
assert.Equal(t, "inside", readExcerpt(brain, "wiki/w/facts/r.md", 500))
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
})
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() {
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
})
}
+3 -8
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"os"
"path/filepath"
"sort"
"strings"
@@ -181,13 +181,8 @@ func readExcerpt(brainDir, relPath string, maxLen int) string {
if relPath == "" {
return ""
}
// Through the brain root, regular files only (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
if err != nil {
return ""
}
defer func() { _ = bfs.Close() }()
content, err := bfs.ReadRegular(filepath.ToSlash(relPath))
full := filepath.Join(brainDir, filepath.FromSlash(relPath))
content, err := os.ReadFile(full)
if err != nil {
return ""
}
+5 -10
View File
@@ -5,7 +5,6 @@ import (
"encoding/hex"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"os"
"path/filepath"
"strings"
@@ -72,18 +71,14 @@ func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) {
}
func readContained(t Target) ([]byte, error) {
// Through the brain root, regular files only, size checked on the opened
// handle (hyperguild#157): Resolve's Lstat could be raced by a swap, and
// os.ReadFile blocked on a FIFO.
bfs, err := brainfs.Open(t.Brain)
fi, err := os.Stat(t.Abs)
if err != nil {
return nil, fmt.Errorf("notes: %w", err)
return nil, fmt.Errorf("notes: stat %s: %w", t.Rel, err)
}
defer func() { _ = bfs.Close() }()
b, err := bfs.ReadRegularMax(t.Rel, maxNoteBytes)
if errors.Is(err, brainfs.ErrTooLarge) {
return nil, fmt.Errorf("%w: %s (limit %d)", ErrTooLarge, t.Rel, maxNoteBytes)
if fi.Size() > maxNoteBytes {
return nil, fmt.Errorf("%w: %s is %d bytes (limit %d)", ErrTooLarge, t.Rel, fi.Size(), maxNoteBytes)
}
b, err := os.ReadFile(t.Abs)
if err != nil {
return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err)
}
+3 -4
View File
@@ -58,9 +58,8 @@ var (
// Target is a resolved, contained path.
type Target struct {
Rel string // brain-relative, cleaned
Abs string // absolute, symlink-checked
Brain string // the brain root Rel is relative to
Rel string // brain-relative, cleaned
Abs string // absolute, symlink-checked
}
// Resolve canonicalises path, then checks it against the scope's allowlist,
@@ -110,7 +109,7 @@ func Resolve(brainDir, path string, scope Scope) (Target, error) {
return Target{}, err
}
return Target{Rel: clean, Abs: abs, Brain: brainDir}, nil
return Target{Rel: clean, Abs: abs}, nil
}
// allow enforces the scope's allowlist. The two scopes are DISJOINT, not
@@ -1,40 +0,0 @@
package notes
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#157 slice 2: ReadScoped (brain_read) Lstat-checked the final
// component, then reopened by name with os.ReadFile: a swap after the check
// was followed, and a FIFO blocked the read. It now reads through brainfs.
func TestReadScopedRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
p := filepath.Join(facts, "r.md")
read := func() string {
b, _ := ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
return string(b)
}
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
assert.Equal(t, "inside", read())
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
}
func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "big.md"), make([]byte, maxNoteBytes+1), 0o644))
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
assert.ErrorIs(t, err, ErrTooLarge)
}
+8 -17
View File
@@ -3,10 +3,7 @@ package pipeline
import (
"context"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -26,16 +23,10 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
return 0, fmt.Errorf("load inventory: %w", err)
}
// Listed and read through the brain root, regular files only
// (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
sourcesDir := filepath.Join(brainDir, "wiki", "sources")
entries, err := os.ReadDir(sourcesDir)
if err != nil {
return 0, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular("wiki/sources")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read sources dir: %w", err)
@@ -46,20 +37,20 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// get both refs merged before a single write.
pending := make(map[string]wiki.Page)
for _, name := range names {
for _, e := range entries {
if ctx.Err() != nil {
return 0, ctx.Err()
}
if !strings.HasSuffix(name, ".md") {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
b, err := bfs.ReadRegular("wiki/sources/" + name)
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
if err != nil {
continue
}
sourceContent := string(b)
sourceSlug := strings.TrimSuffix(name, ".md")
sourceSlug := strings.TrimSuffix(e.Name(), ".md")
sourceTitle := extractTitle(sourceContent)
if sourceTitle == "" {
sourceTitle = sourceSlug
@@ -79,7 +70,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// Start from already-accumulated version if we've seen this page.
page, seen := pending[relPath]
if !seen {
raw, err := bfs.ReadRegular(relPath)
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
if err != nil {
continue
}
+6 -22
View File
@@ -5,10 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -132,7 +129,7 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
}
content, conflicts, err := reviseOrCreate(brainDir, page)
content, conflicts, err := reviseOrCreate(dest, page)
if err != nil {
return Result{}, err
}
@@ -191,27 +188,14 @@ func hashBody(content string) string {
// readSourcePage returns the full on-disk content of the source page at
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
// It reads through the brain root, regular files only (hyperguild#157): the
// content goes into the LLM prompt.
func readSourcePage(brainDir, sourceSlug string) string {
raw, err := readBrainFile(brainDir, "wiki/sources/"+sourceSlug+".md")
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
if err != nil {
return ""
}
return string(raw)
}
// readBrainFile reads one brain-relative file through the brain root,
// regular files only (hyperguild#157).
func readBrainFile(brainDir, rel string) ([]byte, error) {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, err
}
defer func() { _ = bfs.Close() }()
return bfs.ReadRegular(rel)
}
// storedSourceHash reads source_hash from the source page a previous ingest
// wrote, or "" when there is no such page or no such field.
//
@@ -246,10 +230,10 @@ func storedSourceHash(brainDir, sourceSlug string) string {
// its first-ingest date on a revised page. Left deliberately — the alternative
// is this function editing frontmatter it does not own, and no lint reads that
// field today.
func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) {
existing, err := readBrainFile(brainDir, page.Path)
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest)
switch {
case errors.Is(err, fs.ErrNotExist):
case os.IsNotExist(err):
return page.Content, nil, nil
case err != nil:
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
@@ -283,7 +267,7 @@ See brain/schema.md for the full schema.
`
func loadSchema(brainDir string) string {
b, err := readBrainFile(brainDir, "schema.md")
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md"))
if err != nil {
return defaultSchema
}
+3 -1
View File
@@ -2,6 +2,7 @@
package pipeline
import (
"os"
"path/filepath"
"regexp"
"strings"
@@ -52,7 +53,8 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
if !ok {
continue
}
b, err := readBrainFile(brainDir, "wiki/"+string(pt)+"/"+slug+".md")
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
b, err := os.ReadFile(diskPath)
if err != nil {
continue
}
-167
View File
@@ -1,167 +0,0 @@
package pipeline
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
// hyperguild#157 slice 6: the pipeline read the source page, schema.md, the
// pages it revises and the pages it adds source refs to with os.ReadFile, so
// a symlinked file's content reached the LLM prompt and the written pages,
// and a FIFO hung the ingest.
func mkdirs(t *testing.T, brain string, dirs ...string) {
t.Helper()
for _, d := range dirs {
require.NoError(t, os.MkdirAll(filepath.Join(brain, filepath.FromSlash(d)), 0o755))
}
}
func TestReadSourcePageAndSchemaRefuseSymlinksAndSurviveSwapRaces(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources")
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("zqinside source"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brain, "schema.md"), []byte("zqinside schema"), 0o644))
assert.Equal(t, "zqinside source", readSourcePage(brain, "ok"), "positive control")
assert.Equal(t, "zqinside schema", loadSchema(brain), "positive control")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "sources", "out.md")))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
assert.NotContains(t, readSourcePage(brain, "out"), brainfstest.Sentinel)
assert.NotContains(t, readSourcePage(brain, "in"), brainfstest.Sentinel)
assert.NotContains(t, storedSourceHash(brain, "in"), brainfstest.Sentinel)
src := filepath.Join(brain, "wiki", "sources", "r.md")
read := func() string { return readSourcePage(brain, "r") }
brainfstest.AssertNoLeak(t, src, "zqinside", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, src, "zqinside", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, src, "zqinside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
schema := filepath.Join(brain, "schema.md")
rs := func() string { return loadSchema(brain) }
brainfstest.AssertNoLeakFrom(t, schema, "zqinside schema", inBrain, 1500*time.Millisecond, rs)
brainfstest.AssertNoHang(t, schema, "zqinside schema", 1500*time.Millisecond, 6*time.Second, func() { _ = rs() })
}
func TestReviseOrCreateRefusesASymlinkedExistingPage(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/concepts")
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
page := wiki.Page{Path: "wiki/concepts/c.md", Content: "---\ntitle: C\n---\nnew\n"}
out, _, err := reviseOrCreate(brain, page)
assert.Error(t, err)
assert.NotContains(t, out, brainfstest.Sentinel)
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("---\ntitle: D\n---\nzqold\n"), 0o644))
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/d.md", Content: "---\ntitle: D\n---\nnew\n"})
require.NoError(t, err)
assert.Contains(t, out, "zqold", "positive control: the existing page is read")
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/new.md", Content: "fresh"})
require.NoError(t, err)
assert.Equal(t, "fresh", out, "positive control: a missing page is created")
}
func TestInjectSourceRefsDoesNotReadASymlinkedPage(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/concepts")
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("zqinside d"), 0o644))
inv := map[wiki.PageType][]wiki.Entry{wiki.PageTypeConcept: {
{Slug: "c", Type: wiki.PageTypeConcept}, {Slug: "d", Type: wiki.PageTypeConcept},
}}
src := wiki.Page{Path: "wiki/sources/src.md", Content: "---\ntitle: Src\n---\nsee [[c|C]] and [[d|D]]\n"}
out := injectSourceRefs([]wiki.Page{src}, inv, brain)
got := fmt.Sprintf("%+v", out)
assert.Contains(t, got, "zqinside d", "positive control")
assert.NotContains(t, got, brainfstest.Sentinel)
}
func TestBackfillRefsSkipsSymlinkedSourcesAndPages(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "real.md"), []byte("---\ntitle: R\n---\nbody\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("---\ntitle: zqinside\n---\n[[real|R]]\n"), 0o644))
// A source elsewhere in the brain whose title is the sentinel and which
// links the real concept with the [[slug|title]] form the pipeline follows.
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "src.md"),
[]byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "src.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
// A concept page linked by the regular source that is itself a symlink.
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok2.md"), []byte("---\ntitle: Two\n---\n[[lnk|L]]\n"), 0o644))
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "page.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "page.md"), filepath.Join(brain, "wiki", "concepts", "lnk.md")))
_, err := BackfillRefs(context.Background(), brain)
require.NoError(t, err)
b, _ := os.ReadFile(filepath.Join(brain, "wiki", "concepts", "real.md"))
assert.Contains(t, string(b), "zqinside", "positive control: the regular source's ref is added")
assert.NotContains(t, string(b), brainfstest.Sentinel, "a symlinked source's title was written as a ref")
raw, _ := os.ReadFile(filepath.Join(brain, "raw", "page.md"))
assert.NotContains(t, string(raw), "[[ok2", "the symlinked concept page's target was rewritten")
}
// A source swapped between a regular file and a symlink to an in-brain source
// whose title is the sentinel: the read refuses the swapped-in link, so the
// sentinel never lands in the concept page as a ref.
func TestBackfillRefsDoesNotWriteARefFromASourceSwappedInBehindASymlink(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
realPage := filepath.Join(brain, "wiki", "concepts", "real.md")
target := filepath.Join(brain, "raw", "src.md")
require.NoError(t, os.WriteFile(target, []byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
src := filepath.Join(brain, "wiki", "sources", "r.md")
inside := "---\ntitle: zqinside\n---\n[[real|R]]\n"
read := func() string {
_ = os.WriteFile(realPage, []byte("---\ntitle: R\n---\nbody\n"), 0o644)
_, _ = BackfillRefs(context.Background(), brain)
b, _ := os.ReadFile(realPage)
return string(b)
}
require.NoError(t, os.WriteFile(src, []byte(inside), 0o644))
require.Contains(t, read(), "zqinside", "positive control")
brainfstest.AssertNoLeakFrom(t, src, inside, target, 1500*time.Millisecond, read)
}
// A concept page that is regular when the inventory lists it, then swapped
// for a symlink to an in-brain file before BackfillRefs reads it: only the
// page read's own check refuses it (a static symlink never gets this far,
// because LoadInventory does not list it).
func TestBackfillRefsDoesNotCopyAPageSwappedInBehindASymlink(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "s.md"), []byte("---\ntitle: S\n---\n[[c|C]]\n"), 0o644))
page := filepath.Join(brain, "wiki", "concepts", "c.md")
target := filepath.Join(brain, "raw", "secret.md")
inside := "---\ntitle: C\n---\nzqinside\n"
read := func() string {
// The write path still follows a swapped-in link (#158) and can
// overwrite the target, so restore it each round.
_ = os.WriteFile(target, []byte("---\ntitle: X\n---\n"+brainfstest.Sentinel+"\n"), 0o644)
_, _ = BackfillRefs(context.Background(), brain)
f, err := os.OpenFile(page, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
b, _ := io.ReadAll(f)
return string(b)
}
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
require.Contains(t, read(), "zqinside", "positive control: the regular page gets the ref")
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
}
@@ -61,31 +61,6 @@ func TestQueryDoesNotFollowASymlinkedDirectory(t *testing.T) {
assert.Zero(t, leaked(rs))
}
// A search root that is a symlink staying inside the brain (wiki/<w> -> ..)
// was followed by fs.WalkDir, aliasing raw/ and root files into results
// (PR #166 review). The root is now Lstat-checked and skipped.
func TestQueryDoesNotFollowASymlinkedWingRootInsideTheBrain(t *testing.T) {
brain, _, _ := searchBrain(t)
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "r.md"), []byte("# r\n\nzqrawonly"), 0o644))
require.NoError(t, os.Symlink("..", filepath.Join(brain, "wiki", "alias")))
rs, err := Query(brain, QueryOptions{Query: "zqrawonly", Wing: "alias", Limit: 10})
require.NoError(t, err)
assert.Empty(t, rs)
}
// With a hall the search root is wiki/<wing>/<hall>; a symlinked <wing> with
// a real <hall> directory under its target was followed (PR #166 round 2).
func TestQueryDoesNotFollowASymlinkedWingUnderAWingAndHallRoot(t *testing.T) {
brain, _, _ := searchBrain(t)
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "facts", "r.md"), []byte("# r\n\nzqrawhall"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(brain, "wiki", "alias")))
rs, err := Query(brain, QueryOptions{Query: "zqrawhall", Wing: "alias", Hall: "facts", Limit: 10})
require.NoError(t, err)
assert.Empty(t, 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
+32 -143
View File
@@ -9,7 +9,6 @@ package symlinkguard
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
@@ -22,14 +21,7 @@ import (
)
// DefaultInterval is used when Watch is given a non-positive interval.
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
)
const DefaultInterval = 5 * time.Minute
// Notifier is the out-of-band alert sink (audit.Notifier satisfies it).
type Notifier interface {
@@ -49,7 +41,13 @@ 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
// a name containing a newline cannot blur two findings together.
func (r Result) String() string {
q := r.quoted
q := func(xs []string) string {
out := make([]string, len(xs))
for i, x := range xs {
out[i] = strconv.Quote(x)
}
return strings.Join(out, ", ")
}
var parts []string
if len(r.Symlinks) > 0 {
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
@@ -60,24 +58,6 @@ func (r Result) String() string {
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
// 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
@@ -126,20 +106,15 @@ func Scan(brainDir string) (Result, error) {
}
// Watch rescans every interval (DefaultInterval if non-positive) until ctx
// ends. Every symlink found is removed (the link, never its target:
// hyperguild#157 decision), so one is readable for at most one interval.
// 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.
// ends, and notifies once per distinct non-clean result. A result counts as
// reported only after Notify succeeds, so an alert lost to a notifier outage
// is retried on the next tick.
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
if interval <= 0 {
interval = DefaultInterval
}
t := time.NewTicker(interval)
defer t.Stop()
q := &alertQueue{}
reported := ""
for {
select {
@@ -148,117 +123,31 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
case <-t.C:
}
r, err := Scan(brainDir)
key := ""
if err != nil {
key = "cannot scan the brain dir: " + err.Error()
} else {
removed, failed := removeSymlinks(brainDir, r.Symlinks)
if len(removed) > 0 {
slog.Error("brain symlink tripwire removed symlinks", "count", len(removed))
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
len(removed), Result{}.quoted(removed)))
}
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
}
if key != reported {
reported = key
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.")
}
}
q.flush(ctx, n)
}
}
// 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)
var key, msg string
switch {
case err == nil:
case permanent(err):
slog.Error("symlink tripwire alert rejected permanently; counted in the summary", "err", err)
q.suppressed++
case err != nil:
key = "error: " + err.Error()
msg = "brain symlink tripwire (hyperguild#157): cannot scan the brain dir: " + err.Error()
case r.Clean():
reported = ""
continue
default:
key = r.String()
msg = "brain symlink tripwire (hyperguild#157): " + key +
". Readers may follow symlinks out of the brain; remove them."
}
if key == reported {
continue
}
slog.Error("brain symlink tripwire", "finding", key)
if n == nil {
reported = key
continue
}
if err := n.Notify(ctx, msg); err != nil {
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)
reported = key
}
return removed, failed
}
@@ -3,13 +3,9 @@ package symlinkguard
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -186,313 +182,8 @@ func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) {
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.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)
}
// 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")
}
+17 -32
View File
@@ -2,15 +2,12 @@ package vectorstore
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"os"
"path/filepath"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// Embedder produces dense vectors. The embed package's Client satisfies
@@ -93,38 +90,31 @@ func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder)
}
seenParents := make(map[string]struct{})
// Through the brain root, regular files only (hyperguild#157): the brain
// is a Syncthing folder, and os.ReadFile followed a synced symlink into
// the embeddings and blocked on a FIFO.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return res, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
for _, sub := range scanDirs {
if _, err := bfs.Lstat(sub); errors.Is(err, fs.ErrNotExist) {
root := filepath.Join(brainDir, sub)
if _, err := os.Stat(root); os.IsNotExist(err) {
continue
}
// Any other walk error, including a scan root that is a symlink,
// aborts the run below.
var walkErr error
onErr := func(rel string, err error) {
if walkErr == nil {
walkErr = fmt.Errorf("%s: %w", rel, err)
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
}
err = bfs.WalkRegular(sub, func(relSlash string) error {
if !strings.HasSuffix(relSlash, ".md") || path.Base(relSlash) == "_index.md" {
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
seenParents[relSlash] = struct{}{}
if ps, ok := parents[relSlash]; ok {
// File already has chunks in the store. Re-embed only when
// the file has been edited since the oldest chunk was
// written. Tolerate clock skew with a sub-second grace.
info, statErr := bfs.Lstat(relSlash)
info, statErr := d.Info()
if statErr != nil {
res.Errors = append(res.Errors, fmt.Errorf("stat %s: %w", relSlash, statErr))
return nil
@@ -142,7 +132,7 @@ func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder)
}
}
content, readErr := bfs.ReadRegular(relSlash)
content, readErr := os.ReadFile(path)
if readErr != nil {
res.Errors = append(res.Errors, fmt.Errorf("read %s: %w", relSlash, readErr))
return nil
@@ -161,12 +151,7 @@ func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder)
res.Added++
}
return nil
}, onErr)
if err == nil {
err = walkErr
}
// Abort before the delete pass: an incomplete walk would leave
// seenParents short and delete chunks of files that still exist.
})
if err != nil {
return res, fmt.Errorf("walk %s: %w", sub, err)
}
@@ -1,126 +0,0 @@
package vectorstore_test
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
)
// recordingEmbedder keeps every text it was asked to embed.
type recordingEmbedder struct {
mu sync.Mutex
texts []string
}
func (e *recordingEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.texts = append(e.texts, text)
return []float32{1}, nil
}
func (e *recordingEmbedder) all() string {
e.mu.Lock()
defer e.mu.Unlock()
return strings.Join(e.texts, "\n")
}
// hyperguild#157 slice 3: Sync walked wiki/ and knowledge/ and read each .md
// with os.ReadFile, so a symlinked note had its target embedded and served by
// semantic search, and a FIFO stalled the sync.
func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
emb := &recordingEmbedder{}
store := &stubStore{}
_, err := vectorstore.Sync(context.Background(), brain, store, emb)
require.NoError(t, err)
assert.Contains(t, emb.all(), "inside note", "positive control: the regular note is embedded")
assert.NotContains(t, emb.all(), brainfstest.Sentinel)
for p := range store.upserts {
assert.NotContains(t, p, "leak.md")
}
p := filepath.Join(brain, "knowledge", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
sync := func() string {
e := &recordingEmbedder{}
_, _ = vectorstore.Sync(context.Background(), brain, &stubStore{}, e)
return e.all()
}
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
}
// Chunks embedded from a symlinked note before this fix are deleted: the
// note is no longer seen, so the delete pass drops its rows.
func TestSyncDeletesChunksOfASymlinkedNote(t *testing.T) {
brain := t.TempDir()
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
store := &stubStore{known: map[string]time.Time{
"wiki/w/facts/leak.md#0001": {},
"wiki/w/facts/ok.md#0001": {},
}}
_, err := vectorstore.Sync(context.Background(), brain, store, &recordingEmbedder{})
require.NoError(t, err)
assert.Equal(t, []string{"wiki/w/facts/leak.md#0001"}, store.deletes)
}
// A walk error aborts Sync before the delete pass, so chunks of files the
// walk could not see are kept.
func TestSyncAbortsBeforeDeletingWhenASubdirIsUnreadable(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
brain := t.TempDir()
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
locked := filepath.Join(brain, "wiki", "w")
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
store := &stubStore{known: map[string]time.Time{"wiki/w/facts/ok.md#0001": {}}}
_, err := vectorstore.Sync(context.Background(), brain, store, &recordingEmbedder{})
assert.Error(t, err)
assert.Empty(t, store.deletes)
}
// A scan root that is a symlink inside the brain (knowledge -> .) was
// followed by fs.WalkDir, aliasing raw/, sessions/ and root files into the
// index. Sync now aborts on it, before embedding or deleting anything.
func TestSyncAbortsOnASymlinkedScanRoot(t *testing.T) {
brain := t.TempDir()
writeNote(t, brain, "wiki/w/a.md", "wiki note")
writeNote(t, brain, "sessions/s1.md", "session note")
require.NoError(t, os.Symlink(".", filepath.Join(brain, "knowledge")))
emb := &recordingEmbedder{}
store := &stubStore{known: map[string]time.Time{"wiki/w/a.md#0001": {}}}
_, err := vectorstore.Sync(context.Background(), brain, store, emb)
assert.Error(t, err)
assert.NotContains(t, emb.all(), "session note")
for p := range store.upserts {
assert.False(t, strings.HasPrefix(p, "knowledge/"), "upserted %s", p)
}
assert.Empty(t, store.deletes)
}
// A missing scan root is skipped; the other root still syncs.
func TestSyncSkipsAMissingScanRoot(t *testing.T) {
brain := t.TempDir()
writeNote(t, brain, "wiki/w/a.md", "wiki note")
emb := &recordingEmbedder{}
_, err := vectorstore.Sync(context.Background(), brain, &stubStore{}, emb)
require.NoError(t, err)
assert.Contains(t, emb.all(), "wiki note")
}
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
@@ -344,12 +343,7 @@ func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brainDir := setupBrainDir(t)
raw := filepath.Join(brainDir, "raw")
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() {
defer wg.Done()
for i := 0; !stop.Load(); i++ {
tmp := filepath.Join(raw, ".swap")
_ = os.Remove(tmp)
+4 -11
View File
@@ -7,7 +7,6 @@ import (
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
)
@@ -17,11 +16,6 @@ func RebuildIndex(brainDir, date string) error {
if err != nil {
return fmt.Errorf("load inventory: %w", err)
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
total := len(inv[PageTypeConcept]) + len(inv[PageTypeEntity]) + len(inv[PageTypeSource])
var sb strings.Builder
@@ -40,7 +34,7 @@ func RebuildIndex(brainDir, date string) error {
label := strings.ToUpper(string(pt)[:1]) + string(pt)[1:]
fmt.Fprintf(&sb, "## %s\n\n", label)
for _, e := range entries {
summary := pageFirstSentence(bfs, e)
summary := pageFirstSentence(brainDir, e)
if summary != "" {
fmt.Fprintf(&sb, "- [[%s|%s]] — %s\n", e.Slug, e.Title, summary)
} else {
@@ -54,10 +48,9 @@ func RebuildIndex(brainDir, date string) error {
return os.WriteFile(dest, []byte(sb.String()), 0o644)
}
// pageFirstSentence reads the page through the brain root, regular files
// only (hyperguild#157).
func pageFirstSentence(bfs *brainfs.Brain, e Entry) string {
content, err := bfs.ReadRegular("wiki/" + string(e.Type) + "/" + e.Slug + ".md")
func pageFirstSentence(brainDir string, e Entry) string {
path := filepath.Join(brainDir, "wiki", string(e.Type), e.Slug+".md")
content, err := os.ReadFile(path)
if err != nil {
return ""
}
+15 -23
View File
@@ -3,45 +3,36 @@ package wiki
import (
"bufio"
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// LoadInventory walks brain/wiki/ and returns all pages grouped by type.
// Missing subdirectories are silently skipped. Pages are listed and read
// through the brain root, regular files only (hyperguild#157); a type
// directory that is a symlink is an error.
// Missing subdirectories are silently skipped.
func LoadInventory(brainDir string) (map[PageType][]Entry, error) {
result := map[PageType][]Entry{
PageTypeConcept: {},
PageTypeEntity: {},
PageTypeSource: {},
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
for pt := range result {
dir := "wiki/" + string(pt)
names, err := bfs.ReadDirRegular(dir)
if errors.Is(err, fs.ErrNotExist) {
dir := filepath.Join(brainDir, "wiki", string(pt))
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", dir, err)
}
for _, name := range names {
if !strings.HasSuffix(name, ".md") {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
slug := strings.TrimSuffix(name, ".md")
title, aliases := readFrontmatter(bfs, dir+"/"+name, slug)
slug := strings.TrimSuffix(e.Name(), ".md")
path := filepath.Join(dir, e.Name())
title, aliases := readFrontmatter(path, slug)
result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt})
}
}
@@ -50,14 +41,15 @@ func LoadInventory(brainDir string) (map[PageType][]Entry, error) {
// readFrontmatter extracts title and aliases from YAML frontmatter.
// Falls back to slug for title and empty aliases on any error.
func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string, aliases []string) {
func readFrontmatter(path, fallbackSlug string) (title string, aliases []string) {
title = fallbackSlug
content, err := bfs.ReadRegular(rel)
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close() //nolint:errcheck
scanner := bufio.NewScanner(bytes.NewReader(content))
scanner := bufio.NewScanner(f)
inFM := false
inAliases := false
for scanner.Scan() {
-79
View File
@@ -1,79 +0,0 @@
package wiki
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#157 slice 4: LoadInventory listed wiki/<type>/ with os.ReadDir
// and opened each .md by path, so a symlinked page put the target's title and
// aliases into the inventory (and the LLM prompts built from it), and a FIFO
// hung it. RebuildIndex read first sentences the same way.
func TestLoadInventoryRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
dir := filepath.Join(brain, "wiki", "concepts")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "ok.md"), []byte("---\ntitle: zqinside\n---\n"), 0o644))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(dir, "leak.md")))
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(dir, "inlink.md")))
inv, err := LoadInventory(brain)
require.NoError(t, err)
got := fmt.Sprintf("%+v", inv)
assert.Contains(t, got, "zqinside", "positive control")
assert.NotContains(t, got, brainfstest.Sentinel)
assert.NotContains(t, got, "leak", "a symlinked page is not listed")
assert.NotContains(t, got, "inlink", "a page linked to another brain file is not listed")
p := filepath.Join(dir, "r.md")
read := func() string {
inv, _ := LoadInventory(brain)
return fmt.Sprintf("%+v", inv)
}
brainfstest.AssertNoLeak(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: zqinside\n---\n", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
}
func TestLoadInventoryReturnsErrNotRegularForASymlinkedTypeDir(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "r.md"), []byte("---\ntitle: zqrawonly\n---\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(brain, "wiki", "concepts")))
inv, err := LoadInventory(brain)
require.ErrorIs(t, err, brainfs.ErrNotRegular)
assert.NotContains(t, fmt.Sprintf("%+v", inv), "zqrawonly")
}
func TestRebuildIndexDoesNotReadAPageSwappedForASymlink(t *testing.T) {
brain := t.TempDir()
dir := filepath.Join(brain, "wiki", "concepts")
require.NoError(t, os.MkdirAll(dir, 0o755))
p := filepath.Join(dir, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\ntitle: R\n---\nzqinside sentence.\n"), 0o644))
require.NoError(t, RebuildIndex(brain, "2026-10-04"))
idx, err := os.ReadFile(filepath.Join(brain, "wiki", "index.md"))
require.NoError(t, err)
require.Contains(t, string(idx), "zqinside", "positive control: the summary is read")
read := func() string {
_ = RebuildIndex(brain, "2026-10-04")
b, _ := os.ReadFile(filepath.Join(brain, "wiki", "index.md"))
return string(b)
}
brainfstest.AssertNoLeak(t, p, "---\ntitle: R\n---\nzqinside sentence.\n", 1500*time.Millisecond, read)
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: R\n---\nzqinside sentence.\n", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\ntitle: R\n---\nzqinside sentence.\n", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
}