Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5.5 31071bc9b1 fix(brainfs): check every component of the walk root (#157)
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped
Review round 2 on PR #166: Lstat on the walk root checks only its last
component, so search with Wing+Hall (root wiki/<wing>/<hall>) walked a
symlinked <wing> whose target had a real <hall> directory.

WalkRegular now Lstats every prefix of the root and requires each to be
a real directory. New brainfs and search (Wing+Hall) tests; the Wing-only
search test is renamed to say what it covers.

The doc now records the accepted residual: a directory swapped for a
symlink between listing and read is still followed; os.Root keeps the
read inside the brain but not inside the walk root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:16:50 +02:00
mathiasandClaude Opus 5.5 66b6cf8a77 fix(brainfs): refuse a symlinked walk root (#157)
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Mirror to GitHub (pull_request) Skipped
Review round 1 on PR #166: fs.WalkDir stats its root with a follow, and
os.Root allows a symlink that stays inside the brain, so knowledge -> .
aliased raw/, sessions/ and root files into the embeddings (and a
wiki/<w> -> .. root into search results). On main, filepath.WalkDir
Lstat'ed the root and did not descend.

- WalkRegular Lstats its root and requires a real directory; anything
  else goes to onErr as ErrNotRegular and nothing is yielded.
- Sync skips a missing scan root via bfs.Lstat before walking, and every
  other walk error, including a symlinked root, aborts before the delete
  pass. This removes the unpinned rel == sub special case.
- Search warns and skips a symlinked root (new test).
- Test renamed to TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces.
- wrapAfterStat comment: brainfs tests must not use t.Parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:11:16 +02:00
mathiasandClaude Opus 5.5 74a9621ba6 fix(vectorstore): walk and read the brain through brainfs (#157, slice 3)
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Mirror to GitHub (pull_request) Skipped
Sync walked wiki/ and knowledge/ with filepath.WalkDir and read each .md
with os.ReadFile. A symlinked note (Syncthing syncs symlinks) had its
target embedded into pgvector and served by semantic search; a FIFO
stalled the sync. The walk and read now go through brainfs: regular
files only, type checked on the opened handle.

- Chunks already embedded from a symlinked note are deleted on the next
  sync, because the note is no longer seen.
- A walk error still aborts Sync before the delete pass, so an unreadable
  subdir does not delete chunks of files that exist. A missing scan dir
  is skipped as before.
- brainfs gains Lstat, used for the mtime staleness check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:02:16 +02:00
19 changed files with 99 additions and 800 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 // The budget stays bounded: retrying forever would turn a misconfiguration
// into a process that looks alive and never serves. // into a process that looks alive and never serves.
jwtValidator, err := buildJWTValidator(ctx, logger, jwtValidator, err := startupretry.Do(ctx,
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"), startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
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 { if err != nil {
logger.Error("build jwt validator", "err", err) logger.Error("build jwt validator", "err", err)
os.Exit(1) os.Exit(1)
+6 -6
View File
@@ -34,9 +34,9 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/x448/float16 v0.8.4 // 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/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 golang.org/x/time v0.3.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // 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/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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-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-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-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.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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-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-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.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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= 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 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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-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-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-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.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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+4 -33
View File
@@ -82,28 +82,6 @@ func (b *Brain) realDir(rel string) error {
return nil 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 // ReadRegular reads a brain-relative path, refusing anything that is not a
// regular file, both before the open and on the opened handle. // regular file, both before the open and on the opened handle.
func (b *Brain) ReadRegular(rel string) ([]byte, error) { func (b *Brain) ReadRegular(rel string) ([]byte, error) {
@@ -114,7 +92,7 @@ func (b *Brain) ReadRegular(rel string) ([]byte, error) {
if !fi.Mode().IsRegular() { if !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular) 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 // readOpened opens rel non-blocking and reads it only if the OPENED handle is
@@ -128,7 +106,7 @@ func (b *Brain) ReadRegular(rel string) ([]byte, error) {
// cannot create one; fs.protected_hardlinks=1 and the pod's secrets are on a // 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 // different filesystem from the brain volume, so a local caller cannot link
// them in. Refusing Nlink > 1 is the next step if either changes. // 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) } func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(rel, -1) }
// ReadRegularMax is ReadRegular with a size limit, checked on the opened // ReadRegularMax is ReadRegular with a size limit, checked on the opened
// handle before reading (max < 0 means no limit). // handle before reading (max < 0 means no limit).
@@ -140,14 +118,10 @@ func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
if !fi.Mode().IsRegular() { if !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular) return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
} }
return b.readOpenedMax(rel, max, fi) return b.readOpenedMax(rel, max)
} }
// readOpenedMax opens rel and reads it if the opened handle is a regular file func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) {
// 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) {
f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0) f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -160,9 +134,6 @@ func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]byte, e
if !hfi.Mode().IsRegular() { if !hfi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular) 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 { if max >= 0 && hfi.Size() > max {
return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge) return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge)
} }
@@ -6,8 +6,6 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -191,74 +189,3 @@ func TestWalkRegularReportsASymlinkedIntermediateComponentOfTheWalkRoot(t *testi
require.Len(t, errs, 1) require.Len(t, errs, 1)
assert.ErrorIs(t, errs[0], ErrNotRegular) 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")
}
@@ -7,7 +7,6 @@
package brainfstest package brainfstest
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -21,23 +20,16 @@ import (
// Sentinel marks the outside file's contents. // Sentinel marks the outside file's contents.
const Sentinel = "zqbrainfssentinelonefiftyseven" 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. // Outside writes a file outside any brain dir containing Sentinel.
func Outside(t *testing.T) string { func Outside(t *testing.T) string {
t.Helper() t.Helper()
p := filepath.Join(t.TempDir(), "secret.md") p := filepath.Join(t.TempDir(), "secret.md")
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil { if err := os.WriteFile(p, []byte("# secret\n\n"+Sentinel), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return p return p
} }
var swapSeq atomic.Int64
// StartSwap renames, in a loop, a regular note (content inside) and either // 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 // 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 // writer held open until the swapper exits, so a reader that skips the
@@ -50,15 +42,7 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
// A name of its own: swappers on one path share a test, and one tmp := path + ".swap"
// 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 var writers []*os.File
defer func() { defer func() {
for _, w := range writers { for _, w := range writers {
@@ -77,7 +61,7 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
} }
} }
default: default:
_ = os.Symlink(link, tmp) _ = os.Symlink(outside, tmp)
} }
_ = os.Rename(tmp, path) _ = os.Rename(tmp, path)
} }
@@ -85,33 +69,11 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
t.Cleanup(func() { stop.Store(true); wg.Wait() }) 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 // AssertNoLeak loops read for d while a symlink swapper runs on path, and
// fails if any output contains Sentinel. The symlink points outside the // fails if any output contains Sentinel.
// 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) { func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) {
t.Helper() t.Helper()
AssertNoLeakFrom(t, path, inside, Outside(t), d, read) StartSwap(t, path, inside, Outside(t), false)
}
// 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 leaks, iters := 0, 0
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
leaks += strings.Count(read(), Sentinel) leaks += strings.Count(read(), Sentinel)
@@ -16,10 +16,9 @@
package classification package classification
import ( import (
"errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "os"
"io/fs" "path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -111,20 +110,9 @@ type rawConfig struct {
func Load(brainDir string) (*Config, error) { func Load(brainDir string) (*Config, error) {
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}} cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
// Read through the brain root, regular files only (hyperguild#157): a data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
// 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
}
if err != nil { if err != nil {
return nil, fmt.Errorf("open brain: %w", err) if os.IsNotExist(err) {
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil return cfg, nil
} }
return nil, fmt.Errorf("read classification.yaml: %w", err) 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 ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "os"
"path"
"path/filepath" "path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/graph" "github.com/mathiasbq/hyperguild/ingestion/internal/graph"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
) )
@@ -50,25 +46,8 @@ func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error
if relPath == "" { if relPath == "" {
return nil return nil
} }
// Callers pass caller-supplied paths (brain_tunnel, brain_update): index abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
// only a clean path under wiki/ or knowledge/. content, err := os.ReadFile(abs)
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)
if err != nil { if err != nil {
return fmt.Errorf("read %q: %w", relPath, err) 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 { if store == nil {
return 0, nil return 0, nil
} }
bfs, err := brainfs.Open(brainDir) roots := []string{"wiki", "knowledge"}
if err != nil { for _, root := range roots {
return 0, fmt.Errorf("open brain: %w", err) base := filepath.Join(brainDir, root)
} if _, err := os.Stat(base); os.IsNotExist(err) {
defer func() { _ = bfs.Close() }()
for _, root := range []string{"wiki", "knowledge"} {
if _, err := bfs.Lstat(root); errors.Is(err, fs.ErrNotExist) {
continue continue
} }
// Any walk error, including a root that is a symlink, aborts. err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
var walkErr error if walkErr != nil {
err := bfs.WalkRegular(root, func(rel string) error { return walkErr
if path.Ext(rel) != ".md" { }
if d.IsDir() {
return nil 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) return fmt.Errorf("index %q: %w", rel, err)
} }
indexed++ indexed++
return nil 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 { if err != nil {
return indexed, fmt.Errorf("walk %s: %w", root, err) 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)
}
}
+8 -17
View File
@@ -3,10 +3,7 @@ package pipeline
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -26,16 +23,10 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
return 0, fmt.Errorf("load inventory: %w", err) return 0, fmt.Errorf("load inventory: %w", err)
} }
// Listed and read through the brain root, regular files only sourcesDir := filepath.Join(brainDir, "wiki", "sources")
// (hyperguild#157). entries, err := os.ReadDir(sourcesDir)
bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
return 0, fmt.Errorf("open brain: %w", err) if os.IsNotExist(err) {
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular("wiki/sources")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return 0, nil return 0, nil
} }
return 0, fmt.Errorf("read sources dir: %w", err) 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. // get both refs merged before a single write.
pending := make(map[string]wiki.Page) pending := make(map[string]wiki.Page)
for _, name := range names { for _, e := range entries {
if ctx.Err() != nil { if ctx.Err() != nil {
return 0, ctx.Err() return 0, ctx.Err()
} }
if !strings.HasSuffix(name, ".md") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue continue
} }
b, err := bfs.ReadRegular("wiki/sources/" + name) b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
if err != nil { if err != nil {
continue continue
} }
sourceContent := string(b) sourceContent := string(b)
sourceSlug := strings.TrimSuffix(name, ".md") sourceSlug := strings.TrimSuffix(e.Name(), ".md")
sourceTitle := extractTitle(sourceContent) sourceTitle := extractTitle(sourceContent)
if sourceTitle == "" { if sourceTitle == "" {
sourceTitle = sourceSlug 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. // Start from already-accumulated version if we've seen this page.
page, seen := pending[relPath] page, seen := pending[relPath]
if !seen { if !seen {
raw, err := bfs.ReadRegular(relPath) raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
if err != nil { if err != nil {
continue continue
} }
+6 -22
View File
@@ -5,10 +5,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "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 { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err) 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 { if err != nil {
return Result{}, err return Result{}, err
} }
@@ -191,27 +188,14 @@ func hashBody(content string) string {
// readSourcePage returns the full on-disk content of the source page at // readSourcePage returns the full on-disk content of the source page at
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet. // 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 { 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 { if err != nil {
return "" return ""
} }
return string(raw) 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 // storedSourceHash reads source_hash from the source page a previous ingest
// wrote, or "" when there is no such page or no such field. // 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 // 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 // is this function editing frontmatter it does not own, and no lint reads that
// field today. // field today.
func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) { func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
existing, err := readBrainFile(brainDir, page.Path) existing, err := os.ReadFile(dest)
switch { switch {
case errors.Is(err, fs.ErrNotExist): case os.IsNotExist(err):
return page.Content, nil, nil return page.Content, nil, nil
case err != nil: case err != nil:
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err) 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 { func loadSchema(brainDir string) string {
b, err := readBrainFile(brainDir, "schema.md") b, err := os.ReadFile(filepath.Join(brainDir, "schema.md"))
if err != nil { if err != nil {
return defaultSchema return defaultSchema
} }
+3 -1
View File
@@ -2,6 +2,7 @@
package pipeline package pipeline
import ( import (
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -52,7 +53,8 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
if !ok { if !ok {
continue 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 { if err != nil {
continue 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)
}
+4 -11
View File
@@ -7,7 +7,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil" "github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
) )
@@ -17,11 +16,6 @@ func RebuildIndex(brainDir, date string) error {
if err != nil { if err != nil {
return fmt.Errorf("load inventory: %w", err) 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]) total := len(inv[PageTypeConcept]) + len(inv[PageTypeEntity]) + len(inv[PageTypeSource])
var sb strings.Builder var sb strings.Builder
@@ -40,7 +34,7 @@ func RebuildIndex(brainDir, date string) error {
label := strings.ToUpper(string(pt)[:1]) + string(pt)[1:] label := strings.ToUpper(string(pt)[:1]) + string(pt)[1:]
fmt.Fprintf(&sb, "## %s\n\n", label) fmt.Fprintf(&sb, "## %s\n\n", label)
for _, e := range entries { for _, e := range entries {
summary := pageFirstSentence(bfs, e) summary := pageFirstSentence(brainDir, e)
if summary != "" { if summary != "" {
fmt.Fprintf(&sb, "- [[%s|%s]] — %s\n", e.Slug, e.Title, summary) fmt.Fprintf(&sb, "- [[%s|%s]] — %s\n", e.Slug, e.Title, summary)
} else { } else {
@@ -54,10 +48,9 @@ func RebuildIndex(brainDir, date string) error {
return os.WriteFile(dest, []byte(sb.String()), 0o644) return os.WriteFile(dest, []byte(sb.String()), 0o644)
} }
// pageFirstSentence reads the page through the brain root, regular files func pageFirstSentence(brainDir string, e Entry) string {
// only (hyperguild#157). path := filepath.Join(brainDir, "wiki", string(e.Type), e.Slug+".md")
func pageFirstSentence(bfs *brainfs.Brain, e Entry) string { content, err := os.ReadFile(path)
content, err := bfs.ReadRegular("wiki/" + string(e.Type) + "/" + e.Slug + ".md")
if err != nil { if err != nil {
return "" return ""
} }
+15 -23
View File
@@ -3,45 +3,36 @@ package wiki
import ( import (
"bufio" "bufio"
"bytes"
"errors"
"fmt" "fmt"
"io/fs" "os"
"path/filepath"
"strings" "strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
) )
// LoadInventory walks brain/wiki/ and returns all pages grouped by type. // LoadInventory walks brain/wiki/ and returns all pages grouped by type.
// Missing subdirectories are silently skipped. Pages are listed and read // Missing subdirectories are silently skipped.
// through the brain root, regular files only (hyperguild#157); a type
// directory that is a symlink is an error.
func LoadInventory(brainDir string) (map[PageType][]Entry, error) { func LoadInventory(brainDir string) (map[PageType][]Entry, error) {
result := map[PageType][]Entry{ result := map[PageType][]Entry{
PageTypeConcept: {}, PageTypeConcept: {},
PageTypeEntity: {}, PageTypeEntity: {},
PageTypeSource: {}, 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 { for pt := range result {
dir := "wiki/" + string(pt) dir := filepath.Join(brainDir, "wiki", string(pt))
names, err := bfs.ReadDirRegular(dir) entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) { if os.IsNotExist(err) {
continue continue
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("read dir %s: %w", dir, err) return nil, fmt.Errorf("read dir %s: %w", dir, err)
} }
for _, name := range names { for _, e := range entries {
if !strings.HasSuffix(name, ".md") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue continue
} }
slug := strings.TrimSuffix(name, ".md") slug := strings.TrimSuffix(e.Name(), ".md")
title, aliases := readFrontmatter(bfs, dir+"/"+name, slug) 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}) 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. // readFrontmatter extracts title and aliases from YAML frontmatter.
// Falls back to slug for title and empty aliases on any error. // 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 title = fallbackSlug
content, err := bfs.ReadRegular(rel) f, err := os.Open(path)
if err != nil { if err != nil {
return return
} }
defer f.Close() //nolint:errcheck
scanner := bufio.NewScanner(bytes.NewReader(content)) scanner := bufio.NewScanner(f)
inFM := false inFM := false
inAliases := false inAliases := false
for scanner.Scan() { 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() })
}